- Migration wizard from Sonarr/Radarr/Prowlarr (unarr migrate) [pre-beta] - Auto-detect instances via Docker, config files, port scan, Prowlarr - Import wanted list (monitored+missing movies/series) - Import download history and blocklist to avoid re-downloading - Extract debrid tokens from *arr download clients - Quality profile mapping to preferred_quality config - DISTINCT ON PostgreSQL query for optimal torrent selection - JSON export with --dry-run --json (text to stderr, JSON to stdout) - Media server detection (Plex/Jellyfin/Emby) in unarr init - Detects library paths and offers them as download directory options - Debrid auto-configuration in unarr init - Scans *arr instances for debrid tokens - Validates and saves via API if user confirms - New preferred_quality setting in config (2160p/1080p/720p) - Library scan command (unarr scan) with ffprobe metadata extraction
210 lines
4.8 KiB
Go
210 lines
4.8 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/torrentclaw/torrentclaw-cli/internal/library/mediainfo"
|
|
"github.com/torrentclaw/torrentclaw-cli/internal/parser"
|
|
)
|
|
|
|
// videoExts are file extensions considered as video files.
|
|
var videoExts = map[string]bool{
|
|
".mkv": true, ".mp4": true, ".avi": true, ".m4v": true,
|
|
".ts": true, ".wmv": true, ".mov": true, ".webm": true,
|
|
".flv": true, ".mpg": true, ".mpeg": true, ".vob": true,
|
|
}
|
|
|
|
// excludePatterns are path substrings that indicate non-content files.
|
|
var excludePatterns = []string{
|
|
"sample", "trailer", "featurette", "extras", "bonus",
|
|
"behind the scenes", "deleted scenes", "interview",
|
|
}
|
|
|
|
const minFileSize = 100 * 1024 * 1024 // 100MB minimum
|
|
|
|
// ScanOptions configures the library scanner.
|
|
type ScanOptions struct {
|
|
Workers int // concurrent ffprobe processes (default 8)
|
|
FFprobePath string // explicit path, or auto-resolve
|
|
Incremental bool // skip unchanged files (mtime+size match cache)
|
|
OnProgress func(scanned, total int, current string)
|
|
}
|
|
|
|
// Scan walks a directory recursively, finds video files, and runs ffprobe on each.
|
|
func Scan(ctx context.Context, dirPath string, existing *LibraryCache, opts ScanOptions) (*LibraryCache, error) {
|
|
if opts.Workers <= 0 {
|
|
opts.Workers = 8
|
|
}
|
|
|
|
// Resolve ffprobe
|
|
ffprobePath, err := mediainfo.ResolveFFprobe(opts.FFprobePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ffprobe: %w", err)
|
|
}
|
|
|
|
// Discover video files
|
|
files, err := discoverFiles(dirPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("discover files: %w", err)
|
|
}
|
|
|
|
if len(files) == 0 {
|
|
return &LibraryCache{
|
|
Version: cacheVersion,
|
|
ScannedAt: time.Now().UTC().Format(time.RFC3339),
|
|
Path: dirPath,
|
|
}, nil
|
|
}
|
|
|
|
// Build cache index for incremental mode
|
|
cacheIdx := BuildCacheIndex(existing)
|
|
|
|
// Scan files concurrently
|
|
var (
|
|
scanned atomic.Int32
|
|
total = len(files)
|
|
mu sync.Mutex
|
|
items = make([]LibraryItem, 0, total)
|
|
)
|
|
|
|
sem := make(chan struct{}, opts.Workers)
|
|
var wg sync.WaitGroup
|
|
|
|
for _, filePath := range files {
|
|
select {
|
|
case <-ctx.Done():
|
|
break
|
|
case sem <- struct{}{}:
|
|
}
|
|
|
|
wg.Add(1)
|
|
go func(fp string) {
|
|
defer wg.Done()
|
|
defer func() { <-sem }()
|
|
|
|
item := scanSingleFile(ctx, ffprobePath, fp, cacheIdx, existing, opts.Incremental)
|
|
|
|
mu.Lock()
|
|
items = append(items, item)
|
|
mu.Unlock()
|
|
|
|
n := int(scanned.Add(1))
|
|
if opts.OnProgress != nil {
|
|
opts.OnProgress(n, total, filepath.Base(fp))
|
|
}
|
|
}(filePath)
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
return &LibraryCache{
|
|
Version: cacheVersion,
|
|
ScannedAt: time.Now().UTC().Format(time.RFC3339),
|
|
Path: dirPath,
|
|
Items: items,
|
|
}, nil
|
|
}
|
|
|
|
func scanSingleFile(ctx context.Context, ffprobePath, filePath string, cacheIdx map[string]int, existing *LibraryCache, incremental bool) LibraryItem {
|
|
info, err := os.Stat(filePath)
|
|
if err != nil {
|
|
return LibraryItem{
|
|
FilePath: filePath,
|
|
FileName: filepath.Base(filePath),
|
|
ScanError: err.Error(),
|
|
}
|
|
}
|
|
|
|
item := LibraryItem{
|
|
FilePath: filePath,
|
|
FileName: filepath.Base(filePath),
|
|
FileSize: info.Size(),
|
|
ModTime: info.ModTime().UTC().Format(time.RFC3339),
|
|
}
|
|
|
|
// Parse filename for title, year, quality, codec
|
|
parsed := parser.Parse(item.FileName)
|
|
item.Quality = parsed.Quality
|
|
item.Codec = parsed.Codec
|
|
item.Year = parsed.Year
|
|
|
|
// Extract title from filename
|
|
item.Title = CleanTitle(item.FileName)
|
|
if item.Title == "" {
|
|
item.Title = item.FileName
|
|
}
|
|
|
|
// Parse season/episode
|
|
item.Season, item.Episode = ParseSeasonEpisode(item.FileName)
|
|
|
|
// Incremental: skip if file hasn't changed
|
|
if incremental && existing != nil {
|
|
if idx, ok := cacheIdx[filePath]; ok {
|
|
cached := existing.Items[idx]
|
|
if cached.FileSize == item.FileSize && cached.ModTime == item.ModTime && cached.MediaInfo != nil {
|
|
item.MediaInfo = cached.MediaInfo
|
|
return item
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run ffprobe
|
|
mi, err := mediainfo.ExtractMediaInfo(ctx, ffprobePath, filePath)
|
|
if err != nil {
|
|
item.ScanError = err.Error()
|
|
return item
|
|
}
|
|
item.MediaInfo = mi
|
|
|
|
return item
|
|
}
|
|
|
|
// discoverFiles walks a directory and returns paths of video files.
|
|
func discoverFiles(root string) ([]string, error) {
|
|
var files []string
|
|
|
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil // skip errors, continue walking
|
|
}
|
|
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
if !videoExts[ext] {
|
|
return nil
|
|
}
|
|
|
|
// Check file size (stat is lazy on some systems)
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if info.Size() < minFileSize {
|
|
return nil
|
|
}
|
|
|
|
// Exclude non-content files
|
|
lower := strings.ToLower(path)
|
|
for _, pattern := range excludePatterns {
|
|
if strings.Contains(lower, pattern) {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
files = append(files, path)
|
|
return nil
|
|
})
|
|
|
|
return files, err
|
|
}
|