package library import ( "context" "database/sql" "fmt" "io/fs" "log/slog" "os" "path/filepath" "strings" "syscall" "time" ) // ScanResult summarises one scan pass. type ScanResult struct { LibraryID int64 `json:"library_id"` Scanned int `json:"scanned"` Added int `json:"added"` Updated int `json:"updated"` Restored int `json:"restored"` Missing int `json:"missing"` Unchanged int `json:"unchanged"` Duration int64 `json:"duration_ms"` Errors []string `json:"errors,omitempty"` } // candidate is a supported file found on disk. type candidate struct { relPath string size int64 mtime int64 inode int64 ext string } // knownFile is an existing row for this library. type knownFile struct { itemID int64 fileID int64 size int64 mtime int64 missing bool } // Scan walks a library's root and reconciles it with the database. // // Files that changed are re-upserted; files that vanished are marked missing // rather than deleted, so their progress survives a temporarily unavailable // mount. A scan never fails because of a single unreadable file — it records // the error and continues, because a partial library is better than none. func Scan(ctx context.Context, db *sql.DB, lib Library, log *slog.Logger) (ScanResult, error) { start := time.Now() res := ScanResult{LibraryID: lib.ID} exts := extensionsFor(lib.Kind) if len(exts) == 0 { return res, fmt.Errorf("library %d: no extensions registered for kind %q", lib.ID, lib.Kind) } // 1. What do we already know? known, err := loadKnownFiles(ctx, db, lib.ID) if err != nil { return res, err } // 2. What is on disk? found, errs := walkRoot(lib.RootPath, exts) res.Errors = errs // 3. Reconcile in one transaction. tx, err := db.BeginTx(ctx, nil) if err != nil { return res, err } defer tx.Rollback() //nolint:errcheck now := time.Now().UTC().Format(time.RFC3339Nano) for rel, c := range found { res.Scanned++ prev, exists := known[rel] switch { case exists && !prev.missing && prev.size == c.size && prev.mtime == c.mtime: res.Unchanged++ continue case exists && prev.missing: res.Restored++ case exists: res.Updated++ default: res.Added++ } itemID, err := upsertItem(ctx, tx, lib, rel, c) if err != nil { res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", rel, err)) continue } if err := upsertFile(ctx, tx, itemID, rel, c); err != nil { res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", rel, err)) continue } } // 4. Anything we knew about that is gone. for rel := range known { if _, ok := found[rel]; ok { continue } if err := markMissing(ctx, tx, lib.ID, rel, now); err != nil { res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", rel, err)) continue } res.Missing++ } if err := tx.Commit(); err != nil { return res, fmt.Errorf("library: commit scan: %w", err) } res.Duration = time.Since(start).Milliseconds() log.Info("scan complete", "library", lib.Name, "kind", lib.Kind, "scanned", res.Scanned, "added", res.Added, "updated", res.Updated, "restored", res.Restored, "missing", res.Missing, "unchanged", res.Unchanged, "duration_ms", res.Duration, "errors", len(res.Errors)) return res, nil } // walkRoot collects supported files under root, keyed by slash-separated // path relative to root. func walkRoot(root string, exts map[string]bool) (map[string]candidate, []string) { out := make(map[string]candidate) var errs []string err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { errs = append(errs, fmt.Sprintf("%s: %v", path, err)) // An unreadable directory should not abort the walk. if d != nil && d.IsDir() { return fs.SkipDir } return nil } name := d.Name() if d.IsDir() { if path != root && strings.HasPrefix(name, ".") { return fs.SkipDir } return nil } if strings.HasPrefix(name, ".") { return nil } ext := strings.ToLower(filepath.Ext(name)) if !exts[ext] { return nil } info, err := d.Info() if err != nil { errs = append(errs, fmt.Sprintf("%s: %v", path, err)) return nil } if !info.Mode().IsRegular() { return nil } rel, err := filepath.Rel(root, path) if err != nil { errs = append(errs, fmt.Sprintf("%s: %v", path, err)) return nil } c := candidate{ relPath: filepath.ToSlash(rel), size: info.Size(), mtime: info.ModTime().UnixNano(), ext: strings.TrimPrefix(ext, "."), } if st, ok := info.Sys().(*syscall.Stat_t); ok { c.inode = int64(st.Ino) } out[c.relPath] = c return nil }) if err != nil { errs = append(errs, fmt.Sprintf("%s: %v", root, err)) } return out, errs } func loadKnownFiles(ctx context.Context, db *sql.DB, libraryID int64) (map[string]knownFile, error) { rows, err := db.QueryContext(ctx, ` SELECT f.path, f.item_id, f.id, f.size, f.mtime, f.missing FROM media_files f JOIN media_items i ON i.id = f.item_id WHERE i.library_id = ?`, libraryID) if err != nil { return nil, fmt.Errorf("library: load known files: %w", err) } defer rows.Close() out := make(map[string]knownFile) for rows.Next() { var ( absPath string kf knownFile mtime string missing int ) if err := rows.Scan(&absPath, &kf.itemID, &kf.fileID, &kf.size, &mtime, &missing); err != nil { return nil, err } kf.mtime = parseUnixNano(mtime) kf.missing = missing == 1 out[absPath] = kf } return out, rows.Err() } func upsertItem(ctx context.Context, tx *sql.Tx, lib Library, rel string, c candidate) (int64, error) { title := CleanTitle(rel) var id int64 err := tx.QueryRowContext(ctx, ` INSERT INTO media_items (library_id, kind, path, title, sort_title, missing) VALUES (?, ?, ?, ?, ?, 0) ON CONFLICT (library_id, path) DO UPDATE SET missing = 0 RETURNING id`, lib.ID, lib.Kind, rel, title, strings.ToLower(title), ).Scan(&id) if err != nil { return 0, fmt.Errorf("upsert item: %w", err) } return id, nil } func upsertFile(ctx context.Context, tx *sql.Tx, itemID int64, rel string, c candidate) error { _, err := tx.ExecContext(ctx, ` INSERT INTO media_files (item_id, path, size, mtime, inode, container, missing) VALUES (?, ?, ?, ?, ?, ?, 0) ON CONFLICT (path) DO UPDATE SET item_id = excluded.item_id, size = excluded.size, mtime = excluded.mtime, inode = excluded.inode, container = excluded.container, missing = 0`, itemID, rel, c.size, fmt.Sprint(c.mtime), c.inode, c.ext) if err != nil { return fmt.Errorf("upsert file: %w", err) } return nil } func markMissing(ctx context.Context, tx *sql.Tx, libraryID int64, rel, now string) error { if _, err := tx.ExecContext(ctx, `UPDATE media_files SET missing = 1 WHERE path = ?`, rel); err != nil { return err } _, err := tx.ExecContext(ctx, ` UPDATE media_items SET missing = 1 WHERE library_id = ? AND path = ?`, libraryID, rel) return err } func parseUnixNano(s string) int64 { var n int64 for i := 0; i < len(s); i++ { if s[i] < '0' || s[i] > '9' { return 0 } n = n*10 + int64(s[i]-'0') } return n } // RootExists reports whether a library's root is currently reachable. func RootExists(lib Library) bool { info, err := os.Stat(lib.RootPath) return err == nil && info.IsDir() }