stremio/node/media-node/internal/scanner/scanner.go
Jos Vooges | STH eb8ae1e85a Fix Stremio playback, folder-aware scan, and remove STH branding.
Parse Plex-style Title (Year) and Show/Season folders; restore notWebReady with direct node stream URLs; hide unavailable titles by default.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 02:16:46 +02:00

339 lines
7.8 KiB
Go

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 ""
}