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>
299 lines
9.1 KiB
TypeScript
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 ?? '')
|
|
})
|
|
}
|