From 7129d9c5b545bbb858550c87df76ec452d6b28ef Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 10 Jul 2026 23:26:08 +0200 Subject: [PATCH] fix: episode cards navigate to season detail + anchor (API SeasonId on browse items) (fixes #220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Episode cards in the SPA search and media-browse screens were inert (mediaDetailPath had no Episode case, and LibraryBrowseItemResponseModel carried no parent-season id to route with). - API: add nullable SeasonId to LibraryBrowseItemResponseModel; populate it in LibraryBrowseItemMapper.GetEpisodes (the single shared hydration site used by both the library-browse search/browse handler and the season episode drill-in), leave it null for every other kind. Regenerated v1.json + v1.d.ts per docs/api-conventions.md §5. - SPA: mediaDetailPath now routes Episode items with a seasonId to /app/media/seasons/{seasonId}#episode-{id} (matching Blazor's Search.razor:241 link), null otherwise. MediaPosterCard accepts an id/highlighted pair; SeasonDetailScreen's episode grid gives each card a stable `episode-{id}` anchor and scrolls/highlights it on mount and on hashchange (deep-link support). - Tests: GetLibraryBrowseItemsHandlerTests asserts SeasonId is populated for episode drill-in results and null for other kinds; web tests cover mediaDetailPath's episode cases and the anchor/scroll/highlight behavior (jsdom scrollIntoView stub). - Docs: blazor-route-parity.md's episode-browse row and the Search cluster verdict updated — the standalone SPA episode browse exists and episode cards now navigate, closing the adversarial-reviewer#18 finding. Co-Authored-By: Claude Fable 5 --- .../LibraryBrowse/LibraryBrowseItemMapper.cs | 3 +- .../LibraryBrowseItemResponseModel.cs | 3 +- .../GetLibraryBrowseItemsHandlerTests.cs | 30 ++++++++++++++ ErsatzTV/wwwroot/openapi/v1.json | 7 ++++ docs/blazor-route-parity.md | 4 +- web/src/api/generated/v1.d.ts | 1 + web/src/media/MediaPosterCard.tsx | 11 +++++- web/src/media/mediaKinds.test.ts | 35 +++++++++++++++++ web/src/media/mediaKinds.ts | 2 + web/src/screens/MediaDetailScreen.test.tsx | 39 +++++++++++++++++++ web/src/screens/MediaDetailScreen.tsx | 21 ++++++++++ web/src/shell.css | 16 ++++++++ 12 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 web/src/media/mediaKinds.test.ts diff --git a/ErsatzTV.Application/LibraryBrowse/LibraryBrowseItemMapper.cs b/ErsatzTV.Application/LibraryBrowse/LibraryBrowseItemMapper.cs index 245101502..874f1c9a2 100644 --- a/ErsatzTV.Application/LibraryBrowse/LibraryBrowseItemMapper.cs +++ b/ErsatzTV.Application/LibraryBrowse/LibraryBrowseItemMapper.cs @@ -274,7 +274,8 @@ internal static class LibraryBrowseItemMapper null, em.EpisodeId, null, - EpisodeSubtitle(em))).ToList()); + EpisodeSubtitle(em), + em.Episode.SeasonId)).ToList()); } public static async Task> GetMusicVideos( diff --git a/ErsatzTV.Core/Api/LibraryBrowse/LibraryBrowseItemResponseModel.cs b/ErsatzTV.Core/Api/LibraryBrowse/LibraryBrowseItemResponseModel.cs index 67f6cffd2..c7a84a5f1 100644 --- a/ErsatzTV.Core/Api/LibraryBrowse/LibraryBrowseItemResponseModel.cs +++ b/ErsatzTV.Core/Api/LibraryBrowse/LibraryBrowseItemResponseModel.cs @@ -20,4 +20,5 @@ public record LibraryBrowseItemResponseModel( int? RerunCollectionId, int? MediaItemId, int? PlaylistId, - string? Subtitle = null); + string? Subtitle = null, + int? SeasonId = null); diff --git a/ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs b/ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs index b04b5c3b8..5afc03187 100644 --- a/ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs +++ b/ErsatzTV.Tests/Application/LibraryBrowse/GetLibraryBrowseItemsHandlerTests.cs @@ -288,6 +288,10 @@ public class GetLibraryBrowseItemsHandlerTests result.Page[1].MediaItemId.ShouldBe(711); result.Page[2].MediaItemId.ShouldBe(713); + // #220: every episode carries its parent season id so the SPA can route to the season + // detail page and anchor to the episode (`/app/media/seasons/{seasonId}#episode-{id}`). + result.Page.ShouldAllBe(p => p.SeasonId == 701); + await _searchIndex.DidNotReceive().Search( Arg.Any(), Arg.Any(), @@ -297,6 +301,32 @@ public class GetLibraryBrowseItemsHandlerTests Arg.Any()); } + [Test] + public async Task Handle_Should_Leave_SeasonId_Null_For_Non_Episode_Kinds() + { + await SeedLibraryGraph(); + _searchIndex.Search( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any()) + .Returns(new SearchResult( + [ + new SearchItem(LuceneSearchIndex.ShowType, 20), + new SearchItem(LuceneSearchIndex.MovieType, 10) + ], + 2)); + var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory); + + PagedLibraryBrowseItemsResponseModel result = await handler.Handle( + new GetLibraryBrowseItems("", null, null, 0, 10), + CancellationToken.None); + + result.Page.ShouldAllBe(p => p.SeasonId == null); + } + [Test] public async Task Handle_Should_Browse_Music_Videos_For_A_Specific_Artist_By_ParentId() { diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 847e90c5a..1b6da0c9e 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -18013,6 +18013,13 @@ "null", "string" ] + }, + "seasonId": { + "type": [ + "null", + "integer" + ], + "format": "int32" } } }, diff --git a/docs/blazor-route-parity.md b/docs/blazor-route-parity.md index bb617695a..eb1e7b49d 100644 --- a/docs/blazor-route-parity.md +++ b/docs/blazor-route-parity.md @@ -64,7 +64,7 @@ redirect). > | Collections | PARITY-OK (custom-order endpoint + reorder UI, all-10-kind add picker, 2026-07-09) | — | > | Channel editor | GAPS (external logo URL, bare create, pickers) | #212 | > | Channels-numbers / Logs | PARITY-OK / minors | #213 | -> | Search | PARITY-OK (card nav, per-card/multi-select add-to, add-all, save-as-smart-collection, 2026-07-10) | — | +> | Search | PARITY-OK (card nav — episode cards now navigate too, closing the adversarial-reviewer#18 finding tracked as #220, 2026-07-10; per-card/multi-select add-to, add-all, save-as-smart-collection, 2026-07-10) | — | > | Media browse/detail (read paths + image browser) | PARITY-OK | — | > | Media browse/detail (mutations, per-show scan, episode info/troubleshoot) | PARITY-OK (shared Add-to layer + scan/info/troubleshoot wired, 2026-07-10) | — | > | Schedules editors | **disproven — moved to Section 3** | **#207** | @@ -115,7 +115,7 @@ been added to the redirect map yet. | `/media/tv/shows/{ShowId:int}` | `TelevisionSeasonList.razor` | `/app/media/shows/{id}` | show + season list (`ShowDetailScreen`); PR #183 / #141 | | `/media/tv/seasons`(`/page/{n}`) | `TelevisionSeasonSearchResults.razor` | `/app/media?kind=seasons` | seasons browsable as a top-level kind (`MediaBrowseScreen`; also reachable via show drill-in); #209 review fix | | `/media/tv/seasons/{SeasonId:int}` | `TelevisionEpisodeList.razor` | `/app/media/seasons/{id}` | season + episode list (`SeasonDetailScreen`); PR #183 / #141 | -| `/media/tv/episodes`(`/page/{n}`) | `EpisodeList.razor` | `/app/media/seasons/{id}` | no standalone SPA episode browse; covered via season detail drill-in; PR #183 / #141 | +| `/media/tv/episodes`(`/page/{n}`) | `EpisodeList.razor` | `/app/media?kind=episodes` | standalone SPA episode browse EXISTS (`MediaBrowseScreen`, generic grid, top-level `episodes` kind); episode cards there and on the Search screen now navigate to the season detail page and anchor/highlight the episode (`/app/media/seasons/{seasonId}#episode-{id}`), matching `Search.razor:241`'s `media/tv/seasons/{SeasonId}#episode-{EpisodeId}` link (`LibraryBrowseItemResponseModel.SeasonId`, `mediaDetailPath`); #220 | | `/media/music/artists`(`/page/{n}`) | `ArtistList.razor` | `/app/media?kind=artists` | generic browse; PR #183 / #141 | | `/media/music/artists/{ArtistId:int}` | `Artist.razor` | `/app/media/artists/{id}` | detail page (`ArtistDetailScreen`); PR #183 / #141 | | `/media/music/videos`(`/page/{n}`) | `MusicVideoList.razor` | `/app/media?kind=music-videos` | generic browse; PR #183 / #141 | diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index 9c9024447..005a6ff25 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -761,6 +761,7 @@ export interface components { "mediaItemId": null | number; "playlistId": null | number; "subtitle"?: null | string; + "seasonId"?: null | number; }; "LibraryBrowseMediaType": "Movie" | "TelevisionShow" | "TelevisionSeason" | "Artist" | "Collection" | "SmartCollection" | "MultiCollection" | "RerunCollection" | "Playlist" | "Episode" | "MusicVideo" | "Song" | "OtherVideo" | "Image" | "RemoteStream"; "LibraryMediaKind": "Movies" | "Shows" | "MusicVideos" | "OtherVideos" | "Songs" | "Images" | "RemoteStreams"; diff --git a/web/src/media/MediaPosterCard.tsx b/web/src/media/MediaPosterCard.tsx index d7cbecaf8..e00fa271b 100644 --- a/web/src/media/MediaPosterCard.tsx +++ b/web/src/media/MediaPosterCard.tsx @@ -13,7 +13,9 @@ export function MediaPosterCard({ onToggleSelect, onOpen, height = 150, - actions + actions, + id, + highlighted }: { item: LibraryBrowseItem; selected?: boolean; @@ -21,6 +23,10 @@ export function MediaPosterCard({ onOpen?: (item: LibraryBrowseItem) => void; height?: number; actions?: ReactNode; + // Stable DOM id (e.g. `episode-{id}`) so callers can deep-link/scroll to this card. + id?: string; + // Applies a temporary visual highlight, e.g. when this card is the deep-link target. + highlighted?: boolean; }) { const hue = hueOf(item.title); const Icon = TYPE_ICON[item.mediaType] ?? Film; @@ -45,7 +51,8 @@ export function MediaPosterCard({ return (
): LibraryBrowseItem { + return { + artwork: '', + id: 91, + mediaType: 'Episode', + title: 'Pilot', + ...overrides + } as unknown as LibraryBrowseItem; +} + +describe('mediaDetailPath', () => { + it('routes an episode with a seasonId to the season detail page, anchored to the episode (#220)', () => { + const item = episodeItem({ seasonId: 8 } as Partial); + expect(mediaDetailPath(item)).toBe('/app/media/seasons/8#episode-91'); + }); + + it('returns null for an episode with no seasonId', () => { + const item = episodeItem({}); + expect(mediaDetailPath(item)).toBeNull(); + }); + + it('still routes a movie to its detail page', () => { + const item = { + artwork: '', + id: 5, + mediaType: 'Movie', + title: 'Blade Runner' + } as unknown as LibraryBrowseItem; + expect(mediaDetailPath(item)).toBe('/app/media/movies/5'); + }); +}); diff --git a/web/src/media/mediaKinds.ts b/web/src/media/mediaKinds.ts index 763dfeb05..96fb8f939 100644 --- a/web/src/media/mediaKinds.ts +++ b/web/src/media/mediaKinds.ts @@ -61,6 +61,8 @@ export function mediaDetailPath(item: LibraryBrowseItem): string | null { return `/app/media/seasons/${item.id}`; case 'Artist': return `/app/media/artists/${item.id}`; + case 'Episode': + return item.seasonId != null ? `/app/media/seasons/${item.seasonId}#episode-${item.id}` : null; default: return null; } diff --git a/web/src/screens/MediaDetailScreen.test.tsx b/web/src/screens/MediaDetailScreen.test.tsx index a36d80dbd..e3f92bdb4 100644 --- a/web/src/screens/MediaDetailScreen.test.tsx +++ b/web/src/screens/MediaDetailScreen.test.tsx @@ -178,4 +178,43 @@ describe('media detail screens', () => { expect(screen.getByRole('button', { name: 'Media Info' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Troubleshoot Playback' })).toBeInTheDocument(); }); + + it('anchors and highlights the episode targeted by an #episode-{id} hash (#220)', async () => { + vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => { + const url = input.toString(); + if (url.startsWith('/api/library/browse')) { + return Promise.resolve( + jsonResponse({ + page: [ + { artwork: '', collectionType: 'Episode', id: 90, mediaType: 'Episode', title: 'Pilot' }, + { artwork: '', collectionType: 'Episode', id: 91, mediaType: 'Episode', title: 'Second' } + ], + totalCount: 2 + }) + ); + } + if (url === '/api/collections' || url === '/api/playlists/groups' || url === '/api/schedules') { + return Promise.resolve(jsonResponse([])); + } + return Promise.resolve(jsonResponse(season)); + }); + + const scrollIntoView = vi.fn(); + vi.stubGlobal('HTMLElement', HTMLElement); + Element.prototype.scrollIntoView = scrollIntoView; + window.location.hash = '#episode-91'; + + render(); + await waitFor(() => expect(screen.getByText('Second')).toBeInTheDocument()); + + const target = document.getElementById('episode-91'); + expect(target).not.toBeNull(); + await waitFor(() => expect(scrollIntoView).toHaveBeenCalled()); + expect(target?.className).toContain('ctv-media-card-highlighted'); + + const other = document.getElementById('episode-90'); + expect(other?.className).not.toContain('ctv-media-card-highlighted'); + + window.location.hash = ''; + }); }); diff --git a/web/src/screens/MediaDetailScreen.tsx b/web/src/screens/MediaDetailScreen.tsx index 14e8c8aaa..f80ce857c 100644 --- a/web/src/screens/MediaDetailScreen.tsx +++ b/web/src/screens/MediaDetailScreen.tsx @@ -211,6 +211,7 @@ function ChildGrid({ const [pageNum, setPageNum] = useState(0); const [status, setStatus] = useState<'loading' | 'error' | 'success'>('loading'); const [error, setError] = useState(null); + const [anchorId, setAnchorId] = useState(() => window.location.hash.slice(1) || null); const activeRef = useRef(true); const seqRef = useRef(0); @@ -221,6 +222,23 @@ function ChildGrid({ }; }, []); + // Deep-link support (#220): a season detail's episode grid can be opened with an + // `#episode-{id}` hash (from search/browse cards, or Blazor-parity links). Track hash changes + // so re-navigating to the same season with a different anchor still scrolls/highlights. + useEffect(() => { + const onHashChange = () => setAnchorId(window.location.hash.slice(1) || null); + window.addEventListener('hashchange', onHashChange); + return () => window.removeEventListener('hashchange', onHashChange); + }, []); + + useEffect(() => { + if (status !== 'success' || mediaType !== 'Episode' || !anchorId) { + return; + } + const target = document.getElementById(anchorId); + target?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, [status, mediaType, anchorId, items]); + const load = useCallback(() => { const id = ++seqRef.current; getLibraryBrowseItems({ mediaType, pageNum, pageSize: CHILD_PAGE_SIZE, parentId }) @@ -278,9 +296,12 @@ function ChildGrid({
{items.map((item) => { const detailPath = mediaDetailPath(item); + const cardId = item.mediaType === 'Episode' ? `episode-${item.id}` : undefined; return ( navigateToPath(detailPath) : undefined} diff --git a/web/src/shell.css b/web/src/shell.css index f51cc1482..7a48e3ae2 100644 --- a/web/src/shell.css +++ b/web/src/shell.css @@ -2529,6 +2529,22 @@ body { box-shadow: 0 0 0 1px var(--action-primary); } +/* Deep-link target highlight (#220), e.g. `#episode-{id}` from search/browse episode cards. */ +.ctv-media-card-highlighted { + border-color: var(--action-primary); + box-shadow: 0 0 0 2px var(--action-primary); + animation: ctv-media-card-highlight-fade 2400ms ease-out 1; +} + +@keyframes ctv-media-card-highlight-fade { + 0% { + box-shadow: 0 0 0 4px var(--action-primary); + } + 100% { + box-shadow: 0 0 0 2px var(--action-primary); + } +} + .ctv-media-card-poster { position: relative; overflow: hidden;