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
86
internal/library/cache.go
Normal file
86
internal/library/cache.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package library
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/torrentclaw/torrentclaw-cli/internal/config"
|
||||
)
|
||||
|
||||
// CachePath returns the default library cache file path.
|
||||
func CachePath() string {
|
||||
return filepath.Join(config.DataDir(), "library.json")
|
||||
}
|
||||
|
||||
// LoadCache reads the library cache from disk. Returns nil if file doesn't exist.
|
||||
func LoadCache() (*LibraryCache, error) {
|
||||
return LoadCacheFrom(CachePath())
|
||||
}
|
||||
|
||||
// LoadCacheFrom reads the library cache from a specific path.
|
||||
func LoadCacheFrom(path string) (*LibraryCache, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read cache: %w", err)
|
||||
}
|
||||
|
||||
var cache LibraryCache
|
||||
if err := json.Unmarshal(data, &cache); err != nil {
|
||||
return nil, fmt.Errorf("parse cache: %w", err)
|
||||
}
|
||||
|
||||
if cache.Version != cacheVersion {
|
||||
return nil, nil // incompatible version, treat as missing
|
||||
}
|
||||
|
||||
return &cache, nil
|
||||
}
|
||||
|
||||
// SaveCache writes the library cache to disk atomically.
|
||||
func SaveCache(cache *LibraryCache) error {
|
||||
return SaveCacheTo(cache, CachePath())
|
||||
}
|
||||
|
||||
// SaveCacheTo writes the library cache to a specific path atomically.
|
||||
func SaveCacheTo(cache *LibraryCache, path string) error {
|
||||
cache.Version = cacheVersion
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create cache dir: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(cache, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode cache: %w", err)
|
||||
}
|
||||
|
||||
tmpPath := path + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write temp cache: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("rename cache: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildCacheIndex creates a lookup map from filePath → index for incremental scanning.
|
||||
func BuildCacheIndex(cache *LibraryCache) map[string]int {
|
||||
if cache == nil {
|
||||
return nil
|
||||
}
|
||||
idx := make(map[string]int, len(cache.Items))
|
||||
for i, item := range cache.Items {
|
||||
idx[item.FilePath] = i
|
||||
}
|
||||
return idx
|
||||
}
|
||||
99
internal/library/cache_test.go
Normal file
99
internal/library/cache_test.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package library
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveCacheAndLoad(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "library.json")
|
||||
|
||||
cache := &LibraryCache{
|
||||
Version: cacheVersion,
|
||||
ScannedAt: "2026-03-29T10:00:00Z",
|
||||
Path: "/media/movies",
|
||||
Items: []LibraryItem{
|
||||
{
|
||||
FilePath: "/media/movies/Inception.mkv",
|
||||
FileName: "Inception.mkv",
|
||||
FileSize: 5000000000,
|
||||
ModTime: "2026-01-15T12:00:00Z",
|
||||
Title: "Inception",
|
||||
Year: "2010",
|
||||
Quality: "1080p",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Save
|
||||
if err := SaveCacheTo(cache, path); err != nil {
|
||||
t.Fatalf("SaveCacheTo: %v", err)
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("cache file not found: %v", err)
|
||||
}
|
||||
|
||||
// Load
|
||||
loaded, err := LoadCacheFrom(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCacheFrom: %v", err)
|
||||
}
|
||||
if loaded == nil {
|
||||
t.Fatal("loaded cache is nil")
|
||||
}
|
||||
|
||||
if loaded.Version != cacheVersion {
|
||||
t.Errorf("version = %d, want %d", loaded.Version, cacheVersion)
|
||||
}
|
||||
if loaded.Path != "/media/movies" {
|
||||
t.Errorf("path = %q, want %q", loaded.Path, "/media/movies")
|
||||
}
|
||||
if len(loaded.Items) != 1 {
|
||||
t.Fatalf("items count = %d, want 1", len(loaded.Items))
|
||||
}
|
||||
if loaded.Items[0].Title != "Inception" {
|
||||
t.Errorf("title = %q, want %q", loaded.Items[0].Title, "Inception")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCacheNonExistent(t *testing.T) {
|
||||
cache, err := LoadCacheFrom("/tmp/nonexistent-unarr-test.json")
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got: %v", err)
|
||||
}
|
||||
if cache != nil {
|
||||
t.Fatalf("expected nil cache, got: %v", cache)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCacheIndex(t *testing.T) {
|
||||
cache := &LibraryCache{
|
||||
Items: []LibraryItem{
|
||||
{FilePath: "/a.mkv"},
|
||||
{FilePath: "/b.mkv"},
|
||||
{FilePath: "/c.mkv"},
|
||||
},
|
||||
}
|
||||
|
||||
idx := BuildCacheIndex(cache)
|
||||
if idx["/a.mkv"] != 0 {
|
||||
t.Errorf("expected index 0 for /a.mkv, got %d", idx["/a.mkv"])
|
||||
}
|
||||
if idx["/b.mkv"] != 1 {
|
||||
t.Errorf("expected index 1 for /b.mkv, got %d", idx["/b.mkv"])
|
||||
}
|
||||
if idx["/c.mkv"] != 2 {
|
||||
t.Errorf("expected index 2 for /c.mkv, got %d", idx["/c.mkv"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCacheIndexNil(t *testing.T) {
|
||||
idx := BuildCacheIndex(nil)
|
||||
if idx != nil {
|
||||
t.Errorf("expected nil, got %v", idx)
|
||||
}
|
||||
}
|
||||
281
internal/library/mediainfo/ffprobe.go
Normal file
281
internal/library/mediainfo/ffprobe.go
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
package mediainfo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ffprobeOutput matches the JSON structure from `ffprobe -show_streams -show_format`.
|
||||
type ffprobeOutput struct {
|
||||
Streams []ffprobeStream `json:"streams"`
|
||||
Format ffprobeFormat `json:"format"`
|
||||
}
|
||||
|
||||
type ffprobeFormat struct {
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
type ffprobeStream struct {
|
||||
CodecType string `json:"codec_type"`
|
||||
CodecName string `json:"codec_name"`
|
||||
Profile string `json:"profile"`
|
||||
Channels int `json:"channels"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
BitsPerRaw string `json:"bits_per_raw_sample"`
|
||||
PixFmt string `json:"pix_fmt"`
|
||||
ColorSpace string `json:"color_space"`
|
||||
ColorTransfer string `json:"color_transfer"`
|
||||
ColorPrimaries string `json:"color_primaries"`
|
||||
RFrameRate string `json:"r_frame_rate"`
|
||||
Duration string `json:"duration"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
Disposition map[string]int `json:"disposition"`
|
||||
SideDataList []sideData `json:"side_data_list"`
|
||||
}
|
||||
|
||||
type sideData struct {
|
||||
SideDataType string `json:"side_data_type"`
|
||||
}
|
||||
|
||||
// hdrProfiles maps (color_space, color_transfer) to HDR type.
|
||||
var hdrProfiles = map[[2]string]string{
|
||||
{"bt2020nc", "smpte2084"}: "HDR10",
|
||||
{"bt2020nc", "arib-std-b67"}: "HLG",
|
||||
}
|
||||
|
||||
// ExtractMediaInfo runs ffprobe on a file and parses audio, subtitle, and video streams.
|
||||
func ExtractMediaInfo(ctx context.Context, ffprobePath, filePath string) (*MediaInfo, error) {
|
||||
cmd := exec.CommandContext(ctx, ffprobePath,
|
||||
"-v", "error",
|
||||
"-print_format", "json",
|
||||
"-show_streams",
|
||||
"-show_format",
|
||||
filePath,
|
||||
)
|
||||
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
if _, statErr := os.Stat(filePath); statErr != nil {
|
||||
return nil, fmt.Errorf("ffprobe: file not found: %s", filePath)
|
||||
}
|
||||
return nil, fmt.Errorf("ffprobe failed (file=%s): %s", filePath, stderr.String())
|
||||
}
|
||||
|
||||
var data ffprobeOutput
|
||||
if err := json.Unmarshal(output, &data); err != nil {
|
||||
return nil, fmt.Errorf("ffprobe JSON parse failed: %w", err)
|
||||
}
|
||||
|
||||
if len(data.Streams) == 0 {
|
||||
return nil, fmt.Errorf("ffprobe returned no streams")
|
||||
}
|
||||
|
||||
var audioTracks []AudioTrack
|
||||
var subtitleTracks []SubtitleTrack
|
||||
var videoInfo *VideoInfo
|
||||
|
||||
for _, s := range data.Streams {
|
||||
switch s.CodecType {
|
||||
case "audio":
|
||||
langRaw := tagValue(s.Tags, "language")
|
||||
track := AudioTrack{
|
||||
Lang: NormalizeLang(langRaw),
|
||||
Codec: s.CodecName,
|
||||
Channels: s.Channels,
|
||||
}
|
||||
if title := tagValue(s.Tags, "title"); title != "" {
|
||||
track.Title = title
|
||||
}
|
||||
if s.Disposition["default"] == 1 {
|
||||
track.Default = true
|
||||
}
|
||||
audioTracks = append(audioTracks, track)
|
||||
|
||||
case "subtitle":
|
||||
langRaw := tagValue(s.Tags, "language")
|
||||
track := SubtitleTrack{
|
||||
Lang: NormalizeLang(langRaw),
|
||||
Codec: s.CodecName,
|
||||
}
|
||||
if title := tagValue(s.Tags, "title"); title != "" {
|
||||
track.Title = title
|
||||
}
|
||||
if s.Disposition["forced"] == 1 {
|
||||
track.Forced = true
|
||||
}
|
||||
subtitleTracks = append(subtitleTracks, track)
|
||||
|
||||
case "video":
|
||||
if videoInfo != nil {
|
||||
continue // only first video stream
|
||||
}
|
||||
vi := &VideoInfo{
|
||||
Codec: s.CodecName,
|
||||
Width: s.Width,
|
||||
Height: s.Height,
|
||||
}
|
||||
|
||||
// Bit depth
|
||||
if s.BitsPerRaw != "" {
|
||||
if bd, err := strconv.Atoi(s.BitsPerRaw); err == nil {
|
||||
vi.BitDepth = bd
|
||||
}
|
||||
} else if containsAny(s.PixFmt, "10le", "10be", "p010") {
|
||||
vi.BitDepth = 10
|
||||
} else if containsAny(s.PixFmt, "12le", "12be") {
|
||||
vi.BitDepth = 12
|
||||
}
|
||||
|
||||
// HDR detection
|
||||
hdrKey := [2]string{s.ColorSpace, s.ColorTransfer}
|
||||
if hdr, ok := hdrProfiles[hdrKey]; ok {
|
||||
vi.HDR = hdr
|
||||
} else if s.ColorTransfer == "smpte2084" {
|
||||
vi.HDR = "HDR10"
|
||||
} else if s.ColorTransfer == "arib-std-b67" {
|
||||
vi.HDR = "HLG"
|
||||
}
|
||||
|
||||
// Dolby Vision via side_data_list
|
||||
for _, sd := range s.SideDataList {
|
||||
if sd.SideDataType == "DOVI configuration record" {
|
||||
if vi.HDR != "" {
|
||||
vi.HDR = "DV+" + vi.HDR
|
||||
} else {
|
||||
vi.HDR = "DV"
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Frame rate from r_frame_rate (e.g., "24000/1001")
|
||||
if s.RFrameRate != "" && strings.Contains(s.RFrameRate, "/") {
|
||||
parts := strings.SplitN(s.RFrameRate, "/", 2)
|
||||
if num, err1 := strconv.ParseFloat(parts[0], 64); err1 == nil {
|
||||
if den, err2 := strconv.ParseFloat(parts[1], 64); err2 == nil && den > 0 {
|
||||
vi.FrameRate = math.Round(num/den*1000) / 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Profile
|
||||
if s.Profile != "" {
|
||||
vi.Profile = s.Profile
|
||||
}
|
||||
|
||||
// Duration: prefer format.duration, fallback to stream duration
|
||||
if dur := parseDuration(data.Format.Duration); dur > 0 {
|
||||
vi.Duration = dur
|
||||
} else if dur := parseDuration(s.Duration); dur > 0 {
|
||||
vi.Duration = dur
|
||||
}
|
||||
|
||||
videoInfo = vi
|
||||
}
|
||||
}
|
||||
|
||||
result := &MediaInfo{
|
||||
Video: videoInfo,
|
||||
}
|
||||
if len(audioTracks) > 0 {
|
||||
result.Audio = audioTracks
|
||||
result.Languages = ComputeLanguages(audioTracks)
|
||||
}
|
||||
if len(subtitleTracks) > 0 {
|
||||
result.Subtitles = subtitleTracks
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ResolveFFprobe finds the ffprobe binary. Search order:
|
||||
// 1. Explicit path (--ffprobe flag)
|
||||
// 2. FFPROBE_PATH env var
|
||||
// 3. "ffprobe" in PATH
|
||||
// 4. Adjacent to the current executable
|
||||
// 5. Previously downloaded in cache dir
|
||||
// 6. Auto-download static binary
|
||||
func ResolveFFprobe(explicit string) (string, error) {
|
||||
if explicit != "" {
|
||||
if _, err := os.Stat(explicit); err == nil {
|
||||
return explicit, nil
|
||||
}
|
||||
return "", fmt.Errorf("ffprobe not found at explicit path: %s", explicit)
|
||||
}
|
||||
|
||||
if envPath := os.Getenv("FFPROBE_PATH"); envPath != "" {
|
||||
if _, err := os.Stat(envPath); err == nil {
|
||||
return envPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
if p, err := exec.LookPath("ffprobe"); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
name := "ffprobe"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "ffprobe.exe"
|
||||
}
|
||||
adjacent := filepath.Join(filepath.Dir(exePath), name)
|
||||
if _, err := os.Stat(adjacent); err == nil {
|
||||
return adjacent, nil
|
||||
}
|
||||
}
|
||||
|
||||
if cached, err := FFprobeCachePath(); err == nil {
|
||||
if _, err := os.Stat(cached); err == nil {
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
|
||||
if p, err := DownloadFFprobe(); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("ffprobe not found. Install ffmpeg or provide --ffprobe path")
|
||||
}
|
||||
|
||||
// tagValue gets a tag value case-insensitively.
|
||||
func tagValue(tags map[string]string, key string) string {
|
||||
if v, ok := tags[key]; ok {
|
||||
return v
|
||||
}
|
||||
if v, ok := tags[strings.ToUpper(key)]; ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func containsAny(s string, substrs ...string) bool {
|
||||
for _, sub := range substrs {
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseDuration converts a duration string (e.g. "7423.500000") to float64 seconds.
|
||||
func parseDuration(s string) float64 {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
d, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil || d <= 0 {
|
||||
return 0
|
||||
}
|
||||
return math.Round(d*1000) / 1000
|
||||
}
|
||||
176
internal/library/mediainfo/ffprobe_download.go
Normal file
176
internal/library/mediainfo/ffprobe_download.go
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
package mediainfo
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ffprobeAPIClient = &http.Client{Timeout: 30 * time.Second}
|
||||
ffprobeDLClient = &http.Client{Timeout: 10 * time.Minute}
|
||||
)
|
||||
|
||||
const maxFFprobeZipSize = 100 * 1024 * 1024 // 100MB
|
||||
|
||||
const ffbinariesAPI = "https://ffbinaries.com/api/v1/version/latest"
|
||||
|
||||
type ffbinariesResponse struct {
|
||||
Version string `json:"version"`
|
||||
Bin map[string]map[string]string `json:"bin"`
|
||||
}
|
||||
|
||||
// ffprobePlatformKey maps GOOS/GOARCH to ffbinaries platform keys.
|
||||
func ffprobePlatformKey() (string, error) {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
switch runtime.GOARCH {
|
||||
case "amd64":
|
||||
return "linux-64", nil
|
||||
case "arm64":
|
||||
return "linux-arm64", nil
|
||||
}
|
||||
case "darwin":
|
||||
return "osx-64", nil
|
||||
case "windows":
|
||||
if runtime.GOARCH == "amd64" {
|
||||
return "windows-64", nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("unsupported platform: %s/%s", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
|
||||
// FFprobeCacheDir returns the directory where the downloaded ffprobe binary is stored.
|
||||
func FFprobeCacheDir() (string, error) {
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(cacheDir, "unarr", "bin"), nil
|
||||
}
|
||||
|
||||
// FFprobeCachePath returns the full path to the cached ffprobe binary.
|
||||
func FFprobeCachePath() (string, error) {
|
||||
dir, err := FFprobeCacheDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := "ffprobe"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "ffprobe.exe"
|
||||
}
|
||||
return filepath.Join(dir, name), nil
|
||||
}
|
||||
|
||||
// DownloadFFprobe downloads a static ffprobe binary for the current platform
|
||||
// and caches it locally. Returns the path to the binary.
|
||||
func DownloadFFprobe() (string, error) {
|
||||
dest, err := FFprobeCachePath()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot determine cache path: %w", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(dest); err == nil {
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
platform, err := ffprobePlatformKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
url, err := resolveFFprobeURL(platform)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "ffprobe not found — downloading for %s...\n", platform)
|
||||
|
||||
resp, err := ffprobeDLClient.Get(url)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
zipData, err := io.ReadAll(io.LimitReader(resp.Body, maxFFprobeZipSize))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("download read failed: %w", err)
|
||||
}
|
||||
|
||||
name := "ffprobe"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "ffprobe.exe"
|
||||
}
|
||||
|
||||
binary, err := extractFromZip(zipData, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return "", fmt.Errorf("cannot create cache directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(dest, binary, 0o755); err != nil {
|
||||
return "", fmt.Errorf("cannot write ffprobe binary: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "ffprobe installed to %s\n", dest)
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
func resolveFFprobeURL(platform string) (string, error) {
|
||||
resp, err := ffprobeAPIClient.Get(ffbinariesAPI)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot reach ffbinaries.com: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var data ffbinariesResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return "", fmt.Errorf("cannot parse ffbinaries response: %w", err)
|
||||
}
|
||||
|
||||
bins, ok := data.Bin[platform]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no ffprobe binary available for platform %q", platform)
|
||||
}
|
||||
|
||||
url, ok := bins["ffprobe"]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no ffprobe download URL for platform %q", platform)
|
||||
}
|
||||
|
||||
return url, nil
|
||||
}
|
||||
|
||||
func extractFromZip(data []byte, target string) ([]byte, error) {
|
||||
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot open downloaded archive: %w", err)
|
||||
}
|
||||
|
||||
for _, f := range r.File {
|
||||
if filepath.Base(f.Name) == target {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot extract %s from archive: %w", target, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%s not found in downloaded archive", target)
|
||||
}
|
||||
115
internal/library/mediainfo/lang.go
Normal file
115
internal/library/mediainfo/lang.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package mediainfo
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// langNormalize maps ISO 639-2/B, 639-2/T, 639-1 codes, and full English
|
||||
// language names (as returned by some ffprobe metadata) to ISO 639-1.
|
||||
var langNormalize = map[string]string{
|
||||
// ISO codes
|
||||
"eng": "en", "en": "en",
|
||||
"spa": "es", "es": "es",
|
||||
"fre": "fr", "fra": "fr", "fr": "fr",
|
||||
"ger": "de", "deu": "de", "de": "de",
|
||||
"ita": "it", "it": "it",
|
||||
"por": "pt", "pt": "pt",
|
||||
"rus": "ru", "ru": "ru",
|
||||
"jpn": "ja", "ja": "ja",
|
||||
"kor": "ko", "ko": "ko",
|
||||
"chi": "zh", "zho": "zh", "zh": "zh",
|
||||
"hin": "hi", "hi": "hi",
|
||||
"ara": "ar", "ar": "ar",
|
||||
"dut": "nl", "nld": "nl", "nl": "nl",
|
||||
"pol": "pl", "pl": "pl",
|
||||
"tur": "tr", "tr": "tr",
|
||||
"swe": "sv", "sv": "sv",
|
||||
"nor": "no", "nob": "no", "nno": "no", "no": "no",
|
||||
"dan": "da", "da": "da",
|
||||
"fin": "fi", "fi": "fi",
|
||||
"cze": "cs", "ces": "cs", "cs": "cs",
|
||||
"hun": "hu", "hu": "hu",
|
||||
"rum": "ro", "ron": "ro", "ro": "ro",
|
||||
"gre": "el", "ell": "el", "el": "el",
|
||||
"tha": "th", "th": "th",
|
||||
"vie": "vi", "vi": "vi",
|
||||
"ind": "id", "id": "id",
|
||||
"heb": "he", "he": "he",
|
||||
"ukr": "uk", "uk": "uk",
|
||||
"cat": "ca", "ca": "ca",
|
||||
"bul": "bg", "bg": "bg",
|
||||
"hrv": "hr", "hr": "hr",
|
||||
"srp": "sr", "sr": "sr",
|
||||
"slv": "sl", "sl": "sl",
|
||||
"lit": "lt", "lt": "lt",
|
||||
"lav": "lv", "lv": "lv",
|
||||
"est": "et", "et": "et",
|
||||
"per": "fa", "fas": "fa", "fa": "fa",
|
||||
"may": "ms", "msa": "ms", "ms": "ms",
|
||||
"tgl": "tl", "tl": "tl",
|
||||
"tam": "ta", "ta": "ta",
|
||||
"tel": "te", "te": "te",
|
||||
"ben": "bn", "bn": "bn",
|
||||
"urd": "ur", "ur": "ur",
|
||||
"geo": "ka", "kat": "ka", "ka": "ka",
|
||||
"arm": "hy", "hye": "hy", "hy": "hy",
|
||||
"alb": "sq", "sqi": "sq", "sq": "sq",
|
||||
"mac": "mk", "mkd": "mk", "mk": "mk",
|
||||
"ice": "is", "isl": "is", "is": "is",
|
||||
"glg": "gl", "gl": "gl",
|
||||
"baq": "eu", "eus": "eu", "eu": "eu",
|
||||
"wel": "cy", "cym": "cy", "cy": "cy",
|
||||
"gle": "ga", "ga": "ga",
|
||||
"mlt": "mt", "mt": "mt",
|
||||
"swa": "sw", "sw": "sw",
|
||||
"afr": "af", "af": "af",
|
||||
"lat": "la", "la": "la",
|
||||
|
||||
// Full English names (ffprobe sometimes returns these instead of codes)
|
||||
"english": "en", "spanish": "es", "french": "fr", "german": "de",
|
||||
"italian": "it", "portuguese": "pt", "russian": "ru", "japanese": "ja",
|
||||
"korean": "ko", "chinese": "zh", "hindi": "hi", "arabic": "ar",
|
||||
"dutch": "nl", "polish": "pl", "turkish": "tr", "swedish": "sv",
|
||||
"norwegian": "no", "danish": "da", "finnish": "fi", "czech": "cs",
|
||||
"hungarian": "hu", "romanian": "ro", "greek": "el", "thai": "th",
|
||||
"vietnamese": "vi", "indonesian": "id", "hebrew": "he", "ukrainian": "uk",
|
||||
"catalan": "ca", "bulgarian": "bg", "croatian": "hr", "serbian": "sr",
|
||||
"slovenian": "sl", "lithuanian": "lt", "latvian": "lv", "estonian": "et",
|
||||
"persian": "fa", "malay": "ms", "tagalog": "tl", "tamil": "ta",
|
||||
"telugu": "te", "bengali": "bn", "urdu": "ur", "georgian": "ka",
|
||||
"armenian": "hy", "albanian": "sq", "macedonian": "mk", "icelandic": "is",
|
||||
"galician": "gl", "basque": "eu", "welsh": "cy", "irish": "ga",
|
||||
"maltese": "mt", "swahili": "sw", "afrikaans": "af", "latin": "la",
|
||||
}
|
||||
|
||||
// NormalizeLang converts a language code to ISO 639-1.
|
||||
// Returns "und" for empty input, the input lowercased if no mapping is found.
|
||||
func NormalizeLang(raw string) string {
|
||||
if raw == "" {
|
||||
return "und"
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
if mapped, ok := langNormalize[lower]; ok {
|
||||
return mapped
|
||||
}
|
||||
return lower
|
||||
}
|
||||
|
||||
// ComputeLanguages extracts unique ISO 639-1 language codes from audio tracks.
|
||||
func ComputeLanguages(audioTracks []AudioTrack) []string {
|
||||
seen := make(map[string]struct{})
|
||||
for _, t := range audioTracks {
|
||||
lang := t.Lang
|
||||
if lang != "" && lang != "und" && len(lang) <= 3 {
|
||||
seen[lang] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(seen))
|
||||
for l := range seen {
|
||||
result = append(result, l)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
64
internal/library/mediainfo/lang_test.go
Normal file
64
internal/library/mediainfo/lang_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package mediainfo
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeLang(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"", "und"},
|
||||
{"eng", "en"},
|
||||
{"spa", "es"},
|
||||
{"fre", "fr"},
|
||||
{"fra", "fr"},
|
||||
{"ger", "de"},
|
||||
{"deu", "de"},
|
||||
{"en", "en"},
|
||||
{"es", "es"},
|
||||
{"English", "en"},
|
||||
{"SPANISH", "es"},
|
||||
{"Japanese", "ja"},
|
||||
{"jpn", "ja"},
|
||||
{"chi", "zh"},
|
||||
{"zho", "zh"},
|
||||
{"und", "und"},
|
||||
{"xyz", "xyz"}, // unknown → lowercase passthrough
|
||||
{"POR", "pt"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := NormalizeLang(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalizeLang(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeLanguages(t *testing.T) {
|
||||
tracks := []AudioTrack{
|
||||
{Lang: "en", Codec: "aac", Channels: 2},
|
||||
{Lang: "es", Codec: "ac3", Channels: 6},
|
||||
{Lang: "en", Codec: "dts", Channels: 6}, // duplicate
|
||||
{Lang: "und", Codec: "aac", Channels: 2},
|
||||
{Lang: "", Codec: "aac", Channels: 2},
|
||||
}
|
||||
|
||||
langs := ComputeLanguages(tracks)
|
||||
|
||||
if len(langs) != 2 {
|
||||
t.Fatalf("expected 2 languages, got %d: %v", len(langs), langs)
|
||||
}
|
||||
if langs[0] != "en" || langs[1] != "es" {
|
||||
t.Errorf("expected [en es], got %v", langs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeLanguagesEmpty(t *testing.T) {
|
||||
langs := ComputeLanguages(nil)
|
||||
if len(langs) != 0 {
|
||||
t.Errorf("expected empty, got %v", langs)
|
||||
}
|
||||
}
|
||||
38
internal/library/mediainfo/types.go
Normal file
38
internal/library/mediainfo/types.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package mediainfo
|
||||
|
||||
// MediaInfo holds the media analysis result from ffprobe.
|
||||
type MediaInfo struct {
|
||||
Video *VideoInfo `json:"video"`
|
||||
Audio []AudioTrack `json:"audio"`
|
||||
Subtitles []SubtitleTrack `json:"subtitles"`
|
||||
Languages []string `json:"languages"` // derived from audio tracks
|
||||
}
|
||||
|
||||
// VideoInfo represents the primary video stream metadata.
|
||||
type VideoInfo struct {
|
||||
Codec string `json:"codec"` // "hevc", "h264", "av1"
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
BitDepth int `json:"bitDepth"` // 8, 10, 12
|
||||
HDR string `json:"hdr"` // "HDR10", "DV", "HLG", "DV+HDR10", ""
|
||||
FrameRate float64 `json:"frameRate"` // e.g. 23.976
|
||||
Profile string `json:"profile"` // e.g. "Main 10", "High"
|
||||
Duration float64 `json:"duration"` // seconds
|
||||
}
|
||||
|
||||
// AudioTrack represents a single audio stream.
|
||||
type AudioTrack struct {
|
||||
Lang string `json:"lang"` // ISO 639-1
|
||||
Codec string `json:"codec"` // "aac", "ac3", "dts", "truehd"
|
||||
Channels int `json:"channels"` // 2, 6, 8
|
||||
Title string `json:"title"`
|
||||
Default bool `json:"default"`
|
||||
}
|
||||
|
||||
// SubtitleTrack represents a single subtitle stream.
|
||||
type SubtitleTrack struct {
|
||||
Lang string `json:"lang"`
|
||||
Codec string `json:"codec"`
|
||||
Title string `json:"title"`
|
||||
Forced bool `json:"forced"`
|
||||
}
|
||||
142
internal/library/resolve.go
Normal file
142
internal/library/resolve.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package library
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/torrentclaw/torrentclaw-cli/internal/library/mediainfo"
|
||||
)
|
||||
|
||||
var (
|
||||
seasonRegex = regexp.MustCompile(`(?i)S(\d{1,2})E(\d{1,2})`)
|
||||
seasonOnly = regexp.MustCompile(`(?i)S(\d{1,2})(?:\b|$)`)
|
||||
altEpRegex = regexp.MustCompile(`(?i)(\d{1,2})x(\d{2})`)
|
||||
)
|
||||
|
||||
// ResolveResolution maps a pixel height to a standard resolution label.
|
||||
func ResolveResolution(height int) string {
|
||||
switch {
|
||||
case height >= 2000:
|
||||
return "2160p"
|
||||
case height >= 900:
|
||||
return "1080p"
|
||||
case height >= 600:
|
||||
return "720p"
|
||||
case height >= 400:
|
||||
return "480p"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// DeriveContentType guesses "movie" or "show" from parsed metadata.
|
||||
func DeriveContentType(item LibraryItem) string {
|
||||
if item.Season > 0 || item.Episode > 0 {
|
||||
return "show"
|
||||
}
|
||||
// Check filename for season/episode patterns
|
||||
if seasonRegex.MatchString(item.FileName) || altEpRegex.MatchString(item.FileName) || seasonOnly.MatchString(item.FileName) {
|
||||
return "show"
|
||||
}
|
||||
return "movie"
|
||||
}
|
||||
|
||||
// ParseSeasonEpisode extracts season and episode numbers from a filename.
|
||||
func ParseSeasonEpisode(filename string) (season, episode int) {
|
||||
// S01E05
|
||||
if m := seasonRegex.FindStringSubmatch(filename); len(m) > 2 {
|
||||
season = atoi(m[1])
|
||||
episode = atoi(m[2])
|
||||
return
|
||||
}
|
||||
// 1x05
|
||||
if m := altEpRegex.FindStringSubmatch(filename); len(m) > 2 {
|
||||
season = atoi(m[1])
|
||||
episode = atoi(m[2])
|
||||
return
|
||||
}
|
||||
// S01 only (season pack)
|
||||
if m := seasonOnly.FindStringSubmatch(filename); len(m) > 1 {
|
||||
season = atoi(m[1])
|
||||
return
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// PrimaryAudioTrack returns the codec and channel count of the default or first audio track.
|
||||
func PrimaryAudioTrack(tracks []mediainfo.AudioTrack) (codec string, channels int) {
|
||||
if len(tracks) == 0 {
|
||||
return "", 0
|
||||
}
|
||||
for _, t := range tracks {
|
||||
if t.Default {
|
||||
return t.Codec, t.Channels
|
||||
}
|
||||
}
|
||||
return tracks[0].Codec, tracks[0].Channels
|
||||
}
|
||||
|
||||
// AudioLanguages extracts unique language codes from audio tracks.
|
||||
func AudioLanguages(tracks []mediainfo.AudioTrack) []string {
|
||||
return mediainfo.ComputeLanguages(tracks)
|
||||
}
|
||||
|
||||
// SubtitleLanguages extracts unique language codes from subtitle tracks.
|
||||
func SubtitleLanguages(tracks []mediainfo.SubtitleTrack) []string {
|
||||
seen := make(map[string]struct{})
|
||||
for _, t := range tracks {
|
||||
if t.Lang != "" && t.Lang != "und" {
|
||||
seen[t.Lang] = struct{}{}
|
||||
}
|
||||
}
|
||||
result := make([]string, 0, len(seen))
|
||||
for l := range seen {
|
||||
result = append(result, l)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// CleanTitle extracts a clean title from a filename for searching.
|
||||
// Removes extension, replaces separators with spaces, strips release artifacts.
|
||||
func CleanTitle(filename string) string {
|
||||
// Remove extension
|
||||
name := strings.TrimSuffix(filename, extOf(filename))
|
||||
|
||||
// Remove release group at end BEFORE replacing separators (e.g. "-SPARKS", "-FGT")
|
||||
name = regexp.MustCompile(`-[A-Za-z0-9]+$`).ReplaceAllString(name, "")
|
||||
|
||||
// Remove brackets
|
||||
name = regexp.MustCompile(`[\[\(].*?[\]\)]`).ReplaceAllString(name, "")
|
||||
|
||||
// Replace common separators with spaces
|
||||
name = strings.NewReplacer(".", " ", "_", " ", "-", " ").Replace(name)
|
||||
|
||||
// Remove quality/codec/release artifacts
|
||||
name = regexp.MustCompile(`(?i)\b(2160p|1080p|720p|480p|4K|UHD|BluRay|BDRip|WEBRip|WEB-DL|HDTV|DVDRip|BRRip|x264|x265|HEVC|AVC|AV1|AAC|DTS|AC3|Atmos|FLAC|10bit|HDR10?\+?|DV|DoVi|PROPER|REPACK|REMUX|EXTENDED|DUAL|MULTi)\b`).ReplaceAllString(name, "")
|
||||
|
||||
// Remove year
|
||||
name = regexp.MustCompile(`\b(19|20)\d{2}\b`).ReplaceAllString(name, "")
|
||||
|
||||
// Collapse whitespace
|
||||
name = regexp.MustCompile(`\s+`).ReplaceAllString(name, " ")
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
func extOf(filename string) string {
|
||||
for i := len(filename) - 1; i >= 0; i-- {
|
||||
if filename[i] == '.' {
|
||||
return filename[i:]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func atoi(s string) int {
|
||||
n := 0
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
156
internal/library/resolve_test.go
Normal file
156
internal/library/resolve_test.go
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
package library
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/torrentclaw/torrentclaw-cli/internal/library/mediainfo"
|
||||
)
|
||||
|
||||
func TestResolveResolution(t *testing.T) {
|
||||
tests := []struct {
|
||||
height int
|
||||
want string
|
||||
}{
|
||||
{2160, "2160p"},
|
||||
{2000, "2160p"},
|
||||
{1080, "1080p"},
|
||||
{1920, "1080p"}, // 1920 is width, not height — height for 1080p is ~1080
|
||||
{900, "1080p"},
|
||||
{720, "720p"},
|
||||
{600, "720p"},
|
||||
{576, "480p"},
|
||||
{480, "480p"},
|
||||
{400, "480p"},
|
||||
{360, ""},
|
||||
{0, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := ResolveResolution(tt.height)
|
||||
if got != tt.want {
|
||||
t.Errorf("ResolveResolution(%d) = %q, want %q", tt.height, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveContentType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item LibraryItem
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"movie by default",
|
||||
LibraryItem{FileName: "Inception.2010.1080p.mkv"},
|
||||
"movie",
|
||||
},
|
||||
{
|
||||
"show by season field",
|
||||
LibraryItem{FileName: "something.mkv", Season: 1},
|
||||
"show",
|
||||
},
|
||||
{
|
||||
"show by episode field",
|
||||
LibraryItem{FileName: "something.mkv", Episode: 5},
|
||||
"show",
|
||||
},
|
||||
{
|
||||
"show by S01E01 in filename",
|
||||
LibraryItem{FileName: "Breaking.Bad.S01E01.1080p.mkv"},
|
||||
"show",
|
||||
},
|
||||
{
|
||||
"show by 1x05 in filename",
|
||||
LibraryItem{FileName: "show.1x05.720p.mkv"},
|
||||
"show",
|
||||
},
|
||||
{
|
||||
"show by S02 in filename",
|
||||
LibraryItem{FileName: "Show.Name.S02.Complete.mkv"},
|
||||
"show",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := DeriveContentType(tt.item)
|
||||
if got != tt.want {
|
||||
t.Errorf("DeriveContentType() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSeasonEpisode(t *testing.T) {
|
||||
tests := []struct {
|
||||
filename string
|
||||
season int
|
||||
episode int
|
||||
}{
|
||||
{"Breaking.Bad.S01E05.1080p.mkv", 1, 5},
|
||||
{"Show.S02E10.720p.mkv", 2, 10},
|
||||
{"show.1x05.mkv", 1, 5},
|
||||
{"show.12x03.mkv", 12, 3},
|
||||
{"Show.S01.Complete.mkv", 1, 0},
|
||||
{"Inception.2010.1080p.mkv", 0, 0},
|
||||
{"s3e7.mkv", 3, 7},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.filename, func(t *testing.T) {
|
||||
s, e := ParseSeasonEpisode(tt.filename)
|
||||
if s != tt.season || e != tt.episode {
|
||||
t.Errorf("ParseSeasonEpisode(%q) = (%d, %d), want (%d, %d)", tt.filename, s, e, tt.season, tt.episode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryAudioTrack(t *testing.T) {
|
||||
// Default track
|
||||
tracks := []mediainfo.AudioTrack{
|
||||
{Lang: "en", Codec: "aac", Channels: 2, Default: false},
|
||||
{Lang: "es", Codec: "ac3", Channels: 6, Default: true},
|
||||
}
|
||||
codec, ch := PrimaryAudioTrack(tracks)
|
||||
if codec != "ac3" || ch != 6 {
|
||||
t.Errorf("expected ac3/6, got %s/%d", codec, ch)
|
||||
}
|
||||
|
||||
// No default → first
|
||||
tracks2 := []mediainfo.AudioTrack{
|
||||
{Lang: "en", Codec: "dts", Channels: 8},
|
||||
{Lang: "es", Codec: "aac", Channels: 2},
|
||||
}
|
||||
codec, ch = PrimaryAudioTrack(tracks2)
|
||||
if codec != "dts" || ch != 8 {
|
||||
t.Errorf("expected dts/8, got %s/%d", codec, ch)
|
||||
}
|
||||
|
||||
// Empty
|
||||
codec, ch = PrimaryAudioTrack(nil)
|
||||
if codec != "" || ch != 0 {
|
||||
t.Errorf("expected empty, got %s/%d", codec, ch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"Inception.2010.1080p.BluRay.x264-SPARKS.mkv", "Inception"},
|
||||
{"Breaking.Bad.S01E05.720p.HDTV.mkv", "Breaking Bad S01E05"},
|
||||
{"The.Matrix.1999.2160p.UHD.BluRay.REMUX.mkv", "The Matrix"},
|
||||
{"Movie [YTS.MX].mp4", "Movie"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := CleanTitle(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("CleanTitle(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
29
internal/library/types.go
Normal file
29
internal/library/types.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package library
|
||||
|
||||
import "github.com/torrentclaw/torrentclaw-cli/internal/library/mediainfo"
|
||||
|
||||
// LibraryItem represents a single scanned media file.
|
||||
type LibraryItem struct {
|
||||
FilePath string `json:"filePath"`
|
||||
FileName string `json:"fileName"`
|
||||
FileSize int64 `json:"fileSize"`
|
||||
ModTime string `json:"modTime"` // ISO 8601
|
||||
Title string `json:"title"`
|
||||
Year string `json:"year,omitempty"`
|
||||
Season int `json:"season,omitempty"`
|
||||
Episode int `json:"episode,omitempty"`
|
||||
Quality string `json:"quality,omitempty"` // "1080p" etc (from filename)
|
||||
Codec string `json:"codec,omitempty"` // "x265" etc (from filename)
|
||||
MediaInfo *mediainfo.MediaInfo `json:"mediaInfo,omitempty"`
|
||||
ScanError string `json:"scanError,omitempty"`
|
||||
}
|
||||
|
||||
// LibraryCache is the on-disk cache of scanned library items.
|
||||
type LibraryCache struct {
|
||||
Version int `json:"version"`
|
||||
ScannedAt string `json:"scannedAt"`
|
||||
Path string `json:"path"`
|
||||
Items []LibraryItem `json:"items"`
|
||||
}
|
||||
|
||||
const cacheVersion = 1
|
||||
Loading…
Add table
Add a link
Reference in a new issue