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
312
internal/arr/mapper.go
Normal file
312
internal/arr/mapper.go
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue