summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/mediacenter.md25
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js36
-rw-r--r--packages/meshbay-hub/tests/test_video_default_season.py107
3 files changed, 156 insertions, 12 deletions
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