Watch-first library updates with nightly 03:30 catch-up, plus TMDB episode titles and thumbnails.

Realtime fsnotify handles add/remove; optional interval scans default off; Stremio series meta now enriches episodes from TMDB (name, still, air date).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-25 04:21:35 +02:00
parent f24b3f1409
commit f113a1ad8c
10 changed files with 344 additions and 54 deletions

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "episodes" ADD COLUMN "still_url" TEXT;

View file

@ -196,6 +196,7 @@ model Episode {
episodeNumber Int @map("episode_number") episodeNumber Int @map("episode_number")
title String? title String?
overview String? overview String?
stillUrl String? @map("still_url")
runtime Int? runtime Int?
airDate DateTime? @map("air_date") airDate DateTime? @map("air_date")
imdbId String? @map("imdb_id") imdbId String? @map("imdb_id")

View file

@ -16,6 +16,7 @@ interface TmdbSearchResult {
} }
const TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w500"; 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_BACKDROP_BASE = "https://image.tmdb.org/t/p/original";
const TMDB_LANG = "nl-NL"; const TMDB_LANG = "nl-NL";
@ -28,16 +29,40 @@ type TmdbMeta = {
backdropUrl?: string; backdropUrl?: string;
}; };
type TmdbEpisodeMeta = {
episodeNumber: number;
title: string;
overview?: string;
airDate?: string;
stillUrl?: string;
runtime?: number;
tmdbId?: number;
};
type MovieRow = Awaited<ReturnType<typeof prisma.movie.create>>; type MovieRow = Awaited<ReturnType<typeof prisma.movie.create>>;
type SeriesRow = Awaited<ReturnType<typeof prisma.series.create>>; type SeriesRow = Awaited<ReturnType<typeof prisma.series.create>>;
type EpisodeRow = Awaited<ReturnType<typeof prisma.episode.create>>;
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 { export class MetadataService {
private movieCache = new Map<number, TmdbMeta>(); private movieCache = new Map<number, TmdbMeta>();
private seriesCache = new Map<number, TmdbMeta>(); private seriesCache = new Map<number, TmdbMeta>();
private movieSearchCache = new Map<string, TmdbMeta | null>(); private movieSearchCache = new Map<string, TmdbMeta | null>();
private seriesSearchCache = new Map<string, TmdbMeta | null>(); private seriesSearchCache = new Map<string, TmdbMeta | null>();
private seasonCache = new Map<string, TmdbEpisodeMeta[]>();
private refreshedMovieIds = new Set<string>(); private refreshedMovieIds = new Set<string>();
private refreshedSeriesIds = new Set<string>(); private refreshedSeriesIds = new Set<string>();
private enrichedSeriesSeasons = new Set<string>();
constructor(private readonly tmdbApiKey?: string) {} 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 }); await this.upsertMediaFile(nodeId, file, { episodeId: episode.id });
return episode.id; return episode.id;
} }
/** Fill episode titles/stills from TMDB for a series (lazy, used by Stremio meta). */
async enrichSeriesEpisodes(seriesId: string): Promise<void> {
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<EpisodeRow> {
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<TmdbEpisodeMeta[]> {
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( private async absorbOrphanMovies(
canonical: MovieRow, canonical: MovieRow,
localTitle: string, localTitle: string,

View file

@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify";
import { prisma } from "../database/client"; import { prisma } from "../database/client";
import { validateAddonToken } from "../auth/routes"; import { validateAddonToken } from "../auth/routes";
import { PlaybackService } from "../playback/service"; import { PlaybackService } from "../playback/service";
import { MetadataService } from "../metadata/service";
import type { Config } from "../config"; import type { Config } from "../config";
const ID_PREFIX = "mc"; const ID_PREFIX = "mc";
@ -49,6 +50,7 @@ function unwrapLocalId(id: string, kind: "movie" | "series"): string {
export async function registerStremioRoutes(app: FastifyInstance, config: Config) { export async function registerStremioRoutes(app: FastifyInstance, config: Config) {
const playback = new PlaybackService(config); const playback = new PlaybackService(config);
const metadata = new MetadataService(config.TMDB_API_KEY);
app.addHook("onRequest", async (request, reply) => { app.addHook("onRequest", async (request, reply) => {
if (!request.url.startsWith("/stremio/")) return; 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" } }; return { meta: meta ?? { id, type: "movie", name: "Unknown" } };
} }
if (type === "series") { if (type === "series") {
const meta = await getSeriesMeta(id); const meta = await getSeriesMeta(id, metadata);
return { meta: meta ?? { id, type: "series", name: "Unknown" } }; return { meta: meta ?? { id, type: "series", name: "Unknown" } };
} }
return { meta: { id, type, 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({ const series = await prisma.series.findFirst({
where: { where: {
OR: [{ imdbId: id }, { id: unwrapLocalId(id, "series") }], 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: { include: {
seasons: { seasons: {
include: { 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 sid = seriesId(refreshed.imdbId, refreshed.id);
const videos = series.seasons.flatMap((season) => const videos = refreshed.seasons.flatMap((season) =>
season.episodes season.episodes
.filter((ep) => ep.mediaFiles.length > 0) .filter((ep) => ep.mediaFiles.length > 0)
.map((ep) => ({ .map((ep) => ({
@ -344,19 +353,21 @@ async function getSeriesMeta(id: string) {
season: ep.seasonNumber, season: ep.seasonNumber,
episode: ep.episodeNumber, episode: ep.episodeNumber,
overview: ep.overview ?? undefined, overview: ep.overview ?? undefined,
thumbnail: ep.stillUrl ?? undefined,
released: ep.airDate?.toISOString().slice(0, 10), released: ep.airDate?.toISOString().slice(0, 10),
runtime: ep.runtime ? `${ep.runtime} min` : undefined,
})) }))
); );
return { return {
id: sid, id: sid,
type: "series" as const, type: "series" as const,
name: series.title, name: refreshed.title,
poster: series.posterUrl ?? undefined, poster: refreshed.posterUrl ?? undefined,
background: series.backdropUrl ?? undefined, background: refreshed.backdropUrl ?? undefined,
description: series.overview ?? undefined, description: refreshed.overview ?? undefined,
releaseInfo: series.year ? String(series.year) : undefined, releaseInfo: refreshed.year ? String(refreshed.year) : undefined,
genres: series.genres, genres: refreshed.genres,
videos, videos,
}; };
} }

View file

@ -15,7 +15,9 @@ media:
- /mnt/disk3/series - /mnt/disk3/series
scanner: 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: security:
session_idle_timeout: 20m session_idle_timeout: 20m

View file

@ -150,6 +150,8 @@ docker run -d \
-e "MEDIA_NODE_MOVIES=/media/movies" \ -e "MEDIA_NODE_MOVIES=/media/movies" \
-e "MEDIA_NODE_SERIES=$ENV_SERIES" \ -e "MEDIA_NODE_SERIES=$ENV_SERIES" \
-e "MEDIA_NODE_ENROLLMENT_TOKEN=$TOKEN" \ -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}" \ -e "TZ=${TZ:-Europe/Amsterdam}" \
--user 99:100 \ --user 99:100 \
"${VOLUMES[@]}" \ "${VOLUMES[@]}" \

View file

@ -169,7 +169,7 @@ func runInstall() {
Movies: nonEmpty(moviesDir), Movies: nonEmpty(moviesDir),
Series: nonEmpty(seriesDir), Series: nonEmpty(seriesDir),
}, },
Scanner: config.ScannerConfig{FullScanInterval: "6h"}, Scanner: config.ScannerConfig{FullScanInterval: "off", FullScanAt: "03:30"},
Security: config.SecurityConfig{SessionIdleTimeout: "20m", MaxConcurrentPerIP: 64}, Security: config.SecurityConfig{SessionIdleTimeout: "20m", MaxConcurrentPerIP: 64},
} }

View file

@ -3,6 +3,7 @@ package config
import ( import (
"fmt" "fmt"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
@ -45,7 +46,10 @@ type MediaConfig struct {
} }
type ScannerConfig struct { type ScannerConfig struct {
// FullScanInterval: duration like "6h", or "off" to disable interval scans.
FullScanInterval string `yaml:"full_scan_interval"` 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 { 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) return nil, fmt.Errorf("read config: %w (and env incomplete: %v)", err, envErr)
} }
if saveErr := Save(path, cfg); saveErr != nil { if saveErr := Save(path, cfg); saveErr != nil {
// Still usable in-memory when the path is not writable. return applyDefaults(applyEnvOverrides(cfg)), nil
return applyDefaults(cfg), nil
} }
return applyDefaults(cfg), nil return applyDefaults(applyEnvOverrides(cfg)), nil
} }
return nil, fmt.Errorf("read config: %w", err) 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 { if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err) 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). // FromEnv builds config from MEDIA_NODE_* environment variables (Docker/Unraid).
@ -94,6 +97,8 @@ func FromEnv() (*Config, error) {
if listen == "" { if listen == "" {
listen = DefaultListen listen = DefaultListen
} }
scanAt := strings.TrimSpace(os.Getenv("MEDIA_NODE_FULL_SCAN_AT"))
scanInterval := strings.TrimSpace(os.Getenv("MEDIA_NODE_FULL_SCAN_INTERVAL"))
return &Config{ return &Config{
Node: NodeConfig{Name: name}, Node: NodeConfig{Name: name},
Master: MasterConfig{URL: master}, Master: MasterConfig{URL: master},
@ -102,17 +107,33 @@ func FromEnv() (*Config, error) {
Movies: splitPaths(os.Getenv("MEDIA_NODE_MOVIES")), Movies: splitPaths(os.Getenv("MEDIA_NODE_MOVIES")),
Series: splitPaths(os.Getenv("MEDIA_NODE_SERIES")), Series: splitPaths(os.Getenv("MEDIA_NODE_SERIES")),
}, },
Scanner: ScannerConfig{FullScanInterval: "6h"}, Scanner: ScannerConfig{
FullScanInterval: scanInterval,
FullScanAt: scanAt,
},
Security: SecurityConfig{SessionIdleTimeout: "20m", MaxConcurrentPerIP: 64}, Security: SecurityConfig{SessionIdleTimeout: "20m", MaxConcurrentPerIP: 64},
}, nil }, 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 { func applyDefaults(cfg *Config) *Config {
if cfg.Stream.Listen == "" { if cfg.Stream.Listen == "" {
cfg.Stream.Listen = DefaultListen cfg.Stream.Listen = DefaultListen
} }
if cfg.Scanner.FullScanInterval == "" { 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 == "" { if cfg.Security.SessionIdleTimeout == "" {
cfg.Security.SessionIdleTimeout = "20m" cfg.Security.SessionIdleTimeout = "20m"
@ -140,14 +161,37 @@ func splitPaths(raw string) []string {
return out return out
} }
// FullScanDuration returns 0 when interval scanning is disabled.
func (c *Config) FullScanDuration() time.Duration { 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) d, err := time.ParseDuration(c.Scanner.FullScanInterval)
if err != nil { if err != nil {
return 6 * time.Hour return 0
} }
return d 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 { func Save(path string, cfg *Config) error {
data, err := yaml.Marshal(cfg) data, err := yaml.Marshal(cfg)
if err != nil { if err != nil {

View file

@ -141,6 +141,31 @@ func (s *Store) AllFiles() ([]LocalFile, error) {
return files, rows.Err() 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) { func (s *Store) FileCount() (int, error) {
var count int var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM local_files`).Scan(&count) err := s.db.QueryRow(`SELECT COUNT(*) FROM local_files`).Scan(&count)

View file

@ -60,18 +60,55 @@ func (s *Scanner) Status() string {
} }
func (s *Scanner) Start() { 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() go s.watchFilesystem()
} }
func (s *Scanner) runFullScanLoop() { func (s *Scanner) runStartupScan() {
log.Println("Startup library inventory scan...")
s.FullScan() 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 { for range ticker.C {
s.FullScan() 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() { func (s *Scanner) FullScan() {
s.mu.Lock() s.mu.Lock()
if s.scanning { if s.scanning {
@ -141,11 +178,11 @@ func (s *Scanner) watchFilesystem() {
defer watcher.Close() defer watcher.Close()
rootType := map[string]string{} rootType := map[string]string{}
for _, dir := range s.cfg.Media.Movies { addTree := func(dir, kind string) {
if dir == "" { 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 { filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err == nil && info.IsDir() { if err == nil && info.IsDir() {
_ = watcher.Add(path) _ = watcher.Add(path)
@ -153,38 +190,55 @@ func (s *Scanner) watchFilesystem() {
return nil return nil
}) })
} }
for _, dir := range s.cfg.Media.Movies {
addTree(dir, "movie")
}
for _, dir := range s.cfg.Media.Series { for _, dir := range s.cfg.Media.Series {
if dir == "" { addTree(dir, "episode")
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
})
} }
debounce := time.NewTimer(0) debounce := time.NewTimer(0)
<-debounce.C <-debounce.C
pending := make(map[string]struct{}) pending := make(map[string]struct{})
const settle = 5 * time.Second
for { for {
select { select {
case event, ok := <-watcher.Events: case event, ok := <-watcher.Events:
if !ok { if !ok {
return 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 || if event.Op&fsnotify.Create == fsnotify.Create ||
event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Write == fsnotify.Write ||
event.Op&fsnotify.Rename == fsnotify.Rename { event.Op&fsnotify.Rename == fsnotify.Rename {
if isVideo(event.Name) { if isVideo(name) {
pending[event.Name] = struct{}{} pending[name] = struct{}{}
} }
} }
if event.Op&fsnotify.Remove == fsnotify.Remove { if event.Op&fsnotify.Remove == fsnotify.Remove || event.Op&fsnotify.Rename == fsnotify.Rename {
s.handleRemove(event.Name) s.handleRemove(name)
} }
if !debounce.Stop() { if !debounce.Stop() {
select { select {
@ -192,11 +246,11 @@ func (s *Scanner) watchFilesystem() {
default: default:
} }
} }
debounce.Reset(2 * time.Second) debounce.Reset(settle)
case <-debounce.C: case <-debounce.C:
for path := range pending { for path := range pending {
info, err := os.Stat(path) 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)) 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 return
} }
// Validate we can actually parse this path for the chosen type
if mediaType == "movie" && parseMovieFromPath(path) == nil { if mediaType == "movie" && parseMovieFromPath(path) == nil {
return return
} }
@ -261,19 +314,26 @@ func (s *Scanner) indexFile(path string, info os.FileInfo, eventType, preferredT
} }
func (s *Scanner) handleRemove(path string) { func (s *Scanner) handleRemove(path string) {
localFileID := database.FileID(path) files, err := s.store.FilesUnderPath(path)
f, err := s.store.GetFile(localFileID) if err != nil || len(files) == 0 {
if err != nil { // Exact file id lookup for leaf files
return localFileID := database.FileID(path)
f, getErr := s.store.GetFile(localFileID)
if getErr != nil {
return
}
files = []database.LocalFile{*f}
} }
rev, _ := s.store.NextRevision() for _, f := range files {
event := LibraryEvent{ rev, _ := s.store.NextRevision()
Type: "FILE_REMOVED", event := LibraryEvent{
NodeRevision: rev, Type: "FILE_REMOVED",
File: fileToInfo(*f), 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 { func (s *Scanner) AllMediaFiles() []MediaFileInfo {