From 7129d9c5b545bbb858550c87df76ec452d6b28ef Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 10 Jul 2026 23:26:08 +0200 Subject: [PATCH 1/3] 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; From 5b945c308d9b3516993dfea1977ff226d7b345bf Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 10 Jul 2026 23:36:15 +0200 Subject: [PATCH 2/3] fix(spa): episode anchor nav must handle in-app popstate + not re-scroll on refetch (#220 review) Adversarial review of #220 found in-grid episode card clicks never scrolled/highlighted: navigateToPath() (routing.ts) does pushState + a synthetic popstate, not a real hash change, so the anchor effect's hashchange-only listener never fired for same-pathname navigation. Now listens to both hashchange and popstate. Also: track the last anchor value actually scrolled to so a refetch/pagination that recreates the items array (anchor unchanged) doesn't hijack scroll position; document the known CHILD_PAGE_SIZE deep-link limitation (parity with the Blazor fragment link); and fix the MediaPosterCard/shell.css comments that described the highlight ring as "temporary" when only its glow pulse fades, not the ring itself. Co-Authored-By: Claude Fable 5 --- web/src/media/MediaPosterCard.tsx | 5 +- web/src/screens/MediaDetailScreen.test.tsx | 92 ++++++++++++++++++++++ web/src/screens/MediaDetailScreen.tsx | 35 ++++++-- web/src/shell.css | 8 +- 4 files changed, 130 insertions(+), 10 deletions(-) diff --git a/web/src/media/MediaPosterCard.tsx b/web/src/media/MediaPosterCard.tsx index e00fa271b..9ae7b152a 100644 --- a/web/src/media/MediaPosterCard.tsx +++ b/web/src/media/MediaPosterCard.tsx @@ -25,7 +25,10 @@ export function MediaPosterCard({ 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. + // Applies a ring highlight while this card is the deep-link target (e.g. the current + // `#episode-{id}` hash) — persists for as long as the anchor matches this card, with only the + // ring's outer glow pulse fading shortly after mount (see .ctv-media-card-highlighted in + // shell.css). highlighted?: boolean; }) { const hue = hueOf(item.title); diff --git a/web/src/screens/MediaDetailScreen.test.tsx b/web/src/screens/MediaDetailScreen.test.tsx index e3f92bdb4..ed20d30bd 100644 --- a/web/src/screens/MediaDetailScreen.test.tsx +++ b/web/src/screens/MediaDetailScreen.test.tsx @@ -1,5 +1,6 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { navigateToPath } from '../routing'; import { ArtistDetailScreen, MovieDetailScreen, @@ -217,4 +218,95 @@ describe('media detail screens', () => { window.location.hash = ''; }); + + it('updates the anchor and scrolls on same-pathname in-app navigation (synthetic popstate, review #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(); + Element.prototype.scrollIntoView = scrollIntoView; + window.location.hash = ''; + + render(); + await waitFor(() => expect(screen.getByText('Second')).toBeInTheDocument()); + expect(scrollIntoView).not.toHaveBeenCalled(); + + // `routing.ts`'s navigateToPath is what App.tsx uses for in-app clicks (e.g. an episode card + // inside this same season grid): pushState + a synthetic `popstate`, not a real hash change, + // so `hashchange` alone would never fire. This exercises that same-pathname path directly. + navigateToPath('/app/media/seasons/8#episode-91'); + + await waitFor(() => expect(scrollIntoView).toHaveBeenCalledTimes(1)); + const target = document.getElementById('episode-91'); + expect(target?.className).toContain('ctv-media-card-highlighted'); + + window.location.hash = ''; + }); + + it('does not re-scroll on refetch/pagination when the anchor is unchanged (review #220)', async () => { + vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => { + const url = input.toString(); + if (url.startsWith('/api/library/browse')) { + const params = new URL(url, 'http://localhost').searchParams; + const pageNum = params.get('pageNum'); + if (pageNum === '1') { + return Promise.resolve( + jsonResponse({ + page: [{ artwork: '', collectionType: 'Episode', id: 92, mediaType: 'Episode', title: 'Third' }], + totalCount: 61 + }) + ); + } + return Promise.resolve( + jsonResponse({ + page: [ + { artwork: '', collectionType: 'Episode', id: 90, mediaType: 'Episode', title: 'Pilot' }, + { artwork: '', collectionType: 'Episode', id: 91, mediaType: 'Episode', title: 'Second' } + ], + totalCount: 61 + }) + ); + } + if (url === '/api/collections' || url === '/api/playlists/groups' || url === '/api/schedules') { + return Promise.resolve(jsonResponse([])); + } + return Promise.resolve(jsonResponse(season)); + }); + + const scrollIntoView = vi.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + window.location.hash = '#episode-91'; + + render(); + await waitFor(() => expect(screen.getByText('Second')).toBeInTheDocument()); + await waitFor(() => expect(scrollIntoView).toHaveBeenCalledTimes(1)); + + // Page away (target leaves the DOM) and back (a fresh `items` array is fetched, but the + // anchor value itself never changed) — must not re-trigger the scroll. + fireEvent.click(screen.getByTitle('Next page')); + await waitFor(() => expect(screen.getByText('Third')).toBeInTheDocument()); + + fireEvent.click(screen.getByTitle('Previous page')); + await waitFor(() => expect(screen.getByText('Second')).toBeInTheDocument()); + + expect(scrollIntoView).toHaveBeenCalledTimes(1); + + window.location.hash = ''; + }); }); diff --git a/web/src/screens/MediaDetailScreen.tsx b/web/src/screens/MediaDetailScreen.tsx index f80ce857c..1e87154fc 100644 --- a/web/src/screens/MediaDetailScreen.tsx +++ b/web/src/screens/MediaDetailScreen.tsx @@ -214,6 +214,9 @@ function ChildGrid({ const [anchorId, setAnchorId] = useState(() => window.location.hash.slice(1) || null); const activeRef = useRef(true); const seqRef = useRef(0); + // The anchor value we last actually scrolled to, so a refetch/pagination that recreates + // `items` (but leaves the hash unchanged) doesn't hijack the user's scroll position. + const scrolledAnchorRef = useRef(null); useEffect(() => { activeRef.current = true; @@ -223,20 +226,40 @@ 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. + // `#episode-{id}` hash (from search/browse cards, or Blazor-parity links). The SPA's own + // in-app navigation (routing.ts navigateToPath) uses `history.pushState` + a synthetic + // `popstate` dispatch rather than a real hash change — real browser hash navigation + // (address bar, back/forward across a hash-only change) fires `hashchange` instead — so both + // events must be handled to catch in-grid episode card clicks (same season pathname, new + // fragment) as well as deep links that remount this screen. useEffect(() => { - const onHashChange = () => setAnchorId(window.location.hash.slice(1) || null); - window.addEventListener('hashchange', onHashChange); - return () => window.removeEventListener('hashchange', onHashChange); + const onHashNav = () => setAnchorId(window.location.hash.slice(1) || null); + window.addEventListener('hashchange', onHashNav); + window.addEventListener('popstate', onHashNav); + return () => { + window.removeEventListener('hashchange', onHashNav); + window.removeEventListener('popstate', onHashNav); + }; }, []); useEffect(() => { if (status !== 'success' || mediaType !== 'Episode' || !anchorId) { return; } + // One-shot per anchor value: only scroll the first time we see this anchor resolve + // successfully, so a later refetch/pagination (which recreates `items`) doesn't re-jump the + // page back to it. Known limitation shared with the Blazor fragment link this replaces: the + // anchor only resolves against the currently-loaded page (CHILD_PAGE_SIZE) — a target beyond + // page 1 won't be found (and so won't scroll) until that page is loaded. + if (scrolledAnchorRef.current === anchorId) { + return; + } const target = document.getElementById(anchorId); - target?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + if (!target) { + return; + } + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + scrolledAnchorRef.current = anchorId; }, [status, mediaType, anchorId, items]); const load = useCallback(() => { diff --git a/web/src/shell.css b/web/src/shell.css index 7a48e3ae2..f1c624fe0 100644 --- a/web/src/shell.css +++ b/web/src/shell.css @@ -2529,14 +2529,16 @@ body { box-shadow: 0 0 0 1px var(--action-primary); } -/* Deep-link target highlight (#220), e.g. `#episode-{id}` from search/browse episode cards. */ +/* Deep-link target highlight (#220), e.g. `#episode-{id}` from search/browse episode cards. + The ring itself (border + 2px box-shadow) persists for as long as the hash names this card; + only the outer glow pulse fades out shortly after mount. */ .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; + animation: ctv-media-card-highlight-pulse 2400ms ease-out 1; } -@keyframes ctv-media-card-highlight-fade { +@keyframes ctv-media-card-highlight-pulse { 0% { box-shadow: 0 0 0 4px var(--action-primary); } From 0156077e184dbeffd0244f8625e33b1be981db9c Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 10 Jul 2026 23:52:29 +0200 Subject: [PATCH 3/3] docs(e2e): add local TV library seeding recipe (#220) Document the on-disk media + direct-SQLite LibraryPath + scan recipe for E2E, since a local library is not API-seedable. Capture two gotchas hit while verifying the episode-nav PR: deleting search-index/ leaves search permanently empty (migration doesn't reindex from DB; rescan skips unchanged files), and /api/search needs field/wildcard queries (title:Alpha), not bare title words. Co-Authored-By: Claude Fable 5 --- docs/e2e-local.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/docs/e2e-local.md b/docs/e2e-local.md index de54c04f0..cb6d62a1f 100644 --- a/docs/e2e-local.md +++ b/docs/e2e-local.md @@ -99,3 +99,66 @@ scripts/e2e-local.sh [CONFIG_DIR] - Waits (up to 120s) for the `Done migrating search index` log line. - Prints the PID and port, then **exits leaving the server running** — the caller is responsible for killing the PID when done (`kill `). + +## Seeding a local TV library for E2E + +Channels/playouts are API-seedable (step 5 above), but a **local media library is not** — there +is no `/api/*` endpoint to add a local library folder. To exercise media-browse / search / detail +screens you need real scanned items. Recipe (used to verify the #220 episode-nav PR): + +1. **Generate tiny media files on disk** — one show (one season, ~3 episodes), plus a second show + whose title *contains the first as a substring* (good substring-search sanity data), plus a + movie if you need a non-episode kind. Keep TV and movies under **separate roots** so each + library scans cleanly (a Shows library pointed at a folder that also contains movies will try + to parse the movies as shows). Each file is a 2-second `testsrc` clip: + ```bash + MEDIA=/tmp/etv-media # any scratch path + mkdir -p "$MEDIA/tv/Show Alpha/Season 01" \ + "$MEDIA/tv/Show Alpha Returns/Season 01" \ + "$MEDIA/movies/Test Movie (2020)" + for n in 01 02 03; do + ffmpeg -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -c:v libx264 -pix_fmt yuv420p \ + "$MEDIA/tv/Show Alpha/Season 01/Show Alpha - s01e$n.mkv" + done + ffmpeg -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -c:v libx264 -pix_fmt yuv420p \ + "$MEDIA/tv/Show Alpha Returns/Season 01/Show Alpha Returns - s01e01.mkv" + ffmpeg -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -c:v libx264 -pix_fmt yuv420p \ + "$MEDIA/movies/Test Movie (2020)/Test Movie (2020).mkv" + ``` + +2. **Attach the folders to the built-in local libraries via SQLite.** A fresh config DB already + has the seven default local libraries (`Library` rows for a single `LocalMediaSource`): `Movies` + is `Id=1`, `Shows` is `Id=2`. `LibraryPath` is just `(Path TEXT, LibraryId INT)` — insert one + row per root, pointing each at the matching library: + ```bash + DB="$CONFIG_DIR/ersatztv.sqlite3" # CONFIG_DIR from the run above; server may be running + sqlite3 "$DB" "INSERT INTO LibraryPath (Path, LibraryId) VALUES ('$MEDIA/tv', 2);" # Shows + sqlite3 "$DB" "INSERT INTO LibraryPath (Path, LibraryId) VALUES ('$MEDIA/movies', 1);" # Movies + ``` + +3. **Trigger a scan and wait for items to appear.** The scan endpoint takes an empty body: + ```bash + curl -s -X POST http://localhost:8409/api/libraries/2/scan -H 'Content-Type: application/json' -d '{}' + curl -s -X POST http://localhost:8409/api/libraries/1/scan -H 'Content-Type: application/json' -d '{}' + # poll until episodes show up (scanner runs as a background subprocess): + curl -s "http://localhost:8409/api/library/browse?mediaType=Episode&pageSize=50" + ``` + The scan runs even though `LibraryPath` was inserted after startup — the scan handler re-reads + the library from the DB. Browse (`/api/library/browse`) reads straight from the DB, so items + appear there within a few seconds. + +### Gotchas + +- **Do NOT delete the `search-index/` folder to "reset" search.** On startup the app *recreates the + index empty* (`Search index failed to initialize; will delete and recreate` → `Migrating search + index to version N`) and that migration does **not** re-index from the DB — only a **scan** + writes documents into the Lucene index. The scanner subprocess writes the index while running; a + restart never rebuilds it from existing DB rows. If you wipe `search-index/`, a *rescan of + unchanged files won't repopulate it* (the scanner skips unchanged items), so search stays empty. + The clean recovery is a fresh `CONFIG_DIR`: launch → insert `LibraryPath` → scan **once** → leave + the index alone. +- **Search query relevance is field-scoped, not free-text.** The `/api/search` default field does + **not** match bare title words: `Alpha` and `Show` return nothing for a "Show Alpha" title, while + `title:Alpha`, `Show*`, or `*Alpha*` all match. The SPA search box forwards the query verbatim, so + when driving search-result screens in E2E use a field/wildcard query (e.g. `title:Alpha`) to get + deterministic hits. (This is pre-existing ErsatzTV search behavior, independent of any SPA change.)