fix search/filter bugs in game and TV libraries #12

Merged
gpatti merged 1 commits from search-tweaks into main 2026-04-06 18:24:30 +00:00
4 changed files with 92 additions and 8 deletions
Showing only changes of commit 5d27ba351b - Show all commits

View File

@@ -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))
}

View File

@@ -74,10 +74,21 @@ export default function GamesView({ libraryId }: Props) {
: items : items
const filtered = visibleItems.filter((item) => { 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 (search && !item.title.toLowerCase().includes(search.toLowerCase())) return false
if (selectedTagIds.size > 0) { 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}`] ?? [] const gameTags = assignments[`${libraryId}:${item.id}`] ?? []
if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false
} }

View File

@@ -28,6 +28,7 @@ export default function TvView({ libraryId }: Props) {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set()) const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({}) const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [seriesEpisodeTags, setSeriesEpisodeTags] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0) const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(true) const [showFilters, setShowFilters] = useState(true)
const [tagPanel, setTagPanel] = useState<{ mediaKey: string; title: string } | null>(null) const [tagPanel, setTagPanel] = useState<{ mediaKey: string; title: string } | null>(null)
@@ -66,6 +67,15 @@ export default function TvView({ libraryId }: Props) {
useEffect(() => { fetchAssignments() }, [fetchAssignments]) 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) => { const openSeries = (s: TvSeries) => {
setSelectedSeries(s) setSelectedSeries(s)
setView('seasons') setView('seasons')
@@ -188,8 +198,21 @@ export default function TvView({ libraryId }: Props) {
const filteredSeries = series.filter((s) => { const filteredSeries = series.filter((s) => {
if (search && !s.title.toLowerCase().includes(search.toLowerCase())) return false if (search && !s.title.toLowerCase().includes(search.toLowerCase())) return false
if (selectedTagIds.size > 0) { if (selectedTagIds.size > 0) {
const tags = assignments[`${libraryId}:${s.id}`] ?? [] const seriesTags = assignments[`${libraryId}:${s.id}`] ?? []
if (![...selectedTagIds].every((id) => tags.includes(id))) return false 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 return true
}) })
@@ -203,7 +226,7 @@ export default function TvView({ libraryId }: Props) {
url={videoUrl} url={videoUrl}
name={playingEpisode.title} name={playingEpisode.title}
mediaKey={`${libraryId}:${playingEpisode.id}`} mediaKey={`${libraryId}:${playingEpisode.id}`}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }}
onClose={() => setPlayingEpisodeIndex(null)} onClose={() => setPlayingEpisodeIndex(null)}
onPrev={playingEpisodeIndex > 0 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i - 1 : null)) : undefined} onPrev={playingEpisodeIndex > 0 ? () => setPlayingEpisodeIndex((i) => (i !== null ? i - 1 : null)) : undefined}
onNext={playingEpisodeIndex < episodes.length - 1 ? () => 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) {
</div> </div>
) : ( ) : (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"> <div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{episodes.map((ep, idx) => ( {filteredEpisodes.map((ep) => (
<EpisodeCard <EpisodeCard
key={ep.id} key={ep.id}
episode={ep} episode={ep}
onClick={() => setPlayingEpisodeIndex(idx)} onClick={() => setPlayingEpisodeIndex(episodes.indexOf(ep))}
onTag={() => setTagPanel({ mediaKey: `${libraryId}:${ep.id}`, title: ep.title })} onTag={() => setTagPanel({ mediaKey: `${libraryId}:${ep.id}`, title: ep.title })}
/> />
))} ))}
@@ -555,7 +578,7 @@ export default function TvView({ libraryId }: Props) {
<div className="px-5 py-4"> <div className="px-5 py-4">
<TagSelector <TagSelector
mediaKey={tagPanel.mediaKey} mediaKey={tagPanel.mediaKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); setTagPanel(null) }} onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }}
/> />
</div> </div>
</div> </div>

View File

@@ -215,6 +215,42 @@ export function getTagAssignmentsForLibrary(libraryId: string): Record<string, s
return result return result
} }
// Returns seriesId -> 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<string, string[]> {
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<string, string[]> = {}
for (const row of tagRows) {
;(tagsByMediaKey[row.media_key] ??= []).push(row.tag_id)
}
const result: Record<string, string[]> = {}
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 { export function removeAllAssignmentsForLibrary(libraryId: string): void {
const db = getDb() const db = getDb()
db.prepare("DELETE FROM media_tags WHERE media_key LIKE ?").run(`${libraryId}:%`) db.prepare("DELETE FROM media_tags WHERE media_key LIKE ?").run(`${libraryId}:%`)