unarr/internal/library/resolve.go
Deivid Soto 677a8fe083 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
2026-03-29 16:54:32 +02:00

142 lines
3.8 KiB
Go

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
}