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

230 lines
7.1 KiB
Go

package arr
import (
"testing"
)
func TestMapQualityProfile_2160p(t *testing.T) {
profile := QualityProfile{
ID: 1,
Name: "Ultra-HD",
Cutoff: 31, // 2160p Remux
Items: []QualityItem{
{Quality: &Quality{ID: 31, Name: "Remux-2160p", Resolution: 2160}, Allowed: true},
{Quality: &Quality{ID: 18, Name: "HDTV-2160p", Resolution: 2160}, Allowed: true},
{Quality: &Quality{ID: 7, Name: "Bluray-1080p", Resolution: 1080}, Allowed: true},
},
}
if got := MapQualityProfile(profile); got != "2160p" {
t.Errorf("MapQualityProfile = %q, want 2160p", got)
}
}
func TestMapQualityProfile_1080p(t *testing.T) {
profile := QualityProfile{
ID: 2,
Name: "HD-1080p",
Cutoff: 7,
Items: []QualityItem{
{Quality: &Quality{ID: 7, Name: "Bluray-1080p", Resolution: 1080}, Allowed: true},
{Quality: &Quality{ID: 3, Name: "HDTV-720p", Resolution: 720}, Allowed: true},
},
}
if got := MapQualityProfile(profile); got != "1080p" {
t.Errorf("MapQualityProfile = %q, want 1080p", got)
}
}
func TestMapQualityProfile_720p_fallback(t *testing.T) {
profile := QualityProfile{
ID: 3,
Name: "SD",
Cutoff: 1,
Items: []QualityItem{
{Quality: &Quality{ID: 1, Name: "SDTV", Resolution: 480}, Allowed: true},
},
}
if got := MapQualityProfile(profile); got != "720p" {
t.Errorf("MapQualityProfile = %q, want 720p", got)
}
}
func TestMapQualityProfile_NestedGroups(t *testing.T) {
profile := QualityProfile{
ID: 4,
Name: "Any",
Cutoff: 7,
Items: []QualityItem{
{
Items: []QualityItem{
{Quality: &Quality{ID: 7, Name: "Bluray-1080p", Resolution: 1080}, Allowed: true},
{Quality: &Quality{ID: 3, Name: "HDTV-720p", Resolution: 720}, Allowed: true},
},
},
},
}
if got := MapQualityProfile(profile); got != "1080p" {
t.Errorf("MapQualityProfile = %q, want 1080p", got)
}
}
func TestMostUsedProfile(t *testing.T) {
profiles := []QualityProfile{
{ID: 1, Name: "HD-1080p"},
{ID: 2, Name: "Ultra-HD"},
{ID: 3, Name: "SD"},
}
counts := map[int]int{1: 5, 2: 20, 3: 3}
p := MostUsedProfile(counts, profiles)
if p == nil || p.ID != 2 {
t.Errorf("MostUsedProfile = %v, want profile ID 2", p)
}
}
func TestMostUsedProfile_EmptyCounts(t *testing.T) {
profiles := []QualityProfile{
{ID: 1, Name: "HD-1080p"},
}
p := MostUsedProfile(map[int]int{}, profiles)
if p == nil || p.ID != 1 {
t.Errorf("MostUsedProfile with empty counts should return first profile")
}
}
func TestExtractWantedMovies(t *testing.T) {
movies := []Movie{
{TmdbID: 1, Title: "Has file", Monitored: true, HasFile: true},
{TmdbID: 2, Title: "Wanted", Monitored: true, HasFile: false},
{TmdbID: 3, Title: "Unmonitored", Monitored: false, HasFile: false},
{TmdbID: 0, Title: "No TMDB", Monitored: true, HasFile: false}, // no tmdbId
}
wanted := ExtractWantedMovies(movies)
if len(wanted) != 1 {
t.Fatalf("ExtractWantedMovies = %d items, want 1", len(wanted))
}
if wanted[0].TmdbID != 2 || wanted[0].Type != "movie" {
t.Errorf("ExtractWantedMovies[0] = %+v, want tmdbId=2 type=movie", wanted[0])
}
}
func TestExtractWantedSeries(t *testing.T) {
series := []Series{
{ImdbID: "tt1", Title: "Complete", Monitored: true, Statistics: SeriesStatistics{EpisodeCount: 10, EpisodeFileCount: 10}},
{ImdbID: "tt2", Title: "Missing eps", Monitored: true, Statistics: SeriesStatistics{EpisodeCount: 10, EpisodeFileCount: 5}},
{ImdbID: "tt3", Title: "Unmonitored", Monitored: false, Statistics: SeriesStatistics{EpisodeCount: 10, EpisodeFileCount: 0}},
}
wanted := ExtractWantedSeries(series)
if len(wanted) != 1 {
t.Fatalf("ExtractWantedSeries = %d items, want 1", len(wanted))
}
if wanted[0].ImdbID != "tt2" || wanted[0].Type != "show" {
t.Errorf("ExtractWantedSeries[0] = %+v, want imdbId=tt2 type=show", wanted[0])
}
}
func TestExtractBlocklistedHashes(t *testing.T) {
items := []BlocklistItem{
{Data: BlocklistData{InfoHash: "AAAA"}},
{Data: BlocklistData{InfoHash: "AAAA"}}, // duplicate
{Data: BlocklistData{InfoHash: "BBBB"}},
{Data: BlocklistData{InfoHash: ""}}, // empty
}
hashes := ExtractBlocklistedHashes(items)
if len(hashes) != 2 {
t.Fatalf("ExtractBlocklistedHashes = %d, want 2", len(hashes))
}
}
func TestExtractDownloadedHashes(t *testing.T) {
records := []HistoryRecord{
{EventType: "downloadFolderImported", Data: HistoryData{InfoHash: "hash1"}},
{EventType: "grabbed", Data: HistoryData{InfoHash: "hash2"}}, // not imported
{EventType: "downloadFolderImported", Data: HistoryData{InfoHash: "hash1"}}, // duplicate
{EventType: "downloadFolderImported", Data: HistoryData{InfoHash: "hash3"}},
}
hashes := ExtractDownloadedHashes(records)
if len(hashes) != 2 {
t.Fatalf("ExtractDownloadedHashes = %d, want 2", len(hashes))
}
}
func TestMapRootFolders_MostUsed(t *testing.T) {
folders := []RootFolder{
{Path: "/data/movies1"},
{Path: "/data/movies2"},
}
movies := []Movie{
{RootFolderPath: "/data/movies1"},
{RootFolderPath: "/data/movies2"},
{RootFolderPath: "/data/movies2"},
{RootFolderPath: "/data/movies2"},
}
moviesDir, _ := MapRootFolders(folders, nil, movies, nil)
if moviesDir != "/data/movies2" {
t.Errorf("MapRootFolders = %q, want /data/movies2", moviesDir)
}
}
func TestMapRootFolders_SingleFolder(t *testing.T) {
folders := []RootFolder{{Path: "/data/movies"}}
moviesDir, _ := MapRootFolders(folders, nil, nil, nil)
if moviesDir != "/data/movies" {
t.Errorf("MapRootFolders = %q, want /data/movies", moviesDir)
}
}
func TestMapRootFolders_Empty(t *testing.T) {
moviesDir, tvDir := MapRootFolders(nil, nil, nil, nil)
if moviesDir != "" || tvDir != "" {
t.Errorf("MapRootFolders empty = %q, %q, want empty", moviesDir, tvDir)
}
}
func TestHasDockerPaths(t *testing.T) {
tests := []struct {
name string
movies string
tv string
expected bool
}{
{"docker paths", "/data/media/movies", "/data/media/tv", true},
{"host paths", "/home/user/Media/Movies", "/home/user/Media/TV", false},
{"empty", "", "", false},
{"mixed", "/home/user/movies", "/data/media/tv", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &MigrationResult{MoviesDir: tt.movies, TVShowsDir: tt.tv}
if got := HasDockerPaths(r); got != tt.expected {
t.Errorf("HasDockerPaths = %v, want %v", got, tt.expected)
}
})
}
}
func TestExtractDebridTokens(t *testing.T) {
clients := []DownloadClient{
{ID: 1, Name: "TorBox", Enable: true, Implementation: "TorBox", ImplementationName: "TorBox"},
{ID: 2, Name: "qBittorrent", Enable: true, Implementation: "QBittorrent", ImplementationName: "qBittorrent"},
{ID: 3, Name: "Disabled Debrid", Enable: false, Implementation: "RealDebrid", ImplementationName: "Real-Debrid"},
}
getFields := func(id int) []Field {
if id == 1 {
return []Field{
{Name: "ApiKey", Value: "tb_test_token_123"},
{Name: "Host", Value: "torbox.app"},
}
}
return nil
}
tokens := ExtractDebridTokens(clients, getFields)
if len(tokens) != 1 {
t.Fatalf("ExtractDebridTokens = %d tokens, want 1", len(tokens))
}
if tokens[0].Provider != "torbox" || tokens[0].Token != "tb_test_token_123" {
t.Errorf("ExtractDebridTokens[0] = %+v, want torbox with token", tokens[0])
}
}