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 } func New(cfg *config.Config, store *database.Store, onEvents EventHandler) *Scanner { return &Scanner{cfg: cfg, store: store, onEvents: onEvents, status: "idle"} } func (s *Scanner) Status() string { s.mu.Lock() defer s.mu.Unlock() return s.status } func (s *Scanner) Start() { go s.runFullScanLoop() go s.watchFilesystem() } func (s *Scanner) runFullScanLoop() { s.FullScan() ticker := time.NewTicker(s.cfg.FullScanDuration()) for range ticker.C { s.FullScan() } } 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{} for _, dir := range s.cfg.Media.Movies { if dir == "" { continue } rootType[filepath.Clean(dir)] = "movie" 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.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 }) } debounce := time.NewTimer(0) <-debounce.C pending := make(map[string]struct{}) for { select { case event, ok := <-watcher.Events: if !ok { return } 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 event.Op&fsnotify.Remove == fsnotify.Remove { s.handleRemove(event.Name) } if !debounce.Stop() { select { case <-debounce.C: default: } } debounce.Reset(2 * time.Second) case <-debounce.C: for path := range pending { info, err := os.Stat(path) if err == nil && !info.IsDir() { 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 } // Validate we can actually parse this path for the chosen type 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) { localFileID := database.FileID(path) f, err := s.store.GetFile(localFileID) if err != nil { return } rev, _ := s.store.NextRevision() event := LibraryEvent{ Type: "FILE_REMOVED", NodeRevision: rev, File: fileToInfo(*f), } _ = s.store.DeleteFile(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 "" }