Compare commits

..

4 Commits

Author SHA1 Message Date
1ca90184f5 Merge pull request 'clean-up' (#15) from clean-up into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m42s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/15
2026-04-11 01:33:10 +00:00
Garret Patti
6c2443fa2c Filter non-browser-playable formats from Doom Scroll
Formats like .mkv, .avi, .wmv, .ts, .m2ts and .tiff are not natively
supported by browsers and would stall silently in Doom Scroll mode.

Add src/lib/browser-media.ts with BROWSER_VIDEO_EXTENSIONS (.mp4,
.webm, .mov, .m4v), BROWSER_IMAGE_EXTENSIONS (.jpg, .jpeg, .png, .gif,
.webp, .bmp), and an isBrowserPlayable() helper that extracts the
extension without importing Node's path module.

Filter doomScrollItems in MixedView, MoviesView, and TvView using this
helper so only natively renderable files are passed to DoomScrollView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:29:17 -04:00
Garret Patti
5d4d11512d Fix DoomScrollView going blank after 100 items
When history hit the 100-entry cap, setHistory sliced the array back to
indices 0–99 but setHistoryIndex still returned idx + 1 = 100, making
current = history[100] = undefined. Nothing rendered and no API calls
were made until the user went back (decrementing to index 99, which
held the newly-picked item).

Fix: cap the returned historyIndex at HISTORY_CAP - 1 so it always
points to a valid entry in the sliced array. Extract HISTORY_CAP = 100
as a named constant so the slice and the index cap stay in sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:13:06 -04:00
Garret Patti
6f86750a99 Unify media_key and item_key — use item_key everywhere
media_key was a lossy shortening of item_key (libraryId:lastSegment) that
introduced a real collision bug: two TV episodes from different series with
the same filename would share the same media_key and each other's tags.

- DB migration converts existing media_tags rows from short format to full
  item_key by joining against media_items; ambiguous/orphaned rows are dropped
- media_tags column renamed media_key → item_key
- Removed itemKeyToMediaKey() from scanner; reconcileAndPrune now passes
  item_key directly to reKeyMediaItem
- DB reader functions (tv, movies, games) now expose item_key on returned
  entities; frontend components use entity.item_key instead of constructing
  the short libraryId:id form
- MixedView now constructs the full mixed_file: item_key format
- Tag API renamed mediaKey param → itemKey throughout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 18:04:29 -04:00
21 changed files with 191 additions and 130 deletions

View File

@@ -64,7 +64,7 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'Failed to delete movie directory' }, { status: 500 }) return NextResponse.json({ error: 'Failed to delete movie directory' }, { status: 500 })
} }
removeAllAssignmentsForItem(`${libraryId}:${movieId}`) removeAllAssignmentsForItem(`${libraryId}:movie:${movieId}`)
return new NextResponse(null, { status: 204 }) return new NextResponse(null, { status: 204 })
} }

View File

@@ -2,27 +2,27 @@ import { NextRequest, NextResponse } from 'next/server'
import { getResolvedTagsForItem, addTagToItem, removeTagFromItem } from '@/lib/tags' import { getResolvedTagsForItem, addTagToItem, removeTagFromItem } from '@/lib/tags'
import { requireLibraryAccess } from '@/lib/auth' import { requireLibraryAccess } from '@/lib/auth'
function extractLibraryId(mediaKey: string): string | null { function extractLibraryId(itemKey: string): string | null {
const colonIdx = mediaKey.indexOf(':') const colonIdx = itemKey.indexOf(':')
if (colonIdx === -1) return null if (colonIdx === -1) return null
return mediaKey.slice(0, colonIdx) return itemKey.slice(0, colonIdx)
} }
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const mediaKey = searchParams.get('mediaKey') const itemKey = searchParams.get('itemKey')
if (!mediaKey) { if (!itemKey) {
return NextResponse.json({ error: 'mediaKey is required' }, { status: 400 }) return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
} }
const libraryId = extractLibraryId(mediaKey) const libraryId = extractLibraryId(itemKey)
if (!libraryId) { if (!libraryId) {
return NextResponse.json({ error: 'Invalid mediaKey' }, { status: 400 }) return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
} }
const auth = await requireLibraryAccess(request, libraryId) const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
return NextResponse.json(getResolvedTagsForItem(mediaKey)) return NextResponse.json(getResolvedTagsForItem(itemKey))
} catch (err) { } catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 500 }) return NextResponse.json({ error: (err as Error).message }, { status: 500 })
} }
@@ -30,18 +30,18 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const { mediaKey, tagId } = await request.json() const { itemKey, tagId } = await request.json()
if (!mediaKey || !tagId) { if (!itemKey || !tagId) {
return NextResponse.json({ error: 'mediaKey and tagId are required' }, { status: 400 }) return NextResponse.json({ error: 'itemKey and tagId are required' }, { status: 400 })
} }
const libraryId = extractLibraryId(mediaKey) const libraryId = extractLibraryId(itemKey)
if (!libraryId) { if (!libraryId) {
return NextResponse.json({ error: 'Invalid mediaKey' }, { status: 400 }) return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
} }
const auth = await requireLibraryAccess(request, libraryId) const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
addTagToItem(mediaKey, tagId) addTagToItem(itemKey, tagId)
return new NextResponse(null, { status: 204 }) return new NextResponse(null, { status: 204 })
} catch (err) { } catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 400 }) return NextResponse.json({ error: (err as Error).message }, { status: 400 })
@@ -51,19 +51,19 @@ export async function POST(request: NextRequest) {
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const mediaKey = searchParams.get('mediaKey') const itemKey = searchParams.get('itemKey')
const tagId = searchParams.get('tagId') const tagId = searchParams.get('tagId')
if (!mediaKey || !tagId) { if (!itemKey || !tagId) {
return NextResponse.json({ error: 'mediaKey and tagId are required' }, { status: 400 }) return NextResponse.json({ error: 'itemKey and tagId are required' }, { status: 400 })
} }
const libraryId = extractLibraryId(mediaKey) const libraryId = extractLibraryId(itemKey)
if (!libraryId) { if (!libraryId) {
return NextResponse.json({ error: 'Invalid mediaKey' }, { status: 400 }) return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
} }
const auth = await requireLibraryAccess(request, libraryId) const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
removeTagFromItem(mediaKey, tagId) removeTagFromItem(itemKey, tagId)
return new NextResponse(null, { status: 204 }) return new NextResponse(null, { status: 204 })
} catch (err) { } catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 400 }) return NextResponse.json({ error: (err as Error).message }, { status: 400 })

View File

@@ -74,7 +74,7 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'Failed to delete series directory' }, { status: 500 }) return NextResponse.json({ error: 'Failed to delete series directory' }, { status: 500 })
} }
removeAllAssignmentsForItem(`${libraryId}:${seriesId}`) removeAllAssignmentsForItem(`${libraryId}:tv_series:${seriesId}`)
return new NextResponse(null, { status: 204 }) return new NextResponse(null, { status: 204 })
} }

View File

@@ -7,7 +7,7 @@ export interface DoomScrollItem {
url: string url: string
name: string name: string
mediaType: 'video' | 'image' mediaType: 'video' | 'image'
mediaKey?: string itemKey?: string
} }
interface Props { interface Props {
@@ -16,6 +16,8 @@ interface Props {
onClose: () => void onClose: () => void
} }
const HISTORY_CAP = 100
function pickRandom(items: DoomScrollItem[], excludeRecent: DoomScrollItem[]): DoomScrollItem { function pickRandom(items: DoomScrollItem[], excludeRecent: DoomScrollItem[]): DoomScrollItem {
const excludeCount = Math.min(excludeRecent.length, items.length - 1) const excludeCount = Math.min(excludeRecent.length, items.length - 1)
const recentUrls = new Set(excludeRecent.slice(-excludeCount).map((i) => i.url)) const recentUrls = new Set(excludeRecent.slice(-excludeCount).map((i) => i.url))
@@ -55,9 +57,9 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose
const next = pickRandom(items, history) const next = pickRandom(items, history)
setHistory((h) => { setHistory((h) => {
const updated = [...h, next] const updated = [...h, next]
return updated.length > 100 ? updated.slice(-100) : updated return updated.length > HISTORY_CAP ? updated.slice(-HISTORY_CAP) : updated
}) })
return idx + 1 return Math.min(idx + 1, HISTORY_CAP - 1)
}) })
}, [items, history]) }, [items, history])

View File

@@ -142,7 +142,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}> <p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Tags Tags
</p> </p>
<TagSelector mediaKey={`${libraryId}:${game.id}`} onTagsChanged={onTagsChanged} /> <TagSelector itemKey={game.item_key!} onTagsChanged={onTagsChanged} />
</div> </div>
</div> </div>
</> </>

View File

@@ -81,7 +81,7 @@ export default function GamesView({ libraryId }: Props) {
if (!searchMatch) return false if (!searchMatch) return false
if (selectedTagIds.size > 0) { if (selectedTagIds.size > 0) {
return item.games.some((g) => { return item.games.some((g) => {
const gameTags = assignments[`${libraryId}:${g.id}`] ?? [] const gameTags = assignments[g.item_key!] ?? []
return [...selectedTagIds].every((id) => gameTags.includes(id)) return [...selectedTagIds].every((id) => gameTags.includes(id))
}) })
} }
@@ -89,7 +89,7 @@ export default function GamesView({ libraryId }: Props) {
} }
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) {
const gameTags = assignments[`${libraryId}:${item.id}`] ?? [] const gameTags = assignments[item.item_key!] ?? []
if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false
} }
return true return true

View File

@@ -9,14 +9,14 @@ interface Props {
onClose: () => void onClose: () => void
onPrev?: () => void onPrev?: () => void
onNext?: () => void onNext?: () => void
mediaKey?: string itemKey?: string
onTagsChanged?: () => void onTagsChanged?: () => void
} }
export default function ImageLightbox({ url, name, onClose, onPrev, onNext, mediaKey, onTagsChanged }: Props) { export default function ImageLightbox({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged }: Props) {
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
const [showTags, setShowTags] = useState( const [showTags, setShowTags] = useState(
() => !!mediaKey && typeof window !== 'undefined' && window.innerWidth >= 1280 () => !!itemKey && typeof window !== 'undefined' && window.innerWidth >= 1280
) )
useEffect(() => { useEffect(() => {
@@ -50,7 +50,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, medi
{name} {name}
</span> </span>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
{mediaKey && ( {itemKey && (
<button <button
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }} onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }}
className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors" className="w-12 h-12 rounded-full flex items-center justify-center text-sm transition-colors"
@@ -125,7 +125,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, medi
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}> <p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
Tags Tags
</p> </p>
<TagSelector mediaKey={mediaKey!} onTagsChanged={onTagsChanged} /> <TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} />
</div> </div>
</div> </div>
) : ( ) : (

View File

@@ -7,6 +7,7 @@ import ImageLightbox from './ImageLightbox'
import TagSelector from '@/components/tags/TagSelector' import TagSelector from '@/components/tags/TagSelector'
import FilterPanel from '@/components/FilterPanel' import FilterPanel from '@/components/FilterPanel'
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView' import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
import { isBrowserPlayable } from '@/lib/browser-media'
interface Props { interface Props {
libraryId: string libraryId: string
@@ -14,11 +15,11 @@ interface Props {
} }
type ModalState = type ModalState =
| { type: 'video'; url: string; name: string; mediaKey: string; mediaIndex: number } | { type: 'video'; url: string; name: string; itemKey: string; mediaIndex: number }
| { type: 'image'; url: string; name: string; mediaKey: string; mediaIndex: number } | { type: 'image'; url: string; name: string; itemKey: string; mediaIndex: number }
| null | null
type TagPanelState = { entry: FileEntry; mediaKey: string } | null type TagPanelState = { entry: FileEntry; itemKey: string } | null
export default function MixedView({ libraryId, initialPath }: Props) { export default function MixedView({ libraryId, initialPath }: Props) {
const [currentPath, setCurrentPath] = useState(initialPath) const [currentPath, setCurrentPath] = useState(initialPath)
@@ -100,11 +101,11 @@ export default function MixedView({ libraryId, initialPath }: Props) {
fetchRecursive() fetchRecursive()
}, [filtersActive, fetchRecursive]) }, [filtersActive, fetchRecursive])
const mediaKeyFor = (entry: FileEntry) => { const itemKeyFor = (entry: FileEntry) => {
// In recursive mode entry.name is already the full relative path from the library root // In recursive mode entry.name is already the full relative path from the library root
if (filtersActive) return `${libraryId}:${encodeURIComponent(entry.name)}` if (filtersActive) return `${libraryId}:mixed_file:${encodeURIComponent(entry.name)}`
const rel = currentPath ? `${currentPath}/${entry.name}` : entry.name const rel = currentPath ? `${currentPath}/${entry.name}` : entry.name
return `${libraryId}:${encodeURIComponent(rel)}` return `${libraryId}:mixed_file:${encodeURIComponent(rel)}`
} }
const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? []) const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? [])
@@ -112,7 +113,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
const filteredEntries = sourceEntries.filter((entry) => { const filteredEntries = sourceEntries.filter((entry) => {
if (search && !entry.name.toLowerCase().includes(search.toLowerCase())) return false if (search && !entry.name.toLowerCase().includes(search.toLowerCase())) return false
if (selectedTagIds.size > 0 && entry.type !== 'directory') { if (selectedTagIds.size > 0 && entry.type !== 'directory') {
const entryTags = assignments[mediaKeyFor(entry)] ?? [] const entryTags = assignments[itemKeyFor(entry)] ?? []
if (![...selectedTagIds].every((id) => entryTags.includes(id))) return false if (![...selectedTagIds].every((id) => entryTags.includes(id))) return false
} }
return true return true
@@ -124,11 +125,11 @@ export default function MixedView({ libraryId, initialPath }: Props) {
const openMediaEntry = (entry: FileEntry, idx: number) => { const openMediaEntry = (entry: FileEntry, idx: number) => {
if (!entry.url) return if (!entry.url) return
const mediaKey = mediaKeyFor(entry) const itemKey = itemKeyFor(entry)
if (entry.mediaType === 'video') { if (entry.mediaType === 'video') {
setModal({ type: 'video', url: entry.url, name: entry.name, mediaKey, mediaIndex: idx }) setModal({ type: 'video', url: entry.url, name: entry.name, itemKey, mediaIndex: idx })
} else if (entry.mediaType === 'image') { } else if (entry.mediaType === 'image') {
setModal({ type: 'image', url: entry.url, name: entry.name, mediaKey, mediaIndex: idx }) setModal({ type: 'image', url: entry.url, name: entry.name, itemKey, mediaIndex: idx })
} }
} }
@@ -155,7 +156,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
} }
const handleTagEntry = (entry: FileEntry) => { const handleTagEntry = (entry: FileEntry) => {
setTagPanel({ entry, mediaKey: mediaKeyFor(entry) }) setTagPanel({ entry, itemKey: itemKeyFor(entry) })
} }
const navigateUp = () => { const navigateUp = () => {
@@ -200,7 +201,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
// When filters are active, doom scroll uses filteredEntries (already filtered by search/tags). // When filters are active, doom scroll uses filteredEntries (already filtered by search/tags).
// When no filters, doom scroll uses the full recursiveEntries. // When no filters, doom scroll uses the full recursiveEntries.
const doomScrollItems: DoomScrollItem[] = (filtersActive ? filteredEntries : recursiveEntries) const doomScrollItems: DoomScrollItem[] = (filtersActive ? filteredEntries : recursiveEntries)
.filter((e) => e.type === 'file' && (e.mediaType === 'video' || e.mediaType === 'image') && e.url) .filter((e) => e.type === 'file' && (e.mediaType === 'video' || e.mediaType === 'image') && e.url && isBrowserPlayable(e.name))
.map((e) => ({ url: e.url!, name: e.name, mediaType: e.mediaType as 'video' | 'image' })) .map((e) => ({ url: e.url!, name: e.name, mediaType: e.mediaType as 'video' | 'image' }))
return ( return (
@@ -326,7 +327,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
<VideoPlayerModal <VideoPlayerModal
url={modal.url} url={modal.url}
name={modal.name} name={modal.name}
mediaKey={modal.mediaKey} itemKey={modal.itemKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onClose={() => setModal(null)} onClose={() => setModal(null)}
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined} onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
@@ -337,7 +338,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
<ImageLightbox <ImageLightbox
url={modal.url} url={modal.url}
name={modal.name} name={modal.name}
mediaKey={modal.mediaKey} itemKey={modal.itemKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onClose={() => setModal(null)} onClose={() => setModal(null)}
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined} onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
@@ -378,7 +379,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
</div> </div>
<div className="px-5 py-4"> <div className="px-5 py-4">
<TagSelector <TagSelector
mediaKey={tagPanel.mediaKey} itemKey={tagPanel.itemKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }} onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
/> />
</div> </div>

View File

@@ -10,19 +10,19 @@ interface Props {
onClose: () => void onClose: () => void
onPrev?: () => void onPrev?: () => void
onNext?: () => void onNext?: () => void
mediaKey?: string itemKey?: string
onTagsChanged?: () => void onTagsChanged?: () => void
context?: 'mixed' | 'movies' | 'tv' context?: 'mixed' | 'movies' | 'tv'
} }
export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, mediaKey, onTagsChanged, context = 'mixed' }: Props) { export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, itemKey, onTagsChanged, context = 'mixed' }: Props) {
const settings = useUserSettings() const settings = useUserSettings()
const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay const autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay
const loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop const loop = context === 'mixed' ? settings.mixedLoop : context === 'movies' ? settings.moviesLoop : settings.tvLoop
const muted = context === 'mixed' ? settings.mixedMuted : context === 'movies' ? settings.moviesMuted : settings.tvMuted const muted = context === 'mixed' ? settings.mixedMuted : context === 'movies' ? settings.moviesMuted : settings.tvMuted
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
const [showTags, setShowTags] = useState( const [showTags, setShowTags] = useState(
() => !!mediaKey && typeof window !== 'undefined' && window.innerWidth >= 1280 () => !!itemKey && typeof window !== 'undefined' && window.innerWidth >= 1280
) )
useEffect(() => { useEffect(() => {
@@ -56,7 +56,7 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, m
{name} {name}
</span> </span>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
{mediaKey && ( {itemKey && (
<button <button
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }} onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors" className="w-8 h-8 rounded-full flex items-center justify-center text-sm transition-colors"
@@ -134,7 +134,7 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, m
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}> <p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--text-secondary)' }}>
Tags Tags
</p> </p>
<TagSelector mediaKey={mediaKey!} onTagsChanged={onTagsChanged} /> <TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} />
</div> </div>
</div> </div>
) : ( ) : (

View File

@@ -85,7 +85,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
<VideoPlayerModal <VideoPlayerModal
url={videoUrl} url={videoUrl}
name={movie.title} name={movie.title}
mediaKey={`${libraryId}:${movie.id}`} itemKey={movie.item_key!}
onTagsChanged={onTagsChanged} onTagsChanged={onTagsChanged}
onClose={() => setPlaying(false)} onClose={() => setPlaying(false)}
onPrev={onPrev} onPrev={onPrev}
@@ -288,7 +288,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}> <p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Tags Tags
</p> </p>
<TagSelector mediaKey={`${libraryId}:${movie.id}`} onTagsChanged={onTagsChanged} /> <TagSelector itemKey={movie.item_key!} onTagsChanged={onTagsChanged} />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -5,6 +5,7 @@ import type { Movie } from '@/types'
import MovieDetailModal from './MovieDetailModal' import MovieDetailModal from './MovieDetailModal'
import FilterPanel from '@/components/FilterPanel' import FilterPanel from '@/components/FilterPanel'
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView' import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
import { isBrowserPlayable } from '@/lib/browser-media'
interface Props { interface Props {
libraryId: string libraryId: string
@@ -57,7 +58,7 @@ export default function MoviesView({ libraryId }: Props) {
const filtered = movies.filter((movie) => { const filtered = movies.filter((movie) => {
if (search && !movie.title.toLowerCase().includes(search.toLowerCase())) return false if (search && !movie.title.toLowerCase().includes(search.toLowerCase())) return false
if (selectedTagIds.size > 0) { if (selectedTagIds.size > 0) {
const movieTags = assignments[`${libraryId}:${movie.id}`] ?? [] const movieTags = assignments[movie.item_key!] ?? []
if (![...selectedTagIds].every((id) => movieTags.includes(id))) return false if (![...selectedTagIds].every((id) => movieTags.includes(id))) return false
} }
return true return true
@@ -74,7 +75,7 @@ export default function MoviesView({ libraryId }: Props) {
const handleDoomScroll = () => { const handleDoomScroll = () => {
// Use filtered movies — respects any active search/tag filters automatically // Use filtered movies — respects any active search/tag filters automatically
const items: DoomScrollItem[] = filtered.map((m) => ({ const items: DoomScrollItem[] = filtered.filter((m) => isBrowserPlayable(m.videoPath)).map((m) => ({
url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(m.videoPath)}`, url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(m.videoPath)}`,
name: m.title, name: m.title,
mediaType: 'video' as const, mediaType: 'video' as const,

View File

@@ -5,7 +5,7 @@ import type { Tag, TagCategory } from '@/types'
import TagBadge from './TagBadge' import TagBadge from './TagBadge'
interface Props { interface Props {
mediaKey: string itemKey: string
onTagsChanged?: () => void onTagsChanged?: () => void
} }
@@ -14,7 +14,7 @@ interface AllTags {
tags: Tag[] tags: Tag[]
} }
export default function TagSelector({ mediaKey, onTagsChanged }: Props) { export default function TagSelector({ itemKey, onTagsChanged }: Props) {
const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({ const [assigned, setAssigned] = useState<{ tags: Tag[]; categories: TagCategory[] }>({
tags: [], tags: [],
categories: [], categories: [],
@@ -39,10 +39,10 @@ export default function TagSelector({ mediaKey, onTagsChanged }: Props) {
const [savingCategory, setSavingCategory] = useState(false) const [savingCategory, setSavingCategory] = useState(false)
const fetchAssigned = useCallback(() => { const fetchAssigned = useCallback(() => {
return fetch(`/api/tags/assignments?mediaKey=${encodeURIComponent(mediaKey)}`) return fetch(`/api/tags/assignments?itemKey=${encodeURIComponent(itemKey)}`)
.then((r) => r.json()) .then((r) => r.json())
.then((data) => setAssigned(data)) .then((data) => setAssigned(data))
}, [mediaKey]) }, [itemKey])
const fetchAll = useCallback(() => { const fetchAll = useCallback(() => {
return Promise.all([ return Promise.all([
@@ -66,14 +66,14 @@ export default function TagSelector({ mediaKey, onTagsChanged }: Props) {
try { try {
if (isAssigned(tag.id)) { if (isAssigned(tag.id)) {
await fetch( await fetch(
`/api/tags/assignments?mediaKey=${encodeURIComponent(mediaKey)}&tagId=${encodeURIComponent(tag.id)}`, `/api/tags/assignments?itemKey=${encodeURIComponent(itemKey)}&tagId=${encodeURIComponent(tag.id)}`,
{ method: 'DELETE' } { method: 'DELETE' }
) )
} else { } else {
await fetch('/api/tags/assignments', { await fetch('/api/tags/assignments', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mediaKey, tagId: tag.id }), body: JSON.stringify({ itemKey, tagId: tag.id }),
}) })
} }
await fetchAssigned() await fetchAssigned()
@@ -106,7 +106,7 @@ export default function TagSelector({ mediaKey, onTagsChanged }: Props) {
fetch('/api/tags/assignments', { fetch('/api/tags/assignments', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mediaKey, tagId: newTag.id }), body: JSON.stringify({ itemKey, tagId: newTag.id }),
}), }),
fetchAll(), fetchAll(),
]) ])

View File

@@ -8,6 +8,7 @@ import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
import TagSelector from '@/components/tags/TagSelector' import TagSelector from '@/components/tags/TagSelector'
import EpisodeCard from './EpisodeCard' import EpisodeCard from './EpisodeCard'
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView' import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
import { isBrowserPlayable } from '@/lib/browser-media'
interface Props { interface Props {
libraryId: string libraryId: string
@@ -31,7 +32,7 @@ export default function TvView({ libraryId }: Props) {
const [seriesEpisodeTags, setSeriesEpisodeTags] = 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<{ itemKey: string; title: string } | null>(null)
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [confirming, setConfirming] = useState(false) const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
@@ -184,7 +185,7 @@ export default function TvView({ libraryId }: Props) {
return seasonEps.flat() return seasonEps.flat()
}) })
) )
items = episodeLists.flat().map((ep) => ({ items = episodeLists.flat().filter((ep) => isBrowserPlayable(ep.videoPath)).map((ep) => ({
url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`, url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`,
name: ep.title, name: ep.title,
mediaType: 'video' as const, mediaType: 'video' as const,
@@ -209,7 +210,7 @@ export default function TvView({ libraryId }: Props) {
return seasonEps.flat() return seasonEps.flat()
}) })
) )
items = episodeLists.flat().map((ep) => ({ items = episodeLists.flat().filter((ep) => isBrowserPlayable(ep.videoPath)).map((ep) => ({
url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`, url: `/api/file?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(ep.videoPath)}`,
name: ep.title, name: ep.title,
mediaType: 'video' as const, mediaType: 'video' as const,
@@ -229,7 +230,7 @@ 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 seriesTags = assignments[`${libraryId}:${s.id}`] ?? [] const seriesTags = assignments[s.item_key!] ?? []
const episodeTags = seriesEpisodeTags[s.id] ?? [] const episodeTags = seriesEpisodeTags[s.id] ?? []
const allTags = seriesTags.length === 0 ? episodeTags const allTags = seriesTags.length === 0 ? episodeTags
: episodeTags.length === 0 ? seriesTags : episodeTags.length === 0 ? seriesTags
@@ -242,7 +243,7 @@ export default function TvView({ libraryId }: Props) {
const filteredEpisodes = episodes.filter((ep) => { const filteredEpisodes = episodes.filter((ep) => {
if (search && !ep.title.toLowerCase().includes(search.toLowerCase())) return false if (search && !ep.title.toLowerCase().includes(search.toLowerCase())) return false
if (selectedTagIds.size > 0) { if (selectedTagIds.size > 0) {
const epTags = assignments[`${libraryId}:${ep.id}`] ?? [] const epTags = assignments[ep.item_key!] ?? []
if (![...selectedTagIds].every((id) => epTags.includes(id))) return false if (![...selectedTagIds].every((id) => epTags.includes(id))) return false
} }
return true return true
@@ -256,7 +257,7 @@ export default function TvView({ libraryId }: Props) {
<VideoPlayerModal <VideoPlayerModal
url={videoUrl} url={videoUrl}
name={playingEpisode.title} name={playingEpisode.title}
mediaKey={`${libraryId}:${playingEpisode.id}`} itemKey={playingEpisode.item_key!}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }} 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}
@@ -391,7 +392,7 @@ export default function TvView({ libraryId }: Props) {
<div className="absolute inset-0 flex items-center justify-center text-4xl">📺</div> <div className="absolute inset-0 flex items-center justify-center text-4xl">📺</div>
)} )}
<button <button
onClick={(e) => { e.stopPropagation(); setTagPanel({ mediaKey: `${libraryId}:${s.id}`, title: s.title }) }} onClick={(e) => { e.stopPropagation(); setTagPanel({ itemKey: s.item_key!, title: s.title }) }}
className="absolute top-2 left-2 w-6 h-6 rounded-full items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:flex" className="absolute top-2 left-2 w-6 h-6 rounded-full items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:flex"
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }} style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
aria-label={`Tag ${s.title}`} aria-label={`Tag ${s.title}`}
@@ -579,7 +580,7 @@ export default function TvView({ libraryId }: Props) {
key={ep.id} key={ep.id}
episode={ep} episode={ep}
onClick={() => setPlayingEpisodeIndex(episodes.indexOf(ep))} onClick={() => setPlayingEpisodeIndex(episodes.indexOf(ep))}
onTag={() => setTagPanel({ mediaKey: `${libraryId}:${ep.id}`, title: ep.title })} onTag={() => setTagPanel({ itemKey: ep.item_key!, title: ep.title })}
/> />
))} ))}
</div> </div>
@@ -618,7 +619,7 @@ export default function TvView({ libraryId }: Props) {
</div> </div>
<div className="px-5 py-4"> <div className="px-5 py-4">
<TagSelector <TagSelector
mediaKey={tagPanel.mediaKey} itemKey={tagPanel.itemKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }} onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }}
/> />
</div> </div>

19
src/lib/browser-media.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Browser-native media formats safe for use in <video> and <img> elements.
* Kept separate from the broader scanner extension sets (media-utils.ts, files.ts)
* which include server-side-only formats like .mkv, .avi, .tiff, etc.
*/
export const BROWSER_VIDEO_EXTENSIONS = new Set(['.mp4', '.webm', '.mov', '.m4v'])
export const BROWSER_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'])
/**
* Returns true if the file at `filename` (or path) has a browser-playable extension.
* Uses lastIndexOf to avoid importing the Node `path` module in client components.
*/
export function isBrowserPlayable(filename: string): boolean {
const dot = filename.lastIndexOf('.')
if (dot === -1) return false
const ext = filename.slice(dot).toLowerCase()
return BROWSER_VIDEO_EXTENSIONS.has(ext) || BROWSER_IMAGE_EXTENSIONS.has(ext)
}

View File

@@ -32,9 +32,9 @@ function initDb(db: Database.Database): void {
CREATE UNIQUE INDEX IF NOT EXISTS tags_name_category ON tags(name, category_id); CREATE UNIQUE INDEX IF NOT EXISTS tags_name_category ON tags(name, category_id);
CREATE TABLE IF NOT EXISTS media_tags ( CREATE TABLE IF NOT EXISTS media_tags (
media_key TEXT NOT NULL, item_key TEXT NOT NULL,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (media_key, tag_id) PRIMARY KEY (item_key, tag_id)
); );
CREATE TABLE IF NOT EXISTS libraries ( CREATE TABLE IF NOT EXISTS libraries (
@@ -101,6 +101,7 @@ function initDb(db: Database.Database): void {
migrateLibrariesType(db) migrateLibrariesType(db)
migrateMediaItemsSchema(db) migrateMediaItemsSchema(db)
migrateMediaItemsFingerprint(db) migrateMediaItemsFingerprint(db)
migrateMediaTagsToItemKey(db)
seedAppSettings(db) seedAppSettings(db)
} }
@@ -177,6 +178,56 @@ function migrateMediaItemsFingerprint(db: Database.Database): void {
} }
} }
function migrateMediaTagsToItemKey(db: Database.Database): void {
const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='media_tags'")
.get() as { sql: string } | undefined
if (!row || !row.sql.includes('media_key')) return // Already migrated or table doesn't exist
// Create replacement table with item_key column
db.exec(`
CREATE TABLE media_tags_new (
item_key TEXT NOT NULL,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (item_key, tag_id)
)
`)
// Build reverse mapping: short media_key → full item_key
// Uses same logic as the old itemKeyToMediaKey: libraryId + lastSegment
const items = db
.prepare('SELECT item_key FROM media_items')
.all() as { item_key: string }[]
const shortToFull: Record<string, string[]> = {}
for (const { item_key } of items) {
const firstColon = item_key.indexOf(':')
const lastColon = item_key.lastIndexOf(':')
const libraryId = item_key.slice(0, firstColon)
const shortId = item_key.slice(lastColon + 1)
const mediaKey = `${libraryId}:${shortId}`
;(shortToFull[mediaKey] ??= []).push(item_key)
}
const tagRows = db
.prepare('SELECT media_key, tag_id FROM media_tags')
.all() as { media_key: string; tag_id: string }[]
const insert = db.prepare('INSERT OR IGNORE INTO media_tags_new (item_key, tag_id) VALUES (?, ?)')
db.transaction(() => {
for (const { media_key, tag_id } of tagRows) {
const candidates = shortToFull[media_key]
if (!candidates || candidates.length !== 1) continue // orphaned or ambiguous collision
insert.run(candidates[0], tag_id)
}
})()
db.exec(`
DROP TABLE media_tags;
ALTER TABLE media_tags_new RENAME TO media_tags;
`)
}
function migrateLibrariesType(db: Database.Database): void { function migrateLibrariesType(db: Database.Database): void {
const row = db const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'") .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'")

View File

@@ -149,6 +149,7 @@ export function gamesFromDb(libraryId: string): (Game | GameSeries)[] {
const idPart = row.item_key.split(':game_series:')[1] ?? row.item_key const idPart = row.item_key.split(':game_series:')[1] ?? row.item_key
seriesMap.set(row.item_key, { seriesMap.set(row.item_key, {
id: idPart, id: idPart,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(idPart), title: row.title ?? decodeURIComponent(idPart),
coverUrl: meta.coverUrl ?? null, coverUrl: meta.coverUrl ?? null,
wideCoverUrl: meta.wideCoverUrl ?? null, wideCoverUrl: meta.wideCoverUrl ?? null,
@@ -163,6 +164,7 @@ export function gamesFromDb(libraryId: string): (Game | GameSeries)[] {
const idPart = row.item_key.split(':game:')[1] ?? row.item_key const idPart = row.item_key.split(':game:')[1] ?? row.item_key
const game: Game = { const game: Game = {
id: idPart, id: idPart,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(idPart), title: row.title ?? decodeURIComponent(idPart),
coverUrl: meta.coverUrl ?? null, coverUrl: meta.coverUrl ?? null,
wideCoverUrl: meta.wideCoverUrl ?? null, wideCoverUrl: meta.wideCoverUrl ?? null,

View File

@@ -97,6 +97,7 @@ export function moviesFromDb(libraryId: string): Movie[] {
const idPart = row.item_key.split(':movie:')[1] ?? row.item_key const idPart = row.item_key.split(':movie:')[1] ?? row.item_key
return { return {
id: idPart, id: idPart,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(idPart), title: row.title ?? decodeURIComponent(idPart),
year: row.year ?? null, year: row.year ?? null,
plot: row.plot ?? null, plot: row.plot ?? null,

View File

@@ -556,22 +556,6 @@ function detectMoves(
* Tags on deleted items are intentionally left as orphans — harmless and * Tags on deleted items are intentionally left as orphans — harmless and
* recoverable if the file reappears. * recoverable if the file reappears.
*/ */
/**
* Converts an item_key (used in media_items) to the media_key format used in
* media_tags. The UI constructs media_keys as `${libraryId}:${shortId}` where
* shortId is only the terminal path segment — e.g.:
* "lib1:movie:Inception%20(2010)" → "lib1:Inception%20(2010)"
* "lib1:tv_episode:Show:S1:ep.mkv" → "lib1:ep.mkv"
* "lib1:mixed_file:dir%2Ffile.mp4" → "lib1:dir%2Ffile.mp4"
*/
function itemKeyToMediaKey(itemKey: string): string {
const firstColon = itemKey.indexOf(':')
const lastColon = itemKey.lastIndexOf(':')
const libraryId = itemKey.slice(0, firstColon)
const shortId = itemKey.slice(lastColon + 1)
return `${libraryId}:${shortId}`
}
function reconcileAndPrune( function reconcileAndPrune(
db: Database.Database, db: Database.Database,
libraryId: string, libraryId: string,
@@ -583,11 +567,8 @@ function reconcileAndPrune(
// Apply moves first (outside transaction so console.log is visible as they happen) // Apply moves first (outside transaction so console.log is visible as they happen)
for (const { oldKey, newKey } of moves) { for (const { oldKey, newKey } of moves) {
renameItem.run(newKey, oldKey) renameItem.run(newKey, oldKey)
// Convert item_keys to the media_key format actually used in media_tags if (oldKey !== newKey) {
const oldMediaKey = itemKeyToMediaKey(oldKey) reKeyMediaItem(oldKey, newKey)
const newMediaKey = itemKeyToMediaKey(newKey)
if (oldMediaKey !== newMediaKey) {
reKeyMediaItem(oldMediaKey, newMediaKey)
} }
console.log(`[scanner] fingerprint match: renamed "${oldKey}" → "${newKey}"`) console.log(`[scanner] fingerprint match: renamed "${oldKey}" → "${newKey}"`)
} }

View File

@@ -164,20 +164,20 @@ export function deleteTag(id: string): void {
// ─── Assignments ────────────────────────────────────────────────────────────── // ─── Assignments ──────────────────────────────────────────────────────────────
export function addTagToItem(mediaKey: string, tagId: string): void { export function addTagToItem(itemKey: string, tagId: string): void {
const db = getDb() const db = getDb()
const tag = db.prepare('SELECT 1 FROM tags WHERE id = ?').get(tagId) const tag = db.prepare('SELECT 1 FROM tags WHERE id = ?').get(tagId)
if (!tag) throw new Error(`Tag not found: ${tagId}`) if (!tag) throw new Error(`Tag not found: ${tagId}`)
// INSERT OR IGNORE handles duplicate gracefully // INSERT OR IGNORE handles duplicate gracefully
db.prepare('INSERT OR IGNORE INTO media_tags (media_key, tag_id) VALUES (?, ?)').run(mediaKey, tagId) db.prepare('INSERT OR IGNORE INTO media_tags (item_key, tag_id) VALUES (?, ?)').run(itemKey, tagId)
} }
export function removeTagFromItem(mediaKey: string, tagId: string): void { export function removeTagFromItem(itemKey: string, tagId: string): void {
const db = getDb() const db = getDb()
db.prepare('DELETE FROM media_tags WHERE media_key = ? AND tag_id = ?').run(mediaKey, tagId) db.prepare('DELETE FROM media_tags WHERE item_key = ? AND tag_id = ?').run(itemKey, tagId)
} }
export function getResolvedTagsForItem(mediaKey: string): { tags: Tag[]; categories: TagCategory[] } { export function getResolvedTagsForItem(itemKey: string): { tags: Tag[]; categories: TagCategory[] } {
const db = getDb() const db = getDb()
const tags = db const tags = db
@@ -185,10 +185,10 @@ export function getResolvedTagsForItem(mediaKey: string): { tags: Tag[]; categor
`SELECT t.id, t.name, t.category_id as categoryId `SELECT t.id, t.name, t.category_id as categoryId
FROM tags t FROM tags t
JOIN media_tags mt ON mt.tag_id = t.id JOIN media_tags mt ON mt.tag_id = t.id
WHERE mt.media_key = ? WHERE mt.item_key = ?
ORDER BY t.name` ORDER BY t.name`
) )
.all(mediaKey) as Tag[] .all(itemKey) as Tag[]
const categoryIds = [...new Set(tags.map((t) => t.categoryId))] const categoryIds = [...new Set(tags.map((t) => t.categoryId))]
const categories: TagCategory[] = const categories: TagCategory[] =
@@ -206,11 +206,11 @@ export function getResolvedTagsForItem(mediaKey: string): { tags: Tag[]; categor
export function getTagAssignmentsForLibrary(libraryId: string): Record<string, string[]> { export function getTagAssignmentsForLibrary(libraryId: string): Record<string, string[]> {
const db = getDb() const db = getDb()
const rows = db const rows = db
.prepare('SELECT media_key, tag_id FROM media_tags WHERE media_key LIKE ?') .prepare('SELECT item_key, tag_id FROM media_tags WHERE item_key LIKE ?')
.all(`${libraryId}:%`) as { media_key: string; tag_id: string }[] .all(`${libraryId}:%`) as { item_key: string; tag_id: string }[]
const result: Record<string, string[]> = {} const result: Record<string, string[]> = {}
for (const row of rows) { for (const row of rows) {
;(result[row.media_key] ??= []).push(row.tag_id) ;(result[row.item_key] ??= []).push(row.tag_id)
} }
return result return result
} }
@@ -220,49 +220,42 @@ export function getTagAssignmentsForLibrary(libraryId: string): Record<string, s
export function getSeriesEpisodeTagMap(libraryId: string): Record<string, string[]> { export function getSeriesEpisodeTagMap(libraryId: string): Record<string, string[]> {
const db = getDb() const db = getDb()
const prefix = `${libraryId}:tv_episode:` 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 // Join media_items with media_tags directly on item_key
.prepare('SELECT media_key, tag_id FROM media_tags WHERE media_key LIKE ?') const rows = db
.all(`${libraryId}:%`) as { media_key: string; tag_id: string }[] .prepare(
const tagsByMediaKey: Record<string, string[]> = {} `SELECT mi.item_key, mt.tag_id
for (const row of tagRows) { FROM media_items mi
;(tagsByMediaKey[row.media_key] ??= []).push(row.tag_id) JOIN media_tags mt ON mt.item_key = mi.item_key
} WHERE mi.library_id = ? AND mi.item_type = 'tv_episode'`
)
.all(libraryId) as { item_key: string; tag_id: string }[]
const result: Record<string, string[]> = {} const result: Record<string, string[]> = {}
for (const { item_key } of episodes) { for (const { item_key, tag_id } of rows) {
if (!item_key.startsWith(prefix)) continue if (!item_key.startsWith(prefix)) continue
// item_key: "libraryId:tv_episode:seriesId:seasonId:episodeId" // item_key: "libraryId:tv_episode:seriesId:seasonId:episodeId"
const parts = item_key.split(':') const parts = item_key.split(':')
if (parts.length < 5) continue if (parts.length < 5) continue
const seriesId = parts[2] const seriesId = parts[2]
const episodeId = parts[parts.length - 1]
const episodeTags = tagsByMediaKey[`${libraryId}:${episodeId}`]
if (!episodeTags) continue
const seriesTags = (result[seriesId] ??= []) const seriesTags = (result[seriesId] ??= [])
for (const tagId of episodeTags) { if (!seriesTags.includes(tag_id)) seriesTags.push(tag_id)
if (!seriesTags.includes(tagId)) seriesTags.push(tagId)
}
} }
return result 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 item_key LIKE ?').run(`${libraryId}:%`)
} }
export function removeAllAssignmentsForItem(mediaKey: string): void { export function removeAllAssignmentsForItem(itemKey: string): void {
const db = getDb() const db = getDb()
db.prepare("DELETE FROM media_tags WHERE media_key = ?").run(mediaKey) db.prepare('DELETE FROM media_tags WHERE item_key = ?').run(itemKey)
} }
export function reKeyMediaItem(oldKey: string, newKey: string): void { export function reKeyMediaItem(oldKey: string, newKey: string): void {
getDb() getDb()
.prepare('UPDATE media_tags SET media_key = ? WHERE media_key = ?') .prepare('UPDATE media_tags SET item_key = ? WHERE item_key = ?')
.run(newKey, oldKey) .run(newKey, oldKey)
} }

View File

@@ -215,6 +215,7 @@ export function tvSeriesFromDb(libraryId: string): TvSeries[] {
const idPart = row.item_key.split(':tv_series:')[1] ?? row.item_key const idPart = row.item_key.split(':tv_series:')[1] ?? row.item_key
return { return {
id: idPart, id: idPart,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(idPart), title: row.title ?? decodeURIComponent(idPart),
year: row.year ?? null, year: row.year ?? null,
plot: row.plot ?? null, plot: row.plot ?? null,
@@ -242,6 +243,7 @@ export function tvSeasonsFromDb(libraryId: string, seriesId: string): TvSeason[]
const seasonId = parts[1]?.split(':').slice(1).join(':') ?? row.item_key const seasonId = parts[1]?.split(':').slice(1).join(':') ?? row.item_key
return { return {
id: seasonId, id: seasonId,
item_key: row.item_key,
seriesId, seriesId,
title: row.title ?? seasonId, title: row.title ?? seasonId,
seasonNumber: meta.seasonNumber ?? null, seasonNumber: meta.seasonNumber ?? null,
@@ -276,6 +278,7 @@ export function tvEpisodesFromDb(
const episodeId = suffix.split(':').slice(2).join(':') const episodeId = suffix.split(':').slice(2).join(':')
return { return {
id: episodeId, id: episodeId,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(episodeId), title: row.title ?? decodeURIComponent(episodeId),
episodeNumber: meta.episodeNumber ?? null, episodeNumber: meta.episodeNumber ?? null,
seasonNumber: meta.seasonNumber ?? null, seasonNumber: meta.seasonNumber ?? null,

View File

@@ -10,6 +10,7 @@ export interface Library {
export interface Game { export interface Game {
id: string id: string
item_key?: string
title: string title: string
coverUrl: string | null coverUrl: string | null
wideCoverUrl: string | null wideCoverUrl: string | null
@@ -18,6 +19,7 @@ export interface Game {
export interface GameSeries { export interface GameSeries {
id: string id: string
item_key?: string
title: string title: string
coverUrl: string | null coverUrl: string | null
wideCoverUrl: string | null wideCoverUrl: string | null
@@ -36,6 +38,7 @@ export interface FileEntry {
export interface Movie { export interface Movie {
id: string id: string
item_key?: string
title: string title: string
year: number | null year: number | null
plot: string | null plot: string | null
@@ -49,6 +52,7 @@ export interface Movie {
export interface TvSeries { export interface TvSeries {
id: string id: string
item_key?: string
title: string title: string
year: number | null year: number | null
plot: string | null plot: string | null
@@ -61,6 +65,7 @@ export interface TvSeries {
export interface TvSeason { export interface TvSeason {
id: string id: string
item_key?: string
seriesId: string seriesId: string
title: string title: string
seasonNumber: number | null seasonNumber: number | null
@@ -70,6 +75,7 @@ export interface TvSeason {
export interface TvEpisode { export interface TvEpisode {
id: string id: string
item_key?: string
title: string title: string
episodeNumber: number | null episodeNumber: number | null
seasonNumber: number | null seasonNumber: number | null