unarr/internal/arr/mapper.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

312 lines
7.9 KiB
Go

package arr
import "strings"
// MapQualityProfile determines the preferred resolution from a quality profile.
// Uses the cutoff quality as the primary signal (what the user "wants"),
// falling back to the highest allowed resolution.
func MapQualityProfile(profile QualityProfile) string {
maxResolution := 0
cutoffResolution := 0
var walk func(items []QualityItem)
walk = func(items []QualityItem) {
for _, item := range items {
if len(item.Items) > 0 {
walk(item.Items)
continue
}
if item.Quality == nil || !item.Allowed {
continue
}
if item.Quality.Resolution > maxResolution {
maxResolution = item.Quality.Resolution
}
if item.Quality.ID == profile.Cutoff && item.Quality.Resolution > 0 {
cutoffResolution = item.Quality.Resolution
}
}
}
walk(profile.Items)
// Prefer the cutoff (what user wants), fall back to max allowed
res := cutoffResolution
if res == 0 {
res = maxResolution
}
switch {
case res >= 2160:
return "2160p"
case res >= 1080:
return "1080p"
default:
return "720p"
}
}
// MostUsedProfile finds the quality profile used by the most items.
func MostUsedProfile(profileCounts map[int]int, profiles []QualityProfile) *QualityProfile {
if len(profiles) == 0 {
return nil
}
bestID := profiles[0].ID
bestCount := 0
for id, count := range profileCounts {
if count > bestCount {
bestCount = count
bestID = id
}
}
for i := range profiles {
if profiles[i].ID == bestID {
return &profiles[i]
}
}
return &profiles[0]
}
// MapRootFolders picks the most-used root folder from each app.
// Uses item paths to count which root folder is most popular.
func MapRootFolders(radarrFolders []RootFolder, sonarrFolders []RootFolder, movies []Movie, series []Series) (moviesDir, tvDir string) {
moviesDir = mostUsedFolder(radarrFolders, func() []string {
paths := make([]string, len(movies))
for i, m := range movies {
paths[i] = m.RootFolderPath
}
return paths
}())
tvDir = mostUsedFolder(sonarrFolders, func() []string {
paths := make([]string, len(series))
for i, s := range series {
paths[i] = s.RootFolderPath
}
return paths
}())
return moviesDir, tvDir
}
// mostUsedFolder returns the folder path used by the most items.
// Falls back to the first folder if no items reference any folder.
func mostUsedFolder(folders []RootFolder, itemPaths []string) string {
if len(folders) == 0 {
return ""
}
if len(folders) == 1 {
return folders[0].Path
}
counts := map[string]int{}
for _, p := range itemPaths {
if p != "" {
counts[p]++
}
}
best := folders[0].Path
bestCount := 0
for _, f := range folders {
if c := counts[f.Path]; c > bestCount {
bestCount = c
best = f.Path
}
}
return best
}
// ExtractWantedMovies returns movies that are monitored but missing files.
func ExtractWantedMovies(movies []Movie) []WantedItem {
var wanted []WantedItem
for _, m := range movies {
if m.Monitored && !m.HasFile && m.TmdbID > 0 {
wanted = append(wanted, WantedItem{
TmdbID: m.TmdbID,
ImdbID: m.ImdbID,
Title: m.Title,
Year: m.Year,
Type: "movie",
})
}
}
return wanted
}
// ExtractWantedSeries returns series that are monitored with missing episodes.
func ExtractWantedSeries(series []Series) []WantedItem {
var wanted []WantedItem
for _, s := range series {
if !s.Monitored {
continue
}
// Series with less than 100% of episodes downloaded
if s.Statistics.EpisodeCount > 0 && s.Statistics.EpisodeFileCount < s.Statistics.EpisodeCount {
id := s.ImdbID
wanted = append(wanted, WantedItem{
ImdbID: id,
Title: s.Title,
Year: s.Year,
Type: "show",
})
}
}
return wanted
}
// BuildMigrationResult aggregates all extracted data into a single result.
func BuildMigrationResult(
movies []Movie,
series []Series,
radarrProfiles, sonarrProfiles []QualityProfile,
radarrFolders, sonarrFolders []RootFolder,
indexers []Indexer,
downloadClients []DownloadClient,
) *MigrationResult {
result := &MigrationResult{}
// Stats
result.TotalMovies = len(movies)
for _, m := range movies {
if m.HasFile {
result.MoviesWithFiles++
}
}
result.TotalSeries = len(series)
for _, s := range series {
if s.Statistics.EpisodeCount > 0 && s.Statistics.EpisodeFileCount >= s.Statistics.EpisodeCount {
result.SeriesComplete++
}
}
result.IndexerCount = len(indexers)
for _, dc := range downloadClients {
if dc.Enable {
result.DownloadClients = append(result.DownloadClients, dc.ImplementationName)
}
}
// Root folders → paths (uses most-popular folder based on item paths)
result.MoviesDir, result.TVShowsDir = MapRootFolders(radarrFolders, sonarrFolders, movies, series)
if result.MoviesDir != "" || result.TVShowsDir != "" {
result.OrganizeEnabled = true
}
// Quality profile — use the most popular one across both apps
profileCounts := map[int]int{}
for _, m := range movies {
profileCounts[m.QualityProfileID]++
}
for _, s := range series {
profileCounts[s.QualityProfileID]++
}
allProfiles := make([]QualityProfile, 0, len(radarrProfiles)+len(sonarrProfiles))
allProfiles = append(allProfiles, radarrProfiles...)
allProfiles = append(allProfiles, sonarrProfiles...)
if p := MostUsedProfile(profileCounts, allProfiles); p != nil {
result.Quality = MapQualityProfile(*p)
result.QualitySource = p.Name
}
// Wanted lists
result.WantedMovies = ExtractWantedMovies(movies)
result.WantedSeries = ExtractWantedSeries(series)
return result
}
// ExtractBlocklistedHashes returns unique infoHashes from blocklist entries.
func ExtractBlocklistedHashes(items []BlocklistItem) []string {
seen := map[string]bool{}
var hashes []string
for _, item := range items {
h := strings.ToLower(strings.TrimSpace(item.Data.InfoHash))
if h != "" && !seen[h] {
seen[h] = true
hashes = append(hashes, h)
}
}
return hashes
}
// ExtractDownloadedHashes returns unique infoHashes from history (imported items).
func ExtractDownloadedHashes(records []HistoryRecord) []string {
seen := map[string]bool{}
var hashes []string
for _, r := range records {
// Only count actually imported downloads, not just grabs
if r.EventType != "downloadFolderImported" && r.EventType != "downloadImported" {
continue
}
h := strings.ToLower(strings.TrimSpace(r.Data.InfoHash))
if h != "" && !seen[h] {
seen[h] = true
hashes = append(hashes, h)
}
}
return hashes
}
// ExtractDebridTokens looks for debrid-related download clients and extracts tokens.
func ExtractDebridTokens(clients []DownloadClient, getFields func(id int) []Field) []DebridToken {
debridKeywords := map[string]string{
"realdebrid": "real-debrid",
"real-debrid": "real-debrid",
"alldebrid": "alldebrid",
"torbox": "torbox",
"premiumize": "premiumize",
}
var tokens []DebridToken
for _, dc := range clients {
if !dc.Enable {
continue
}
impl := strings.ToLower(dc.Implementation + dc.ImplementationName)
provider := ""
for kw, prov := range debridKeywords {
if strings.Contains(impl, kw) {
provider = prov
break
}
}
if provider == "" {
continue
}
// Get the fields for this download client to find the API key/token
fields := getFields(dc.ID)
for _, f := range fields {
name := strings.ToLower(f.Name)
if name == "apikey" || name == "api_key" || name == "token" || name == "apitoken" {
if s, ok := f.Value.(string); ok && s != "" {
tokens = append(tokens, DebridToken{
Provider: provider,
Token: s,
Name: dc.Name,
})
break
}
}
}
}
return tokens
}
// HasDockerPaths checks if any paths look like Docker container paths
// (e.g. /data, /movies, /tv) rather than real host paths.
func HasDockerPaths(result *MigrationResult) bool {
dockerPrefixes := []string{"/data/", "/movies", "/tv", "/media", "/downloads"}
for _, path := range []string{result.MoviesDir, result.TVShowsDir} {
if path == "" {
continue
}
for _, prefix := range dockerPrefixes {
if strings.HasPrefix(path, prefix) {
return true
}
}
}
return false
}