package unit import ( "bytes" "fmt" "testing" metadataPb "homelab.lan/music-agregator/gen/metadata/v1" "homelab.lan/music-agregator/internal/release" "homelab.lan/music-agregator/internal/tracker" ) type testFile struct { path string size int64 } func buildTorrentData(name string, files []testFile) []byte { var buf bytes.Buffer buf.WriteString("d8:announce35:http://tracker.example.com/announce4:infod") if len(files) == 0 { buf.WriteString(fmt.Sprintf("6:lengthi0e4:name%d:%s12:piece lengthi16384e6:pieces20:01234567890123456789", len(name), name)) } else if len(files) == 1 { buf.WriteString(fmt.Sprintf("6:lengthi%de4:name%d:%s12:piece lengthi16384e6:pieces20:01234567890123456789", files[0].size, len(files[0].path), files[0].path)) } else { buf.WriteString("5:filesl") for _, f := range files { buf.WriteString(fmt.Sprintf("d6:lengthi%de4:pathl%d:%see", f.size, len(f.path), f.path)) } buf.WriteString(fmt.Sprintf("e4:name%d:%s12:piece lengthi16384e6:pieces20:01234567890123456789", len(name), name)) } buf.WriteString("ee") return buf.Bytes() } func TestGenericParser_Parse(t *testing.T) { p := tracker.NewGenericParser() tests := []struct { name string title string wantBitrate string wantBitDepth int wantSampleRate int wantSource release.Source wantRipType string }{ { name: "discography no hires", title: "System Of A Down - Discography [FLAC Songs] [PMEDIA]", }, { name: "hiphop hires 24-44", title: "Snoop Dogg - 10 Til' Midnight (2026 Hip Hop Rap) [Flac 24-44]", wantBitDepth: 24, wantSampleRate: 44000, }, { name: "pop hires 24bit", title: "Sabrina Carpenter - Short n' Sweet [Deluxe] [2025] [Hi-Res FLAC 24bit]-Sc4r3cr0w", wantBitDepth: 24, }, { name: "rock hires 24bit", title: "Linkin Park - From Zero [Deluxe Edition] [2025] [Hi-Res] [FLAC-24bit]-Sc4r3cr0w", }, { name: "rock hires 24-48", title: "Linkin Park - From Zero (2024) [24Bit-48kHz] FLAC [PMEDIA]", wantBitDepth: 24, wantSampleRate: 48000, }, { name: "hiphop hires 24-96", title: "J. Cole - The Fall-Off (2026 Hip Hop Rap) [Flac 24-96]", wantBitDepth: 24, wantSampleRate: 96000, }, { name: "minimal format", title: "Bjork-Bastards.2012.FLAC-NewAlbumReleases", }, { name: "vinyl hires", title: "Gorillaz - Demon Days [Live From The Apollo Theater] [2025] [Vinyl Hi-Res] [FLAC-24bit]-Sc4r3cr0w", }, { name: "cd with log", title: "Linkin Park - Meteora (Tracks, Log, Cue, Scans) (2003) [FLAC] 88", }, { name: "rock 16-44", title: "Heart - Jupiters Darling (2004 Rock) [Flac 16-44]", wantBitDepth: 16, wantSampleRate: 44000, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := p.Parse(tt.title) if tt.wantBitrate != "" && r.Bitrate != tt.wantBitrate { t.Errorf("Bitrate = %q, want %q", r.Bitrate, tt.wantBitrate) } if tt.wantBitDepth != 0 && r.BitDepth != tt.wantBitDepth { t.Errorf("BitDepth = %d, want %d", r.BitDepth, tt.wantBitDepth) } if tt.wantSampleRate != 0 && r.SampleRate != tt.wantSampleRate { t.Errorf("SampleRate = %d, want %d", r.SampleRate, tt.wantSampleRate) } if tt.wantSource != release.SourceUnknown && r.Source != tt.wantSource { t.Errorf("Source = %v, want %v", r.Source, tt.wantSource) } if tt.wantRipType != "" && r.RipType != tt.wantRipType { t.Errorf("RipType = %q, want %q", r.RipType, tt.wantRipType) } }) } } func TestGenericParser_ParseTorrent(t *testing.T) { p := tracker.NewGenericParser() makeFlacFiles := func(count int, sizeMB float64) []testFile { files := make([]testFile, count) for i := range files { files[i] = testFile{ path: fmt.Sprintf("%02d - Track %d.flac", i+1, i+1), size: int64(sizeMB * 1024 * 1024), } } return files } makeMp3Files := func(count int, sizeMB float64) []testFile { files := make([]testFile, count) for i := range files { files[i] = testFile{ path: fmt.Sprintf("%02d - Track %d.mp3", i+1, i+1), size: int64(sizeMB * 1024 * 1024), } } return files } tests := []struct { name string torrentName string files []testFile album *metadataPb.Album wantFormat release.AudioFormat wantAudioFileCount int wantHasCoverArt bool wantHasCueSheet bool wantHasRipLog bool wantSource release.Source wantInfoHashEmpty bool wantBitDepth int wantSampleRate int wantTrackNames []string wantArtist string wantAlbum string wantYear int wantType release.Type wantGenres []string wantLabel string wantParseErrors bool }{ { name: "flac album with cover cue log", torrentName: "Test Artist - Test Album (2024) [FLAC]", files: append(append(makeFlacFiles(12, 30), testFile{path: "cover.jpg", size: 500000}, testFile{path: "album.cue", size: 2000}), testFile{path: "rip.log", size: 5000}), album: &metadataPb.Album{ Title: "Test Album", AlbumType: "Album", ReleaseDate: "2024-01-15", TotalTracks: 12, Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Test Artist"}}}, Genres: []*metadataPb.Genre{{Name: "Rock"}}, Label: &metadataPb.Label{Name: "Test Label"}, }, wantFormat: release.FormatFLAC, wantAudioFileCount: 12, wantHasCoverArt: true, wantHasCueSheet: true, wantHasRipLog: true, wantSource: release.SourceCD, wantArtist: "Test Artist", wantAlbum: "Test Album", wantYear: 2024, wantType: release.TypeAlbum, wantGenres: []string{"Rock"}, wantLabel: "Test Label", }, { name: "mp3 album with cover", torrentName: "Artist - MP3 Album (2023)", files: append(makeMp3Files(10, 10), testFile{path: "cover.jpg", size: 300000}), album: &metadataPb.Album{ Title: "MP3 Album", Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}}, ReleaseDate: "2023-05-20", }, wantFormat: release.FormatMP3, wantAudioFileCount: 10, wantHasCoverArt: true, wantHasCueSheet: false, wantHasRipLog: false, wantArtist: "Artist", wantAlbum: "MP3 Album", wantYear: 2023, }, { name: "mixed format dominant wins", torrentName: "Mixed Format Album", files: append(makeFlacFiles(10, 30), testFile{path: "bonus1.mp3", size: 10485760}, testFile{path: "bonus2.mp3", size: 10485760}), album: &metadataPb.Album{ Title: "Mixed Format Album", Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}}, }, wantFormat: release.FormatFLAC, wantAudioFileCount: 10, }, { name: "single file torrent flac", torrentName: "Single Track.flac", files: []testFile{{path: "Single Track.flac", size: 50 * 1024 * 1024}}, album: &metadataPb.Album{ Title: "Single Track", Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}}, }, wantFormat: release.FormatFLAC, wantAudioFileCount: 1, }, { name: "single file torrent mp3", torrentName: "Single.mp3", files: []testFile{{path: "Single.mp3", size: 10 * 1024 * 1024}}, album: &metadataPb.Album{ Title: "Single", Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}}, }, wantFormat: release.FormatMP3, wantAudioFileCount: 1, }, { name: "no audio files", torrentName: "Not Music", files: []testFile{ {path: "readme.txt", size: 1000}, {path: "image.jpg", size: 500000}, }, album: &metadataPb.Album{ Title: "Not Music", Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Someone"}}}, }, wantFormat: release.FormatUnknown, wantAudioFileCount: 0, wantHasCoverArt: true, }, { name: "hires in title", torrentName: "Artist - Album (2024) [24Bit-96kHz] FLAC", files: makeFlacFiles(12, 100), album: &metadataPb.Album{ Title: "Album", Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}}, }, wantFormat: release.FormatFLAC, wantAudioFileCount: 12, wantBitDepth: 24, wantSampleRate: 96000, }, { name: "source from title", torrentName: "Artist - Album [WEB] FLAC", files: makeFlacFiles(10, 30), album: &metadataPb.Album{ Title: "Album", Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}}, }, wantFormat: release.FormatFLAC, wantAudioFileCount: 10, wantSource: release.SourceWEB, }, { name: "track names cleaned", torrentName: "Artist - Album", files: []testFile{ {path: "01 - First Track.flac", size: 30 * 1024 * 1024}, {path: "02 - Second Track.flac", size: 30 * 1024 * 1024}, }, album: &metadataPb.Album{ Title: "Album", Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}}, }, wantFormat: release.FormatFLAC, wantAudioFileCount: 2, wantTrackNames: []string{"First Track", "Second Track"}, }, { name: "metadata fills release fields", torrentName: "Test Torrent", files: makeFlacFiles(8, 30), album: &metadataPb.Album{ Title: "Metadata Album", AlbumType: "EP", ReleaseDate: "2020-06-15", TotalTracks: 8, TotalDiscs: 1, Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Metadata Artist"}}}, Genres: []*metadataPb.Genre{{Name: "Electronic"}, {Name: "Ambient"}}, Label: &metadataPb.Label{Name: "Metadata Label"}, }, wantFormat: release.FormatFLAC, wantAudioFileCount: 8, wantArtist: "Metadata Artist", wantAlbum: "Metadata Album", wantYear: 2020, wantType: release.TypeEP, wantGenres: []string{"Electronic", "Ambient"}, wantLabel: "Metadata Label", }, { name: "empty torrent data", torrentName: "", files: nil, album: &metadataPb.Album{ Title: "Album Only", ReleaseDate: "2022-01-01", Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist Only"}}}, }, wantFormat: release.FormatUnknown, wantAudioFileCount: 0, wantInfoHashEmpty: true, wantArtist: "Artist Only", wantAlbum: "Album Only", wantYear: 2022, }, { name: "invalid torrent data", torrentName: "invalid", files: nil, album: &metadataPb.Album{Title: "Album", Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}}}, wantArtist: "Artist", wantAlbum: "Album", wantParseErrors: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var torrentData []byte if tt.name == "empty torrent data" { torrentData = nil } else if tt.name == "invalid torrent data" { torrentData = []byte("garbage data that is not valid bencode") } else { torrentData = buildTorrentData(tt.torrentName, tt.files) } r := p.ParseTorrent(torrentData, tt.album) if r.Format != tt.wantFormat { t.Errorf("Format = %v, want %v", r.Format, tt.wantFormat) } if r.AudioFileCount != tt.wantAudioFileCount { t.Errorf("AudioFileCount = %d, want %d", r.AudioFileCount, tt.wantAudioFileCount) } if r.HasCoverArt != tt.wantHasCoverArt { t.Errorf("HasCoverArt = %v, want %v", r.HasCoverArt, tt.wantHasCoverArt) } if r.HasCueSheet != tt.wantHasCueSheet { t.Errorf("HasCueSheet = %v, want %v", r.HasCueSheet, tt.wantHasCueSheet) } if r.HasRipLog != tt.wantHasRipLog { t.Errorf("HasRipLog = %v, want %v", r.HasRipLog, tt.wantHasRipLog) } if tt.wantSource != release.SourceUnknown && r.Source != tt.wantSource { t.Errorf("Source = %v, want %v", r.Source, tt.wantSource) } if tt.wantInfoHashEmpty && r.InfoHash != "" { t.Errorf("InfoHash = %q, want empty", r.InfoHash) } if !tt.wantInfoHashEmpty && tt.name != "invalid torrent data" && r.InfoHash == "" { t.Error("InfoHash should not be empty") } if tt.wantBitDepth != 0 && r.BitDepth != tt.wantBitDepth { t.Errorf("BitDepth = %d, want %d", r.BitDepth, tt.wantBitDepth) } if tt.wantSampleRate != 0 && r.SampleRate != tt.wantSampleRate { t.Errorf("SampleRate = %d, want %d", r.SampleRate, tt.wantSampleRate) } if len(tt.wantTrackNames) > 0 { if len(r.TrackNames) != len(tt.wantTrackNames) { t.Errorf("TrackNames length = %d, want %d", len(r.TrackNames), len(tt.wantTrackNames)) } else { for i, name := range tt.wantTrackNames { if r.TrackNames[i] != name { t.Errorf("TrackNames[%d] = %q, want %q", i, r.TrackNames[i], name) } } } } if tt.wantArtist != "" && r.Artist != tt.wantArtist { t.Errorf("Artist = %q, want %q", r.Artist, tt.wantArtist) } if tt.wantAlbum != "" && r.Album != tt.wantAlbum { t.Errorf("Album = %q, want %q", r.Album, tt.wantAlbum) } if tt.wantYear != 0 && r.Year != tt.wantYear { t.Errorf("Year = %d, want %d", r.Year, tt.wantYear) } if tt.wantType != release.TypeUnknown && r.Type != tt.wantType { t.Errorf("Type = %v, want %v", r.Type, tt.wantType) } if len(tt.wantGenres) > 0 { if len(r.Genres) != len(tt.wantGenres) { t.Errorf("Genres length = %d, want %d", len(r.Genres), len(tt.wantGenres)) } else { for i, g := range tt.wantGenres { if r.Genres[i] != g { t.Errorf("Genres[%d] = %q, want %q", i, r.Genres[i], g) } } } } if tt.wantLabel != "" && r.Label != tt.wantLabel { t.Errorf("Label = %q, want %q", r.Label, tt.wantLabel) } if tt.wantParseErrors && len(r.ParseErrors) == 0 { t.Error("expected ParseErrors but got none") } }) } } func TestGenericParser_DeduceFromFileSize(t *testing.T) { p := tracker.NewGenericParser() makeFlacFiles := func(count int, avgSizeMB float64) []testFile { files := make([]testFile, count) size := int64(avgSizeMB * 1024 * 1024) for i := range files { files[i] = testFile{path: fmt.Sprintf("%02d - Track %d.flac", i+1, i+1), size: size} } return files } makeMp3Files := func(count int, avgSizeMB float64) []testFile { files := make([]testFile, count) size := int64(avgSizeMB * 1024 * 1024) for i := range files { files[i] = testFile{path: fmt.Sprintf("%02d - Track %d.mp3", i+1, i+1), size: size} } return files } tests := []struct { name string torrentName string files []testFile wantBitDepth int wantSampleRate int wantBitrate string }{ { name: "flac 16/44.1 from small files", torrentName: "Artist - Album FLAC", files: makeFlacFiles(12, 30), wantBitDepth: 16, wantSampleRate: 44100, }, { name: "flac 24/48 from medium files", torrentName: "Artist - Album FLAC", files: makeFlacFiles(12, 50), wantBitDepth: 24, wantSampleRate: 48000, }, { name: "flac 24/96 from large files", torrentName: "Artist - Album FLAC", files: makeFlacFiles(12, 100), wantBitDepth: 24, wantSampleRate: 96000, }, { name: "flac 24/192 from very large files", torrentName: "Artist - Album FLAC", files: makeFlacFiles(12, 200), wantBitDepth: 24, wantSampleRate: 192000, }, { name: "title overrides heuristic", torrentName: "Artist - Album [24Bit-48kHz] FLAC", files: makeFlacFiles(12, 30), wantBitDepth: 24, wantSampleRate: 48000, }, { name: "mp3 320kbps from large files", torrentName: "Artist - Album MP3", files: makeMp3Files(12, 10), wantBitrate: "320 kbps", }, { name: "mp3 128kbps from small files", torrentName: "Artist - Album MP3", files: makeMp3Files(12, 3.5), wantBitrate: "128 kbps", }, { name: "mp3 title overrides", torrentName: "Artist - Album 320 kbps MP3", files: makeMp3Files(12, 3.5), wantBitrate: "320 kbps", }, { name: "no audio files skips deduction", torrentName: "Artist - Album", files: []testFile{ {path: "cover.jpg", size: 500000}, }, }, { name: "aac files no deduction", torrentName: "Artist - Album", files: func() []testFile { files := make([]testFile, 12) for i := range files { files[i] = testFile{path: fmt.Sprintf("%02d.aac", i+1), size: 50 * 1024 * 1024} } return files }(), }, } album := &metadataPb.Album{ Title: "Album", TotalTracks: 12, Artists: []*metadataPb.ArtistCredit{{Artist: &metadataPb.Artist{Name: "Artist"}}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { data := buildTorrentData(tt.torrentName, tt.files) r := p.ParseTorrent(data, album) if tt.wantBitDepth != 0 && r.BitDepth != tt.wantBitDepth { t.Errorf("BitDepth = %d, want %d", r.BitDepth, tt.wantBitDepth) } if tt.wantSampleRate != 0 && r.SampleRate != tt.wantSampleRate { t.Errorf("SampleRate = %d, want %d", r.SampleRate, tt.wantSampleRate) } if tt.wantBitrate != "" && r.Bitrate != tt.wantBitrate { t.Errorf("Bitrate = %q, want %q", r.Bitrate, tt.wantBitrate) } if tt.name == "no audio files skips deduction" || tt.name == "aac files no deduction" { if r.BitDepth != 0 || r.SampleRate != 0 || r.Bitrate != "" { t.Errorf("expected no deduction, got BitDepth=%d, SampleRate=%d, Bitrate=%q", r.BitDepth, r.SampleRate, r.Bitrate) } } }) } }