diff --git a/apps/master-api/prisma/migrations/20260825043000_episode_still_url/migration.sql b/apps/master-api/prisma/migrations/20260825043000_episode_still_url/migration.sql new file mode 100644 index 0000000..6911030 --- /dev/null +++ b/apps/master-api/prisma/migrations/20260825043000_episode_still_url/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "episodes" ADD COLUMN "still_url" TEXT; diff --git a/apps/master-api/prisma/schema.prisma b/apps/master-api/prisma/schema.prisma index fdbefae..ee53912 100644 --- a/apps/master-api/prisma/schema.prisma +++ b/apps/master-api/prisma/schema.prisma @@ -196,6 +196,7 @@ model Episode { episodeNumber Int @map("episode_number") title String? overview String? + stillUrl String? @map("still_url") runtime Int? airDate DateTime? @map("air_date") imdbId String? @map("imdb_id") diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index 9804aca..72e9767 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -16,6 +16,7 @@ interface TmdbSearchResult { } const TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w500"; +const TMDB_STILL_BASE = "https://image.tmdb.org/t/p/w300"; const TMDB_BACKDROP_BASE = "https://image.tmdb.org/t/p/original"; const TMDB_LANG = "nl-NL"; @@ -28,16 +29,40 @@ type TmdbMeta = { backdropUrl?: string; }; +type TmdbEpisodeMeta = { + episodeNumber: number; + title: string; + overview?: string; + airDate?: string; + stillUrl?: string; + runtime?: number; + tmdbId?: number; +}; + type MovieRow = Awaited>; type SeriesRow = Awaited>; +type EpisodeRow = Awaited>; + +function needsEpisodeEnrichment(ep: { + title: string | null; + stillUrl?: string | null; + overview: string | null; +}): boolean { + if (!ep.stillUrl) return true; + if (!ep.overview) return true; + if (!ep.title || /^episode\s+\d+$/i.test(ep.title.trim())) return true; + return false; +} export class MetadataService { private movieCache = new Map(); private seriesCache = new Map(); private movieSearchCache = new Map(); private seriesSearchCache = new Map(); + private seasonCache = new Map(); private refreshedMovieIds = new Set(); private refreshedSeriesIds = new Set(); + private enrichedSeriesSeasons = new Set(); constructor(private readonly tmdbApiKey?: string) {} @@ -329,10 +354,128 @@ export class MetadataService { }); } + if (series.tmdbId && this.tmdbApiKey) { + episode = await this.applyTmdbEpisodeMeta(series.tmdbId, episode); + } + await this.upsertMediaFile(nodeId, file, { episodeId: episode.id }); return episode.id; } + /** Fill episode titles/stills from TMDB for a series (lazy, used by Stremio meta). */ + async enrichSeriesEpisodes(seriesId: string): Promise { + if (!this.tmdbApiKey) return; + const series = await prisma.series.findUnique({ + where: { id: seriesId }, + include: { + episodes: { + select: { + id: true, + seasonNumber: true, + episodeNumber: true, + title: true, + overview: true, + stillUrl: true, + }, + }, + }, + }); + if (!series?.tmdbId || series.episodes.length === 0) return; + + const seasons = new Set( + series.episodes.filter((ep) => needsEpisodeEnrichment(ep)).map((ep) => ep.seasonNumber) + ); + for (const seasonNumber of seasons) { + const key = `${series.id}:${seasonNumber}`; + if (this.enrichedSeriesSeasons.has(key)) continue; + const metas = await this.fetchTmdbSeasonEpisodes(series.tmdbId, seasonNumber); + if (!metas.length) continue; + for (const ep of series.episodes.filter((e) => e.seasonNumber === seasonNumber)) { + const meta = metas.find((m) => m.episodeNumber === ep.episodeNumber); + if (!meta) continue; + await prisma.episode.update({ + where: { id: ep.id }, + data: { + title: meta.title, + overview: meta.overview ?? undefined, + stillUrl: meta.stillUrl ?? undefined, + runtime: meta.runtime ?? undefined, + tmdbId: meta.tmdbId ?? undefined, + airDate: meta.airDate ? new Date(meta.airDate) : undefined, + }, + }); + } + this.enrichedSeriesSeasons.add(key); + } + } + + private async applyTmdbEpisodeMeta( + seriesTmdbId: number, + episode: EpisodeRow + ): Promise { + if (!needsEpisodeEnrichment(episode)) return episode; + const metas = await this.fetchTmdbSeasonEpisodes(seriesTmdbId, episode.seasonNumber); + const meta = metas.find((m) => m.episodeNumber === episode.episodeNumber); + if (!meta) return episode; + return prisma.episode.update({ + where: { id: episode.id }, + data: { + title: meta.title, + overview: meta.overview ?? undefined, + stillUrl: meta.stillUrl ?? undefined, + runtime: meta.runtime ?? undefined, + tmdbId: meta.tmdbId ?? undefined, + airDate: meta.airDate ? new Date(meta.airDate) : undefined, + }, + }); + } + + private async fetchTmdbSeasonEpisodes( + seriesTmdbId: number, + seasonNumber: number + ): Promise { + const cacheKey = `${seriesTmdbId}:${seasonNumber}`; + const cached = this.seasonCache.get(cacheKey); + if (cached) return cached; + if (!this.tmdbApiKey) return []; + + const params = new URLSearchParams({ + api_key: this.tmdbApiKey, + language: TMDB_LANG, + }); + const res = await fetch( + `https://api.themoviedb.org/3/tv/${seriesTmdbId}/season/${seasonNumber}?${params}` + ); + if (!res.ok) { + this.seasonCache.set(cacheKey, []); + return []; + } + const data = (await res.json()) as { + episodes?: Array<{ + id?: number; + episode_number?: number; + name?: string; + overview?: string; + air_date?: string; + still_path?: string | null; + runtime?: number | null; + }>; + }; + const metas: TmdbEpisodeMeta[] = (data.episodes ?? []) + .filter((ep) => ep.episode_number != null) + .map((ep) => ({ + episodeNumber: ep.episode_number!, + title: ep.name?.trim() || `Episode ${ep.episode_number}`, + overview: ep.overview || undefined, + airDate: ep.air_date || undefined, + stillUrl: ep.still_path ? `${TMDB_STILL_BASE}${ep.still_path}` : undefined, + runtime: ep.runtime ?? undefined, + tmdbId: ep.id, + })); + this.seasonCache.set(cacheKey, metas); + return metas; + } + private async absorbOrphanMovies( canonical: MovieRow, localTitle: string, diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index 6842193..f463615 100644 --- a/apps/master-api/src/stremio/routes.ts +++ b/apps/master-api/src/stremio/routes.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify"; import { prisma } from "../database/client"; import { validateAddonToken } from "../auth/routes"; import { PlaybackService } from "../playback/service"; +import { MetadataService } from "../metadata/service"; import type { Config } from "../config"; const ID_PREFIX = "mc"; @@ -49,6 +50,7 @@ function unwrapLocalId(id: string, kind: "movie" | "series"): string { export async function registerStremioRoutes(app: FastifyInstance, config: Config) { const playback = new PlaybackService(config); + const metadata = new MetadataService(config.TMDB_API_KEY); app.addHook("onRequest", async (request, reply) => { if (!request.url.startsWith("/stremio/")) return; @@ -123,7 +125,7 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config return { meta: meta ?? { id, type: "movie", name: "Unknown" } }; } if (type === "series") { - const meta = await getSeriesMeta(id); + const meta = await getSeriesMeta(id, metadata); return { meta: meta ?? { id, type: "series", name: "Unknown" } }; } return { meta: { id, type, name: "Unknown" } }; @@ -315,11 +317,18 @@ async function getMovieMeta(id: string) { }; } -async function getSeriesMeta(id: string) { +async function getSeriesMeta(id: string, metadata: MetadataService) { const series = await prisma.series.findFirst({ where: { OR: [{ imdbId: id }, { id: unwrapLocalId(id, "series") }], }, + }); + if (!series) return null; + + await metadata.enrichSeriesEpisodes(series.id); + + const refreshed = await prisma.series.findFirst({ + where: { id: series.id }, include: { seasons: { include: { @@ -332,10 +341,10 @@ async function getSeriesMeta(id: string) { }, }, }); - if (!series) return null; + if (!refreshed) return null; - const sid = seriesId(series.imdbId, series.id); - const videos = series.seasons.flatMap((season) => + const sid = seriesId(refreshed.imdbId, refreshed.id); + const videos = refreshed.seasons.flatMap((season) => season.episodes .filter((ep) => ep.mediaFiles.length > 0) .map((ep) => ({ @@ -344,19 +353,21 @@ async function getSeriesMeta(id: string) { season: ep.seasonNumber, episode: ep.episodeNumber, overview: ep.overview ?? undefined, + thumbnail: ep.stillUrl ?? undefined, released: ep.airDate?.toISOString().slice(0, 10), + runtime: ep.runtime ? `${ep.runtime} min` : undefined, })) ); return { id: sid, type: "series" as const, - name: series.title, - poster: series.posterUrl ?? undefined, - background: series.backdropUrl ?? undefined, - description: series.overview ?? undefined, - releaseInfo: series.year ? String(series.year) : undefined, - genres: series.genres, + name: refreshed.title, + poster: refreshed.posterUrl ?? undefined, + background: refreshed.backdropUrl ?? undefined, + description: refreshed.overview ?? undefined, + releaseInfo: refreshed.year ? String(refreshed.year) : undefined, + genres: refreshed.genres, videos, }; } diff --git a/deploy/node/config.example.yaml b/deploy/node/config.example.yaml index 0a1776a..66fb4d1 100644 --- a/deploy/node/config.example.yaml +++ b/deploy/node/config.example.yaml @@ -15,7 +15,9 @@ media: - /mnt/disk3/series scanner: - full_scan_interval: 6h + # Realtime fsnotify watch handles add/remove. Nightly catch-up at 03:30 (local TZ). + full_scan_interval: off + full_scan_at: "03:30" security: session_idle_timeout: 20m diff --git a/deploy/node/install-unraid.sh b/deploy/node/install-unraid.sh index 793058e..d549b8d 100644 --- a/deploy/node/install-unraid.sh +++ b/deploy/node/install-unraid.sh @@ -150,6 +150,8 @@ docker run -d \ -e "MEDIA_NODE_MOVIES=/media/movies" \ -e "MEDIA_NODE_SERIES=$ENV_SERIES" \ -e "MEDIA_NODE_ENROLLMENT_TOKEN=$TOKEN" \ + -e "MEDIA_NODE_FULL_SCAN_INTERVAL=off" \ + -e "MEDIA_NODE_FULL_SCAN_AT=03:30" \ -e "TZ=${TZ:-Europe/Amsterdam}" \ --user 99:100 \ "${VOLUMES[@]}" \ diff --git a/node/media-node/cmd/media-node/main.go b/node/media-node/cmd/media-node/main.go index c496ed2..1844ee5 100644 --- a/node/media-node/cmd/media-node/main.go +++ b/node/media-node/cmd/media-node/main.go @@ -169,7 +169,7 @@ func runInstall() { Movies: nonEmpty(moviesDir), Series: nonEmpty(seriesDir), }, - Scanner: config.ScannerConfig{FullScanInterval: "6h"}, + Scanner: config.ScannerConfig{FullScanInterval: "off", FullScanAt: "03:30"}, Security: config.SecurityConfig{SessionIdleTimeout: "20m", MaxConcurrentPerIP: 64}, } diff --git a/node/media-node/internal/config/config.go b/node/media-node/internal/config/config.go index 475607d..d28e4bf 100644 --- a/node/media-node/internal/config/config.go +++ b/node/media-node/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "strconv" "strings" "time" @@ -45,7 +46,10 @@ type MediaConfig struct { } type ScannerConfig struct { + // FullScanInterval: duration like "6h", or "off" to disable interval scans. FullScanInterval string `yaml:"full_scan_interval"` + // FullScanAt: local clock time HH:MM for nightly catch-up scan (default 03:30). + FullScanAt string `yaml:"full_scan_at"` } type SecurityConfig struct { @@ -68,10 +72,9 @@ func Load(path string) (*Config, error) { return nil, fmt.Errorf("read config: %w (and env incomplete: %v)", err, envErr) } if saveErr := Save(path, cfg); saveErr != nil { - // Still usable in-memory when the path is not writable. - return applyDefaults(cfg), nil + return applyDefaults(applyEnvOverrides(cfg)), nil } - return applyDefaults(cfg), nil + return applyDefaults(applyEnvOverrides(cfg)), nil } return nil, fmt.Errorf("read config: %w", err) } @@ -79,7 +82,7 @@ func Load(path string) (*Config, error) { if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parse config: %w", err) } - return applyDefaults(&cfg), nil + return applyDefaults(applyEnvOverrides(&cfg)), nil } // FromEnv builds config from MEDIA_NODE_* environment variables (Docker/Unraid). @@ -94,6 +97,8 @@ func FromEnv() (*Config, error) { if listen == "" { listen = DefaultListen } + scanAt := strings.TrimSpace(os.Getenv("MEDIA_NODE_FULL_SCAN_AT")) + scanInterval := strings.TrimSpace(os.Getenv("MEDIA_NODE_FULL_SCAN_INTERVAL")) return &Config{ Node: NodeConfig{Name: name}, Master: MasterConfig{URL: master}, @@ -102,17 +107,33 @@ func FromEnv() (*Config, error) { Movies: splitPaths(os.Getenv("MEDIA_NODE_MOVIES")), Series: splitPaths(os.Getenv("MEDIA_NODE_SERIES")), }, - Scanner: ScannerConfig{FullScanInterval: "6h"}, + Scanner: ScannerConfig{ + FullScanInterval: scanInterval, + FullScanAt: scanAt, + }, Security: SecurityConfig{SessionIdleTimeout: "20m", MaxConcurrentPerIP: 64}, }, nil } +func applyEnvOverrides(cfg *Config) *Config { + if v := strings.TrimSpace(os.Getenv("MEDIA_NODE_FULL_SCAN_AT")); v != "" { + cfg.Scanner.FullScanAt = v + } + if v := strings.TrimSpace(os.Getenv("MEDIA_NODE_FULL_SCAN_INTERVAL")); v != "" { + cfg.Scanner.FullScanInterval = v + } + return cfg +} + func applyDefaults(cfg *Config) *Config { if cfg.Stream.Listen == "" { cfg.Stream.Listen = DefaultListen } if cfg.Scanner.FullScanInterval == "" { - cfg.Scanner.FullScanInterval = "6h" + cfg.Scanner.FullScanInterval = "off" + } + if cfg.Scanner.FullScanAt == "" { + cfg.Scanner.FullScanAt = "03:30" } if cfg.Security.SessionIdleTimeout == "" { cfg.Security.SessionIdleTimeout = "20m" @@ -140,14 +161,37 @@ func splitPaths(raw string) []string { return out } +// FullScanDuration returns 0 when interval scanning is disabled. func (c *Config) FullScanDuration() time.Duration { + raw := strings.TrimSpace(strings.ToLower(c.Scanner.FullScanInterval)) + if raw == "" || raw == "off" || raw == "0" || raw == "disabled" { + return 0 + } d, err := time.ParseDuration(c.Scanner.FullScanInterval) if err != nil { - return 6 * time.Hour + return 0 } return d } +// FullScanClock returns hour and minute for the nightly scan (local timezone). +func (c *Config) FullScanClock() (hour, minute int, ok bool) { + raw := strings.TrimSpace(c.Scanner.FullScanAt) + if raw == "" || strings.EqualFold(raw, "off") { + return 0, 0, false + } + parts := strings.Split(raw, ":") + if len(parts) != 2 { + return 3, 30, true + } + h, err1 := strconv.Atoi(parts[0]) + m, err2 := strconv.Atoi(parts[1]) + if err1 != nil || err2 != nil || h < 0 || h > 23 || m < 0 || m > 59 { + return 3, 30, true + } + return h, m, true +} + func Save(path string, cfg *Config) error { data, err := yaml.Marshal(cfg) if err != nil { diff --git a/node/media-node/internal/database/store.go b/node/media-node/internal/database/store.go index 001c9bc..60b95e9 100644 --- a/node/media-node/internal/database/store.go +++ b/node/media-node/internal/database/store.go @@ -141,6 +141,31 @@ func (s *Store) AllFiles() ([]LocalFile, error) { return files, rows.Err() } +// FilesUnderPath returns the exact path and any files nested under it. +func (s *Store) FilesUnderPath(path string) ([]LocalFile, error) { + clean := filepath.Clean(path) + like := clean + string(filepath.Separator) + "%" + rows, err := s.db.Query(` + SELECT local_file_id, path, size_bytes, modified_at, media_type, release_name, revision + FROM local_files + WHERE path = ? OR path LIKE ? + ORDER BY path + `, clean, like) + if err != nil { + return nil, err + } + defer rows.Close() + var files []LocalFile + for rows.Next() { + f, err := scanFile(rows) + if err != nil { + return nil, err + } + files = append(files, *f) + } + return files, rows.Err() +} + func (s *Store) FileCount() (int, error) { var count int err := s.db.QueryRow(`SELECT COUNT(*) FROM local_files`).Scan(&count) diff --git a/node/media-node/internal/scanner/scanner.go b/node/media-node/internal/scanner/scanner.go index 2767853..da841c1 100644 --- a/node/media-node/internal/scanner/scanner.go +++ b/node/media-node/internal/scanner/scanner.go @@ -60,18 +60,55 @@ func (s *Scanner) Status() string { } func (s *Scanner) Start() { - go s.runFullScanLoop() + // One inventory pass at boot, then realtime watch + nightly catch-up. + go s.runStartupScan() + go s.runNightlyFullScan() + go s.runIntervalFullScan() go s.watchFilesystem() } -func (s *Scanner) runFullScanLoop() { +func (s *Scanner) runStartupScan() { + log.Println("Startup library inventory scan...") s.FullScan() - ticker := time.NewTicker(s.cfg.FullScanDuration()) +} + +func (s *Scanner) runNightlyFullScan() { + hour, minute, ok := s.cfg.FullScanClock() + if !ok { + log.Println("Nightly full scan disabled") + return + } + for { + next := nextLocalClock(time.Now(), hour, minute) + log.Printf("Next nightly full scan at %s", next.Format(time.RFC3339)) + timer := time.NewTimer(time.Until(next)) + <-timer.C + log.Println("Nightly catch-up full scan starting...") + s.FullScan() + } +} + +func (s *Scanner) runIntervalFullScan() { + d := s.cfg.FullScanDuration() + if d <= 0 { + return + } + log.Printf("Interval full scan every %s", d) + ticker := time.NewTicker(d) for range ticker.C { s.FullScan() } } +func nextLocalClock(now time.Time, hour, minute int) time.Time { + loc := now.Location() + next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, loc) + if !next.After(now) { + next = next.Add(24 * time.Hour) + } + return next +} + func (s *Scanner) FullScan() { s.mu.Lock() if s.scanning { @@ -141,11 +178,11 @@ func (s *Scanner) watchFilesystem() { defer watcher.Close() rootType := map[string]string{} - for _, dir := range s.cfg.Media.Movies { + addTree := func(dir, kind string) { if dir == "" { - continue + return } - rootType[filepath.Clean(dir)] = "movie" + rootType[filepath.Clean(dir)] = kind filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err == nil && info.IsDir() { _ = watcher.Add(path) @@ -153,38 +190,55 @@ func (s *Scanner) watchFilesystem() { return nil }) } + for _, dir := range s.cfg.Media.Movies { + addTree(dir, "movie") + } for _, dir := range s.cfg.Media.Series { - if dir == "" { - continue - } - rootType[filepath.Clean(dir)] = "episode" - filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { - if err == nil && info.IsDir() { - _ = watcher.Add(path) - } - return nil - }) + addTree(dir, "episode") } debounce := time.NewTimer(0) <-debounce.C pending := make(map[string]struct{}) + const settle = 5 * time.Second + for { select { case event, ok := <-watcher.Events: if !ok { return } + name := event.Name + + if event.Op&fsnotify.Create == fsnotify.Create { + if info, err := os.Stat(name); err == nil && info.IsDir() { + _ = watcher.Add(name) + filepath.Walk(name, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + _ = watcher.Add(path) + return nil + } + if isVideo(path) { + pending[path] = struct{}{} + } + return nil + }) + } + } + if event.Op&fsnotify.Create == fsnotify.Create || event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Rename == fsnotify.Rename { - if isVideo(event.Name) { - pending[event.Name] = struct{}{} + if isVideo(name) { + pending[name] = struct{}{} } } - if event.Op&fsnotify.Remove == fsnotify.Remove { - s.handleRemove(event.Name) + if event.Op&fsnotify.Remove == fsnotify.Remove || event.Op&fsnotify.Rename == fsnotify.Rename { + s.handleRemove(name) } if !debounce.Stop() { select { @@ -192,11 +246,11 @@ func (s *Scanner) watchFilesystem() { default: } } - debounce.Reset(2 * time.Second) + debounce.Reset(settle) case <-debounce.C: for path := range pending { info, err := os.Stat(path) - if err == nil && !info.IsDir() { + if err == nil && !info.IsDir() && isVideo(path) { s.indexFile(path, info, "FILE_ADDED", s.detectRootType(path, rootType)) } } @@ -229,7 +283,6 @@ func (s *Scanner) indexFile(path string, info os.FileInfo, eventType, preferredT return } - // Validate we can actually parse this path for the chosen type if mediaType == "movie" && parseMovieFromPath(path) == nil { return } @@ -261,19 +314,26 @@ func (s *Scanner) indexFile(path string, info os.FileInfo, eventType, preferredT } func (s *Scanner) handleRemove(path string) { - localFileID := database.FileID(path) - f, err := s.store.GetFile(localFileID) - if err != nil { - return + files, err := s.store.FilesUnderPath(path) + if err != nil || len(files) == 0 { + // Exact file id lookup for leaf files + localFileID := database.FileID(path) + f, getErr := s.store.GetFile(localFileID) + if getErr != nil { + return + } + files = []database.LocalFile{*f} } - rev, _ := s.store.NextRevision() - event := LibraryEvent{ - Type: "FILE_REMOVED", - NodeRevision: rev, - File: fileToInfo(*f), + for _, f := range files { + rev, _ := s.store.NextRevision() + event := LibraryEvent{ + Type: "FILE_REMOVED", + NodeRevision: rev, + File: fileToInfo(f), + } + _ = s.store.DeleteFile(f.LocalFileID) + s.onEvents([]LibraryEvent{event}) } - _ = s.store.DeleteFile(localFileID) - s.onEvents([]LibraryEvent{event}) } func (s *Scanner) AllMediaFiles() []MediaFileInfo {