This repository has been archived on 2026-06-15. You can view files and clone it, but cannot push or open issues or pull requests.
Files
MediaLore/src/lib/tv.ts
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

299 lines
9.1 KiB
TypeScript

import fs from 'fs'
import path from 'path'
import type { TvSeries, TvSeason, TvEpisode } from '@/types'
import { getDb } from './db'
import { HIDDEN_FILES, VIDEO_EXTENSIONS, fileApiUrl, thumbnailApiUrl, findFile } from './media-utils'
function isVideoFile(name: string): boolean {
return VIDEO_EXTENSIONS.has(path.extname(name).toLowerCase())
}
function readDirs(dir: string): string[] {
try {
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((d) => d.isDirectory() && !HIDDEN_FILES.test(d.name))
.map((d) => d.name)
} catch {
return []
}
}
function readFiles(dir: string): string[] {
try {
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((d) => d.isFile() && !HIDDEN_FILES.test(d.name))
.map((d) => d.name)
} catch {
return []
}
}
function parseSeasonNumber(dirName: string): number | null {
// Matches: "Season 01", "Season 1", "S01", "S1", bare "1", "01"
const m =
dirName.match(/^season\s*(\d+)$/i) ??
dirName.match(/^s(\d+)$/i) ??
dirName.match(/^(\d+)$/)
return m ? parseInt(m[1], 10) : null
}
function countVideosInDir(dir: string): number {
return readFiles(dir).filter(isVideoFile).length
}
export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[] {
const seriesDirs = readDirs(libraryRoot)
const series: TvSeries[] = []
for (const dirName of seriesDirs) {
const seriesPath = path.join(libraryRoot, dirName)
const posterFile = findFile(seriesPath, /^(poster|folder)$/i)
const backdropFile = findFile(seriesPath, /^(backdrop|fanart|background)$/i)
const seasonDirs = readDirs(seriesPath)
const seasonDirCount = seasonDirs.filter((sd) => {
const sdPath = path.join(seriesPath, sd)
return countVideosInDir(sdPath) > 0
}).length
// If no season subdirectories contain videos, fall back to counting
// video files directly in the series root (flat/seasonless structure).
const seasonCount =
seasonDirCount > 0 ? seasonDirCount : countVideosInDir(seriesPath) > 0 ? 1 : 0
const id = encodeURIComponent(dirName)
series.push({
id,
title: dirName,
year: null,
plot: null,
genres: [],
status: null,
posterUrl: posterFile
? thumbnailApiUrl(libraryId, path.join(dirName, posterFile))
: null,
backdropUrl: backdropFile
? fileApiUrl(libraryId, path.join(dirName, backdropFile))
: null,
seasonCount,
})
}
return series.sort((a, b) => a.title.localeCompare(b.title))
}
export function scanTvSeasons(
libraryRoot: string,
libraryId: string,
seriesId: string
): TvSeason[] {
const seriesDirName = decodeURIComponent(seriesId)
const seriesPath = path.join(libraryRoot, seriesDirName)
const seasonDirs = readDirs(seriesPath)
const seasons: TvSeason[] = []
for (const dirName of seasonDirs) {
const seasonPath = path.join(seriesPath, dirName)
const episodeCount = countVideosInDir(seasonPath)
if (episodeCount === 0) continue
const seasonNumber = parseSeasonNumber(dirName)
// Look for season poster: "season01-poster.*" or "poster.*" in season dir
const seasonPosterPattern = seasonNumber !== null
? new RegExp(`^season${String(seasonNumber).padStart(2, '0')}-poster$`, 'i')
: /^poster$/i
const posterFile =
findFile(seasonPath, seasonPosterPattern) ?? findFile(seasonPath, /^poster$/i)
const id = encodeURIComponent(dirName)
const title = seasonNumber !== null ? `Season ${seasonNumber}` : dirName
seasons.push({
id,
seriesId,
title,
seasonNumber,
posterUrl: posterFile
? thumbnailApiUrl(libraryId, path.join(seriesDirName, dirName, posterFile))
: null,
episodeCount,
})
}
// Flat series fallback: no season subdirectories have videos, but the
// series root itself does. Return a single synthetic season with id '.'
// so that scanTvEpisodes can scan the series directory directly.
if (seasons.length === 0 && countVideosInDir(seriesPath) > 0) {
const posterFile = findFile(seriesPath, /^(poster|folder)$/i)
seasons.push({
id: '.',
seriesId,
title: 'Episodes',
seasonNumber: null,
posterUrl: posterFile
? thumbnailApiUrl(libraryId, path.join(seriesDirName, posterFile))
: null,
episodeCount: countVideosInDir(seriesPath),
})
}
return seasons.sort((a, b) => {
if (a.seasonNumber !== null && b.seasonNumber !== null) {
return a.seasonNumber - b.seasonNumber
}
return a.title.localeCompare(b.title)
})
}
export function scanTvEpisodes(
libraryRoot: string,
libraryId: string,
seriesId: string,
seasonId: string
): TvEpisode[] {
const seriesDirName = decodeURIComponent(seriesId)
const seasonDirName = decodeURIComponent(seasonId)
const seasonPath = path.join(libraryRoot, seriesDirName, seasonDirName)
const files = readFiles(seasonPath)
const videoFiles = files.filter(isVideoFile)
const episodes: TvEpisode[] = []
for (const videoFile of videoFiles) {
const baseName = path.basename(videoFile, path.extname(videoFile))
const videoRelPath = path.join(seriesDirName, seasonDirName, videoFile)
const id = encodeURIComponent(videoFile)
episodes.push({
id,
title: baseName,
episodeNumber: null,
seasonNumber: null,
plot: null,
aired: null,
rating: null,
thumbnailUrl: thumbnailApiUrl(libraryId, videoRelPath),
videoPath: videoRelPath,
})
}
return episodes.sort((a, b) => {
if (a.episodeNumber !== null && b.episodeNumber !== null) {
return a.episodeNumber - b.episodeNumber
}
return (a.title ?? '').localeCompare(b.title ?? '')
})
}
// ---------------------------------------------------------------------------
// DB readers
// ---------------------------------------------------------------------------
type DbRow = {
item_key: string
title: string | null
year: number | null
plot: string | null
genres: string | null
metadata: string | null
file_path: string | null
}
export function tvSeriesFromDb(libraryId: string): TvSeries[] {
const db = getDb()
const rows = db
.prepare(`SELECT * FROM media_items WHERE library_id = ? AND item_type = 'tv_series' ORDER BY title`)
.all(libraryId) as DbRow[]
return rows.map((row) => {
const meta = row.metadata ? JSON.parse(row.metadata) : {}
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,
genres: row.genres ? JSON.parse(row.genres) : [],
status: meta.status ?? null,
posterUrl: meta.posterUrl ?? null,
backdropUrl: meta.backdropUrl ?? null,
seasonCount: meta.seasonCount ?? 0,
}
})
}
export function tvSeasonsFromDb(libraryId: string, seriesId: string): TvSeason[] {
const db = getDb()
const parentKey = `${libraryId}:tv_series:${seriesId}`
const rows = db
.prepare(`SELECT * FROM media_items WHERE parent_key = ? AND item_type = 'tv_season'`)
.all(parentKey) as DbRow[]
return rows
.map((row) => {
const meta = row.metadata ? JSON.parse(row.metadata) : {}
// item_key format: {libraryId}:tv_season:{seriesId}:{seasonId}
const parts = row.item_key.split(':tv_season:')
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,
posterUrl: meta.posterUrl ?? null,
episodeCount: meta.episodeCount ?? 0,
}
})
.sort((a, b) => {
if (a.seasonNumber !== null && b.seasonNumber !== null) {
return a.seasonNumber - b.seasonNumber
}
return a.title.localeCompare(b.title)
})
}
export function tvEpisodesFromDb(
libraryId: string,
seriesId: string,
seasonId: string
): TvEpisode[] {
const db = getDb()
const parentKey = `${libraryId}:tv_season:${seriesId}:${seasonId}`
const rows = db
.prepare(`SELECT * FROM media_items WHERE parent_key = ? AND item_type = 'tv_episode'`)
.all(parentKey) as DbRow[]
return rows
.map((row) => {
const meta = row.metadata ? JSON.parse(row.metadata) : {}
// item_key format: {libraryId}:tv_episode:{seriesId}:{seasonId}:{episodeId}
const suffix = row.item_key.split(':tv_episode:')[1] ?? ''
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,
plot: row.plot ?? null,
aired: meta.aired ?? null,
rating: meta.rating ?? null,
thumbnailUrl: meta.thumbnailUrl ?? null,
videoPath: row.file_path ?? '',
}
})
.sort((a, b) => {
if (a.episodeNumber !== null && b.episodeNumber !== null) {
return a.episodeNumber - b.episodeNumber
}
return (a.title ?? '').localeCompare(b.title ?? '')
})
}