From 5d27ba351b085b990d2479099e47b42a740fea1d Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:23:34 -0400 Subject: [PATCH] fix search/filter bugs in game and TV libraries - Game series: filter now checks child games for both search and tag matches instead of always passing series through - TV episodes: tag selector no longer closes after picking a tag - TV episodes: filter panel now filters episodes within a season view - TV series list: series now appear when any of their episodes match the active tag filter (via new /api/tv/series-episode-tags endpoint backed by media_items) Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/tv/series-episode-tags/route.ts | 14 ++++++++ src/components/games/GamesView.tsx | 15 +++++++-- src/components/tv/TvView.tsx | 35 ++++++++++++++++---- src/lib/tags.ts | 36 +++++++++++++++++++++ 4 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 src/app/api/tv/series-episode-tags/route.ts diff --git a/src/app/api/tv/series-episode-tags/route.ts b/src/app/api/tv/series-episode-tags/route.ts new file mode 100644 index 0000000..1f0d4fd --- /dev/null +++ b/src/app/api/tv/series-episode-tags/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getSeriesEpisodeTagMap } from '@/lib/tags' +import { requireLibraryAccess } from '@/lib/auth' + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl + const libraryId = searchParams.get('libraryId') + if (!libraryId) return NextResponse.json({ error: 'libraryId required' }, { status: 400 }) + + const auth = await requireLibraryAccess(request, libraryId) + if (auth instanceof NextResponse) return auth + + return NextResponse.json(getSeriesEpisodeTagMap(libraryId)) +} diff --git a/src/components/games/GamesView.tsx b/src/components/games/GamesView.tsx index d4a0d04..9379235 100644 --- a/src/components/games/GamesView.tsx +++ b/src/components/games/GamesView.tsx @@ -74,10 +74,21 @@ export default function GamesView({ libraryId }: Props) { : items const filtered = visibleItems.filter((item) => { + if ('games' in item) { + const searchMatch = !search || + item.title.toLowerCase().includes(search.toLowerCase()) || + item.games.some((g) => g.title.toLowerCase().includes(search.toLowerCase())) + if (!searchMatch) return false + if (selectedTagIds.size > 0) { + return item.games.some((g) => { + const gameTags = assignments[`${libraryId}:${g.id}`] ?? [] + return [...selectedTagIds].every((id) => gameTags.includes(id)) + }) + } + return true + } if (search && !item.title.toLowerCase().includes(search.toLowerCase())) return false if (selectedTagIds.size > 0) { - // Tag filtering only applies to games (series don't have tags directly) - if ('games' in item) return true const gameTags = assignments[`${libraryId}:${item.id}`] ?? [] if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false } diff --git a/src/components/tv/TvView.tsx b/src/components/tv/TvView.tsx index 5ffc521..4ac0ebc 100644 --- a/src/components/tv/TvView.tsx +++ b/src/components/tv/TvView.tsx @@ -28,6 +28,7 @@ export default function TvView({ libraryId }: Props) { const [search, setSearch] = useState('') const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) const [assignments, setAssignments] = useState>({}) + const [seriesEpisodeTags, setSeriesEpisodeTags] = useState>({}) const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [showFilters, setShowFilters] = useState(true) const [tagPanel, setTagPanel] = useState<{ mediaKey: string; title: string } | null>(null) @@ -66,6 +67,15 @@ export default function TvView({ libraryId }: Props) { useEffect(() => { fetchAssignments() }, [fetchAssignments]) + const fetchSeriesEpisodeTags = useCallback(() => { + fetch(`/api/tv/series-episode-tags?libraryId=${encodeURIComponent(libraryId)}`) + .then((r) => r.json()) + .then(setSeriesEpisodeTags) + .catch(() => {}) + }, [libraryId]) + + useEffect(() => { fetchSeriesEpisodeTags() }, [fetchSeriesEpisodeTags]) + const openSeries = (s: TvSeries) => { setSelectedSeries(s) setView('seasons') @@ -188,8 +198,21 @@ export default function TvView({ libraryId }: Props) { const filteredSeries = series.filter((s) => { if (search && !s.title.toLowerCase().includes(search.toLowerCase())) return false if (selectedTagIds.size > 0) { - const tags = assignments[`${libraryId}:${s.id}`] ?? [] - if (![...selectedTagIds].every((id) => tags.includes(id))) return false + const seriesTags = assignments[`${libraryId}:${s.id}`] ?? [] + const episodeTags = seriesEpisodeTags[s.id] ?? [] + const allTags = seriesTags.length === 0 ? episodeTags + : episodeTags.length === 0 ? seriesTags + : [...new Set([...seriesTags, ...episodeTags])] + if (![...selectedTagIds].every((id) => allTags.includes(id))) return false + } + return true + }) + + const filteredEpisodes = episodes.filter((ep) => { + if (search && !ep.title.toLowerCase().includes(search.toLowerCase())) return false + if (selectedTagIds.size > 0) { + const epTags = assignments[`${libraryId}:${ep.id}`] ?? [] + if (![...selectedTagIds].every((id) => epTags.includes(id))) return false } return true }) @@ -203,7 +226,7 @@ export default function TvView({ libraryId }: Props) { url={videoUrl} name={playingEpisode.title} mediaKey={`${libraryId}:${playingEpisode.id}`} - onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} + onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }} onClose={() => setPlayingEpisodeIndex(null)} onPrev={playingEpisodeIndex > 0 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i - 1 : null)) : undefined} onNext={playingEpisodeIndex < episodes.length - 1 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i + 1 : null)) : undefined} @@ -510,11 +533,11 @@ export default function TvView({ libraryId }: Props) { ) : (
- {episodes.map((ep, idx) => ( + {filteredEpisodes.map((ep) => ( setPlayingEpisodeIndex(idx)} + onClick={() => setPlayingEpisodeIndex(episodes.indexOf(ep))} onTag={() => setTagPanel({ mediaKey: `${libraryId}:${ep.id}`, title: ep.title })} /> ))} @@ -555,7 +578,7 @@ export default function TvView({ libraryId }: Props) {
{ setFilterRefreshKey((k) => k + 1); fetchAssignments(); setTagPanel(null) }} + onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }} />
diff --git a/src/lib/tags.ts b/src/lib/tags.ts index 8b4247a..3386a52 100644 --- a/src/lib/tags.ts +++ b/src/lib/tags.ts @@ -215,6 +215,42 @@ export function getTagAssignmentsForLibrary(libraryId: string): Record tagIds[] aggregated from all episodes in each series. +// Requires media_items to be populated by the scanner. Returns {} if not yet scanned. +export function getSeriesEpisodeTagMap(libraryId: string): Record { + const db = getDb() + const prefix = `${libraryId}:tv_episode:` + const episodes = db + .prepare('SELECT item_key FROM media_items WHERE library_id = ? AND item_type = ?') + .all(libraryId, 'tv_episode') as { item_key: string }[] + if (episodes.length === 0) return {} + + const tagRows = db + .prepare('SELECT media_key, tag_id FROM media_tags WHERE media_key LIKE ?') + .all(`${libraryId}:%`) as { media_key: string; tag_id: string }[] + const tagsByMediaKey: Record = {} + for (const row of tagRows) { + ;(tagsByMediaKey[row.media_key] ??= []).push(row.tag_id) + } + + const result: Record = {} + for (const { item_key } of episodes) { + if (!item_key.startsWith(prefix)) continue + // item_key: "libraryId:tv_episode:seriesId:seasonId:episodeId" + const parts = item_key.split(':') + if (parts.length < 5) continue + const seriesId = parts[2] + const episodeId = parts[parts.length - 1] + const episodeTags = tagsByMediaKey[`${libraryId}:${episodeId}`] + if (!episodeTags) continue + const seriesTags = (result[seriesId] ??= []) + for (const tagId of episodeTags) { + if (!seriesTags.includes(tagId)) seriesTags.push(tagId) + } + } + return result +} + export function removeAllAssignmentsForLibrary(libraryId: string): void { const db = getDb() db.prepare("DELETE FROM media_tags WHERE media_key LIKE ?").run(`${libraryId}:%`)