package scanner import ( "log" "os" "path/filepath" "strings" "sync" "time" "github.com/fsnotify/fsnotify" "github.com/sthmedia/media-node/internal/config" "github.com/sthmedia/media-node/internal/database" ) var videoExtensions = map[string]bool{ ".mkv": true, ".mp4": true, ".m4v": true, ".avi": true, ".ts": true, ".m2ts": true, } type MediaFileInfo struct { LocalFileID string `json:"localFileId"` SizeBytes int64 `json:"sizeBytes"` Container string `json:"container,omitempty"` Resolution string `json:"resolution,omitempty"` VideoCodec string `json:"videoCodec,omitempty"` AudioCodec string `json:"audioCodec,omitempty"` ReleaseName string `json:"releaseName"` ModifiedAt string `json:"modifiedAt"` MediaType string `json:"mediaType"` ParsedMovie map[string]interface{} `json:"parsedMovie,omitempty"` ParsedEpisode map[string]interface{} `json:"parsedEpisode,omitempty"` } type LibraryEvent struct { Type string `json:"type"` NodeRevision int64 `json:"nodeRevision"` File MediaFileInfo `json:"file"` } type EventHandler func(events []LibraryEvent) type Scanner struct { cfg *config.Config store *database.Store onEvents EventHandler mu sync.Mutex scanning bool status string resetSchedule chan struct{} } func New(cfg *config.Config, store *database.Store, onEvents EventHandler) *Scanner { return &Scanner{ cfg: cfg, store: store, onEvents: onEvents, status: "idle", resetSchedule: make(chan struct{}, 2), } } func (s *Scanner) Status() string { s.mu.Lock() defer s.mu.Unlock() return s.status } func (s *Scanner) Start() { // One inventory pass at boot, then realtime watch + scheduled catch-up. go s.runStartupScan() go s.runNightlyFullScan() go s.runIntervalFullScan() go s.watchFilesystem() } // UpdateSchedule applies admin/master scan settings and restarts timers. func (s *Scanner) UpdateSchedule(interval, at string) { s.mu.Lock() if interval != "" { s.cfg.Scanner.FullScanInterval = interval } if at != "" { s.cfg.Scanner.FullScanAt = at } s.mu.Unlock() select { case s.resetSchedule <- struct{}{}: default: } select { case s.resetSchedule <- struct{}{}: default: } log.Printf("Scanner schedule updated: interval=%s at=%s", s.cfg.Scanner.FullScanInterval, s.cfg.Scanner.FullScanAt) } func (s *Scanner) runStartupScan() { log.Println("Startup library inventory scan...") s.FullScan() } func (s *Scanner) runNightlyFullScan() { for { s.mu.Lock() hour, minute, ok := s.cfg.FullScanClock() s.mu.Unlock() if !ok { select { case <-s.resetSchedule: continue } } next := nextLocalClock(time.Now(), hour, minute) log.Printf("Next nightly full scan at %s", next.Format(time.RFC3339)) timer := time.NewTimer(time.Until(next)) select { case <-timer.C: log.Println("Nightly catch-up full scan starting...") s.FullScan() case <-s.resetSchedule: if !timer.Stop() { select { case <-timer.C: default: } } } } } func (s *Scanner) runIntervalFullScan() { for { s.mu.Lock() d := s.cfg.FullScanDuration() s.mu.Unlock() if d <= 0 { select { case <-s.resetSchedule: continue } } log.Printf("Interval full scan every %s", d) timer := time.NewTimer(d) select { case <-timer.C: s.FullScan() case <-s.resetSchedule: if !timer.Stop() { select { case <-timer.C: default: } } } } } 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 { s.mu.Unlock() return } s.scanning = true s.status = "scanning" s.mu.Unlock() defer func() { s.mu.Lock() s.scanning = false s.status = "idle" s.mu.Unlock() }() log.Println("Starting full media scan...") found := make(map[string]bool) for _, dir := range s.cfg.Media.Movies { s.walkRoot(dir, "movie", found) } for _, dir := range s.cfg.Media.Series { s.walkRoot(dir, "episode", found) } existing, _ := s.store.AllFiles() for _, f := range existing { if !found[f.Path] { rev, _ := s.store.NextRevision() event := LibraryEvent{ Type: "FILE_REMOVED", NodeRevision: rev, File: fileToInfo(f), } _ = s.store.DeleteFile(f.LocalFileID) s.onEvents([]LibraryEvent{event}) } } log.Println("Full scan completed") } func (s *Scanner) walkRoot(dir, mediaType string, found map[string]bool) { if dir == "" { return } filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return nil } if !isVideo(path) { return nil } found[path] = true s.indexFile(path, info, "FILE_UPDATED", mediaType) return nil }) } func (s *Scanner) watchFilesystem() { watcher, err := fsnotify.NewWatcher() if err != nil { log.Printf("fsnotify unavailable: %v", err) return } defer watcher.Close() rootType := map[string]string{} addTree := func(dir, kind string) { if dir == "" { return } 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) } return nil }) } for _, dir := range s.cfg.Media.Movies { addTree(dir, "movie") } for _, dir := range s.cfg.Media.Series { 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(name) { pending[name] = struct{}{} } } if event.Op&fsnotify.Remove == fsnotify.Remove || event.Op&fsnotify.Rename == fsnotify.Rename { s.handleRemove(name) } if !debounce.Stop() { select { case <-debounce.C: default: } } debounce.Reset(settle) case <-debounce.C: for path := range pending { info, err := os.Stat(path) if err == nil && !info.IsDir() && isVideo(path) { s.indexFile(path, info, "FILE_ADDED", s.detectRootType(path, rootType)) } } pending = make(map[string]struct{}) case err, ok := <-watcher.Errors: if !ok { return } log.Printf("watch error: %v", err) } } } func (s *Scanner) detectRootType(path string, rootType map[string]string) string { clean := filepath.Clean(path) for root, kind := range rootType { if clean == root || strings.HasPrefix(clean, root+string(filepath.Separator)) { return kind } } return detectMediaType(path) } func (s *Scanner) indexFile(path string, info os.FileInfo, eventType, preferredType string) { mediaType := preferredType if mediaType == "" { mediaType = detectMediaType(path) } if mediaType == "" { return } if mediaType == "movie" && parseMovieFromPath(path) == nil { return } if mediaType == "episode" && parseSeriesFromPath(path) == nil { return } localFileID := database.FileID(path) rev, _ := s.store.NextRevision() lf := database.LocalFile{ LocalFileID: localFileID, Path: path, SizeBytes: info.Size(), ModifiedAt: info.ModTime(), MediaType: mediaType, ReleaseName: filepath.Base(path), Revision: rev, } _ = s.store.UpsertFile(lf) info2 := buildMediaInfo(lf) event := LibraryEvent{ Type: eventType, NodeRevision: rev, File: info2, } s.onEvents([]LibraryEvent{event}) } func (s *Scanner) handleRemove(path string) { 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} } 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}) } } func (s *Scanner) AllMediaFiles() []MediaFileInfo { files, _ := s.store.AllFiles() result := make([]MediaFileInfo, len(files)) for i, f := range files { result[i] = buildMediaInfo(f) } return result } func isVideo(path string) bool { ext := strings.ToLower(filepath.Ext(path)) return videoExtensions[ext] } func detectMediaType(path string) string { if parseSeriesFromPath(path) != nil { return "episode" } if parseMovieFromPath(path) != nil { return "movie" } return "" } func buildMediaInfo(f database.LocalFile) MediaFileInfo { info := MediaFileInfo{ LocalFileID: f.LocalFileID, SizeBytes: f.SizeBytes, Container: strings.TrimPrefix(filepath.Ext(f.ReleaseName), "."), ReleaseName: f.ReleaseName, ModifiedAt: f.ModifiedAt.UTC().Format(time.RFC3339), MediaType: f.MediaType, } if f.MediaType == "movie" { if p := parseMovieFromPath(f.Path); p != nil { info.ParsedMovie = p info.Resolution = strVal(p, "resolution") info.VideoCodec = strVal(p, "videoCodec") info.AudioCodec = strVal(p, "audio") } } else { if p := parseSeriesFromPath(f.Path); p != nil { info.ParsedEpisode = p info.Resolution = strVal(p, "resolution") info.VideoCodec = strVal(p, "videoCodec") info.AudioCodec = strVal(p, "audio") } } return info } func fileToInfo(f database.LocalFile) MediaFileInfo { return buildMediaInfo(f) } func strVal(m map[string]interface{}, key string) string { if v, ok := m[key].(string); ok { return v } return "" }