From 9f6797bfc4d8b55373cc6a957969f03443b5b29d Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 2 Sep 2026 10:00:23 +0200 Subject: fix(hub): a show opens on its first season, not its first thumbnail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported live: a show with a dozen seasons opened on season 6. Every season was in the picker and none was missing — the default was wrong. VideoDetailModal took it from `repEntry.season`. `repEntry` is the show's "representative entry", which the poster grid picks as `episodes.find((e) => e.thumb_hash) || episodes[0]`: the first episode that has a thumbnail, so the card has a fallback frame when TMDB has no poster. That is from the original Videos commit; the season tabs came later and read the same entry as "the episode the reader is looking at", which it never was on that path. Episodes are sorted by (season, episode), so a show whose first five seasons had no thumbnail yet — a partial enrichment pass, or ffmpeg failing on those files — hands back a season-6 episode. `defaultSeason(show)` reads the season list and nothing else: the lowest season present, specials only when there is nothing else, and the lowest *number* rather than the first entry so it does not quietly depend on buildSeasons keeping its sort. The effect's dependency on repEntry goes with it — nothing in it reads that any more. test_video_default_season.py runs the function in node. No input it takes can carry a thumbnail, which is the point. docs/mediacenter.md §10.5. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML --- docs/mediacenter.md | 25 ++++- .../src/meshbay_hub/static/video-app.js | 36 +++++-- .../meshbay-hub/tests/test_video_default_season.py | 107 +++++++++++++++++++++ 3 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 packages/meshbay-hub/tests/test_video_default_season.py diff --git a/docs/mediacenter.md b/docs/mediacenter.md index e983ef9..dbdf85c 100644 --- a/docs/mediacenter.md +++ b/docs/mediacenter.md @@ -594,8 +594,9 @@ season_meta_resp { tmdb_id, season, confidence, name, overview, air_date, `video-app.js`'s `VideoDetailModal` shows a season picker (`Specials` / `Season 1` / `Season 2` / …) whenever a show has more than one -season, defaulting to whichever season the representative episode belongs -to. Selecting a season both filters the episode list to it and swaps in that +season, defaulting to the lowest-numbered season present (`defaultSeason`; +specials only when there is nothing else). It defaulted to the representative +episode's season until §10.5, which is not the same thing at all. Selecting a season both filters the episode list to it and swaps in that season's own `overview`/`air_date` — falling back to the show-level `overview` when a season's own comes back empty (TMDB has no season-level text for every show), the same per-field fallback shape §5.4's English @@ -883,6 +884,26 @@ rectangles, and say so in their own docstrings: `scrollbar-gutter`, because headless Chrome gives the probe zero-width overlay scrollbars. `test_tmdb_show_director.py` covers the credit. +### 10.5 A show that opened on season 6 (2026-09-02) + +Every season was in the picker and none was missing; the *default* was wrong. +`VideoDetailModal` took it from `repEntry.season`, and `repEntry` is the show's +"representative entry", which the poster grid picks as +`episodes.find((e) => e.thumb_hash) || episodes[0]` — the first episode that +has a thumbnail, so the card has a fallback frame when TMDB has no poster. +That choice is from the original Videos commit; the season tabs came later and +read the same entry as "the episode the reader is looking at", which it never +was on that path. Episodes are sorted by (season, episode), so a show whose +first five seasons had no thumbnail yet — a partial enrichment pass, or ffmpeg +failing on those particular files — hands back a season-6 episode. + +Two meanings of "representative" that were never the same thing, and only one +of them is about what the reader is looking at. `defaultSeason(show)` now reads +the season list and nothing else: the lowest-numbered season present, specials +only when there is nothing else, and the lowest *number* rather than the first +entry so it does not quietly depend on `buildSeasons` keeping its sort. +`test_video_default_season.py` — no input it takes can carry a thumbnail. + ## 11. Acceptance before shipping 1. Re-run the §3 validation (real TMDB calls, same corpus, same script diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js index 2fa22d0..19b0f0f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -73,6 +73,28 @@ function buildSeasons(episodes) { .map(([season, seasonEpisodes]) => ({ season, episodes: seasonEpisodes })); } +// The season a show's detail modal opens on. +// +// Deliberately not the representative episode's. `repEntry` is picked for its +// *thumbnail* — `episodes.find((e) => e.thumb_hash)` in the poster grid, so +// the card has a fallback frame when TMDB has no poster — which makes its +// season an accident of which files the node has managed to thumbnail so far. +// Found live on a show whose early seasons had none: the modal opened on +// season 6. The two uses of "representative" were never the same thing, and +// only one of them is about what the reader is looking at. +// +// Specials first is not what anyone means by the beginning of a show, so +// season 0 wins only when it is all there is. +function defaultSeason(show) { + if (!show || !show.seasons.length) return null; + // The lowest number rather than the first entry: `buildSeasons` does sort + // ascending, but reading the answer off that ordering makes this quietly + // depend on a caller keeping it, and there is nothing to gain by that. + const numbers = show.seasons.map((s) => s.season); + const real = numbers.filter((n) => n !== 0); + return Math.min(...(real.length ? real : numbers)); +} + function groupVideoEntries(entries, videoRoot) { const movies = []; const showsByTitle = new Map(); @@ -686,17 +708,11 @@ function VideoDetailModal({ setRematching(false); }, [rematching, repEntry, transportRef]); - // Reset whenever a different file/show is opened in this same modal - // instance — repEntry/show change identity, selectedSeason must not - // silently keep pointing at whatever the previous show's season 4 was. + // Reset whenever a different show is opened in this same modal instance — + // `show` changes identity, and selectedSeason must not silently keep + // pointing at whatever the previous show's season 4 was. const [selectedSeason, setSelectedSeason] = useState(null); - useEffect(() => { - if (!show) { setSelectedSeason(null); return; } - const preferred = repEntry.season != null && show.seasons.some((s) => s.season === repEntry.season) - ? repEntry.season - : (show.seasons.find((s) => s.season !== 0) || show.seasons[0]).season; - setSelectedSeason(preferred); - }, [show, repEntry]); + useEffect(() => { setSelectedSeason(defaultSeason(show)); }, [show]); const showMultiSeason = Boolean(show && show.seasons.length > 1); const seasonMeta = useSeasonMeta( diff --git a/packages/meshbay-hub/tests/test_video_default_season.py b/packages/meshbay-hub/tests/test_video_default_season.py new file mode 100644 index 0000000..898d3f5 --- /dev/null +++ b/packages/meshbay-hub/tests/test_video_default_season.py @@ -0,0 +1,107 @@ +""" +Which season a show's detail modal opens on. + +Reported live: a show with a dozen seasons opened on season 6. Every season was +in the list and none was missing — the *default* was wrong, which is the harder +kind to notice and the easier kind to shrug at. + +`VideoDetailModal` took it from `repEntry.season`. `repEntry` is the show's +"representative entry", and the poster grid picks it as +`episodes.find((e) => e.thumb_hash) || episodes[0]` — the first episode that +has a thumbnail, so the card has a fallback frame when TMDB has no poster +(`6af05ab`). The season tabs then read that same entry as "the episode the +reader is looking at" (`0b0da86`), which it never was on that path. With the +episodes sorted by (season, episode), a show whose first five seasons had no +thumbnail yet — a partial enrichment pass, or ffmpeg failing on those +particular files — hands back a season-6 episode, and the modal opens there. + +Two different meanings of "representative" that were never the same thing. The +default is now `defaultSeason(show)`, which reads the season list and nothing +else, and this holds it to that: no input here carries a thumbnail at all. + +The function is read out of `video-app.js` rather than duplicated — a copy +would keep passing after the original changed. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +APP = STATIC / "video-app.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not APP.exists(), + reason="node or the SPA sources are not available") + +BLOCK = re.compile(r"^function defaultSeason\(show\) \{.*?^\}", re.M | re.S) + + +@pytest.fixture(scope="module") +def source(): + m = BLOCK.search(APP.read_text()) + assert m, ("defaultSeason is no longer where this test reads it from — the " + "season a show opens on is untested until this is fixed") + return m.group(0) + + +def _default(tmp_path, source, seasons): + """`seasons` as `buildSeasons` returns them: ascending, specials first.""" + show = None if seasons is None else { + "seasons": [{"season": n, "episodes": []} for n in seasons]} + script = tmp_path / "case.js" + script.write_text(f""" + {source} + console.log(JSON.stringify(defaultSeason({json.dumps(show)}))); + """) + proc = subprocess.run(["node", str(script)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_a_show_opens_on_its_first_season(tmp_path, source): + assert _default(tmp_path, source, [1, 2, 3, 4, 5, 6, 7, 8]) == 1 + + +def test_specials_do_not_become_the_default(tmp_path, source): + """`buildSeasons` sorts ascending, so season 0 is first in the list and + would win a naive `seasons[0]`. Nobody means the extras by "the start".""" + assert _default(tmp_path, source, [0, 1, 2, 3]) == 1 + + +def test_a_show_of_nothing_but_specials_opens_on_them(tmp_path, source): + """The one case where season 0 is the right answer: there is no other.""" + assert _default(tmp_path, source, [0]) == 0 + + +def test_a_show_that_starts_at_season_two_opens_there(tmp_path, source): + """Half a show on disk is ordinary. The first season *present* is the + answer, not the number 1, which would select a season that is not there + and filter the episode list down to nothing.""" + assert _default(tmp_path, source, [2, 3, 4]) == 2 + + +def test_the_answer_does_not_depend_on_which_episode_has_a_thumbnail(tmp_path, source): + """The defect, as the shape of the input. + + `defaultSeason` is handed the season list and nothing else — there is no + episode, no `thumb_hash`, no representative entry to be misled by. That is + the fix: the reported show opened on season 6 because its first five + seasons had no thumbnails yet, and no argument here can carry that. + """ + reported = list(range(1, 9)) + assert _default(tmp_path, source, reported) == 1 + assert _default(tmp_path, source, list(reversed(reported))) == 1, ( + "the first *season*, not the first entry in whatever order the list " + "arrived in") + + +@pytest.mark.parametrize("show", [None, []]) +def test_no_seasons_means_no_selection(tmp_path, source, show): + """A movie has no `show` at all, and an empty one must not throw: the + modal renders no picker and `selectedSeason` stays null.""" + assert _default(tmp_path, source, show) is None -- cgit v1.2.3