feat: add migrate command, media server detection, and debrid auto-config
- 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
This commit is contained in:
parent
0b6c6849b1
commit
677a8fe083
34 changed files with 4766 additions and 22 deletions
210
internal/library/scanner.go
Normal file
210
internal/library/scanner.go
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue