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 })
}
removeAllAssignmentsForItem(`${libraryId}:${movieId}`)
removeAllAssignmentsForItem(`${libraryId}:movie:${movieId}`)
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 { requireLibraryAccess } from '@/lib/auth'
function extractLibraryId(mediaKey: string): string | null {
const colonIdx = mediaKey.indexOf(':')
function extractLibraryId(itemKey: string): string | null {
const colonIdx = itemKey.indexOf(':')
if (colonIdx === -1) return null
return mediaKey.slice(0, colonIdx)
return itemKey.slice(0, colonIdx)
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const mediaKey = searchParams.get('mediaKey')
if (!mediaKey) {
return NextResponse.json({ error: 'mediaKey is required' }, { status: 400 })
const itemKey = searchParams.get('itemKey')
if (!itemKey) {
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
}
const libraryId = extractLibraryId(mediaKey)
const libraryId = extractLibraryId(itemKey)
if (!libraryId) {
return NextResponse.json({ error: 'Invalid mediaKey' }, { status: 400 })
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
return NextResponse.json(getResolvedTagsForItem(mediaKey))
return NextResponse.json(getResolvedTagsForItem(itemKey))
} catch (err) {
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) {
try {
const { mediaKey, tagId } = await request.json()
if (!mediaKey || !tagId) {
return NextResponse.json({ error: 'mediaKey and tagId are required' }, { status: 400 })
const { itemKey, tagId } = await request.json()
if (!itemKey || !tagId) {
return NextResponse.json({ error: 'itemKey and tagId are required' }, { status: 400 })
}
const libraryId = extractLibraryId(mediaKey)
const libraryId = extractLibraryId(itemKey)
if (!libraryId) {
return NextResponse.json({ error: 'Invalid mediaKey' }, { status: 400 })
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
addTagToItem(mediaKey, tagId)
addTagToItem(itemKey, tagId)
return new NextResponse(null, { status: 204 })
} catch (err) {
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) {
try {
const { searchParams } = new URL(request.url)
const mediaKey = searchParams.get('mediaKey')
const itemKey = searchParams.get('itemKey')
const tagId = searchParams.get('tagId')
if (!mediaKey || !tagId) {
return NextResponse.json({ error: 'mediaKey and tagId are required' }, { status: 400 })
if (!itemKey || !tagId) {
return NextResponse.json({ error: 'itemKey and tagId are required' }, { status: 400 })
}
const libraryId = extractLibraryId(mediaKey)
const libraryId = extractLibraryId(itemKey)
if (!libraryId) {
return NextResponse.json({ error: 'Invalid mediaKey' }, { status: 400 })
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
removeTagFromItem(mediaKey, tagId)
removeTagFromItem(itemKey, tagId)
return new NextResponse(null, { status: 204 })
} catch (err) {
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 })
}
removeAllAssignmentsForItem(`${libraryId}:${seriesId}`)
removeAllAssignmentsForItem(`${libraryId}:tv_series:${seriesId}`)
return new NextResponse(null, { status: 204 })
}

View File

@@ -7,7 +7,7 @@ export interface DoomScrollItem {
url: string
name: string
mediaType: 'video' | 'image'
mediaKey?: string
itemKey?: string
}
interface Props {
@@ -16,6 +16,8 @@ interface Props {
onClose: () => void
}
const HISTORY_CAP = 100
function pickRandom(items: DoomScrollItem[], excludeRecent: DoomScrollItem[]): DoomScrollItem {
const excludeCount = Math.min(excludeRecent.length, items.length - 1)
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)
setHistory((h) => {
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])

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)' }}>
Tags
</p>
<TagSelector mediaKey={`${libraryId}:${game.id}`} onTagsChanged={onTagsChanged} />
<TagSelector itemKey={game.item_key!} onTagsChanged={onTagsChanged} />
</div>
</div>
</>

View File

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

View File

@@ -9,14 +9,14 @@ interface Props {
onClose: () => void
onPrev?: () => void
onNext?: () => void
mediaKey?: string
itemKey?: string
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 [showTags, setShowTags] = useState(
() => !!mediaKey && typeof window !== 'undefined' && window.innerWidth >= 1280
() => !!itemKey && typeof window !== 'undefined' && window.innerWidth >= 1280
)
useEffect(() => {
@@ -50,7 +50,7 @@ export default function ImageLightbox({ url, name, onClose, onPrev, onNext, medi
{name}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
{mediaKey && (
{itemKey && (
<button
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }}
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)' }}>
Tags
</p>
<TagSelector mediaKey={mediaKey!} onTagsChanged={onTagsChanged} />
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} />
</div>
</div>
) : (

View File

@@ -7,6 +7,7 @@ import ImageLightbox from './ImageLightbox'
import TagSelector from '@/components/tags/TagSelector'
import FilterPanel from '@/components/FilterPanel'
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
import { isBrowserPlayable } from '@/lib/browser-media'
interface Props {
libraryId: string
@@ -14,11 +15,11 @@ interface Props {
}
type ModalState =
| { type: 'video'; url: string; name: string; mediaKey: string; mediaIndex: number }
| { type: 'image'; url: string; name: string; mediaKey: string; mediaIndex: number }
| { type: 'video'; url: string; name: string; itemKey: string; mediaIndex: number }
| { type: 'image'; url: string; name: string; itemKey: string; mediaIndex: number }
| null
type TagPanelState = { entry: FileEntry; mediaKey: string } | null
type TagPanelState = { entry: FileEntry; itemKey: string } | null
export default function MixedView({ libraryId, initialPath }: Props) {
const [currentPath, setCurrentPath] = useState(initialPath)
@@ -100,11 +101,11 @@ export default function MixedView({ libraryId, initialPath }: Props) {
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
if (filtersActive) return `${libraryId}:${encodeURIComponent(entry.name)}`
if (filtersActive) return `${libraryId}:mixed_file:${encodeURIComponent(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 ?? [])
@@ -112,7 +113,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
const filteredEntries = sourceEntries.filter((entry) => {
if (search && !entry.name.toLowerCase().includes(search.toLowerCase())) return false
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
}
return true
@@ -124,11 +125,11 @@ export default function MixedView({ libraryId, initialPath }: Props) {
const openMediaEntry = (entry: FileEntry, idx: number) => {
if (!entry.url) return
const mediaKey = mediaKeyFor(entry)
const itemKey = itemKeyFor(entry)
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') {
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) => {
setTagPanel({ entry, mediaKey: mediaKeyFor(entry) })
setTagPanel({ entry, itemKey: itemKeyFor(entry) })
}
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 no filters, doom scroll uses the full 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' }))
return (
@@ -326,7 +327,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
<VideoPlayerModal
url={modal.url}
name={modal.name}
mediaKey={modal.mediaKey}
itemKey={modal.itemKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onClose={() => setModal(null)}
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
@@ -337,7 +338,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
<ImageLightbox
url={modal.url}
name={modal.name}
mediaKey={modal.mediaKey}
itemKey={modal.itemKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
onClose={() => setModal(null)}
onPrev={modal.mediaIndex > 0 ? () => navigateModal(-1) : undefined}
@@ -378,7 +379,7 @@ export default function MixedView({ libraryId, initialPath }: Props) {
</div>
<div className="px-5 py-4">
<TagSelector
mediaKey={tagPanel.mediaKey}
itemKey={tagPanel.itemKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
/>
</div>

View File

@@ -10,19 +10,19 @@ interface Props {
onClose: () => void
onPrev?: () => void
onNext?: () => void
mediaKey?: string
itemKey?: string
onTagsChanged?: () => void
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 autoPlay = context === 'mixed' ? settings.mixedAutoplay : context === 'movies' ? settings.moviesAutoplay : settings.tvAutoplay
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 overlayRef = useRef<HTMLDivElement>(null)
const [showTags, setShowTags] = useState(
() => !!mediaKey && typeof window !== 'undefined' && window.innerWidth >= 1280
() => !!itemKey && typeof window !== 'undefined' && window.innerWidth >= 1280
)
useEffect(() => {
@@ -56,7 +56,7 @@ export default function VideoPlayerModal({ url, name, onClose, onPrev, onNext, m
{name}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
{mediaKey && (
{itemKey && (
<button
onClick={(e) => { e.stopPropagation(); setShowTags((v) => !v) }}
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)' }}>
Tags
</p>
<TagSelector mediaKey={mediaKey!} onTagsChanged={onTagsChanged} />
<TagSelector itemKey={itemKey!} onTagsChanged={onTagsChanged} />
</div>
</div>
) : (

View File

@@ -85,7 +85,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
<VideoPlayerModal
url={videoUrl}
name={movie.title}
mediaKey={`${libraryId}:${movie.id}`}
itemKey={movie.item_key!}
onTagsChanged={onTagsChanged}
onClose={() => setPlaying(false)}
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)' }}>
Tags
</p>
<TagSelector mediaKey={`${libraryId}:${movie.id}`} onTagsChanged={onTagsChanged} />
<TagSelector itemKey={movie.item_key!} onTagsChanged={onTagsChanged} />
</div>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import type { Movie } from '@/types'
import MovieDetailModal from './MovieDetailModal'
import FilterPanel from '@/components/FilterPanel'
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
import { isBrowserPlayable } from '@/lib/browser-media'
interface Props {
libraryId: string
@@ -57,7 +58,7 @@ export default function MoviesView({ libraryId }: Props) {
const filtered = movies.filter((movie) => {
if (search && !movie.title.toLowerCase().includes(search.toLowerCase())) return false
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
}
return true
@@ -74,7 +75,7 @@ export default function MoviesView({ libraryId }: Props) {
const handleDoomScroll = () => {
// 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)}`,
name: m.title,
mediaType: 'video' as const,

View File

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

View File

@@ -8,6 +8,7 @@ import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
import TagSelector from '@/components/tags/TagSelector'
import EpisodeCard from './EpisodeCard'
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
import { isBrowserPlayable } from '@/lib/browser-media'
interface Props {
libraryId: string
@@ -31,7 +32,7 @@ export default function TvView({ libraryId }: Props) {
const [seriesEpisodeTags, setSeriesEpisodeTags] = useState<Record<string, string[]>>({})
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
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 [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false)
@@ -184,7 +185,7 @@ export default function TvView({ libraryId }: Props) {
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)}`,
name: ep.title,
mediaType: 'video' as const,
@@ -209,7 +210,7 @@ export default function TvView({ libraryId }: Props) {
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)}`,
name: ep.title,
mediaType: 'video' as const,
@@ -229,7 +230,7 @@ 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 seriesTags = assignments[`${libraryId}:${s.id}`] ?? []
const seriesTags = assignments[s.item_key!] ?? []
const episodeTags = seriesEpisodeTags[s.id] ?? []
const allTags = seriesTags.length === 0 ? episodeTags
: episodeTags.length === 0 ? seriesTags
@@ -242,7 +243,7 @@ export default function TvView({ libraryId }: Props) {
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}`] ?? []
const epTags = assignments[ep.item_key!] ?? []
if (![...selectedTagIds].every((id) => epTags.includes(id))) return false
}
return true
@@ -256,7 +257,7 @@ export default function TvView({ libraryId }: Props) {
<VideoPlayerModal
url={videoUrl}
name={playingEpisode.title}
mediaKey={`${libraryId}:${playingEpisode.id}`}
itemKey={playingEpisode.item_key!}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }}
onClose={() => setPlayingEpisodeIndex(null)}
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>
)}
<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"
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
aria-label={`Tag ${s.title}`}
@@ -579,7 +580,7 @@ export default function TvView({ libraryId }: Props) {
key={ep.id}
episode={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>
@@ -618,7 +619,7 @@ export default function TvView({ libraryId }: Props) {
</div>
<div className="px-5 py-4">
<TagSelector
mediaKey={tagPanel.mediaKey}
itemKey={tagPanel.itemKey}
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments(); fetchSeriesEpisodeTags() }}
/>
</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 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,
PRIMARY KEY (media_key, tag_id)
PRIMARY KEY (item_key, tag_id)
);
CREATE TABLE IF NOT EXISTS libraries (
@@ -101,6 +101,7 @@ function initDb(db: Database.Database): void {
migrateLibrariesType(db)
migrateMediaItemsSchema(db)
migrateMediaItemsFingerprint(db)
migrateMediaTagsToItemKey(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 {
const row = db
.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
seriesMap.set(row.item_key, {
id: idPart,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(idPart),
coverUrl: meta.coverUrl ?? 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 game: Game = {
id: idPart,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(idPart),
coverUrl: meta.coverUrl ?? 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
return {
id: idPart,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(idPart),
year: row.year ?? null,
plot: row.plot ?? null,

View File

@@ -556,22 +556,6 @@ function detectMoves(
* Tags on deleted items are intentionally left as orphans — harmless and
* 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(
db: Database.Database,
libraryId: string,
@@ -583,11 +567,8 @@ function reconcileAndPrune(
// Apply moves first (outside transaction so console.log is visible as they happen)
for (const { oldKey, newKey } of moves) {
renameItem.run(newKey, oldKey)
// Convert item_keys to the media_key format actually used in media_tags
const oldMediaKey = itemKeyToMediaKey(oldKey)
const newMediaKey = itemKeyToMediaKey(newKey)
if (oldMediaKey !== newMediaKey) {
reKeyMediaItem(oldMediaKey, newMediaKey)
if (oldKey !== newKey) {
reKeyMediaItem(oldKey, newKey)
}
console.log(`[scanner] fingerprint match: renamed "${oldKey}" → "${newKey}"`)
}

View File

@@ -164,20 +164,20 @@ export function deleteTag(id: string): void {
// ─── Assignments ──────────────────────────────────────────────────────────────
export function addTagToItem(mediaKey: string, tagId: string): void {
export function addTagToItem(itemKey: string, tagId: string): void {
const db = getDb()
const tag = db.prepare('SELECT 1 FROM tags WHERE id = ?').get(tagId)
if (!tag) throw new Error(`Tag not found: ${tagId}`)
// 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()
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 tags = db
@@ -185,10 +185,10 @@ export function getResolvedTagsForItem(mediaKey: string): { tags: Tag[]; categor
`SELECT t.id, t.name, t.category_id as categoryId
FROM tags t
JOIN media_tags mt ON mt.tag_id = t.id
WHERE mt.media_key = ?
WHERE mt.item_key = ?
ORDER BY t.name`
)
.all(mediaKey) as Tag[]
.all(itemKey) as Tag[]
const categoryIds = [...new Set(tags.map((t) => t.categoryId))]
const categories: TagCategory[] =
@@ -206,11 +206,11 @@ export function getResolvedTagsForItem(mediaKey: string): { tags: Tag[]; categor
export function getTagAssignmentsForLibrary(libraryId: string): Record<string, string[]> {
const db = getDb()
const rows = db
.prepare('SELECT media_key, tag_id FROM media_tags WHERE media_key LIKE ?')
.all(`${libraryId}:%`) as { media_key: string; tag_id: string }[]
.prepare('SELECT item_key, tag_id FROM media_tags WHERE item_key LIKE ?')
.all(`${libraryId}:%`) as { item_key: string; tag_id: string }[]
const result: Record<string, string[]> = {}
for (const row of rows) {
;(result[row.media_key] ??= []).push(row.tag_id)
;(result[row.item_key] ??= []).push(row.tag_id)
}
return result
}
@@ -220,49 +220,42 @@ export function getTagAssignmentsForLibrary(libraryId: string): Record<string, s
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)
}
// Join media_items with media_tags directly on item_key
const rows = db
.prepare(
`SELECT mi.item_key, mt.tag_id
FROM media_items mi
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[]> = {}
for (const { item_key } of episodes) {
for (const { item_key, tag_id } of rows) {
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)
}
if (!seriesTags.includes(tag_id)) seriesTags.push(tag_id)
}
return result
}
export function removeAllAssignmentsForLibrary(libraryId: string): void {
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()
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 {
getDb()
.prepare('UPDATE media_tags SET media_key = ? WHERE media_key = ?')
.prepare('UPDATE media_tags SET item_key = ? WHERE item_key = ?')
.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
return {
id: idPart,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(idPart),
year: row.year ?? 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
return {
id: seasonId,
item_key: row.item_key,
seriesId,
title: row.title ?? seasonId,
seasonNumber: meta.seasonNumber ?? null,
@@ -276,6 +278,7 @@ export function tvEpisodesFromDb(
const episodeId = suffix.split(':').slice(2).join(':')
return {
id: episodeId,
item_key: row.item_key,
title: row.title ?? decodeURIComponent(episodeId),
episodeNumber: meta.episodeNumber ?? null,
seasonNumber: meta.seasonNumber ?? null,

View File

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