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 ?? '') }) }