- 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
312 lines
7.9 KiB
Go
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
|
|
}
|