diff --git a/go_backend/ape_tags_supplement_test.go b/go_backend/ape_tags_supplement_test.go new file mode 100644 index 00000000..f9f0721d --- /dev/null +++ b/go_backend/ape_tags_supplement_test.go @@ -0,0 +1,121 @@ +package gobackend + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestAPETagReadWriteMergeAndMetadataConversion(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sample.ape") + if err := os.WriteFile(path, []byte("audio-data"), 0600); err != nil { + t.Fatalf("write sample: %v", err) + } + + metadata := &AudioMetadata{ + Title: "Song", + Artist: "Artist", + Album: "Album", + AlbumArtist: "Album Artist", + Genre: "Pop", + Date: "2026", + TrackNumber: 3, + TotalTracks: 12, + DiscNumber: 1, + TotalDiscs: 2, + ISRC: "USRC17607839", + Lyrics: "lyrics", + Label: "Label", + Copyright: "Copyright", + Composer: "Composer", + Comment: "Comment", + ReplayGainTrackGain: "-6.50 dB", + ReplayGainTrackPeak: "0.98", + ReplayGainAlbumGain: "-5.00 dB", + ReplayGainAlbumPeak: "0.99", + } + items := AudioMetadataToAPEItems(metadata) + if len(items) == 0 { + t.Fatal("expected APE items") + } + + tag := &APETag{Items: append(items, APETagItem{Key: "Custom", Value: "Keep"})} + if err := WriteAPETags(path, tag); err != nil { + t.Fatalf("WriteAPETags: %v", err) + } + + readTag, err := ReadAPETags(path) + if err != nil { + t.Fatalf("ReadAPETags: %v", err) + } + if readTag.Version != apeTagVersion2 { + t.Fatalf("version = %d", readTag.Version) + } + readMetadata := APETagToAudioMetadata(readTag) + if readMetadata.Title != "Song" || readMetadata.TrackNumber != 3 || readMetadata.TotalTracks != 12 { + t.Fatalf("metadata = %#v", readMetadata) + } + + readerTag, err := ReadAPETagsFromReader(bytes.NewReader(mustReadFile(t, path)), int64(len(mustReadFile(t, path)))) + if err != nil { + t.Fatalf("ReadAPETagsFromReader: %v", err) + } + if len(readerTag.Items) != len(readTag.Items) { + t.Fatalf("reader items = %d, file items = %d", len(readerTag.Items), len(readTag.Items)) + } + + override := apeKeysFromFields(map[string]string{"title": "", "lyrics": "", "disc_total": ""}) + merged := MergeAPEItems(readTag.Items, []APETagItem{{Key: "Title", Value: "New Song"}}, override) + mergedMeta := APETagToAudioMetadata(&APETag{Items: merged}) + if mergedMeta.Title != "New Song" { + t.Fatalf("merged title = %q", mergedMeta.Title) + } + if mergedMeta.Lyrics != "" { + t.Fatalf("expected lyrics cleared, got %q", mergedMeta.Lyrics) + } + + if err := WriteAPETags(path, &APETag{Items: []APETagItem{{Key: "Title", Value: "Replacement"}}}); err != nil { + t.Fatalf("replace APE tags: %v", err) + } + replaced, err := ReadAPETags(path) + if err != nil { + t.Fatalf("read replacement: %v", err) + } + if got := APETagToAudioMetadata(replaced).Title; got != "Replacement" { + t.Fatalf("replacement title = %q", got) + } + + if _, err := marshalAPETag(nil); err == nil { + t.Fatal("expected empty tag error") + } + if _, err := ReadAPETags(filepath.Join(dir, "missing.ape")); err == nil { + t.Fatal("expected missing file error") + } + if _, err := ReadAPETagsFromReader(bytes.NewReader([]byte("short")), 5); err == nil { + t.Fatal("expected small reader error") + } +} + +func TestAPETagInvalidFooterBranches(t *testing.T) { + footer := buildAPEHeaderFooter(9999, apeTagHeaderSize, 1, 0) + if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil { + t.Fatal("expected unsupported version") + } + + footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize-1, 1, 0) + if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil { + t.Fatal("expected small tag size") + } + + footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize, 1001, 0) + if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil { + t.Fatal("expected too many items") + } + + footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize, 1, apeTagFlagHeader) + if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil { + t.Fatal("expected header flag error") + } +} diff --git a/go_backend/audio_metadata_supplement_test.go b/go_backend/audio_metadata_supplement_test.go new file mode 100644 index 00000000..6dbf999e --- /dev/null +++ b/go_backend/audio_metadata_supplement_test.go @@ -0,0 +1,482 @@ +package gobackend + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAudioMetadataID3ParsingBranches(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "tagged.mp3") + tag := buildID3v23Tag( + id3TextFrame("TIT2", "Title"), + id3TextFrame("TPE1", "Artist"), + id3TextFrame("TPE2", "Album Artist"), + id3TextFrame("TALB", "Album"), + id3TextFrame("TDRC", "2026-05-04"), + id3TextFrame("TCON", "(13)Pop"), + id3TextFrame("TRCK", "4/12"), + id3TextFrame("TPOS", "1/2"), + id3TextFrame("TSRC", "USRC17607839"), + id3TextFrame("TCOM", "Composer"), + id3TextFrame("TPUB", "Label"), + id3TextFrame("TCOP", "Copyright"), + id3CommentFrame("COMM", "Comment"), + id3CommentFrame("USLT", "Lyrics"), + id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_GAIN", "-6.50 dB"), + id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_PEAK", "0.98"), + ) + if err := os.WriteFile(path, append(tag, []byte("audio")...), 0600); err != nil { + t.Fatalf("write ID3v2: %v", err) + } + + meta, err := ReadID3Tags(path) + if err != nil { + t.Fatalf("ReadID3Tags: %v", err) + } + if meta.Title != "Title" || meta.TrackNumber != 4 || meta.TotalTracks != 12 || meta.Genre != "Pop" { + t.Fatalf("metadata = %#v", meta) + } + if meta.Comment != "Comment" || meta.Lyrics != "Lyrics" || meta.ReplayGainTrackGain == "" { + t.Fatalf("metadata comments/lyrics/replaygain = %#v", meta) + } + + id3v1Path := filepath.Join(dir, "id3v1.mp3") + if err := os.WriteFile(id3v1Path, append([]byte("audio"), buildID3v1Tag("V1 Title", "V1 Artist", "V1 Album", "1999", 7, 13)...), 0600); err != nil { + t.Fatalf("write ID3v1: %v", err) + } + v1, err := ReadID3Tags(id3v1Path) + if err != nil { + t.Fatalf("ReadID3Tags v1: %v", err) + } + if v1.Title != "V1 Title" || v1.Artist != "V1 Artist" || v1.Genre == "" { + t.Fatalf("v1 = %#v", v1) + } + + v22Path := filepath.Join(dir, "id3v22.mp3") + v22 := buildID3v22Tag( + id3v22TextFrame("TT2", "V22 Title"), + id3v22TextFrame("TP1", "V22 Artist"), + id3v22TextFrame("TRK", "2/5"), + id3v22CommentFrame("ULT", "V22 Lyrics"), + ) + if err := os.WriteFile(v22Path, append(v22, []byte("audio")...), 0600); err != nil { + t.Fatalf("write ID3v2.2: %v", err) + } + v22Meta, err := ReadID3Tags(v22Path) + if err != nil { + t.Fatalf("ReadID3Tags v2.2: %v", err) + } + if v22Meta.Title != "V22 Title" || v22Meta.Artist != "V22 Artist" || v22Meta.Lyrics != "V22 Lyrics" { + t.Fatalf("v22 = %#v", v22Meta) + } + + if got := decodeUTF16([]byte{0xff, 0xfe, 'H', 0, 'i', 0}); got != "Hi" { + t.Fatalf("decodeUTF16 = %q", got) + } + if got := decodeUTF16BE([]byte{0, 'O', 0, 'K'}); got != "OK" { + t.Fatalf("decodeUTF16BE = %q", got) + } + if n, total := parseIndexPair(" 8 / 10 "); n != 8 || total != 10 { + t.Fatalf("parseIndexPair = %d/%d", n, total) + } + if got := parseTrackNumber("9/11"); got != 9 { + t.Fatalf("parseTrackNumber = %d", got) + } + if got := removeUnsync([]byte{0xff, 0x00, 0xe0}); !bytes.Equal(got, []byte{0xff, 0xe0}) { + t.Fatalf("removeUnsync = %#v", got) + } + if got := extendedHeaderSize([]byte{0, 0, 0, 6, 0, 0, 0, 0, 0, 0}, 3); got != 10 { + t.Fatalf("extendedHeaderSize = %d", got) + } + if got := syncsafeToInt([]byte{0, 0, 2, 0}); got != 256 { + t.Fatalf("syncsafe = %d", got) + } +} + +func TestAudioMetadataCoverAndQualityHelpers(t *testing.T) { + png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0} + if detectCoverMIME("cover.jpg", png) != "image/png" || detectCoverMIME("cover.webp", []byte("RIFFxxxxWEBPdata")) != "image/webp" { + t.Fatal("cover MIME detection mismatch") + } + if _, err := buildPictureBlock("", nil); err == nil { + t.Fatal("expected empty picture block error") + } + + apic := append([]byte{3}, []byte("image/png\x00")...) + apic = append(apic, 3, 0) + apic = append(apic, png...) + image, mime := parseAPICFrame(apic, 3) + if mime != "image/png" || !bytes.Equal(image, png) { + t.Fatalf("APIC = %s/%v", mime, image) + } + pic := append([]byte{0}, []byte("PNG")...) + pic = append(pic, 3, 0) + pic = append(pic, png...) + image, mime = parseAPICFrame(pic, 2) + if mime != "image/png" || !bytes.Equal(image, png) { + t.Fatalf("PIC = %s/%v", mime, image) + } + + frame := make([]byte, 10) + copy(frame[:4], "APIC") + binary.BigEndian.PutUint32(frame[4:8], uint32(len(apic))) + tag := append(frame, apic...) + header := []byte{'I', 'D', '3', 3, 0, 0, byte(len(tag) >> 21), byte(len(tag) >> 14), byte(len(tag) >> 7), byte(len(tag))} + mp3CoverPath := filepath.Join(t.TempDir(), "cover.mp3") + if err := os.WriteFile(mp3CoverPath, append(append(header, tag...), []byte("audio")...), 0600); err != nil { + t.Fatal(err) + } + extracted, extractedMIME, err := extractMP3CoverArt(mp3CoverPath) + if err != nil || extractedMIME != "image/png" || !bytes.Equal(extracted, png) { + t.Fatalf("extractMP3CoverArt = %s/%v/%v", extractedMIME, extracted, err) + } + + var picture bytes.Buffer + binary.Write(&picture, binary.BigEndian, uint32(3)) + binary.Write(&picture, binary.BigEndian, uint32(len("image/png"))) + picture.WriteString("image/png") + binary.Write(&picture, binary.BigEndian, uint32(0)) + binary.Write(&picture, binary.BigEndian, uint32(1)) + binary.Write(&picture, binary.BigEndian, uint32(1)) + binary.Write(&picture, binary.BigEndian, uint32(32)) + binary.Write(&picture, binary.BigEndian, uint32(0)) + binary.Write(&picture, binary.BigEndian, uint32(len(png))) + picture.Write(png) + flacImage, flacMIME := parseFLACPictureBlock(picture.Bytes()) + if flacMIME != "image/png" || !bytes.Equal(flacImage, png) { + t.Fatalf("FLAC picture = %s/%v", flacMIME, flacImage) + } + + comment := "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(picture.Bytes()) + var vorbis bytes.Buffer + binary.Write(&vorbis, binary.LittleEndian, uint32(6)) + vorbis.WriteString("vendor") + binary.Write(&vorbis, binary.LittleEndian, uint32(1)) + binary.Write(&vorbis, binary.LittleEndian, uint32(len(comment))) + vorbis.WriteString(comment) + commentImage, commentMIME := extractPictureFromVorbisComments(vorbis.Bytes()) + if commentMIME != "image/png" || !bytes.Equal(commentImage, png) { + t.Fatalf("vorbis picture = %s/%v", commentMIME, commentImage) + } + decoded := make([]byte, base64StdDecodeLen(len("SGV sbG8="))+4) + n, err := base64StdDecode(decoded, []byte("SGV sbG8=")) + if err != nil || strings.TrimRight(string(decoded[:n]), "\x00") != "Hello" { + t.Fatalf("base64 decode = %q/%v", decoded[:n], err) + } + + if detectOggStreamType([][]byte{[]byte("OpusHeadxxxx")}) != oggStreamOpus { + t.Fatal("expected opus stream") + } + if detectOggStreamType([][]byte{append([]byte{1}, []byte("vorbisxxxx")...)}) != oggStreamVorbis { + t.Fatal("expected vorbis stream") + } + + mp3Path := filepath.Join(t.TempDir(), "quality.mp3") + audio := append([]byte{0xFF, 0xFB, 0x90, 0x64}, bytes.Repeat([]byte{0}, 2000)...) + if err := os.WriteFile(mp3Path, audio, 0600); err != nil { + t.Fatal(err) + } + quality, err := GetMP3Quality(mp3Path) + if err != nil || quality.SampleRate != 44100 || quality.Bitrate != 128000 { + t.Fatalf("MP3 quality = %#v/%v", quality, err) + } + if _, _, err := extractMP3CoverArt(filepath.Join(t.TempDir(), "missing.mp3")); err == nil { + t.Fatal("expected missing MP3 cover error") + } +} + +func TestM4AMetadataAtomHelpers(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "tagged.m4a") + cover := []byte{0xFF, 0xD8, 0xFF, 0x00} + ilstPayload := []byte{} + ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9nam", "M4A Title")...) + ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9ART", "M4A Artist")...) + ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9alb", "M4A Album")...) + ilstPayload = append(ilstPayload, buildM4ATextTag("aART", "Album Artist")...) + ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9day", "2026")...) + ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9gen", "Pop")...) + ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9wrt", "Composer")...) + ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9cmt", "[ti:Comment Lyrics]")...) + ilstPayload = append(ilstPayload, buildM4ATextTag("cprt", "Copyright")...) + ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9lyr", "[00:00.00]M4A Lyrics")...) + ilstPayload = append(ilstPayload, buildM4AIndexTag("trkn", 3, 12)...) + ilstPayload = append(ilstPayload, buildM4AIndexTag("disk", 1, 2)...) + ilstPayload = append(ilstPayload, buildM4AFreeformAtom("ISRC", "USRC17607839")...) + ilstPayload = append(ilstPayload, buildM4AFreeformAtom("LABEL", "Label")...) + ilstPayload = append(ilstPayload, buildM4AFreeformAtom("REPLAYGAIN_TRACK_GAIN", "-6.50 dB")...) + ilstPayload = append(ilstPayload, buildM4AAtom("covr", buildM4AAtom("data", append([]byte{0, 0, 0, 13, 0, 0, 0, 0}, cover...)))...) + fileData := buildM4AFileWithIlst(ilstPayload, true) + if err := os.WriteFile(path, fileData, 0600); err != nil { + t.Fatal(err) + } + + meta, err := ReadM4ATags(path) + if err != nil { + t.Fatalf("ReadM4ATags: %v", err) + } + if meta.Title != "M4A Title" || meta.Artist != "M4A Artist" || meta.TrackNumber != 3 || meta.TotalTracks != 12 || meta.ISRC != "USRC17607839" { + t.Fatalf("M4A metadata = %#v", meta) + } + if lyrics, err := extractLyricsFromM4A(path); err != nil || !strings.Contains(lyrics, "M4A Lyrics") { + t.Fatalf("extractLyricsFromM4A = %q/%v", lyrics, err) + } + if image, err := extractCoverFromM4A(path); err != nil || !bytes.Equal(image, cover) { + t.Fatalf("extractCoverFromM4A = %#v/%v", image, err) + } + if pathInfo, err := func() (m4aMetadataPath, error) { + f, err := os.Open(path) + if err != nil { + return m4aMetadataPath{}, err + } + defer f.Close() + info, _ := f.Stat() + return findM4AMetadataPath(f, info.Size()) + }(); err != nil || pathInfo.udta == nil { + t.Fatalf("findM4AMetadataPath = %#v/%v", pathInfo, err) + } + if err := EditM4AReplayGain(path, map[string]string{"replaygain_track_gain": "-5.00 dB", "replaygain_track_peak": "0.98"}); err != nil { + t.Fatalf("EditM4AReplayGain: %v", err) + } + edited, err := ReadM4ATags(path) + if err != nil || edited.ReplayGainTrackGain != "-5.00 dB" || edited.ReplayGainTrackPeak != "0.98" { + t.Fatalf("edited M4A = %#v/%v", edited, err) + } + + noUdtaPath := filepath.Join(dir, "noudta.m4a") + if err := os.WriteFile(noUdtaPath, buildM4AFileWithIlst(buildM4ATextTag("\xa9nam", "No Udta"), false), 0600); err != nil { + t.Fatal(err) + } + if meta, err := ReadM4ATags(noUdtaPath); err != nil || meta.Title != "No Udta" { + t.Fatalf("ReadM4ATags no udta = %#v/%v", meta, err) + } + if _, err := ReadM4ATags(filepath.Join(dir, "missing.m4a")); err == nil { + t.Fatal("expected missing M4A error") + } + emptyM4A := filepath.Join(dir, "empty.m4a") + if err := os.WriteFile(emptyM4A, buildM4AFileWithIlst(nil, true), 0600); err != nil { + t.Fatal(err) + } + if _, err := ReadM4ATags(emptyM4A); err == nil { + t.Fatal("expected empty M4A tags error") + } + if _, err := extractCoverFromM4A(emptyM4A); err == nil { + t.Fatal("expected missing M4A cover error") + } + if _, err := extractLyricsFromM4A(emptyM4A); err == nil { + t.Fatal("expected missing M4A lyrics error") + } + + sidecarAudio := filepath.Join(dir, "sidecar.mp3") + if err := os.WriteFile(sidecarAudio, []byte("audio"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "sidecar.lrc"), []byte(" [00:00.00]Sidecar "), 0600); err != nil { + t.Fatal(err) + } + if lyrics, err := extractLyricsFromSidecarLRC(sidecarAudio); err != nil || !strings.Contains(lyrics, "Sidecar") { + t.Fatalf("sidecar lyrics = %q/%v", lyrics, err) + } + if !looksLikeEmbeddedLyrics("[ti:Song]") || !looksLikeEmbeddedLyrics("[00:00.00]Line\n[00:01.00]Next") || looksLikeEmbeddedLyrics("plain") { + t.Fatal("embedded lyric heuristic mismatch") + } + if formatIndexValue(3, 12) != "3/12" || formatIndexValue(3, 0) != "3" || formatIndexValue(0, 12) != "" { + t.Fatal("formatIndexValue mismatch") + } + if parsePositiveInt(" 42 ") != 42 || parsePositiveInt("bad") != 0 { + t.Fatal("parsePositiveInt mismatch") + } + if !hasMapKey(map[string]string{"x": "y"}, "x") { + t.Fatal("expected map key") + } + if _, ok := parseReplayGainDb("-6.50 dB"); !ok { + t.Fatal("expected ReplayGain dB parse") + } + if _, ok := parseReplayGainPeak("0.98"); !ok { + t.Fatal("expected ReplayGain peak parse") + } + if norm := buildITunNORMTag("-6.50 dB", "0.98"); norm == "" { + t.Fatal("expected iTunNORM") + } + if fields := collectM4AReplayGainFields(map[string]string{"replaygain_track_gain": "-6 dB", "replaygain_track_peak": "0.9"}); fields["iTunNORM"] == "" { + t.Fatalf("ReplayGain fields = %#v", fields) + } + + qualityPath := filepath.Join(dir, "quality.m4a") + mvhd := make([]byte, 20) + binary.BigEndian.PutUint32(mvhd[12:16], 1000) + binary.BigEndian.PutUint32(mvhd[16:20], 180000) + sampleEntry := make([]byte, 32) + copy(sampleEntry[0:4], "mp4a") + binary.BigEndian.PutUint16(sampleEntry[22:24], 24) + sampleEntry[28] = 0xAC + sampleEntry[29] = 0x44 + qualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), sampleEntry...))...) + if err := os.WriteFile(qualityPath, qualityFile, 0600); err != nil { + t.Fatal(err) + } + if quality, err := GetM4AQuality(qualityPath); err != nil || quality.BitDepth != 24 || quality.SampleRate != 44100 || quality.Duration != 180 { + t.Fatalf("GetM4AQuality = %#v/%v", quality, err) + } + if quality, err := GetAudioQuality(qualityPath); err != nil || quality.SampleRate != 44100 { + t.Fatalf("GetAudioQuality M4A = %#v/%v", quality, err) + } + if _, _, ok := parseALACSpecificConfig(make([]byte, 4)); ok { + t.Fatal("short ALAC config should not parse") + } + alac := make([]byte, 24) + alac[5] = 16 + binary.BigEndian.PutUint32(alac[20:24], 48000) + if depth, rate, ok := parseALACSpecificConfig(alac); !ok || depth != 16 || rate != 48000 { + t.Fatalf("ALAC config = %d/%d/%v", depth, rate, ok) + } +} + +func TestOggMetadataQualityAndCoverHelpers(t *testing.T) { + dir := t.TempDir() + opusHead := make([]byte, 19) + copy(opusHead[0:8], "OpusHead") + binary.LittleEndian.PutUint16(opusHead[10:12], 312) + binary.LittleEndian.PutUint32(opusHead[12:16], 48000) + + var comments bytes.Buffer + binary.Write(&comments, binary.LittleEndian, uint32(6)) + comments.WriteString("vendor") + entries := []string{ + "TITLE=Ogg Title", + "ARTIST=Artist", + "ALBUMARTIST=Album Artist", + "TRACKNUMBER=2/9", + "DISCNUMBER=1/2", + "LYRICS=[00:00.00]Ogg Lyrics", + } + binary.Write(&comments, binary.LittleEndian, uint32(len(entries))) + for _, entry := range entries { + binary.Write(&comments, binary.LittleEndian, uint32(len(entry))) + comments.WriteString(entry) + } + opusTags := append([]byte("OpusTags"), comments.Bytes()...) + oggPath := filepath.Join(dir, "tagged.opus") + oggData := append(buildOggPage(0x02, 0, opusHead), buildOggPage(0x00, 48000+312, opusTags)...) + if err := os.WriteFile(oggPath, oggData, 0600); err != nil { + t.Fatal(err) + } + quality, err := GetOggQuality(oggPath) + if err != nil || quality.SampleRate != 48000 || quality.Duration != 1 { + t.Fatalf("GetOggQuality = %#v/%v", quality, err) + } + meta, err := ReadOggVorbisComments(oggPath) + if err != nil || meta.Title != "Ogg Title" || meta.TrackNumber != 2 || meta.TotalTracks != 9 { + t.Fatalf("ReadOggVorbisComments = %#v/%v", meta, err) + } + + picture := buildTestFLACPictureBlock([]byte{0x89, 0x50, 0x4E, 0x47}, "image/png") + pictureComment := "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(picture) + var coverComments bytes.Buffer + binary.Write(&coverComments, binary.LittleEndian, uint32(6)) + coverComments.WriteString("vendor") + binary.Write(&coverComments, binary.LittleEndian, uint32(1)) + binary.Write(&coverComments, binary.LittleEndian, uint32(len(pictureComment))) + coverComments.WriteString(pictureComment) + coverPath := filepath.Join(dir, "cover.opus") + coverData := append(buildOggPage(0x02, 0, opusHead), buildOggPage(0x00, 48000+312, append([]byte("OpusTags"), coverComments.Bytes()...))...) + if err := os.WriteFile(coverPath, coverData, 0600); err != nil { + t.Fatal(err) + } + if image, mime, err := extractOggCoverArt(coverPath); err != nil || mime != "image/png" || len(image) == 0 { + t.Fatalf("extractOggCoverArt = %s/%#v/%v", mime, image, err) + } + if image, mime, err := extractAnyCoverArtWithHint(coverPath, "cover.opus"); err != nil || mime != "image/png" || len(image) == 0 { + t.Fatalf("extractAnyCoverArtWithHint = %s/%#v/%v", mime, image, err) + } + if image, mime, err := extractAnyCoverArt(coverPath); err != nil || mime != "image/png" || len(image) == 0 { + t.Fatalf("extractAnyCoverArt = %s/%#v/%v", mime, image, err) + } + extractedCoverPath := filepath.Join(dir, "extracted.png") + if err := ExtractCoverToFile(coverPath, extractedCoverPath); err != nil { + t.Fatalf("ExtractCoverToFile = %v", err) + } + if data := mustReadFile(t, extractedCoverPath); len(data) == 0 { + t.Fatal("expected extracted cover data") + } + cachePath, err := SaveCoverToCacheWithHintAndKey(coverPath, "cover.opus", dir, "key") + if err != nil || cachePath == "" { + t.Fatalf("SaveCoverToCacheWithHintAndKey = %q/%v", cachePath, err) + } + cacheDir := filepath.Join(dir, "cache") + if path, err := SaveCoverToCache(coverPath, cacheDir); err != nil || !strings.HasSuffix(path, ".png") { + t.Fatalf("SaveCoverToCache = %q/%v", path, err) + } + if path, err := SaveCoverToCacheWithHint(coverPath, "cover.opus", cacheDir); err != nil || path == "" { + t.Fatalf("SaveCoverToCacheWithHint = %q/%v", path, err) + } + hitPath, err := SaveCoverToCache(coverPath, cacheDir) + if err != nil || hitPath == "" { + t.Fatalf("SaveCoverToCache cache hit = %q/%v", hitPath, err) + } + if _, err := SaveCoverToCacheWithHintAndKey(filepath.Join(dir, "missing.opus"), "missing.opus", dir, "missing"); err == nil { + t.Fatal("expected missing cover cache error") + } + + badPath := filepath.Join(dir, "bad.ogg") + if err := os.WriteFile(badPath, []byte("bad"), 0600); err != nil { + t.Fatal(err) + } + if _, err := GetOggQuality(badPath); err == nil { + t.Fatal("expected invalid Ogg quality error") + } +} + +func buildM4ADataPayload(payload []byte) []byte { + return append([]byte{0, 0, 0, 1, 0, 0, 0, 0}, payload...) +} + +func buildM4ATextTag(atomType, value string) []byte { + return buildM4AAtom(atomType, buildM4AAtom("data", buildM4ADataPayload([]byte(value)))) +} + +func buildM4AIndexTag(atomType string, number, total int) []byte { + payload := []byte{0, 0, 0, byte(number), 0, byte(total), 0, 0} + return buildM4AAtom(atomType, buildM4AAtom("data", buildM4ADataPayload(payload))) +} + +func buildM4AFileWithIlst(ilstPayload []byte, withUdta bool) []byte { + ilst := buildM4AAtom("ilst", ilstPayload) + meta := buildM4AAtom("meta", append([]byte{0, 0, 0, 0}, ilst...)) + moovPayload := meta + if withUdta { + moovPayload = buildM4AAtom("udta", meta) + } + return append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", moovPayload)...) +} + +func buildOggPage(headerType byte, granule uint64, packet []byte) []byte { + header := make([]byte, 27) + copy(header[0:4], "OggS") + header[4] = 0 + header[5] = headerType + binary.LittleEndian.PutUint64(header[6:14], granule) + header[26] = 1 + return append(append(header, byte(len(packet))), packet...) +} + +func buildTestFLACPictureBlock(image []byte, mime string) []byte { + var picture bytes.Buffer + binary.Write(&picture, binary.BigEndian, uint32(3)) + binary.Write(&picture, binary.BigEndian, uint32(len(mime))) + picture.WriteString(mime) + binary.Write(&picture, binary.BigEndian, uint32(0)) + binary.Write(&picture, binary.BigEndian, uint32(1)) + binary.Write(&picture, binary.BigEndian, uint32(1)) + binary.Write(&picture, binary.BigEndian, uint32(32)) + binary.Write(&picture, binary.BigEndian, uint32(0)) + binary.Write(&picture, binary.BigEndian, uint32(len(image))) + picture.Write(image) + return picture.Bytes() +} diff --git a/go_backend/coverage_test_helpers_test.go b/go_backend/coverage_test_helpers_test.go new file mode 100644 index 00000000..16aefc48 --- /dev/null +++ b/go_backend/coverage_test_helpers_test.go @@ -0,0 +1,411 @@ +package gobackend + +import ( + "archive/zip" + "bytes" + "encoding/binary" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func newTestLoadedExtension(t *testing.T, types ...ExtensionType) *loadedExtension { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "index.js"), []byte(testExtensionJS), 0600); err != nil { + t.Fatalf("write index.js: %v", err) + } + return &loadedExtension{ + ID: "coverage-ext", + Manifest: &ExtensionManifest{ + Name: "coverage-ext", + Description: "Coverage extension", + Version: "1.0.0", + Types: types, + Permissions: ExtensionPermissions{File: true, Network: []string{"example.test"}}, + SearchBehavior: &SearchBehaviorConfig{ + Enabled: true, + Placeholder: "Search coverage", + Primary: true, + Icon: "search", + }, + URLHandler: &URLHandlerConfig{Enabled: true, Patterns: []string{"https://example.test/"}}, + TrackMatching: &TrackMatchingConfig{CustomMatching: true}, + PostProcessing: &PostProcessingConfig{ + Enabled: true, + Hooks: []PostProcessingHook{{ID: "hook", Name: "Hook", DefaultEnabled: true, SupportedFormats: []string{"flac"}}}, + }, + }, + Enabled: true, + SourceDir: dir, + DataDir: t.TempDir(), + } +} + +const testExtensionJS = ` +function track(id) { + return { + id: id, + name: "Track " + id, + artists: "Artist", + albumName: "Album", + albumArtist: "Album Artist", + durationMs: 180000, + coverUrl: "https://example.test/cover.jpg", + releaseDate: "2026-05-04", + trackNumber: 1, + totalTracks: 10, + discNumber: 1, + totalDiscs: 1, + isrc: "USRC17607839", + itemType: "track", + albumType: "album", + tidalId: "tidal-1", + qobuzId: "qobuz-1", + deezerId: "deezer-1", + spotifyId: "spotify:track:1", + externalLinks: { tidal: "https://tidal.example/1" }, + label: "Label", + copyright: "Copyright", + genre: "Pop", + composer: "Composer", + audioQuality: "FLAC 24-bit", + audioModes: "DOLBY_ATMOS" + }; +} + +registerExtension({ + searchTracks: function(query, limit) { + return { tracks: [track("search-1")], total: 1 }; + }, + customSearch: function(query, options) { + var t = track("custom-1"); + t.name = "Custom " + query; + return [t]; + }, + getHomeFeed: function() { + return [{ id: "home-1", title: "Home", tracks: [track("home-track")] }]; + }, + getBrowseCategories: function() { + return [{ id: "cat-1", title: "Category" }]; + }, + getTrack: function(id) { + return track(id); + }, + getAlbum: function(id) { + return { + id: id, + name: "Album " + id, + artists: "Artist", + artistId: "artist-1", + coverUrl: "https://example.test/album.jpg", + releaseDate: "2026-05-04", + totalTracks: 1, + albumType: "album", + tracks: [track("album-track")] + }; + }, + getPlaylist: function(id) { + return { + id: id, + name: "Playlist " + id, + artists: "Owner", + coverUrl: "https://example.test/playlist.jpg", + totalTracks: 1, + tracks: [track("playlist-track")] + }; + }, + getArtist: function(id) { + return { + id: id, + name: "Artist", + imageUrl: "https://example.test/artist.jpg", + headerImage: "https://example.test/header.jpg", + listeners: 123, + albums: [{ id: "album-1", name: "Album", artists: "Artist", totalTracks: 1 }], + releases: [{ id: "release-1", name: "Release", artists: "Artist", totalTracks: 1, tracks: [track("release-track")] }], + topTracks: [track("top-track")] + }; + }, + enrichTrack: function(input) { + var t = track(input.id || "enriched"); + t.name = "Enriched"; + return t; + }, + checkAvailability: function(isrc, name, artist, ids) { + return { available: true, reason: "ok", trackId: "download-track", skipFallback: true }; + }, + getDownloadUrl: function(id, quality) { + return { url: "https://example.test/audio.flac", format: "flac", bitDepth: 24, sampleRate: 96000 }; + }, + download: function(id, quality, outputPath, onProgress) { + if (onProgress) onProgress(100); + return { + success: true, + filePath: "EXISTS:" + outputPath, + alreadyExists: false, + bitDepth: 24, + sampleRate: 96000, + title: "Downloaded", + artist: "Artist", + album: "Album", + albumArtist: "Album Artist", + trackNumber: 1, + totalTracks: 10, + discNumber: 1, + totalDiscs: 1, + releaseDate: "2026-05-04", + coverUrl: "https://example.test/cover.jpg", + isrc: "USRC17607839", + genre: "Pop", + label: "Label", + copyright: "Copyright", + composer: "Composer", + lyricsLrc: "[00:00.00]Hello", + decryptionKey: "001122", + decryption: { strategy: "mp4_decryption_key", options: { kid: "1" } } + }; + }, + fetchLyrics: function(name, artist, album, duration) { + return { syncType: "LINE_SYNCED", provider: "coverage-ext", lines: [{ startTimeMs: 0, endTimeMs: 1000, words: "Hello" }] }; + }, + handleUrl: function(url) { + return { type: "track", name: "Handled", coverUrl: "https://example.test/cover.jpg", track: track("url-track"), tracks: [track("url-track")], album: this.getAlbum("url-album"), artist: this.getArtist("url-artist") }; + }, + matchTrack: function(req) { + return { matched: true, trackId: "download-track", confidence: 0.95, reason: "exact" }; + }, + postProcess: function(path, req) { + return { success: true, newFilePath: path, bitDepth: 24, sampleRate: 96000 }; + }, + postProcessV2: function(input, metadata, hookId) { + return { success: true, newFilePath: input.path || input.uri, newFileUri: input.uri || "", bitDepth: 24, sampleRate: 96000 }; + } +}); +` + +func mustReadFile(t *testing.T, path string) []byte { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read file: %v", err) + } + return data +} + +func buildID3v23Tag(frames ...[]byte) []byte { + body := bytes.Join(frames, nil) + header := []byte{'I', 'D', '3', 3, 0, 0, 0, 0, 0, 0} + copy(header[6:10], syncsafeBytes(len(body))) + return append(header, body...) +} + +func id3TextFrame(id, value string) []byte { + return id3v23Frame(id, append([]byte{3}, []byte(value)...)) +} + +func id3CommentFrame(id, value string) []byte { + payload := append([]byte{3, 'e', 'n', 'g', 0}, []byte(value)...) + return id3v23Frame(id, payload) +} + +func id3UserTextFrame(id, desc, value string) []byte { + payload := append([]byte{3}, []byte(desc)...) + payload = append(payload, 0) + payload = append(payload, []byte(value)...) + return id3v23Frame(id, payload) +} + +func id3v23Frame(id string, payload []byte) []byte { + frame := make([]byte, 10+len(payload)) + copy(frame[0:4], id) + binary.BigEndian.PutUint32(frame[4:8], uint32(len(payload))) + copy(frame[10:], payload) + return frame +} + +func buildID3v22Tag(frames ...[]byte) []byte { + body := bytes.Join(frames, nil) + header := []byte{'I', 'D', '3', 2, 0, 0, 0, 0, 0, 0} + copy(header[6:10], syncsafeBytes(len(body))) + return append(header, body...) +} + +func id3v22TextFrame(id, value string) []byte { + return id3v22Frame(id, append([]byte{3}, []byte(value)...)) +} + +func id3v22CommentFrame(id, value string) []byte { + payload := append([]byte{3, 'e', 'n', 'g', 0}, []byte(value)...) + return id3v22Frame(id, payload) +} + +func id3v22Frame(id string, payload []byte) []byte { + frame := make([]byte, 6+len(payload)) + copy(frame[0:3], id) + size := len(payload) + frame[3] = byte(size >> 16) + frame[4] = byte(size >> 8) + frame[5] = byte(size) + copy(frame[6:], payload) + return frame +} + +func syncsafeBytes(size int) []byte { + return []byte{ + byte((size >> 21) & 0x7f), + byte((size >> 14) & 0x7f), + byte((size >> 7) & 0x7f), + byte(size & 0x7f), + } +} + +func buildID3v1Tag(title, artist, album, year string, track, genre byte) []byte { + tag := make([]byte, 128) + copy(tag[0:3], "TAG") + copyPadded(tag[3:33], title) + copyPadded(tag[33:63], artist) + copyPadded(tag[63:93], album) + copyPadded(tag[93:97], year) + tag[125] = 0 + tag[126] = track + tag[127] = genre + return tag +} + +func copyPadded(dst []byte, value string) { + for i := range dst { + dst[i] = ' ' + } + copy(dst, value) +} + +func writeExportCueFixture(t *testing.T, dir string) (string, string) { + t.Helper() + audioPath := filepath.Join(dir, "exports.wav") + if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil { + t.Fatalf("write export audio: %v", err) + } + cuePath := filepath.Join(dir, "exports.cue") + cue := "PERFORMER \"Artist\"\nTITLE \"Album\"\nFILE \"exports.wav\" WAVE\n TRACK 01 AUDIO\n TITLE \"Song\"\n INDEX 01 00:00:00\n" + if err := os.WriteFile(cuePath, []byte(cue), 0600); err != nil { + t.Fatalf("write export cue: %v", err) + } + return cuePath, audioPath +} + +func escapeJSONPath(path string) string { + data, _ := json.Marshal(path) + return strings.Trim(string(data), `"`) +} + +func fakeDeezerResponse(path, rawQuery string) string { + switch { + case path == "/2.0/search/track": + if strings.Contains(rawQuery, "MISSING") { + return `{"data":[]}` + } + return `{"data":[` + fakeDeezerTrackJSON(101, true) + `]}` + case path == "/2.0/search/artist": + return `{"data":[{"id":301,"name":"Artist","picture_xl":"artist-xl","nb_fan":123}]}` + case path == "/2.0/search/album": + return `{"data":[{"id":201,"title":"Album","cover_xl":"album-xl","nb_tracks":2,"release_date":"2026-05-04","record_type":"compile","artist":{"id":301,"name":"Artist"}}]}` + case path == "/2.0/search/playlist": + return `{"data":[{"id":401,"title":"Playlist","picture_xl":"playlist-xl","nb_tracks":2,"user":{"name":"Owner"}}]}` + case path == "/2.0/track/101", path == "/2.0/track/isrc:USRC17607839": + return fakeDeezerTrackJSON(101, true) + case path == "/2.0/track/102": + return fakeDeezerTrackJSON(102, true) + case path == "/2.0/track/isrc:MISSING": + return `{"id":0}` + case path == "/2.0/album/201": + return `{"id":201,"title":"Album","cover_xl":"album-xl","release_date":"2026-05-04","nb_tracks":2,"record_type":"compile","label":"Label","copyright":"Copyright","genres":{"data":[{"name":"Pop"},{"name":"Dance"}]},"artist":{"id":301,"name":"Album Artist"},"contributors":[{"name":"Contributor A"},{"name":"Contributor B"}],"tracks":{"data":[` + fakeDeezerTrackJSON(101, true) + `,` + fakeDeezerTrackJSON(102, false) + `]}}` + case path == "/2.0/artist/301": + return `{"id":301,"name":"Artist","picture_xl":"artist-xl","nb_fan":123,"nb_album":1}` + case path == "/2.0/artist/301/albums": + return `{"data":[{"id":201,"title":"Album","release_date":"2026-05-04","nb_tracks":0,"cover_xl":"album-xl","record_type":"compile"}]}` + case path == "/2.0/artist/301/related": + return `{"data":[{"id":302,"name":"Related","picture_xl":"related-xl","nb_fan":10}]}` + case path == "/2.0/playlist/401": + return `{"id":401,"title":"Playlist","picture_xl":"playlist-xl","nb_tracks":2,"creator":{"name":"Owner"},"tracks":{"data":[` + fakeDeezerTrackJSON(101, true) + `,` + fakeDeezerTrackJSON(102, false) + `]}}` + default: + return "" + } +} + +func fakeDeezerTrackJSON(id int, withISRC bool) string { + isrc := "" + if withISRC { + isrc = `,"isrc":"USRC17607839"` + if id == 102 { + isrc = `,"isrc":"USRC17607840"` + } + } + return fmt.Sprintf(`{"id":%d,"title":"Track %d","duration":180,"track_position":%d,"disk_number":1%s,"link":"https://deezer.test/track/%d","release_date":"2026-05-04","artist":{"id":301,"name":"Artist"},"contributors":[{"name":"Contributor A"},{"name":"Contributor B"}],"album":{"id":201,"title":"Album","cover_xl":"album-xl","release_date":"2026-05-04","record_type":"album"}}`, id, id, id-100, isrc, id) +} + +func createTestExtensionPackage(t *testing.T, path, name, version, js string, extraFiles map[string]string) { + t.Helper() + out, err := os.Create(path) + if err != nil { + t.Fatalf("create extension package: %v", err) + } + defer out.Close() + + zw := zip.NewWriter(out) + defer zw.Close() + + manifest := fmt.Sprintf(`{ + "name": %q, + "displayName": %q, + "version": %q, + "description": "Packaged test extension", + "type": ["metadata_provider", "download_provider", "lyrics_provider"], + "permissions": {"network": ["example.test"], "storage": true, "file": true}, + "icon": "icon.png", + "settings": [{"key":"quality","type":"string","label":"Quality"}], + "qualityOptions": [{"id":"lossless","label":"Lossless","description":"Lossless"}], + "searchBehavior": {"enabled": true, "placeholder": "Search", "primary": true}, + "urlHandler": {"enabled": true, "patterns": ["https://example.test/"]}, + "trackMatching": {"customMatching": true}, + "postProcessing": {"enabled": true, "hooks": [{"id":"hook","name":"Hook"}]}, + "serviceHealth": [{"id":"main","url":"https://example.test/health"}], + "capabilities": {"homeFeed": true} + }`, name, name, version) + + for fileName, content := range map[string]string{ + "manifest.json": manifest, + "index.js": js, + "icon.png": "png", + } { + writer, err := zw.Create(fileName) + if err != nil { + t.Fatalf("zip create %s: %v", fileName, err) + } + if _, err := writer.Write([]byte(content)); err != nil { + t.Fatalf("zip write %s: %v", fileName, err) + } + } + for fileName, content := range extraFiles { + writer, err := zw.Create(fileName) + if err != nil { + t.Fatalf("zip create extra %s: %v", fileName, err) + } + if _, err := writer.Write([]byte(content)); err != nil { + t.Fatalf("zip write extra %s: %v", fileName, err) + } + } +} + +func buildRawAPEItem(key, value string, flags uint32) []byte { + var buf bytes.Buffer + _ = binary.Write(&buf, binary.LittleEndian, uint32(len(value))) + _ = binary.Write(&buf, binary.LittleEndian, flags) + buf.WriteString(key) + buf.WriteByte(0) + buf.WriteString(value) + return buf.Bytes() +} diff --git a/go_backend/cue_duplicate_supplement_test.go b/go_backend/cue_duplicate_supplement_test.go new file mode 100644 index 00000000..34cc19cf --- /dev/null +++ b/go_backend/cue_duplicate_supplement_test.go @@ -0,0 +1,171 @@ +package gobackend + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestCueParserEndToEnd(t *testing.T) { + dir := t.TempDir() + audioPath := filepath.Join(dir, "album.wav") + if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil { + t.Fatalf("write audio: %v", err) + } + cuePath := filepath.Join(dir, "album.cue") + cue := "\ufeffREM GENRE \"Pop\"\n" + + "REM DATE 2026\n" + + "REM COMMENT \"comment\"\n" + + "REM COMPOSER \"Album Composer\"\n" + + "PERFORMER \"Album Artist\"\n" + + "TITLE \"Album Title\"\n" + + "FILE \"album.wav\" WAVE\n" + + " TRACK 01 AUDIO\n" + + " TITLE \"First\"\n" + + " PERFORMER \"Track Artist\"\n" + + " ISRC USRC17607839\n" + + " INDEX 01 00:00:00\n" + + " TRACK 02 AUDIO\n" + + " TITLE \"Second\"\n" + + " SONGWRITER \"Track Composer\"\n" + + " INDEX 00 03:00:00\n" + + " INDEX 01 03:05:00\n" + if err := os.WriteFile(cuePath, []byte(cue), 0600); err != nil { + t.Fatalf("write cue: %v", err) + } + + sheet, err := ParseCueFile(cuePath) + if err != nil { + t.Fatalf("ParseCueFile: %v", err) + } + if sheet.Performer != "Album Artist" || sheet.Title != "Album Title" || len(sheet.Tracks) != 2 { + t.Fatalf("sheet = %#v", sheet) + } + if got := parseCueTimestamp("01:02:37"); got <= 62 || got >= 63 { + t.Fatalf("timestamp = %f", got) + } + if got := formatCueTimestamp(3723.5); got != "01:02:03.500" { + t.Fatalf("format timestamp = %q", got) + } + if got := unquoteCue(" \"quoted\" "); got != "quoted" { + t.Fatalf("unquote = %q", got) + } + fileName, fileType := parseCueFileLine("unquoted album.flac FLAC") + if fileName != "unquoted album.flac" || fileType != "FLAC" { + t.Fatalf("file line = %q/%q", fileName, fileType) + } + + if resolved := ResolveCueAudioPath(cuePath, "album.flac"); resolved != audioPath { + t.Fatalf("resolved = %q want %q", resolved, audioPath) + } + info, err := BuildCueSplitInfo(cuePath, sheet, "") + if err != nil { + t.Fatalf("BuildCueSplitInfo: %v", err) + } + if info.Tracks[0].EndSec != 180 || info.Tracks[1].Composer != "Track Composer" { + t.Fatalf("split info = %#v", info.Tracks) + } + + jsonText, err := ParseCueFileJSON(cuePath, "") + if err != nil { + t.Fatalf("ParseCueFileJSON: %v", err) + } + var decoded CueSplitInfo + if err := json.Unmarshal([]byte(jsonText), &decoded); err != nil { + t.Fatalf("decode cue json: %v", err) + } + if decoded.AudioPath != audioPath { + t.Fatalf("decoded audio path = %q", decoded.AudioPath) + } + + results, err := ScanCueFileForLibraryExt(cuePath, "", "virtual/album.cue", 1234, "scan-time") + if err != nil { + t.Fatalf("ScanCueFileForLibraryExt: %v", err) + } + if len(results) != 2 || results[0].TrackName != "First" || results[0].Duration != 180 { + t.Fatalf("scan results = %#v", results) + } + if results[0].FilePath != "virtual/album.cue#track01" || results[0].Format != "cue+wav" { + t.Fatalf("scan path/format = %q/%q", results[0].FilePath, results[0].Format) + } + + if _, err := ParseCueFile(filepath.Join(dir, "missing.cue")); err == nil { + t.Fatal("expected missing cue error") + } + emptyCue := filepath.Join(dir, "empty.cue") + if err := os.WriteFile(emptyCue, []byte("TITLE \"No tracks\""), 0600); err != nil { + t.Fatal(err) + } + if _, err := ParseCueFile(emptyCue); err == nil { + t.Fatal("expected no tracks error") + } + missingDir := t.TempDir() + missingCuePath := filepath.Join(missingDir, "missing.cue") + if err := os.WriteFile(missingCuePath, []byte(cue), 0600); err != nil { + t.Fatal(err) + } + if _, err := BuildCueSplitInfo(missingCuePath, &CueSheet{FileName: "missing.wav"}, ""); err == nil { + t.Fatal("expected missing audio error") + } + if _, err := resolveCueAudioPathForLibrary(cuePath, nil, ""); err == nil { + t.Fatal("expected nil sheet error") + } + if _, err := scanCueSheetForLibrary(cuePath, nil, audioPath, "", 0, "", ""); err == nil { + t.Fatal("expected nil scan sheet error") + } +} + +func TestDuplicateIndexAndParallelExistence(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, "song.flac") + if err := os.WriteFile(filePath, []byte("audio"), 0600); err != nil { + t.Fatal(err) + } + + idx := &ISRCIndex{index: map[string]string{}, outputDir: dir, buildTime: time.Now()} + idx.Add("usrc17607839", filePath) + if got, ok := idx.lookup("USRC17607839"); !ok || got != filePath { + t.Fatalf("lookup = %q/%v", got, ok) + } + if got, err := idx.Lookup("usrc17607839"); err != nil || got != filePath { + t.Fatalf("Lookup = %q/%v", got, err) + } + idx.remove("usrc17607839") + if _, ok := idx.lookup("usrc17607839"); ok { + t.Fatal("expected removed ISRC") + } + + isrcIndexCacheMu.Lock() + isrcIndexCache[dir] = idx + isrcIndexCacheMu.Unlock() + defer InvalidateISRCCache(dir) + + AddToISRCIndex(dir, "USRC17607839", filePath) + if found, err := CheckISRCExists(dir, "USRC17607839"); err != nil || found != filePath { + t.Fatalf("CheckISRCExists = %q/%v", found, err) + } + if !CheckFileExists(filePath) || CheckFileExists(dir) || CheckFileExists(filepath.Join(dir, "missing.flac")) { + t.Fatal("unexpected file existence result") + } + + tracksJSON := `[{"isrc":"USRC17607839","track_name":"Song","artist_name":"Artist"},{"isrc":"MISSING","track_name":"Other","artist_name":"Artist"}]` + resultJSON, err := CheckFilesExistParallel(dir, tracksJSON) + if err != nil { + t.Fatalf("CheckFilesExistParallel: %v", err) + } + var results []FileExistenceResult + if err := json.Unmarshal([]byte(resultJSON), &results); err != nil { + t.Fatalf("decode results: %v", err) + } + if !results[0].Exists || results[0].FilePath != filePath || results[1].Exists { + t.Fatalf("results = %#v", results) + } + if _, err := CheckFilesExistParallel(dir, `not-json`); err == nil { + t.Fatal("expected invalid json error") + } + if err := PreBuildISRCIndex(""); err == nil { + t.Fatal("expected empty dir error") + } +} diff --git a/go_backend/deezer_supplement_test.go b/go_backend/deezer_supplement_test.go new file mode 100644 index 00000000..6cef61d8 --- /dev/null +++ b/go_backend/deezer_supplement_test.go @@ -0,0 +1,153 @@ +package gobackend + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + "time" +) + +func TestDeezerClientWithFakeHTTP(t *testing.T) { + client := &DeezerClient{ + httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery) + status := http.StatusOK + if body == "" { + status = http.StatusNotFound + body = `{"error":"missing"}` + } + return &http.Response{ + StatusCode: status, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + })}, + searchCache: map[string]*cacheEntry{}, + albumCache: map[string]*cacheEntry{}, + artistCache: map[string]*cacheEntry{}, + isrcCache: map[string]string{}, + cacheCleanupInterval: time.Millisecond, + } + ctx := context.Background() + + search, err := client.SearchAll(ctx, "artist song", 2, 2, "") + if err != nil { + t.Fatalf("SearchAll: %v", err) + } + if len(search.Tracks) != 1 || len(search.Artists) != 1 || len(search.Albums) != 1 || len(search.Playlists) != 1 { + t.Fatalf("search = %#v", search) + } + cached, err := client.SearchAll(ctx, "artist song", 2, 2, "") + if err != nil || cached != search { + t.Fatalf("cached SearchAll = %#v/%v", cached, err) + } + if filtered, err := client.SearchAll(ctx, "artist song", 1, 1, "track"); err != nil || len(filtered.Tracks) != 1 || len(filtered.Artists) != 0 { + t.Fatalf("filtered search = %#v/%v", filtered, err) + } + + track, err := client.GetTrack(ctx, "101") + if err != nil { + t.Fatalf("GetTrack: %v", err) + } + if track.Track.SpotifyID != "deezer:101" || track.Track.Artists != "Contributor A, Contributor B" { + t.Fatalf("track = %#v", track) + } + + album, err := client.GetAlbum(ctx, "201") + if err != nil { + t.Fatalf("GetAlbum: %v", err) + } + if album.AlbumInfo.Name != "Album" || len(album.TrackList) != 2 || album.TrackList[1].ISRC == "" { + t.Fatalf("album = %#v", album) + } + if cachedAlbum, err := client.GetAlbum(ctx, "201"); err != nil || cachedAlbum != album { + t.Fatalf("cached album = %#v/%v", cachedAlbum, err) + } + + artist, err := client.GetArtist(ctx, "301") + if err != nil { + t.Fatalf("GetArtist: %v", err) + } + if artist.ArtistInfo.Name != "Artist" || len(artist.Albums) != 1 || artist.Albums[0].TotalTracks == 0 { + t.Fatalf("artist = %#v", artist) + } + if cachedArtist, err := client.GetArtist(ctx, "301"); err != nil || cachedArtist != artist { + t.Fatalf("cached artist = %#v/%v", cachedArtist, err) + } + + related, err := client.GetRelatedArtists(ctx, "deezer:301", 3) + if err != nil { + t.Fatalf("GetRelatedArtists: %v", err) + } + if len(related) != 1 || related[0].ID != "deezer:302" { + t.Fatalf("related = %#v", related) + } + if _, err := client.GetRelatedArtists(ctx, "", 0); err == nil { + t.Fatal("expected invalid related artist ID") + } + + playlist, err := client.GetPlaylist(ctx, "401") + if err != nil { + t.Fatalf("GetPlaylist: %v", err) + } + if playlist.PlaylistInfo.Tracks.Total != 2 || len(playlist.TrackList) != 2 { + t.Fatalf("playlist = %#v", playlist) + } + + byISRC, err := client.SearchByISRC(ctx, "USRC17607839") + if err != nil { + t.Fatalf("SearchByISRC: %v", err) + } + if byISRC.SpotifyID != "deezer:101" { + t.Fatalf("by ISRC = %#v", byISRC) + } + if _, err := client.SearchByISRC(ctx, "MISSING"); err == nil { + t.Fatal("expected missing ISRC error") + } + + isrc, err := client.GetTrackISRC(ctx, "102") + if err != nil || isrc != "USRC17607840" { + t.Fatalf("GetTrackISRC = %q/%v", isrc, err) + } + albumID, err := client.GetTrackAlbumID(ctx, "101") + if err != nil || albumID != "201" { + t.Fatalf("GetTrackAlbumID = %q/%v", albumID, err) + } + extended, err := client.GetAlbumExtendedMetadata(ctx, "201") + if err != nil { + t.Fatalf("GetAlbumExtendedMetadata: %v", err) + } + if extended.Genre != "Pop, Dance" || extended.Label != "Label" { + t.Fatalf("extended = %#v", extended) + } + if byTrack, err := client.GetExtendedMetadataByTrackID(ctx, "101"); err != nil || byTrack.Label != "Label" { + t.Fatalf("metadata by track = %#v/%v", byTrack, err) + } + if byISRCMeta, err := client.GetExtendedMetadataByISRC(ctx, "USRC17607839"); err != nil || byISRCMeta.Label != "Label" { + t.Fatalf("metadata by isrc = %#v/%v", byISRCMeta, err) + } + if _, err := client.GetExtendedMetadataByISRC(ctx, ""); err == nil { + t.Fatal("expected empty ISRC metadata error") + } + + if typ, id, err := parseDeezerURL("https://www.deezer.com/us/track/101"); err != nil || typ != "track" || id != "101" { + t.Fatalf("parseDeezerURL = %q/%q/%v", typ, id, err) + } + if _, _, err := parseDeezerURL("https://example.com/track/101"); err == nil { + t.Fatal("expected non-Deezer URL error") + } + + client.cacheMu.Lock() + client.searchCache["expired"] = &cacheEntry{expiresAt: time.Now().Add(-time.Hour)} + client.searchCache["keep1"] = &cacheEntry{expiresAt: time.Now().Add(time.Hour)} + client.searchCache["keep2"] = &cacheEntry{expiresAt: time.Now().Add(2 * time.Hour)} + client.pruneExpiredCacheEntriesLocked(client.searchCache, time.Now()) + client.trimCacheEntriesLocked(client.searchCache, 1) + client.isrcCache["1"] = "A" + client.isrcCache["2"] = "B" + client.trimStringCacheEntriesLocked(client.isrcCache, 1) + client.cacheMu.Unlock() +} diff --git a/go_backend/exports_extension_wrappers_supplement_test.go b/go_backend/exports_extension_wrappers_supplement_test.go new file mode 100644 index 00000000..82270704 --- /dev/null +++ b/go_backend/exports_extension_wrappers_supplement_test.go @@ -0,0 +1,83 @@ +package gobackend + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestExtensionPackageExportWrappers(t *testing.T) { + dir := t.TempDir() + extensionsDir := filepath.Join(dir, "extensions") + dataDir := filepath.Join(dir, "data") + if err := InitExtensionSystem(extensionsDir, dataDir); err != nil { + t.Fatalf("InitExtensionSystem: %v", err) + } + CleanupExtensions() + defer CleanupExtensions() + + js := ` +registerExtension({ + initialize: function(settings) { this.settings = settings || {}; }, + cleanup: function() {}, + doAction: function() { return { message: "wrapped", setting_updates: { quality: "lossless" } }; }, + searchTracks: function() { return { tracks: [], total: 0 }; }, + fetchLyrics: function() { return { syncType: "UNSYNCED", lines: [{ words: "hello" }] }; }, + getDownloadUrl: function() { return { url: "https://example.test/a.flac" }; } +}); +` + pkgV1 := filepath.Join(dir, "wrapper-ext-v1.spotiflac-ext") + pkgV2 := filepath.Join(dir, "wrapper-ext-v2.spotiflac-ext") + createTestExtensionPackage(t, pkgV1, "wrapper-ext", "1.0.0", js, nil) + createTestExtensionPackage(t, pkgV2, "wrapper-ext", "1.1.0", js, nil) + + loadedJSON, err := LoadExtensionFromPath(pkgV1) + if err != nil || !strings.Contains(loadedJSON, "wrapper-ext") { + t.Fatalf("LoadExtensionFromPath = %q/%v", loadedJSON, err) + } + if installedJSON, err := GetInstalledExtensions(); err != nil || !strings.Contains(installedJSON, "wrapper-ext") { + t.Fatalf("GetInstalledExtensions = %q/%v", installedJSON, err) + } + if err := SetExtensionEnabledByID("wrapper-ext", true); err != nil { + t.Fatalf("SetExtensionEnabledByID true: %v", err) + } + if actionJSON, err := InvokeExtensionActionJSON("wrapper-ext", "doAction"); err != nil || !strings.Contains(actionJSON, "wrapped") { + t.Fatalf("InvokeExtensionActionJSON = %q/%v", actionJSON, err) + } + if upgradeJSON, err := CheckExtensionUpgradeFromPath(pkgV2); err != nil || !strings.Contains(upgradeJSON, `"can_upgrade":true`) { + t.Fatalf("CheckExtensionUpgradeFromPath = %q/%v", upgradeJSON, err) + } + if upgradedJSON, err := UpgradeExtensionFromPath(pkgV2); err != nil || !strings.Contains(upgradedJSON, "1.1.0") { + t.Fatalf("UpgradeExtensionFromPath = %q/%v", upgradedJSON, err) + } + if err := SetExtensionEnabledByID("wrapper-ext", false); err != nil { + t.Fatalf("SetExtensionEnabledByID false: %v", err) + } + if err := UnloadExtensionByID("wrapper-ext"); err != nil { + t.Fatalf("UnloadExtensionByID: %v", err) + } + + dirExt := filepath.Join(extensionsDir, "wrapper-dir-ext") + if err := createDirectoryExtension(dirExt, "wrapper-dir-ext", "1.0.0"); err != nil { + t.Fatalf("create directory extension: %v", err) + } + if loadedDirJSON, err := LoadExtensionsFromDir(extensionsDir); err != nil || !strings.Contains(loadedDirJSON, "wrapper-dir-ext") { + t.Fatalf("LoadExtensionsFromDir = %q/%v", loadedDirJSON, err) + } + if err := RemoveExtensionByID("wrapper-dir-ext"); err != nil { + t.Fatalf("RemoveExtensionByID: %v", err) + } +} + +func createDirectoryExtension(dir, name, version string) error { + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + manifest := fmt.Sprintf(`{"name":%q,"displayName":%q,"version":%q,"description":"Directory wrapper extension","type":["metadata_provider"],"permissions":{}}`, name, name, version) + if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0600); err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, "index.js"), []byte(`registerExtension({searchTracks:function(){return {tracks:[], total:0};}});`), 0600) +} diff --git a/go_backend/exports_songlink_lyrics_supplement_test.go b/go_backend/exports_songlink_lyrics_supplement_test.go new file mode 100644 index 00000000..0dfe3445 --- /dev/null +++ b/go_backend/exports_songlink_lyrics_supplement_test.go @@ -0,0 +1,158 @@ +package gobackend + +import ( + "context" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLyricsExportWrappersWithoutNetwork(t *testing.T) { + dir := t.TempDir() + audioPath := filepath.Join(dir, "sidecar.mp3") + if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "sidecar.lrc"), []byte("[00:00.00]Sidecar lyric"), 0600); err != nil { + t.Fatal(err) + } + + if jsonText, err := FetchLyrics("spotify-1", "Song Instrumental", "Artist", 180000); err != nil || !strings.Contains(jsonText, `"instrumental":true`) { + t.Fatalf("FetchLyrics instrumental = %q/%v", jsonText, err) + } + if lrc, err := GetLyricsLRC("spotify-1", "Song Instrumental", "Artist", "", 180000); err != nil || lrc != "[instrumental:true]" { + t.Fatalf("GetLyricsLRC instrumental = %q/%v", lrc, err) + } + if jsonText, err := GetLyricsLRCWithSource("spotify-1", "Song Instrumental", "Artist", "", 180000); err != nil || !strings.Contains(jsonText, `"instrumental":true`) { + t.Fatalf("GetLyricsLRCWithSource instrumental = %q/%v", jsonText, err) + } + if lrc, err := GetLyricsLRC("", "", "", audioPath, 0); err != nil || !strings.Contains(lrc, "Sidecar lyric") { + t.Fatalf("GetLyricsLRC sidecar = %q/%v", lrc, err) + } + if jsonText, err := GetLyricsLRCWithSource("", "", "", audioPath, 0); err != nil || !strings.Contains(jsonText, "Sidecar lyric") { + t.Fatalf("GetLyricsLRCWithSource sidecar = %q/%v", jsonText, err) + } + + outPath := filepath.Join(dir, "lyrics.lrc") + if err := FetchAndSaveLyrics("Song", "Artist", "", 0, outPath, audioPath); err != nil { + t.Fatalf("FetchAndSaveLyrics sidecar: %v", err) + } + if data := string(mustReadFile(t, outPath)); !strings.Contains(data, "Sidecar lyric") { + t.Fatalf("saved lyrics = %q", data) + } + if response, err := EmbedLyricsToFile(filepath.Join(dir, "not-flac.mp3"), "lyrics"); err != nil || !strings.Contains(response, `"success":false`) { + t.Fatalf("EmbedLyricsToFile error = %q/%v", response, err) + } + if response, err := RewriteSplitArtistTagsExport(filepath.Join(dir, "not-flac.mp3"), "A;B", "A"); err != nil || !strings.Contains(response, `"success":false`) { + t.Fatalf("RewriteSplitArtistTagsExport error = %q/%v", response, err) + } +} + +func TestSongLinkExportWrappersWithFakeClient(t *testing.T) { + origClient := globalSongLinkClient + origRetryConfig := songLinkRetryConfig + origSearchByISRC := songLinkSearchByISRC + origCheckFromDeezer := songLinkCheckAvailabilityFromDeezer + defer func() { + globalSongLinkClient = origClient + songLinkRetryConfig = origRetryConfig + songLinkSearchByISRC = origSearchByISRC + songLinkCheckAvailabilityFromDeezer = origCheckFromDeezer + SetSongLinkNetworkOptions(false, false) + }() + songLinkRetryConfig = func() RetryConfig { + return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1} + } + globalSongLinkClient = &SongLinkClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + var body string + if req.URL.Host == "api.zarz.moe" { + body = `{"success":true,"songUrls":{"Spotify":"https://open.spotify.com/track/spotify-1","Deezer":"https://www.deezer.com/track/101","Tidal":"https://listen.tidal.com/track/202","YouTube":"https://youtu.be/yt1","AmazonMusic":"https://music.amazon.com/tracks/amz1","Qobuz":"https://open.qobuz.com/track/303"}}` + } else if req.URL.Host == "api.song.link" { + body = `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/spotify-1"},"deezer":{"url":"https://www.deezer.com/track/101"},"tidal":{"url":"https://listen.tidal.com/track/202"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=ytm1"},"amazonMusic":{"url":"https://music.amazon.com/tracks/amz1"},"qobuz":{"url":"https://open.qobuz.com/track/303"}}}` + } else { + t.Fatalf("unexpected SongLink request: %s", req.URL.String()) + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil + })}} + songLinkClientOnce.Do(func() {}) + + SetSongLinkNetworkOptions(true, true) + if availabilityJSON, err := CheckAvailability("spotify-1", ""); err != nil || !strings.Contains(availabilityJSON, `"deezer_id":"101"`) { + t.Fatalf("CheckAvailability = %q/%v", availabilityJSON, err) + } + if availabilityJSON, err := CheckAvailabilityFromDeezerID("101"); err != nil || !strings.Contains(availabilityJSON, `"spotify_id":"spotify-1"`) { + t.Fatalf("CheckAvailabilityFromDeezerID = %q/%v", availabilityJSON, err) + } + if availabilityJSON, err := CheckAvailabilityByPlatformID("deezer", "song", "101"); err != nil || !strings.Contains(availabilityJSON, `"tidal_url"`) { + t.Fatalf("CheckAvailabilityByPlatformID = %q/%v", availabilityJSON, err) + } + if spotifyID, err := GetSpotifyIDFromDeezerTrack("101"); err != nil || spotifyID != "spotify-1" { + t.Fatalf("GetSpotifyIDFromDeezerTrack = %q/%v", spotifyID, err) + } + if tidalURL, err := GetTidalURLFromDeezerTrack("101"); err != nil || !strings.Contains(tidalURL, "tidal") { + t.Fatalf("GetTidalURLFromDeezerTrack = %q/%v", tidalURL, err) + } + if urls, err := NewSongLinkClient().GetStreamingURLs("spotify-1"); err != nil || urls["tidal"] == "" || urls["amazon"] == "" { + t.Fatalf("GetStreamingURLs = %#v/%v", urls, err) + } + if youtubeURL, err := NewSongLinkClient().GetYouTubeURLFromSpotify("spotify-1"); err != nil || !strings.Contains(youtubeURL, "youtu") { + t.Fatalf("GetYouTubeURLFromSpotify = %q/%v", youtubeURL, err) + } + if amazonURL, err := NewSongLinkClient().GetAmazonURLFromDeezer("101"); err != nil || !strings.Contains(amazonURL, "amazon") { + t.Fatalf("GetAmazonURLFromDeezer = %q/%v", amazonURL, err) + } + if youtubeURL, err := NewSongLinkClient().GetYouTubeURLFromDeezer("101"); err != nil || !strings.Contains(youtubeURL, "youtube") { + t.Fatalf("GetYouTubeURLFromDeezer = %q/%v", youtubeURL, err) + } + if deezerID, err := NewSongLinkClient().GetDeezerIDFromSpotify("spotify-1"); err != nil || deezerID != "101" { + t.Fatalf("GetDeezerIDFromSpotify = %q/%v", deezerID, err) + } + if album, err := NewSongLinkClient().CheckAlbumAvailability("album-1"); err != nil || !album.Deezer || album.DeezerID == "" { + t.Fatalf("CheckAlbumAvailability = %#v/%v", album, err) + } + if albumID, err := NewSongLinkClient().GetDeezerAlbumIDFromSpotify("album-1"); err != nil || albumID == "" { + t.Fatalf("GetDeezerAlbumIDFromSpotify = %q/%v", albumID, err) + } + if availability, err := NewSongLinkClient().CheckAvailabilityFromURL("https://www.deezer.com/track/101"); err != nil || !availability.Deezer { + t.Fatalf("CheckAvailabilityFromURL = %#v/%v", availability, err) + } + + songLinkSearchByISRC = func(ctx context.Context, isrc string) (*TrackMetadata, error) { + return &TrackMetadata{SpotifyID: "deezer:101", ExternalURL: "https://www.deezer.com/track/101"}, nil + } + songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) { + return &TrackAvailability{SpotifyID: "spotify-1", Deezer: true, DeezerID: deezerTrackID}, nil + } + if availabilityJSON, err := CheckAvailability("", "USRC17607839"); err != nil || !strings.Contains(availabilityJSON, `"deezer_id":"101"`) { + t.Fatalf("CheckAvailability by ISRC = %q/%v", availabilityJSON, err) + } + if songLinkExtractDeezerTrackID(nil) != "" || songLinkExtractDeezerTrackID(&TrackMetadata{ExternalURL: "https://www.deezer.com/track/202"}) != "202" { + t.Fatal("songLinkExtractDeezerTrackID mismatch") + } + + deezerClient = &DeezerClient{ + httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery) + if body == "" { + body = `{"error":"missing"}` + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil + })}, + searchCache: map[string]*cacheEntry{}, + albumCache: map[string]*cacheEntry{}, + artistCache: map[string]*cacheEntry{}, + isrcCache: map[string]string{}, + cacheCleanupInterval: time.Hour, + } + deezerClientOnce.Do(func() {}) + if jsonText, err := ConvertSpotifyToDeezer("track", "spotify-1"); err != nil || !strings.Contains(jsonText, `"spotify_id":"deezer:101"`) { + t.Fatalf("ConvertSpotifyToDeezer track = %q/%v", jsonText, err) + } + if jsonText, err := ConvertSpotifyToDeezer("album", "album-1"); err != nil || jsonText == "" { + t.Fatalf("ConvertSpotifyToDeezer album = %q/%v", jsonText, err) + } +} diff --git a/go_backend/exports_supplement_test.go b/go_backend/exports_supplement_test.go new file mode 100644 index 00000000..25e7b615 --- /dev/null +++ b/go_backend/exports_supplement_test.go @@ -0,0 +1,428 @@ +package gobackend + +import ( + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) { + dir := t.TempDir() + dataDir := filepath.Join(dir, "data") + extensionsDir := filepath.Join(dir, "extensions") + if err := InitExtensionSystem(extensionsDir, dataDir); err != nil { + t.Fatalf("InitExtensionSystem: %v", err) + } + + ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider, ExtensionTypeDownloadProvider, ExtensionTypeLyricsProvider) + manager := getExtensionManager() + manager.mu.Lock() + if manager.extensions == nil { + manager.extensions = map[string]*loadedExtension{} + } + manager.extensions[ext.ID] = ext + manager.mu.Unlock() + defer func() { + manager.mu.Lock() + delete(manager.extensions, ext.ID) + manager.mu.Unlock() + }() + + if response, err := DownloadTrack(`{}`); err != nil || !strings.Contains(response, "retired") { + t.Fatalf("DownloadTrack = %q/%v", response, err) + } + if response, err := DownloadByStrategy(`not-json`); err != nil || !strings.Contains(response, "Invalid request") { + t.Fatalf("DownloadByStrategy invalid = %q/%v", response, err) + } + if response, err := DownloadByStrategy(`{"use_extensions":false}`); err != nil || !strings.Contains(response, "disabled") { + t.Fatalf("DownloadByStrategy disabled = %q/%v", response, err) + } + if response, err := DownloadWithFallback(`{}`); err != nil || !strings.Contains(response, "retired") { + t.Fatalf("DownloadWithFallback = %q/%v", response, err) + } + + InitItemProgress("item-1") + FinishItemProgress("item-1") + ClearItemProgress("item-1") + CancelDownload("item-1") + if GetDownloadProgress() == "" || GetAllDownloadProgress() == "" || GetAllDownloadProgressDelta(0) == "" { + t.Fatal("expected progress JSON") + } + CleanupConnections() + + cuePath, audioPath := writeExportCueFixture(t, dir) + if jsonText, err := ParseCueSheet(cuePath, ""); err != nil { + t.Fatalf("ParseCueSheet = %q/%v", jsonText, err) + } else { + var parsed CueSplitInfo + if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil { + t.Fatalf("decode ParseCueSheet: %v", err) + } + if parsed.AudioPath != audioPath { + t.Fatalf("ParseCueSheet audio path = %q want %q", parsed.AudioPath, audioPath) + } + } + if jsonText, err := ScanCueSheetForLibrary(cuePath, "", "virtual.cue", 111); err != nil || !strings.Contains(jsonText, "cue+wav") { + t.Fatalf("ScanCueSheetForLibrary = %q/%v", jsonText, err) + } + if jsonText, err := ScanCueSheetForLibraryWithCoverCacheKey(cuePath, "", "virtual.cue", 111, "cover-key"); err != nil || !strings.Contains(jsonText, "cue+wav") { + t.Fatalf("ScanCueSheetForLibraryWithCoverCacheKey = %q/%v", jsonText, err) + } + + apePath := filepath.Join(dir, "edit.ape") + if err := os.WriteFile(apePath, []byte("audio"), 0600); err != nil { + t.Fatal(err) + } + editJSON := `{"title":"Edited","artist":"Artist","track_number":"1","track_total":"2","disc_number":"1","disc_total":"1"}` + if response, err := EditFileMetadata(apePath, editJSON); err != nil || !strings.Contains(response, "native_ape") { + t.Fatalf("EditFileMetadata ape = %q/%v", response, err) + } + if response, err := EditFileMetadata(filepath.Join(dir, "edit.mp3"), editJSON); err != nil || !strings.Contains(response, "ffmpeg") { + t.Fatalf("EditFileMetadata ffmpeg = %q/%v", response, err) + } + if _, err := EditFileMetadata(apePath, `not-json`); err == nil { + t.Fatal("expected invalid metadata JSON") + } + if !hasOnlyM4AReplayGainFields(map[string]string{"replaygain_track_gain": "-1 dB"}) { + t.Fatal("expected replaygain-only fields") + } + if hasOnlyM4AReplayGainFields(map[string]string{"title": "Song"}) { + t.Fatal("expected non-replaygain field rejection") + } + + AllowDownloadDir(dir) + if err := SetDownloadDirectory(dir); err != nil { + t.Fatalf("SetDownloadDirectory: %v", err) + } + if duplicateJSON, err := CheckDuplicate(dir, ""); err != nil || !strings.Contains(duplicateJSON, "exists") { + t.Fatalf("CheckDuplicate = %q/%v", duplicateJSON, err) + } + if batchJSON, err := CheckDuplicatesBatch(dir, `[{"isrc":"","track_name":"Song","artist_name":"Artist"}]`); err != nil || !strings.Contains(batchJSON, "Song") { + t.Fatalf("CheckDuplicatesBatch = %q/%v", batchJSON, err) + } + _ = PreBuildDuplicateIndex(dir) + InvalidateDuplicateIndex(dir) + if filename, err := BuildFilename("{artist} - {title}", `{"artist":"A/B","title":"Song?"}`); err != nil || filename == "" { + t.Fatalf("BuildFilename = %q/%v", filename, err) + } + if _, err := BuildFilename("{title}", `not-json`); err == nil { + t.Fatal("expected BuildFilename JSON error") + } + if got := SanitizeFilename(`A/B:C*D?`); strings.ContainsAny(got, `/:*?`) { + t.Fatalf("SanitizeFilename = %q", got) + } + + if response, err := PreWarmTrackCacheJSON(`not-json`); err != nil || !strings.Contains(response, "Invalid JSON") { + t.Fatalf("PreWarmTrackCacheJSON invalid = %q/%v", response, err) + } + if response, err := PreWarmTrackCacheJSON(`[{"isrc":"ISRC","track_name":"Song","artist_name":"Artist"}]`); err != nil || !strings.Contains(response, "success") { + t.Fatalf("PreWarmTrackCacheJSON = %q/%v", response, err) + } + if GetTrackCacheSize() != 0 { + t.Fatal("expected empty track cache") + } + ClearTrackIDCache() + + if err := SetLyricsProvidersJSON(`["lrclib","apple_music"]`); err != nil { + t.Fatalf("SetLyricsProvidersJSON: %v", err) + } + if providers, err := GetLyricsProvidersJSON(); err != nil || !strings.Contains(providers, "lrclib") { + t.Fatalf("GetLyricsProvidersJSON = %q/%v", providers, err) + } + if available, err := GetAvailableLyricsProvidersJSON(); err != nil || available == "" { + t.Fatalf("GetAvailableLyricsProvidersJSON = %q/%v", available, err) + } + if err := SetLyricsFetchOptionsJSON(`{"include_translation_netease":true}`); err != nil { + t.Fatalf("SetLyricsFetchOptionsJSON: %v", err) + } + if opts, err := GetLyricsFetchOptionsJSON(); err != nil || opts == "" { + t.Fatalf("GetLyricsFetchOptionsJSON = %q/%v", opts, err) + } + + if err := SetProviderPriorityJSON(`["coverage-ext"]`); err != nil { + t.Fatalf("SetProviderPriorityJSON: %v", err) + } + if jsonText, err := GetProviderPriorityJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") { + t.Fatalf("GetProviderPriorityJSON = %q/%v", jsonText, err) + } + if err := SetExtensionFallbackProviderIDsJSON(`["coverage-ext"]`); err != nil { + t.Fatalf("SetExtensionFallbackProviderIDsJSON: %v", err) + } + if jsonText, err := GetExtensionFallbackProviderIDsJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") { + t.Fatalf("GetExtensionFallbackProviderIDsJSON = %q/%v", jsonText, err) + } + if err := SetExtensionFallbackProviderIDsJSON(""); err != nil { + t.Fatalf("reset extension fallback IDs: %v", err) + } + if err := SetMetadataProviderPriorityJSON(`["coverage-ext"]`); err != nil { + t.Fatalf("SetMetadataProviderPriorityJSON: %v", err) + } + if jsonText, err := GetMetadataProviderPriorityJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") { + t.Fatalf("GetMetadataProviderPriorityJSON = %q/%v", jsonText, err) + } + + if err := SetExtensionSettingsJSON(ext.ID, `{"quality":"lossless","_secret":"hidden"}`); err != nil { + t.Fatalf("SetExtensionSettingsJSON: %v", err) + } + if settingsJSON, err := GetExtensionSettingsJSON(ext.ID); err != nil || !strings.Contains(settingsJSON, "quality") { + t.Fatalf("GetExtensionSettingsJSON = %q/%v", settingsJSON, err) + } + if err := SetExtensionSettingsJSON(ext.ID, `not-json`); err == nil { + t.Fatal("expected settings JSON error") + } + + if jsonText, err := SearchTracksWithExtensionsJSON("song", 5); err != nil || !strings.Contains(jsonText, "search-1") { + t.Fatalf("SearchTracksWithExtensionsJSON = %q/%v", jsonText, err) + } + if jsonText, err := SearchTracksWithMetadataProvidersJSON("song", 5, true); err != nil || !strings.Contains(jsonText, "search-1") { + t.Fatalf("SearchTracksWithMetadataProvidersJSON = %q/%v", jsonText, err) + } + if jsonText, err := GetProviderMetadataJSON(ext.ID, "track", "track-1"); err != nil || !strings.Contains(jsonText, "Track track-1") { + t.Fatalf("GetProviderMetadataJSON track = %q/%v", jsonText, err) + } + for _, resourceType := range []string{"album", "playlist", "artist"} { + if jsonText, err := GetProviderMetadataJSON(ext.ID, resourceType, resourceType+"-1"); err != nil || jsonText == "" { + t.Fatalf("GetProviderMetadataJSON %s = %q/%v", resourceType, jsonText, err) + } + } + if _, err := GetProviderMetadataJSON("", "track", "id"); err == nil { + t.Fatal("expected empty provider ID error") + } + if _, err := GetProviderMetadataJSON(ext.ID, "unsupported", "id"); err == nil { + t.Fatal("expected unsupported provider type") + } + if firstNonEmptyTrimmed(" ", " value ") != "value" { + t.Fatal("expected first trimmed value") + } + if jsonText, err := GetBuiltInProvidersJSON(); err != nil || jsonText == "" { + t.Fatalf("GetBuiltInProvidersJSON = %q/%v", jsonText, err) + } + if _, err := SearchProviderAllJSON("missing", "q", 1, 1, ""); err == nil { + t.Fatal("expected unsupported search provider") + } + + requestJSON := `{"use_extensions":true,"use_fallback":false,"service":"coverage-ext","source":"coverage-ext","track_name":"Song","artist_name":"Artist","album_name":"Album","output_dir":"` + escapeJSONPath(dir) + `","output_ext":".flac","quality":"LOSSLESS"}` + if jsonText, err := DownloadWithExtensionsJSON(requestJSON); err != nil || !strings.Contains(jsonText, "coverage-ext") { + t.Fatalf("DownloadWithExtensionsJSON = %q/%v", jsonText, err) + } + if _, err := DownloadWithExtensionsJSON(`not-json`); err == nil { + t.Fatal("expected DownloadWithExtensionsJSON JSON error") + } + + SetExtensionAuthCodeByID(ext.ID, "code") + SetExtensionTokensByID(ext.ID, "access", "refresh", 60) + if !IsExtensionAuthenticatedByID(ext.ID) { + t.Fatal("expected authenticated extension") + } + if pending, err := GetExtensionPendingAuthJSON(ext.ID); err != nil || pending != "" { + t.Fatalf("GetExtensionPendingAuthJSON = %q/%v", pending, err) + } + ClearExtensionPendingAuthByID(ext.ID) + if all, err := GetAllPendingAuthRequestsJSON(); err != nil || all == "" { + t.Fatalf("GetAllPendingAuthRequestsJSON = %q/%v", all, err) + } + + ffmpegCommandsMu.Lock() + ffmpegCommands["cmd-1"] = &FFmpegCommand{ExtensionID: ext.ID, Command: "ffmpeg -version", InputPath: "in", OutputPath: "out"} + ffmpegCommandsMu.Unlock() + if cmdJSON, err := GetPendingFFmpegCommandJSON("cmd-1"); err != nil || !strings.Contains(cmdJSON, "cmd-1") { + t.Fatalf("GetPendingFFmpegCommandJSON = %q/%v", cmdJSON, err) + } + if all, err := GetAllPendingFFmpegCommandsJSON(); err != nil || !strings.Contains(all, "cmd-1") { + t.Fatalf("GetAllPendingFFmpegCommandsJSON = %q/%v", all, err) + } + SetFFmpegCommandResultByID("cmd-1", true, "ok", "") + ClearFFmpegCommand("cmd-1") + if empty, err := GetPendingFFmpegCommandJSON("missing"); err != nil || empty != "" { + t.Fatalf("missing ffmpeg = %q/%v", empty, err) + } + + enrichedJSON, err := EnrichTrackWithExtensionJSON(ext.ID, `{"id":"track-1","name":"Old","artists":"Artist"}`) + if err != nil || !strings.Contains(enrichedJSON, "Enriched") { + t.Fatalf("EnrichTrackWithExtensionJSON = %q/%v", enrichedJSON, err) + } + if sameJSON, err := EnrichTrackWithExtensionJSON("missing", `{"name":"Old"}`); err != nil || !strings.Contains(sameJSON, "Old") { + t.Fatalf("missing EnrichTrackWithExtensionJSON = %q/%v", sameJSON, err) + } + + deezerClient = &DeezerClient{ + httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery) + status := http.StatusOK + if body == "" { + status = http.StatusNotFound + body = `{"error":"missing"}` + } + return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil + })}, + searchCache: map[string]*cacheEntry{}, + albumCache: map[string]*cacheEntry{}, + artistCache: map[string]*cacheEntry{}, + isrcCache: map[string]string{}, + cacheCleanupInterval: time.Hour, + } + deezerClientOnce.Do(func() {}) + for _, item := range []struct { + typ string + id string + }{ + {"track", "101"}, + {"album", "201"}, + {"artist", "301"}, + {"playlist", "401"}, + } { + if jsonText, err := GetDeezerMetadata(item.typ, item.id); err != nil || jsonText == "" { + t.Fatalf("GetDeezerMetadata %s = %q/%v", item.typ, jsonText, err) + } + } + if _, err := GetDeezerMetadata("bad", "1"); err == nil { + t.Fatal("expected unsupported Deezer metadata type") + } + if jsonText, err := GetDeezerRelatedArtists("301", 2); err != nil || !strings.Contains(jsonText, "Related") { + t.Fatalf("GetDeezerRelatedArtists = %q/%v", jsonText, err) + } + if jsonText, err := ParseDeezerURLExport("https://www.deezer.com/track/101"); err != nil || !strings.Contains(jsonText, "101") { + t.Fatalf("ParseDeezerURLExport = %q/%v", jsonText, err) + } + if jsonText, err := ParseProviderURLJSON("https://www.deezer.com/album/201"); err != nil || !strings.Contains(jsonText, "deezer") { + t.Fatalf("ParseProviderURLJSON = %q/%v", jsonText, err) + } + if _, err := ParseProviderURLJSON("https://example.com/1"); err == nil { + t.Fatal("expected unsupported provider URL") + } + if jsonText, err := GetDeezerExtendedMetadata("101"); err != nil || !strings.Contains(jsonText, "Label") { + t.Fatalf("GetDeezerExtendedMetadata = %q/%v", jsonText, err) + } + if _, err := GetDeezerExtendedMetadata(""); err == nil { + t.Fatal("expected empty Deezer metadata ID error") + } + if jsonText, err := SearchDeezerByISRC("USRC17607839"); err != nil || !strings.Contains(jsonText, "deezer:101") { + t.Fatalf("SearchDeezerByISRC = %q/%v", jsonText, err) + } + if jsonText, err := SearchDeezerByISRCForItemID("USRC17607839", "item-isrc"); err != nil || !strings.Contains(jsonText, "deezer:101") { + t.Fatalf("SearchDeezerByISRCForItemID = %q/%v", jsonText, err) + } + + customJSON, err := CustomSearchWithExtensionJSON(ext.ID, "needle", `{"filter":"tracks"}`) + if err != nil || !strings.Contains(customJSON, "Custom needle") { + t.Fatalf("CustomSearchWithExtensionJSON = %q/%v", customJSON, err) + } + if customJSON, err := CustomSearchWithExtensionJSONWithRequestID(ext.ID, "needle", `not-json`, "req-custom"); err != nil || !strings.Contains(customJSON, "custom-1") { + t.Fatalf("CustomSearchWithExtensionJSONWithRequestID = %q/%v", customJSON, err) + } + if providersJSON, err := GetSearchProvidersJSON(); err != nil || !strings.Contains(providersJSON, "coverage-ext") { + t.Fatalf("GetSearchProvidersJSON = %q/%v", providersJSON, err) + } + if found := FindURLHandlerJSON("https://example.test/track/1"); found != ext.ID { + t.Fatalf("FindURLHandlerJSON = %q", found) + } + if handlersJSON, err := GetURLHandlersJSON(); err != nil || !strings.Contains(handlersJSON, "coverage-ext") { + t.Fatalf("GetURLHandlersJSON = %q/%v", handlersJSON, err) + } + if handledJSON, err := HandleURLWithExtensionJSON("https://example.test/track/1"); err != nil || !strings.Contains(handledJSON, "url-track") { + t.Fatalf("HandleURLWithExtensionJSON = %q/%v", handledJSON, err) + } + if postJSON, err := RunPostProcessingJSON(filepath.Join(dir, "song.flac"), `{"title":"Song"}`); err != nil || !strings.Contains(postJSON, "success") { + t.Fatalf("RunPostProcessingJSON = %q/%v", postJSON, err) + } + v2Input := `{"path":"` + escapeJSONPath(filepath.Join(dir, "song.flac")) + `","uri":"content://song","name":"song.flac","mime_type":"audio/flac","size":10}` + if postJSON, err := RunPostProcessingV2JSON(v2Input, `not-json`); err != nil || !strings.Contains(postJSON, "success") { + t.Fatalf("RunPostProcessingV2JSON = %q/%v", postJSON, err) + } + if postProviders, err := GetPostProcessingProvidersJSON(); err != nil || !strings.Contains(postProviders, "hook") { + t.Fatalf("GetPostProcessingProvidersJSON = %q/%v", postProviders, err) + } + if feedJSON, err := GetExtensionHomeFeedJSON(ext.ID); err != nil || !strings.Contains(feedJSON, "home-1") { + t.Fatalf("GetExtensionHomeFeedJSON = %q/%v", feedJSON, err) + } + if feedJSON, err := GetExtensionHomeFeedJSONWithRequestID(ext.ID, "req-home"); err != nil || !strings.Contains(feedJSON, "home-1") { + t.Fatalf("GetExtensionHomeFeedJSONWithRequestID = %q/%v", feedJSON, err) + } + if categoriesJSON, err := GetExtensionBrowseCategoriesJSON(ext.ID); err != nil || !strings.Contains(categoriesJSON, "cat-1") { + t.Fatalf("GetExtensionBrowseCategoriesJSON = %q/%v", categoriesJSON, err) + } + CancelExtensionRequestJSON("req-home") + + storeDir := filepath.Join(dir, "store") + if err := InitExtensionStoreJSON(storeDir); err != nil { + t.Fatalf("InitExtensionStoreJSON: %v", err) + } + if err := SetStoreRegistryURLJSON("https://registry.example.com/index.json"); err != nil { + t.Fatalf("SetStoreRegistryURLJSON: %v", err) + } + store := getExtensionStore() + store.cache = &storeRegistry{Extensions: []storeExtension{{ + ID: "coverage-ext", + Name: "coverage-ext", + Version: "1.0.0", + Description: "Coverage", + Category: CategoryMetadata, + Tags: []string{"metadata"}, + DownloadURL: "https://registry.example.com/coverage.spotiflac-ext", + }}} + store.cacheTime = time.Now() + if registryURL, err := GetStoreRegistryURLJSON(); err != nil || registryURL == "" { + t.Fatalf("GetStoreRegistryURLJSON = %q/%v", registryURL, err) + } + if storeJSON, err := GetStoreExtensionsJSON(false); err != nil || !strings.Contains(storeJSON, "coverage-ext") { + t.Fatalf("GetStoreExtensionsJSON = %q/%v", storeJSON, err) + } + if storeJSON, err := SearchStoreExtensionsJSON("coverage", CategoryMetadata); err != nil || !strings.Contains(storeJSON, "coverage-ext") { + t.Fatalf("SearchStoreExtensionsJSON = %q/%v", storeJSON, err) + } + if catsJSON, err := GetStoreCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") { + t.Fatalf("GetStoreCategoriesJSON = %q/%v", catsJSON, err) + } + if dest, err := buildStoreExtensionDestPath(dir, "coverage/ext"); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") { + t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err) + } + if _, err := buildStoreExtensionDestPath(dir, " "); err == nil { + t.Fatal("expected invalid extension id") + } + if err := ClearStoreCacheJSON(); err != nil { + t.Fatalf("ClearStoreCacheJSON: %v", err) + } + if err := ClearStoreRegistryURLJSON(); err != nil { + t.Fatalf("ClearStoreRegistryURLJSON: %v", err) + } + + SetLibraryCoverCacheDirJSON(filepath.Join(dir, "covers")) + libraryDir := filepath.Join(dir, "library") + if err := os.MkdirAll(libraryDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(libraryDir, "Artist - Song.mp3"), []byte("not mp3"), 0600); err != nil { + t.Fatal(err) + } + if scanJSON, err := ScanLibraryFolderJSON(libraryDir); err != nil || !strings.Contains(scanJSON, "Song") { + t.Fatalf("ScanLibraryFolderJSON = %q/%v", scanJSON, err) + } + if scanJSON, err := ScanLibraryFolderIncrementalJSON(libraryDir, `[]`); err != nil || !strings.Contains(scanJSON, "Song") { + t.Fatalf("ScanLibraryFolderIncrementalJSON = %q/%v", scanJSON, err) + } + snapshotPath := filepath.Join(dir, "snapshot.json") + if err := os.WriteFile(snapshotPath, []byte(`[]`), 0600); err != nil { + t.Fatal(err) + } + if scanJSON, err := ScanLibraryFolderIncrementalFromSnapshotJSON(libraryDir, snapshotPath); err != nil || !strings.Contains(scanJSON, "Song") { + t.Fatalf("ScanLibraryFolderIncrementalFromSnapshotJSON = %q/%v", scanJSON, err) + } + if GetLibraryScanProgressJSON() == "" { + t.Fatal("expected scan progress JSON") + } + CancelLibraryScanJSON() + if metadataJSON, err := ReadAudioMetadataJSON(filepath.Join(libraryDir, "missing.mp3")); err != nil || metadataJSON == "" { + t.Fatalf("ReadAudioMetadataJSON = %q/%v", metadataJSON, err) + } + if metadataJSON, err := ReadAudioMetadataWithHintJSON(filepath.Join(libraryDir, "missing.mp3"), "Missing"); err != nil || metadataJSON == "" { + t.Fatalf("ReadAudioMetadataWithHintJSON = %q/%v", metadataJSON, err) + } + if metadataJSON, err := ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filepath.Join(libraryDir, "missing.mp3"), "Missing", "key"); err != nil || metadataJSON == "" { + t.Fatalf("ReadAudioMetadataWithHintAndCoverCacheKeyJSON = %q/%v", metadataJSON, err) + } +} diff --git a/go_backend/extension_health_misc_supplement_test.go b/go_backend/extension_health_misc_supplement_test.go new file mode 100644 index 00000000..5f0093bf --- /dev/null +++ b/go_backend/extension_health_misc_supplement_test.go @@ -0,0 +1,143 @@ +package gobackend + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +func TestExtensionHealthClassificationAndValidation(t *testing.T) { + if status, msg := classifyExtensionHealthBody([]byte(`{"status":"degraded"}`), ""); status != "degraded" || msg != "degraded" { + t.Fatalf("status/message = %q/%q", status, msg) + } + if status, _ := classifyExtensionHealthBody([]byte(`not-json`), ""); status != "online" { + t.Fatalf("invalid JSON status = %q", status) + } + if status, msg := classifyExtensionHealthBody([]byte(`{"services":{"tidal":{"status":401,"label":"Tidal","detail":"auth_required"}}}`), "tidal"); status != "degraded" || !strings.Contains(msg, "Tidal") { + t.Fatalf("service status/message = %q/%q", status, msg) + } + if status, msg, ok := classifyExtensionHealthService(map[string]interface{}{"services": map[string]interface{}{}}, "missing"); !ok || status != "unknown" || !strings.Contains(msg, "missing") { + t.Fatalf("missing service = %q/%q/%v", status, msg, ok) + } + if n, ok := healthNumber(json.Number("503")); !ok || n != 503 { + t.Fatalf("health number = %d/%v", n, ok) + } + if !isExtensionHealthAuthRequired(" unauthorized ") { + t.Fatal("expected auth required") + } + + if result := CheckExtensionHealth(nil); result.Status != "offline" { + t.Fatalf("nil health = %#v", result) + } + manifest := &ExtensionManifest{Permissions: ExtensionPermissions{Network: []string{"status.example.com"}}} + invalidURL := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "bad", URL: "://bad"}) + if invalidURL.Status != "offline" { + t.Fatalf("invalid URL = %#v", invalidURL) + } + insecure := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "http", URL: "http://status.example.com"}) + if insecure.Status != "offline" || !strings.Contains(insecure.Error, "https") { + t.Fatalf("insecure = %#v", insecure) + } + disallowedHost := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "host", URL: "https://other.example.com"}) + if disallowedHost.Status != "offline" || !strings.Contains(disallowedHost.Error, "permissions") { + t.Fatalf("host = %#v", disallowedHost) + } + badMethod := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "method", URL: "https://status.example.com", Method: "POST"}) + if badMethod.Status != "offline" || !strings.Contains(badMethod.Error, "method") { + t.Fatalf("method = %#v", badMethod) + } + + ext := &loadedExtension{ + ID: "health-ext", + Manifest: &ExtensionManifest{ + ServiceHealth: []ExtensionHealthCheck{ + {ID: "required", URL: "http://status.example.com", Required: true}, + {ID: "optional", URL: "http://status.example.com", Required: false}, + }, + }, + } + if result := CheckExtensionHealth(ext); result.Status != "offline" || len(result.Checks) != 2 { + t.Fatalf("extension health = %#v", result) + } +} + +func TestCoverRomajiParallelAndIDHSHelpers(t *testing.T) { + spotify := "https://i.scdn.co/image/ab67616d00001e02abcdef" + if got := GetCoverFromSpotify(spotify, true); !strings.Contains(got, spotifySizeMax) { + t.Fatalf("spotify cover = %q", got) + } + if got := upgradeToMaxQuality("https://cdn-images.dzcdn.net/images/cover/abc/500x500-000000-80-0-0.jpg"); !strings.Contains(got, "1800x1800") { + t.Fatalf("deezer cover = %q", got) + } + if got := upgradeToMaxQuality("https://resources.tidal.com/images/id/320x320.jpg"); !strings.Contains(got, "origin.jpg") { + t.Fatalf("tidal cover = %q", got) + } + if got := upgradeToMaxQuality("https://static.qobuz.com/images/covers/ab/cd/foo_600.jpg"); !strings.Contains(got, "_max.jpg") { + t.Fatalf("qobuz cover = %q", got) + } + if data, err := downloadCoverToMemory("", false); err == nil || data != nil { + t.Fatalf("expected empty cover error") + } + + if !ContainsJapanese("カタカナ") || ContainsJapanese("abc") { + t.Fatal("unexpected Japanese detection") + } + if got := JapaneseToRomaji("きゃット"); got != "kyatto" { + t.Fatalf("romaji = %q", got) + } + if got := BuildSearchQuery("きゃ! song", "アーティスト"); got != "atisuto kya song" { + t.Fatalf("query = %q", got) + } + if got := CleanToASCII("A, B. C!"); got != "A B C" { + t.Fatalf("ascii = %q", got) + } + + if err := PreWarmCache(`not-json`); err == nil { + t.Fatal("expected prewarm JSON error") + } + if err := PreWarmCache(`[{"isrc":"ISRC","track_name":"Song","artist_name":"Artist","spotify_id":"sp","service":"tidal"}]`); err != nil { + t.Fatalf("PreWarmCache: %v", err) + } + if result := FetchCoverAndLyricsParallel("", false, "", "", "", false, 0); result == nil || result.CoverErr != nil || result.LyricsErr != nil { + t.Fatalf("parallel result = %#v", result) + } + if ClearTrackCache(); GetCacheSize() != 0 { + t.Fatal("expected empty cache size") + } + + client := &IDHSClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodPost { + t.Fatalf("method = %s", req.Method) + } + body := `{"id":"1","type":"song","title":"Song","links":[{"type":"tidal","url":"https://tidal.com/browse/track/7"},{"type":"deezer","url":"https://www.deezer.com/track/9"},{"type":"spotify","url":"https://open.spotify.com/track/abc"}]}` + return &http.Response{ + StatusCode: 200, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + })}} + availability, err := client.GetAvailabilityFromSpotify("spotify-track") + if err != nil { + t.Fatalf("GetAvailabilityFromSpotify: %v", err) + } + if !availability.Tidal || !availability.Deezer || availability.DeezerID != "9" { + t.Fatalf("spotify availability = %#v", availability) + } + deezerAvailability, err := client.GetAvailabilityFromDeezer("9") + if err != nil { + t.Fatalf("GetAvailabilityFromDeezer: %v", err) + } + if deezerAvailability.SpotifyID != "abc" || !deezerAvailability.Tidal { + t.Fatalf("deezer availability = %#v", deezerAvailability) + } + + errorClient := &IDHSClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 429, Body: io.NopCloser(strings.NewReader("")), Request: req}, nil + })}} + if _, err := errorClient.Search("bad", nil); err == nil { + t.Fatal("expected rate limit error") + } +} diff --git a/go_backend/extension_manager_supplement_test.go b/go_backend/extension_manager_supplement_test.go new file mode 100644 index 00000000..bb49cfa6 --- /dev/null +++ b/go_backend/extension_manager_supplement_test.go @@ -0,0 +1,143 @@ +package gobackend + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestExtensionManagerPackageLifecycle(t *testing.T) { + dir := t.TempDir() + extensionsDir := filepath.Join(dir, "extensions") + dataDir := filepath.Join(dir, "data") + manager := &extensionManager{extensions: map[string]*loadedExtension{}} + if err := manager.SetDirectories(extensionsDir, dataDir); err != nil { + t.Fatalf("SetDirectories: %v", err) + } + if err := GetExtensionSettingsStore().SetDataDir(dataDir); err != nil { + t.Fatalf("settings data dir: %v", err) + } + + js := ` +var cleaned = false; +registerExtension({ + initialize: function(settings) { this.settings = settings || {}; }, + cleanup: function() { cleaned = true; }, + doAction: function() { return { message: "done", setting_updates: { quality: "lossless" } }; }, + getHomeFeed: function() { return [{ id: "home", title: "Home" }]; }, + getBrowseCategories: function() { return [{ id: "cat", title: "Category" }]; }, + searchTracks: function() { return { tracks: [], total: 0 }; }, + fetchLyrics: function() { return { syncType: "UNSYNCED", lines: [{ words: "hello" }] }; }, + getDownloadUrl: function() { return { url: "https://example.test/a.flac" }; } +}); +` + pkgV1 := filepath.Join(dir, "manager-ext-v1.spotiflac-ext") + createTestExtensionPackage(t, pkgV1, "manager-ext", "1.0.0", js, map[string]string{"../unsafe.txt": "skip"}) + pkgV2 := filepath.Join(dir, "manager-ext-v2.spotiflac-ext") + createTestExtensionPackage(t, pkgV2, "manager-ext", "1.1.0", js, nil) + + if compareVersions("v1.2.0", "1.1.9") <= 0 || compareVersions("1.0.0", "1.0") != 0 || compareVersions("1.0.0", "1.0.1") >= 0 { + t.Fatal("compareVersions mismatch") + } + if _, err := manager.LoadExtensionFromFile(filepath.Join(dir, "bad.txt")); err == nil { + t.Fatal("expected bad extension suffix error") + } + if _, err := manager.LoadExtensionFromFile(filepath.Join(dir, "missing.spotiflac-ext")); err == nil { + t.Fatal("expected invalid package error") + } + + ext, err := manager.LoadExtensionFromFile(pkgV1) + if err != nil { + t.Fatalf("LoadExtensionFromFile: %v", err) + } + if ext.ID != "manager-ext" || ext.Enabled || ext.SourceDir == "" { + t.Fatalf("loaded extension = %#v", ext) + } + if _, err := os.Stat(filepath.Join(ext.SourceDir, "unsafe.txt")); err == nil { + t.Fatal("unsafe archive path should not be extracted") + } + if _, err := manager.LoadExtensionFromFile(pkgV1); err == nil { + t.Fatal("expected duplicate version error") + } + + installedJSON, err := manager.GetInstalledExtensionsJSON() + if err != nil || !strings.Contains(installedJSON, "manager-ext") || !strings.Contains(installedJSON, "icon_path") { + t.Fatalf("GetInstalledExtensionsJSON = %q/%v", installedJSON, err) + } + var installed []map[string]interface{} + if err := json.Unmarshal([]byte(installedJSON), &installed); err != nil || len(installed) != 1 { + t.Fatalf("decode installed = %#v/%v", installed, err) + } + + if err := GetExtensionSettingsStore().Set("manager-ext", "quality", "lossless"); err != nil { + t.Fatalf("settings Set: %v", err) + } + if err := manager.SetExtensionEnabled("manager-ext", true); err != nil { + t.Fatalf("enable extension: %v", err) + } + if !ext.Enabled || ext.VM == nil || !ext.initialized { + t.Fatalf("enabled extension = %#v", ext) + } + if err := manager.InitializeExtension("manager-ext", map[string]interface{}{"quality": "hires"}); err != nil { + t.Fatalf("InitializeExtension: %v", err) + } + action, err := manager.InvokeAction("manager-ext", "doAction") + if err != nil || action["success"] != true || action["message"] != "done" { + t.Fatalf("InvokeAction = %#v/%v", action, err) + } + if err := manager.CleanupExtension("manager-ext"); err != nil { + t.Fatalf("CleanupExtension: %v", err) + } + if err := manager.SetExtensionEnabled("manager-ext", false); err != nil { + t.Fatalf("disable extension: %v", err) + } + if ext.VM != nil || ext.initialized { + t.Fatalf("expected VM teardown, got %#v", ext) + } + if _, err := manager.InvokeAction("manager-ext", "doAction"); err == nil { + t.Fatal("expected disabled action error") + } + + upgradeJSON, err := manager.CheckExtensionUpgradeJSON(pkgV2) + if err != nil || !strings.Contains(upgradeJSON, `"can_upgrade":true`) { + t.Fatalf("CheckExtensionUpgradeJSON = %q/%v", upgradeJSON, err) + } + upgraded, err := manager.UpgradeExtension(pkgV2) + if err != nil { + t.Fatalf("UpgradeExtension: %v", err) + } + if upgraded.Manifest.Version != "1.1.0" { + t.Fatalf("upgraded = %#v", upgraded.Manifest) + } + if _, err := manager.UpgradeExtension(pkgV1); err == nil { + t.Fatal("expected downgrade error") + } + if err := manager.RemoveExtension("manager-ext"); err != nil { + t.Fatalf("RemoveExtension: %v", err) + } + if _, err := manager.GetExtension("manager-ext"); err == nil { + t.Fatal("expected removed extension missing") + } + + dirExt := filepath.Join(extensionsDir, "dir-ext") + if err := os.MkdirAll(dirExt, 0755); err != nil { + t.Fatal(err) + } + manifest := `{"name":"dir-ext","displayName":"dir-ext","version":"1.0.0","description":"Directory extension","type":["metadata_provider"],"permissions":{}}` + if err := os.WriteFile(filepath.Join(dirExt, "manifest.json"), []byte(manifest), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dirExt, "index.js"), []byte(`registerExtension({searchTracks:function(){return {tracks:[], total:0};}});`), 0600); err != nil { + t.Fatal(err) + } + loaded, loadErrs := manager.LoadExtensionsFromDirectory(extensionsDir) + if len(loadErrs) != 0 || len(loaded) != 1 || loaded[0] != "dir-ext" { + t.Fatalf("LoadExtensionsFromDirectory = %#v/%#v", loaded, loadErrs) + } + manager.UnloadAllExtensions() + if len(manager.GetAllExtensions()) != 0 { + t.Fatal("expected all extensions unloaded") + } +} diff --git a/go_backend/extension_provider_supplement_test.go b/go_backend/extension_provider_supplement_test.go new file mode 100644 index 00000000..4d775677 --- /dev/null +++ b/go_backend/extension_provider_supplement_test.go @@ -0,0 +1,253 @@ +package gobackend + +import ( + "context" + "encoding/json" + "io" + "net/http" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestExtensionProviderWrapperFullSurface(t *testing.T) { + ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider, ExtensionTypeDownloadProvider, ExtensionTypeLyricsProvider) + provider := newExtensionProviderWrapper(ext) + + search, err := provider.SearchTracks("query", 5) + if err != nil { + t.Fatalf("SearchTracks: %v", err) + } + if search.Total != 1 || search.Tracks[0].ProviderID != ext.ID || search.Tracks[0].ExternalLinks["tidal"] == "" { + t.Fatalf("search = %#v", search) + } + + track, err := provider.GetTrack("track-1") + if err != nil { + t.Fatalf("GetTrack: %v", err) + } + if track.Name != "Track track-1" || track.ProviderID != ext.ID || track.AudioQuality == "" { + t.Fatalf("track = %#v", track) + } + + album, err := provider.GetAlbum("album-1") + if err != nil { + t.Fatalf("GetAlbum: %v", err) + } + if album.ProviderID != ext.ID || len(album.Tracks) != 1 || album.Tracks[0].ProviderID != ext.ID { + t.Fatalf("album = %#v", album) + } + + playlist, err := provider.GetPlaylist("playlist-1") + if err != nil { + t.Fatalf("GetPlaylist: %v", err) + } + if playlist.Name != "Playlist playlist-1" || playlist.ProviderID != ext.ID { + t.Fatalf("playlist = %#v", playlist) + } + + artist, err := provider.GetArtist("artist-1") + if err != nil { + t.Fatalf("GetArtist: %v", err) + } + if artist.ProviderID != ext.ID || len(artist.Releases) != 1 || artist.Releases[0].ProviderID != ext.ID { + t.Fatalf("artist = %#v", artist) + } + + enriched, err := provider.EnrichTrack(&ExtTrackMetadata{ID: "track-1", Name: "Old", ProviderID: ext.ID}) + if err != nil { + t.Fatalf("EnrichTrack: %v", err) + } + if enriched.Name != "Enriched" || enriched.ProviderID != ext.ID { + t.Fatalf("enriched = %#v", enriched) + } + + availability, err := provider.CheckAvailability("ISRC", "Song", "Artist", "spotify:1", "dz", "tidal", "qobuz") + if err != nil { + t.Fatalf("CheckAvailability: %v", err) + } + if !availability.Available || availability.TrackID != "download-track" || !availability.SkipFallback { + t.Fatalf("availability = %#v", availability) + } + + downloadURL, err := provider.GetDownloadURL("track-1", "LOSSLESS") + if err != nil { + t.Fatalf("GetDownloadURL: %v", err) + } + if downloadURL.Format != "flac" || downloadURL.BitDepth != 24 || downloadURL.SampleRate != 96000 { + t.Fatalf("download URL = %#v", downloadURL) + } + + progress := []int{} + download, err := provider.Download("track-1", "LOSSLESS", filepath.Join(t.TempDir(), "song.flac"), "", func(percent int) { + progress = append(progress, percent) + }) + if err != nil { + t.Fatalf("Download: %v", err) + } + if !download.Success || download.Decryption == nil || download.DecryptionKey != "001122" || len(progress) != 1 || progress[0] != 100 { + t.Fatalf("download = %#v progress=%v", download, progress) + } + + lyrics, err := provider.FetchLyrics("Song", "Artist", "Album", 180) + if err != nil { + t.Fatalf("GetLyrics: %v", err) + } + if lyrics.Provider != ext.ID || len(lyrics.Lines) != 1 || lyrics.Lines[0].Words != "Hello" { + t.Fatalf("lyrics = %#v", lyrics) + } + + urlResult, err := provider.HandleURL("https://example.test/track/1") + if err != nil { + t.Fatalf("HandleURL: %v", err) + } + if urlResult.Track == nil || urlResult.Track.Name == "" || len(urlResult.Tracks) != 1 || urlResult.Album == nil || urlResult.Artist == nil { + t.Fatalf("url result = %#v", urlResult) + } + + match, err := provider.MatchTrack( + map[string]interface{}{"name": "Song", "artists": "Artist"}, + []map[string]interface{}{{"id": "download-track", "name": "Song"}}, + ) + if err != nil { + t.Fatalf("MatchTrack: %v", err) + } + if !match.Matched || match.TrackID != "download-track" { + t.Fatalf("match = %#v", match) + } + + post, err := provider.PostProcess(filepath.Join(t.TempDir(), "song.flac"), map[string]interface{}{"title": "Song"}, "hook") + if err != nil { + t.Fatalf("PostProcess: %v", err) + } + if !post.Success || post.BitDepth != 24 || post.SampleRate != 96000 { + t.Fatalf("post = %#v", post) + } +} + +func TestBuiltInProviderAndManagerSelectionHelpers(t *testing.T) { + previousRegistry := builtInProviderRegistry + builtInProviderRegistry = []builtInProviderSpec{{ + ID: "deezer", + DisplayName: "Deezer", + SupportsMetadata: true, + SupportsSearch: true, + GetMetadata: GetDeezerMetadata, + SearchAll: func(query string, trackLimit, artistLimit int, filter string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + result, err := GetDeezerClient().SearchAll(ctx, query, trackLimit, artistLimit, filter) + if err != nil { + return "", err + } + data, err := json.Marshal(result) + return string(data), err + }, + SearchTracks: func(query string, limit int) ([]ExtTrackMetadata, error) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + result, err := GetDeezerClient().SearchAll(ctx, query, limit, limit, "track") + if err != nil { + return nil, err + } + tracks := make([]ExtTrackMetadata, len(result.Tracks)) + for i, track := range result.Tracks { + tracks[i] = normalizeBuiltInMetadataTrack(track, "deezer") + } + return tracks, nil + }, + }} + defer func() { builtInProviderRegistry = previousRegistry }() + + deezerClient = &DeezerClient{ + httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery) + if body == "" { + body = `{"data":[]}` + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil + })}, + searchCache: map[string]*cacheEntry{}, + albumCache: map[string]*cacheEntry{}, + artistCache: map[string]*cacheEntry{}, + isrcCache: map[string]string{}, + cacheCleanupInterval: time.Hour, + } + deezerClientOnce.Do(func() {}) + + if !isBuiltInProvider("deezer") || !isBuiltInMetadataProvider("deezer") || !isBuiltInSearchProvider("deezer") { + t.Fatal("expected Deezer built-in provider") + } + if _, ok := getBuiltInProviderSpec(" missing "); ok { + t.Fatal("unexpected missing provider spec") + } + if _, err := getBuiltInProviderMetadata("missing", "track", "1"); err == nil { + t.Fatal("expected unsupported metadata provider") + } + if jsonText, err := getBuiltInProviderMetadata("deezer", "track", "101"); err != nil || !strings.Contains(jsonText, "Track 101") { + t.Fatalf("built-in metadata = %q/%v", jsonText, err) + } + if jsonText, err := searchBuiltInProviderAll("deezer", "artist song", 2, 2, "track"); err != nil || !strings.Contains(jsonText, "Track 101") { + t.Fatalf("built-in search all = %q/%v", jsonText, err) + } + tracks, err := searchBuiltInProviderTracks("deezer", "artist song", 2) + if err != nil || len(tracks) != 1 || tracks[0].ProviderID != "deezer" { + t.Fatalf("built-in tracks = %#v/%v", tracks, err) + } + if _, err := searchBuiltInProviderTracks("missing", "q", 1); err == nil { + t.Fatal("expected unsupported built-in tracks") + } + + manifest := &ExtensionManifest{Capabilities: map[string]interface{}{ + "replacesBuiltInProviders": []interface{}{" Deezer ", 7, ""}, + }} + if values := manifestCapabilityStringList(manifest, "replacesBuiltInProviders"); len(values) != 1 || values[0] != "deezer" { + t.Fatalf("capability list = %#v", values) + } + if !extensionReplacesBuiltInProvider(&loadedExtension{Manifest: manifest}, "deezer") || extensionReplacesBuiltInProvider(nil, "deezer") { + t.Fatal("extension replacement mismatch") + } + if trimKnownProviderPrefix("Deezer:101", "deezer") != "101" || trimKnownProviderPrefix("101", "deezer") != "101" { + t.Fatal("trimKnownProviderPrefix mismatch") + } + normalized := normalizeBuiltInMetadataTrack(TrackMetadata{SpotifyID: "deezer:101", Name: "Song", Artists: "Artist", ISRC: "ISRC"}, "deezer") + if normalized.DeezerID != "101" || normalized.ProviderID != "deezer" { + t.Fatalf("normalized built-in track = %#v", normalized) + } + if metadataTrackDedupKey(ExtTrackMetadata{ISRC: "usrc"}) != "isrc:USRC" || + metadataTrackDedupKey(ExtTrackMetadata{SpotifyID: "sp"}) != "spotify:sp" || + metadataTrackDedupKey(ExtTrackMetadata{ProviderID: "p", ID: "1"}) != "p:1" { + t.Fatal("metadata dedup key mismatch") + } + searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) { + return []ExtTrackMetadata{{ID: "built-in", ProviderID: providerID}}, nil + } + defer func() { searchBuiltInMetadataTracksFunc = searchBuiltInMetadataTracks }() + if tracks, err := searchBuiltInMetadataTracksForItemID("deezer", "q", 1, "item"); err != nil || len(tracks) != 1 { + t.Fatalf("searchBuiltInMetadataTracksForItemID = %#v/%v", tracks, err) + } + + manager := &extensionManager{extensions: map[string]*loadedExtension{}} + downloadExt := newTestLoadedExtension(t, ExtensionTypeDownloadProvider, ExtensionTypeMetadataProvider) + manager.extensions[downloadExt.ID] = downloadExt + if providers := manager.GetDownloadProviders(); len(providers) != 1 { + t.Fatalf("download providers = %#v", providers) + } + SetProviderPriority([]string{"deezer", "coverage-ext", "coverage-ext", " "}) + if priority := GetProviderPriority(); len(priority) != 1 || priority[0] != "coverage-ext" { + t.Fatalf("provider priority = %#v", priority) + } + SetExtensionFallbackProviderIDs([]string{"a", "a", " ", "b"}) + if ids := GetExtensionFallbackProviderIDs(); len(ids) != 2 || !isExtensionFallbackAllowed("a") || isExtensionFallbackAllowed("z") { + t.Fatalf("fallback ids = %#v", ids) + } + SetExtensionFallbackProviderIDs(nil) + if !isExtensionFallbackAllowed("z") { + t.Fatal("nil fallback list should allow all") + } + SetMetadataProviderPriority([]string{"spotify", "deezer", "coverage-ext", "coverage-ext"}) + if priority := GetMetadataProviderPriority(); len(priority) != 2 || priority[0] != "deezer" || priority[1] != "coverage-ext" { + t.Fatalf("metadata priority = %#v", priority) + } +} diff --git a/go_backend/extension_runtime_supplement_test.go b/go_backend/extension_runtime_supplement_test.go new file mode 100644 index 00000000..30748d41 --- /dev/null +++ b/go_backend/extension_runtime_supplement_test.go @@ -0,0 +1,747 @@ +package gobackend + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/dop251/goja" +) + +func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) { + vm := goja.New() + runtime := &extensionRuntime{ + extensionID: "auth-ext", + manifest: &ExtensionManifest{ + Name: "auth-ext", + Description: "Auth extension", + Version: "1.0.0", + Permissions: ExtensionPermissions{ + Network: []string{"auth.example.com", "token.example.com", "api.example.com"}, + }, + }, + settings: map[string]interface{}{}, + httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.Host { + case "token.example.com": + return &http.Response{ + StatusCode: 200, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"access_token":"access","refresh_token":"refresh","expires_in":3600}`)), + Request: req, + }, nil + case "api.example.com": + return &http.Response{ + StatusCode: 200, + Header: http.Header{"X-Test": []string{"yes"}}, + Body: io.NopCloser(strings.NewReader(`{"ok":true,"items":[1,2]}`)), + Request: req, + }, nil + default: + return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil + } + })}, + vm: vm, + } + + if err := validateExtensionAuthURL("https://user:pass@auth.example.com/login"); err == nil { + t.Fatal("expected embedded credential error") + } + if err := validateExtensionAuthURL("http://auth.example.com/login"); err == nil { + t.Fatal("expected non-https auth URL error") + } + if got := summarizeURLForLog("https://auth.example.com/login?token=secret"); got != "https://auth.example.com/login" { + t.Fatalf("summary = %q", got) + } + + openResult := runtime.authOpenUrl(goja.FunctionCall{Arguments: []goja.Value{ + vm.ToValue("https://auth.example.com/login"), + vm.ToValue("app://callback"), + }}).Export().(map[string]interface{}) + if openResult["success"] != true { + t.Fatalf("authOpenUrl = %#v", openResult) + } + if pending := GetPendingAuthRequest("auth-ext"); pending == nil || pending.AuthURL == "" { + t.Fatalf("pending auth = %#v", pending) + } + if code := runtime.authGetCode(goja.FunctionCall{}); !goja.IsUndefined(code) { + t.Fatalf("expected undefined code, got %v", code) + } + if ok := runtime.authSetCode(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(map[string]interface{}{"code": "abc", "access_token": "access", "refresh_token": "refresh", "expires_in": float64(60)})}}); !ok.ToBoolean() { + t.Fatal("authSetCode returned false") + } + if code := runtime.authGetCode(goja.FunctionCall{}).String(); code != "abc" { + t.Fatalf("code = %q", code) + } + if !runtime.authIsAuthenticated(goja.FunctionCall{}).ToBoolean() { + t.Fatal("expected authenticated runtime") + } + tokens := runtime.authGetTokens(goja.FunctionCall{}).Export().(map[string]interface{}) + if tokens["access_token"] != "access" { + t.Fatalf("tokens = %#v", tokens) + } + + pkce := runtime.authGeneratePKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(50))}}).Export().(map[string]interface{}) + if pkce["method"] != "S256" || pkce["verifier"] == "" || pkce["challenge"] == "" { + t.Fatalf("pkce = %#v", pkce) + } + if current := runtime.authGetPKCE(goja.FunctionCall{}).Export().(map[string]interface{}); current["verifier"] == "" { + t.Fatalf("current pkce = %#v", current) + } + oauthConfig := map[string]interface{}{ + "authUrl": "https://auth.example.com/oauth", + "clientId": "client", + "redirectUri": "app://callback", + "scope": "read", + "extraParams": map[string]interface{}{"prompt": "login"}, + } + oauth := runtime.authStartOAuthWithPKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(oauthConfig)}}).Export().(map[string]interface{}) + if oauth["success"] != true || !strings.Contains(oauth["authUrl"].(string), "code_challenge") { + t.Fatalf("oauth = %#v", oauth) + } + tokenConfig := map[string]interface{}{ + "tokenUrl": "https://token.example.com/token", + "clientId": "client", + "redirectUri": "app://callback", + "code": "abc", + } + token := runtime.authExchangeCodeWithPKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(tokenConfig)}}).Export().(map[string]interface{}) + if token["success"] != true || token["access_token"] != "access" { + t.Fatalf("token = %#v", token) + } + + runtime.registerTextEncoderDecoder(vm) + runtime.registerURLClass(vm) + runtime.registerJSONGlobal(vm) + vm.Set("fetch", func(call goja.FunctionCall) goja.Value { + return runtime.fetchPolyfill(call) + }) + vm.Set("atob", func(call goja.FunctionCall) goja.Value { + return runtime.atobPolyfill(call) + }) + vm.Set("btoa", func(call goja.FunctionCall) goja.Value { + return runtime.btoaPolyfill(call) + }) + + value, err := vm.RunString(` + var encoded = btoa("hello"); + var decoded = atob(encoded); + var te = new TextEncoder(); + var bytes = te.encode("hi"); + var into = te.encodeInto("hi", []); + var td = new TextDecoder(); + var text = td.decode(bytes); + var url = new URL("/path?a=1&a=2#frag", "https://api.example.com/base"); + var params = new URLSearchParams("?x=1"); + params.append("x", "2"); + params.set("y", "3"); + var response = fetch("https://api.example.com/data", {method: "POST", body: {q: "x"}, headers: {"X-Client": "test"}}); + JSON.stringify({ + encoded: encoded, + decoded: decoded, + text: text, + read: into.read, + host: url.hostname, + first: url.searchParams.get("a"), + all: url.searchParams.getAll("a").length, + params: params.toString(), + ok: response.ok, + status: response.status, + jsonOk: response.json().ok, + bufferLen: response.arrayBuffer().length + }); + `) + if err != nil { + t.Fatalf("polyfill script: %v", err) + } + var result map[string]interface{} + if err := json.Unmarshal([]byte(value.String()), &result); err != nil { + t.Fatalf("decode polyfill result: %v", err) + } + if result["decoded"] != "hello" || result["host"] != "api.example.com" || result["ok"] != true { + t.Fatalf("polyfill result = %#v", result) + } + + blocked := runtime.fetchPolyfill(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://blocked.example.com")}}).ToObject(vm) + if blocked.Get("ok").ToBoolean() { + t.Fatal("expected blocked fetch") + } + runtime.authClear(goja.FunctionCall{}) + if runtime.authIsAuthenticated(goja.FunctionCall{}).ToBoolean() { + t.Fatal("expected auth cleared") + } +} + +func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) { + dir := t.TempDir() + store := &extensionStore{ + registryURL: "https://registry.example.com/registry.json", + cacheDir: dir, + cacheTTL: time.Hour, + cache: &storeRegistry{ + Version: 1, + UpdatedAt: "2026-05-04", + Extensions: []storeExtension{ + { + ID: "coverage-ext", + Name: "coverage-ext", + DisplayNameAlt: "Coverage Extension", + Version: "2.0.0", + Description: "Metadata and lyrics provider", + DownloadURLAlt: "https://registry.example.com/coverage.spotiflac-ext", + IconURLAlt: "https://registry.example.com/icon.png", + Category: CategoryMetadata, + Tags: []string{"metadata", "lyrics"}, + Downloads: 10, + UpdatedAt: "2026-05-04", + MinAppVersionAlt: "4.5.0", + }, + { + ID: "utility-ext", + Name: "utility-ext", + Version: "1.0.0", + Description: "Utility", + DownloadURL: "https://registry.example.com/utility.spotiflac-ext", + Category: CategoryUtility, + UpdatedAt: "2026-05-04", + }, + }, + }, + cacheTime: time.Now(), + } + store.saveDiskCache() + loadedStore := &extensionStore{cacheDir: dir} + loadedStore.loadDiskCache() + if loadedStore.cache == nil || len(loadedStore.cache.Extensions) != 2 { + t.Fatalf("loaded cache = %#v", loadedStore.cache) + } + if got := store.getRegistryURL(); got != "https://registry.example.com/registry.json" { + t.Fatalf("registry URL = %q", got) + } + store.setRegistryURL("https://registry.example.com/new.json") + if store.cache != nil { + t.Fatal("expected cache reset after registry URL change") + } + store.cache = loadedStore.cache + store.cacheTime = time.Now() + + manager := getExtensionManager() + manager.mu.Lock() + if manager.extensions == nil { + manager.extensions = map[string]*loadedExtension{} + } + manager.extensions["coverage-ext"] = &loadedExtension{ + ID: "coverage-ext", + Manifest: &ExtensionManifest{ + Name: "coverage-ext", + DisplayName: "Coverage Extension", + Version: "1.0.0", + Description: "Installed", + Types: []ExtensionType{ExtensionTypeMetadataProvider}, + }, + Enabled: true, + } + manager.mu.Unlock() + defer func() { + manager.mu.Lock() + delete(manager.extensions, "coverage-ext") + manager.mu.Unlock() + }() + + extensions, err := store.getExtensionsWithStatus(false) + if err != nil { + t.Fatalf("getExtensionsWithStatus: %v", err) + } + if len(extensions) != 2 || !extensions[0].IsInstalled || !extensions[0].HasUpdate { + t.Fatalf("extensions = %#v", extensions) + } + found, err := store.searchExtensions("lyrics", CategoryMetadata) + if err != nil || len(found) != 1 || found[0].ID != "coverage-ext" { + t.Fatalf("search = %#v/%v", found, err) + } + all, err := store.searchExtensions("", "") + if err != nil || len(all) != 2 { + t.Fatalf("all search = %#v/%v", all, err) + } + if cats := store.getCategories(); len(cats) != 5 { + t.Fatalf("categories = %#v", cats) + } + if !containsIgnoreCase("Hello Metadata", "metadata") || findSubstring("abcdef", "cd") != 2 || containsStr("abc", "z") { + t.Fatal("string helper mismatch") + } + if err := requireHTTPSURL("http://example.com", "registry"); err == nil { + t.Fatal("expected HTTPS validation error") + } + if _, err := resolveRegistryURL(""); err == nil { + t.Fatal("expected empty registry URL error") + } + if resolved, err := resolveRegistryURL("http://github.com/owner/repo"); err != nil || !strings.Contains(resolved, "raw.githubusercontent.com/owner/repo") { + t.Fatalf("resolved registry = %q/%v", resolved, err) + } + store.clearCache() + if store.cache != nil { + t.Fatal("expected cleared store cache") + } + + settingsStore := &ExtensionSettingsStore{settings: map[string]map[string]interface{}{}} + if err := settingsStore.SetDataDir(filepath.Join(dir, "settings")); err != nil { + t.Fatalf("SetDataDir: %v", err) + } + if err := settingsStore.Set("ext", "quality", "lossless"); err != nil { + t.Fatalf("settings Set: %v", err) + } + if value, err := settingsStore.Get("ext", "quality"); err != nil || value != "lossless" { + t.Fatalf("settings Get = %#v/%v", value, err) + } + if _, err := settingsStore.Get("ext", "missing"); err == nil { + t.Fatal("expected missing setting error") + } + if err := settingsStore.SetAll("ext", map[string]interface{}{"a": float64(1), "_secret": "hidden"}); err != nil { + t.Fatalf("settings SetAll: %v", err) + } + if all := settingsStore.GetAll("ext"); all["a"] != float64(1) { + t.Fatalf("settings all = %#v", all) + } + if err := settingsStore.Remove("ext", "a"); err != nil { + t.Fatalf("settings Remove: %v", err) + } + if err := settingsStore.RemoveAll("ext"); err != nil { + t.Fatalf("settings RemoveAll: %v", err) + } + if jsonText, err := settingsStore.GetAllExtensionSettingsJSON(); err != nil || jsonText == "" { + t.Fatalf("settings JSON = %q/%v", jsonText, err) + } + reloaded := &ExtensionSettingsStore{settings: map[string]map[string]interface{}{}} + if err := reloaded.SetDataDir(settingsStore.dataDir); err != nil { + t.Fatalf("reload settings: %v", err) + } + + vm := goja.New() + runtime := &extensionRuntime{ + extensionID: "storage-ext", + dataDir: filepath.Join(dir, "runtime"), + vm: vm, + storageFlushDelay: time.Hour, + } + if err := os.MkdirAll(runtime.dataDir, 0755); err != nil { + t.Fatal(err) + } + if got := runtime.storageGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("missing"), vm.ToValue("fallback")}}).String(); got != "fallback" { + t.Fatalf("storage fallback = %q", got) + } + if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key"), vm.ToValue(map[string]interface{}{"nested": "value"})}}); !ok.ToBoolean() { + t.Fatal("storageSet false") + } + if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key"), vm.ToValue(map[string]interface{}{"nested": "value"})}}); !ok.ToBoolean() { + t.Fatal("storageSet equal false") + } + loaded, err := runtime.loadStorage() + if err != nil || loaded["key"] == nil { + t.Fatalf("loadStorage = %#v/%v", loaded, err) + } + if err := runtime.flushStorageNow(); err != nil { + t.Fatalf("flushStorageNow: %v", err) + } + if ok := runtime.storageRemove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key")}}); !ok.ToBoolean() { + t.Fatal("storageRemove false") + } + runtime.closeStorageFlusher() + if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("after_close"), vm.ToValue("x")}}); ok.ToBoolean() { + t.Fatal("expected storageSet false after close") + } + + credRuntime := &extensionRuntime{ + extensionID: "cred-ext", + dataDir: filepath.Join(dir, "creds"), + vm: vm, + } + if err := os.MkdirAll(credRuntime.dataDir, 0755); err != nil { + t.Fatal(err) + } + if result := credRuntime.credentialsStore(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token"), vm.ToValue("secret")}}).Export().(map[string]interface{}); result["success"] != true { + t.Fatalf("credentialsStore = %#v", result) + } + if got := credRuntime.credentialsGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).String(); got != "secret" { + t.Fatalf("credential = %q", got) + } + if !credRuntime.credentialsHas(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).ToBoolean() { + t.Fatal("expected credential") + } + if ok := credRuntime.credentialsRemove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}); !ok.ToBoolean() { + t.Fatal("credentialsRemove false") + } + if credRuntime.credentialsHas(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).ToBoolean() { + t.Fatal("expected credential removed") + } + key, err := credRuntime.getEncryptionKey() + if err != nil { + t.Fatalf("getEncryptionKey: %v", err) + } + encrypted, err := encryptAES([]byte("plain"), key) + if err != nil { + t.Fatalf("encryptAES: %v", err) + } + decrypted, err := decryptAES(encrypted, key) + if err != nil || string(decrypted) != "plain" { + t.Fatalf("decryptAES = %q/%v", decrypted, err) + } + if _, err := decryptAES([]byte("short"), key); err == nil { + t.Fatal("expected short ciphertext error") + } +} + +func TestExtensionRuntimeHTTPMatchingAndMetadataHelpers(t *testing.T) { + vm := goja.New() + jar, _ := newSimpleCookieJar() + runtime := &extensionRuntime{ + extensionID: "http-ext", + manifest: &ExtensionManifest{ + Name: "http-ext", + Description: "HTTP extension", + Version: "1.0.0", + Permissions: ExtensionPermissions{ + Network: []string{"api.example.com"}, + }, + }, + vm: vm, + cookieJar: jar, + httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + var body []byte + if req.Body != nil { + body, _ = io.ReadAll(req.Body) + } + header := make(http.Header) + header.Set("X-Method", req.Method) + if req.URL.Path == "/huge" { + return &http.Response{StatusCode: 200, Header: header, Body: io.NopCloser(io.LimitReader(strings.NewReader(strings.Repeat("x", maxExtensionHTTPResponseBytes+2)), maxExtensionHTTPResponseBytes+2)), Request: req}, nil + } + return &http.Response{ + StatusCode: 201, + Header: header, + Body: io.NopCloser(strings.NewReader(req.Method + ":" + string(body))), + Request: req, + }, nil + })}, + } + + if err := runtime.validateDomain("https://api.example.com/path"); err != nil { + t.Fatalf("validateDomain allowed: %v", err) + } + for _, rawURL := range []string{"notaurl", "http://api.example.com", "https://user:pass@api.example.com", "https://127.0.0.1/x", "https://blocked.example.com/x"} { + if err := runtime.validateDomain(rawURL); err == nil { + t.Fatalf("expected domain validation error for %s", rawURL) + } + } + if got := runtime.httpGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/get"), vm.ToValue(map[string]interface{}{"X-Test": "yes"})}}).Export().(map[string]interface{}); got["status"] != 201 || !strings.Contains(got["body"].(string), "GET") { + t.Fatalf("httpGet = %#v", got) + } + if got := runtime.httpPost(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/post"), vm.ToValue(map[string]interface{}{"a": "b"})}}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), "POST") { + t.Fatalf("httpPost = %#v", got) + } + requestOptions := map[string]interface{}{"method": "patch", "body": []interface{}{"x"}, "headers": map[string]interface{}{"X-Req": "1"}} + if got := runtime.httpRequest(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/request"), vm.ToValue(requestOptions)}}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), "PATCH") { + t.Fatalf("httpRequest = %#v", got) + } + for _, method := range []struct { + name string + call func(goja.FunctionCall) goja.Value + args []goja.Value + }{ + {name: "PUT", call: runtime.httpPut, args: []goja.Value{vm.ToValue("https://api.example.com/put"), vm.ToValue("body")}}, + {name: "DELETE", call: runtime.httpDelete, args: []goja.Value{vm.ToValue("https://api.example.com/delete"), vm.ToValue(map[string]interface{}{"X-Delete": "1"})}}, + {name: "PATCH", call: runtime.httpPatch, args: []goja.Value{vm.ToValue("https://api.example.com/patch"), vm.ToValue(map[string]interface{}{"p": "q"})}}, + } { + if got := method.call(goja.FunctionCall{Arguments: method.args}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), method.name) { + t.Fatalf("%s = %#v", method.name, got) + } + } + if got := runtime.httpGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/huge")}}).Export().(map[string]interface{}); !strings.Contains(got["error"].(string), "exceeds") { + t.Fatalf("huge response = %#v", got) + } + if !runtime.httpClearCookies(goja.FunctionCall{}).ToBoolean() { + t.Fatal("expected cookies cleared") + } + + if runtime.matchingCompareStrings(goja.FunctionCall{}).ToFloat() != 0 { + t.Fatal("missing string compare args should be zero") + } + if runtime.matchingCompareStrings(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("Song"), vm.ToValue("song")}}).ToFloat() != 1 { + t.Fatal("expected exact string similarity") + } + if runtime.matchingCompareDuration(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(180000), vm.ToValue(182000)}}).ToBoolean() != true { + t.Fatal("expected duration match") + } + if runtime.matchingNormalizeString(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("Song (Remastered) feat. Guest!")}}).String() != "song" { + t.Fatalf("normalized = %q", runtime.matchingNormalizeString(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("Song (Remastered) feat. Guest!")}}).String()) + } + + if formatMusicBrainzGenre([]musicBrainzTag{{Count: 1, Name: "rock"}, {Count: 5, Name: "electronic"}, {Count: 10, Name: "rock"}}) != "Electronic" { + t.Fatal("unexpected genre selection") + } + credits := []musicBrainzArtistCredit{{Name: "A", JoinPhrase: " & "}, {Name: "B"}} + if formatMusicBrainzArtistCredit(credits) != "A & B" { + t.Fatal("artist credit format mismatch") + } + releases := []musicBrainzRelease{ + {Title: "Other", ArtistCredit: []musicBrainzArtistCredit{{Name: "Fallback"}}}, + {Title: "Album", ArtistCredit: credits}, + } + if selectMusicBrainzAlbumArtist(releases, "Album") != "A & B" || selectMusicBrainzAlbumArtist(releases, "") != "Fallback" { + t.Fatal("album artist selection mismatch") + } +} + +func TestExtensionRuntimeFileAPIs(t *testing.T) { + vm := goja.New() + dir := t.TempDir() + SetAllowedDownloadDirs(nil) + defer SetAllowedDownloadDirs(nil) + + fileBody := "chunk" + runtime := &extensionRuntime{ + extensionID: "file-ext", + manifest: &ExtensionManifest{ + Name: "file-ext", + Description: "File extension", + Version: "1.0.0", + Permissions: ExtensionPermissions{ + File: true, + Network: []string{"files.example.com"}, + }, + }, + dataDir: dir, + vm: vm, + httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Header.Get("Range") == "" { + body := "downloaded" + return &http.Response{ + StatusCode: 200, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + ContentLength: int64(len(body)), + Request: req, + }, nil + } + rangeHeader := req.Header.Get("Range") + start, end := 0, len(fileBody)-1 + if _, err := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end); err != nil { + start, end = 0, 1 + } + if start < 0 { + start = 0 + } + if end >= len(fileBody) { + end = len(fileBody) - 1 + } + if start > len(fileBody) { + start = len(fileBody) + } + body := fileBody[start : end+1] + header := http.Header{"Content-Range": []string{fmt.Sprintf("bytes %d-%d/%d", start, end, len(fileBody))}} + return &http.Response{StatusCode: 206, Header: header, Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil + })}, + } + runtime.downloadClient = runtime.httpClient + + if _, err := (&extensionRuntime{manifest: &ExtensionManifest{}}).validatePath("x"); err == nil { + t.Fatal("expected file permission error") + } + if _, err := runtime.validatePath("../escape.txt"); err == nil { + t.Fatal("expected sandbox escape error") + } + AddAllowedDownloadDir(dir) + absolutePath := filepath.Join(dir, "allowed.txt") + if got, err := runtime.validatePath(absolutePath); err != nil || got != absolutePath { + t.Fatalf("absolute validatePath = %q/%v", got, err) + } + + write := runtime.fileWrite(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt"), vm.ToValue("hello")}}).Export().(map[string]interface{}) + if write["success"] != true { + t.Fatalf("fileWrite = %#v", write) + } + if !runtime.fileExists(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt")}}).ToBoolean() { + t.Fatal("expected written file to exist") + } + read := runtime.fileRead(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt")}}).Export().(map[string]interface{}) + if read["data"] != "hello" { + t.Fatalf("fileRead = %#v", read) + } + + writeBytes := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{ + vm.ToValue("nested/bytes.bin"), + vm.ToValue("4869"), + vm.ToValue(map[string]interface{}{"encoding": "hex", "truncate": true}), + }}).Export().(map[string]interface{}) + if writeBytes["success"] != true { + t.Fatalf("fileWriteBytes = %#v", writeBytes) + } + appendBytes := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{ + vm.ToValue("nested/bytes.bin"), + vm.ToValue([]interface{}{float64('!')}), + vm.ToValue(map[string]interface{}{"append": true}), + }}).Export().(map[string]interface{}) + if appendBytes["success"] != true { + t.Fatalf("append fileWriteBytes = %#v", appendBytes) + } + readBytes := runtime.fileReadBytes(goja.FunctionCall{Arguments: []goja.Value{ + vm.ToValue("nested/bytes.bin"), + vm.ToValue(map[string]interface{}{"encoding": "text", "offset": float64(1), "length": float64(2)}), + }}).Export().(map[string]interface{}) + if readBytes["data"] != "i!" || readBytes["bytes_read"] != 2 { + t.Fatalf("fileReadBytes = %#v", readBytes) + } + if bad := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{ + vm.ToValue("nested/bad.bin"), + vm.ToValue("x"), + vm.ToValue(map[string]interface{}{"append": true, "offset": float64(1)}), + }}).Export().(map[string]interface{}); bad["success"] != false { + t.Fatalf("expected append+offset failure, got %#v", bad) + } + if bad := runtime.fileReadBytes(goja.FunctionCall{Arguments: []goja.Value{ + vm.ToValue("nested/bytes.bin"), + vm.ToValue(map[string]interface{}{"encoding": "bad"}), + }}).Export().(map[string]interface{}); bad["success"] != false { + t.Fatalf("expected bad encoding failure, got %#v", bad) + } + + copyResult := runtime.fileCopy(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/bytes.bin"), vm.ToValue("nested/copy.bin")}}).Export().(map[string]interface{}) + if copyResult["success"] != true { + t.Fatalf("fileCopy = %#v", copyResult) + } + moveResult := runtime.fileMove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/copy.bin"), vm.ToValue("nested/moved.bin")}}).Export().(map[string]interface{}) + if moveResult["success"] != true { + t.Fatalf("fileMove = %#v", moveResult) + } + sizeResult := runtime.fileGetSize(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/moved.bin")}}).Export().(map[string]interface{}) + if sizeResult["success"] != true || sizeResult["size"] != int64(3) { + t.Fatalf("fileGetSize = %#v", sizeResult) + } + deleteResult := runtime.fileDelete(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/moved.bin")}}).Export().(map[string]interface{}) + if deleteResult["success"] != true { + t.Fatalf("fileDelete = %#v", deleteResult) + } + + download := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{ + vm.ToValue("https://files.example.com/file"), + vm.ToValue("downloads/file.bin"), + }}).Export().(map[string]interface{}) + if download["success"] != true { + t.Fatalf("fileDownload = %#v", download) + } + if data, err := os.ReadFile(filepath.Join(dir, "downloads/file.bin")); err != nil || string(data) != "downloaded" { + t.Fatalf("downloaded data = %q/%v", data, err) + } + + chunked := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{ + vm.ToValue("https://files.example.com/chunk"), + vm.ToValue("downloads/chunk.bin"), + vm.ToValue(map[string]interface{}{"chunked": float64(2), "headers": map[string]interface{}{"X-Test": "yes"}}), + }}).Export().(map[string]interface{}) + if chunked["success"] != true { + t.Fatalf("chunked fileDownload = %#v", chunked) + } + if data, err := os.ReadFile(filepath.Join(dir, "downloads/chunk.bin")); err != nil || string(data) != fileBody { + t.Fatalf("chunked data = %q/%v", data, err) + } + + if missing := runtime.fileDownload(goja.FunctionCall{}).Export().(map[string]interface{}); missing["success"] != false { + t.Fatalf("expected missing download args error, got %#v", missing) + } +} + +func TestExtensionRuntimeUtilityAPIs(t *testing.T) { + vm := goja.New() + runtime := &extensionRuntime{extensionID: "utils-ext", vm: vm} + + if runtime.sha256Hash(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("abc")}}).String() == "" { + t.Fatal("expected sha256") + } + if runtime.hmacSHA256(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("msg"), vm.ToValue("key")}}).String() == "" { + t.Fatal("expected hmac sha256") + } + if runtime.hmacSHA256Base64(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("msg"), vm.ToValue("key")}}).String() == "" { + t.Fatal("expected hmac sha256 base64") + } + if value := runtime.hmacSHA1(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue([]interface{}{float64(1), float64(2)}), vm.ToValue([]interface{}{float64(3)})}}); len(value.Export().([]interface{})) == 0 { + t.Fatal("expected hmac sha1 bytes") + } + if !goja.IsUndefined(runtime.parseJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(`{bad`)}})) { + t.Fatal("expected invalid JSON to return undefined") + } + parsed := runtime.parseJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(`{"ok":true}`)}}).Export().(map[string]interface{}) + if parsed["ok"] != true { + t.Fatalf("parseJSON = %#v", parsed) + } + if text := runtime.stringifyJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(map[string]interface{}{"ok": true})}}).String(); !strings.Contains(text, "ok") { + t.Fatalf("stringifyJSON = %q", text) + } + encrypted := runtime.cryptoEncrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("plain"), vm.ToValue("secret")}}).Export().(map[string]interface{}) + if encrypted["success"] != true || encrypted["data"] == "" { + t.Fatalf("cryptoEncrypt = %#v", encrypted) + } + decrypted := runtime.cryptoDecrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(encrypted["data"]), vm.ToValue("secret")}}).Export().(map[string]interface{}) + if decrypted["success"] != true || decrypted["data"] != "plain" { + t.Fatalf("cryptoDecrypt = %#v", decrypted) + } + if bad := runtime.cryptoDecrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("bad"), vm.ToValue("secret")}}).Export().(map[string]interface{}); bad["success"] != false { + t.Fatalf("expected bad decrypt failure, got %#v", bad) + } + key := runtime.cryptoGenerateKey(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(8))}}).Export().(map[string]interface{}) + if key["success"] != true || key["key"] == "" || key["hex"] == "" { + t.Fatalf("cryptoGenerateKey = %#v", key) + } + if runtime.randomUserAgent(goja.FunctionCall{}).String() == "" || runtime.appUserAgent(goja.FunctionCall{}).String() == "" { + t.Fatal("expected user agents") + } + SetAppVersion("9.9.9") + if runtime.appVersion(goja.FunctionCall{}).String() != "9.9.9" { + t.Fatal("appVersion mismatch") + } + if !runtime.sleep(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(0))}}).ToBoolean() { + t.Fatal("zero sleep should succeed") + } + + itemID := "utils-item" + runtime.setActiveDownloadItemID(itemID) + initDownloadCancel(itemID) + if runtime.isDownloadCancelled(goja.FunctionCall{}).ToBoolean() { + t.Fatal("item should not be cancelled yet") + } + runtime.setDownloadStatus(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(itemProgressStatusDownloading)}}) + cancelDownload(itemID) + if !runtime.isDownloadCancelled(goja.FunctionCall{}).ToBoolean() { + t.Fatal("item should be cancelled") + } + clearDownloadCancel(itemID) + runtime.clearActiveDownloadItemID() + + requestID := "utils-request" + runtime.setActiveRequestID(requestID) + initExtensionRequestCancel(requestID) + if runtime.isRequestCancelled(goja.FunctionCall{}).ToBoolean() { + t.Fatal("request should not be cancelled yet") + } + cancelExtensionRequest(requestID) + if !runtime.isRequestCancelled(goja.FunctionCall{}).ToBoolean() { + t.Fatal("request should be cancelled") + } + clearExtensionRequestCancel(requestID) + runtime.clearActiveRequestID() + + if msg := runtime.formatLogArgs([]goja.Value{vm.ToValue("a"), vm.ToValue(1)}); msg != "a 1" { + t.Fatalf("formatLogArgs = %q", msg) + } + runtime.logDebug(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("debug")}}) + runtime.logInfo(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("info")}}) + runtime.logWarn(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("warn")}}) + runtime.logError(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("error")}}) + if clean := runtime.sanitizeFilenameWrapper(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("A/B?")}}).String(); strings.ContainsAny(clean, "/?") { + t.Fatalf("sanitize wrapper = %q", clean) + } +} diff --git a/go_backend/httputil_supplement_test.go b/go_backend/httputil_supplement_test.go new file mode 100644 index 00000000..5d0f281a --- /dev/null +++ b/go_backend/httputil_supplement_test.go @@ -0,0 +1,158 @@ +package gobackend + +import ( + "errors" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" +) + +func TestHTTPUtilityHelpers(t *testing.T) { + SetAppVersion("7.0.0") + apiURL := mustParseURL(t, "https://api.zarz.moe/test") + if ua := userAgentForURL(apiURL); !strings.Contains(ua, "7.0.0") { + t.Fatalf("api user agent = %q", ua) + } + if userAgentForURL(nil) == "" || userAgentForURL(mustParseURL(t, "https://example.com")) == "" { + t.Fatal("expected fallback user agent") + } + if NewHTTPClientWithTimeout(time.Second).Timeout != time.Second || NewMetadataHTTPClient(time.Second).Timeout != time.Second { + t.Fatal("client timeout mismatch") + } + if GetSharedClient() == nil || GetDownloadClient() == nil { + t.Fatal("expected shared clients") + } + SetNetworkCompatibilityOptions(true, true) + if opts := GetNetworkCompatibilityOptions(); !opts.AllowHTTP || !opts.InsecureTLS { + t.Fatalf("network opts = %#v", opts) + } + SetNetworkCompatibilityOptions(false, false) + if !canFallbackToHTTP(&http.Request{Method: http.MethodGet}) { + t.Fatal("GET should fallback") + } + if canFallbackToHTTP(&http.Request{Method: http.MethodPost}) { + t.Fatal("POST without GetBody should not fallback") + } + req, _ := http.NewRequest(http.MethodPost, "https://example.com/path", strings.NewReader("body")) + req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("body")), nil } + cloned, err := cloneRequestWithHTTPScheme(req, "http") + if err != nil || cloned.URL.Scheme != "http" || cloned.Body == nil { + t.Fatalf("cloneRequestWithHTTPScheme = %#v/%v", cloned, err) + } + + client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Header.Get("User-Agent") == "" { + t.Fatal("missing User-Agent") + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("ok")), Request: req}, nil + })} + resp, err := DoRequestWithUserAgent(client, mustNewRequest(t, "https://example.com/ok")) + if err != nil || resp.StatusCode != 200 { + t.Fatalf("DoRequestWithUserAgent = %#v/%v", resp, err) + } + resp.Body.Close() + + attempts := 0 + retryClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + attempts++ + switch attempts { + case 1: + return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("server")), Request: req}, nil + case 2: + return &http.Response{StatusCode: 429, Header: http.Header{"Retry-After": []string{"0"}}, Body: io.NopCloser(strings.NewReader("rate")), Request: req}, nil + default: + return &http.Response{StatusCode: 204, Body: io.NopCloser(strings.NewReader("")), Request: req}, nil + } + })} + resp, err = DoRequestWithRetry(retryClient, mustNewRequest(t, "https://example.com/retry"), RetryConfig{MaxRetries: 3, InitialDelay: 0, MaxDelay: time.Millisecond, BackoffFactor: 2}) + if err != nil || resp.StatusCode != 204 || attempts != 3 { + t.Fatalf("DoRequestWithRetry = %#v/%v attempts=%d", resp, err, attempts) + } + resp.Body.Close() + blockingClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 403, Body: io.NopCloser(strings.NewReader("access denied by region")), Request: req}, nil + })} + if _, err := DoRequestWithRetry(blockingClient, mustNewRequest(t, "https://blocked.example.com"), RetryConfig{MaxRetries: 0}); err == nil { + t.Fatal("expected blocking retry error") + } + + if _, err := ReadResponseBody(nil); err == nil { + t.Fatal("expected nil response body error") + } + if _, err := ReadResponseBody(&http.Response{Body: io.NopCloser(strings.NewReader(""))}); err == nil { + t.Fatal("expected empty response body error") + } + if body, err := ReadResponseBody(&http.Response{Body: io.NopCloser(strings.NewReader("ok"))}); err != nil || string(body) != "ok" { + t.Fatalf("ReadResponseBody = %q/%v", body, err) + } + if err := ValidateResponse(nil); err == nil { + t.Fatal("expected nil response validation error") + } + if err := ValidateResponse(&http.Response{StatusCode: 404, Status: "404 Not Found"}); err == nil { + t.Fatal("expected bad status validation error") + } + if err := ValidateResponse(&http.Response{StatusCode: 200}); err != nil { + t.Fatalf("ValidateResponse: %v", err) + } + if msg := BuildErrorMessage("api", 500, strings.Repeat("x", 120)); !strings.Contains(msg, "...") { + t.Fatalf("BuildErrorMessage = %q", msg) + } + if calculateNextDelay(10*time.Millisecond, RetryConfig{BackoffFactor: 3, MaxDelay: 20 * time.Millisecond}) != 20*time.Millisecond { + t.Fatal("calculateNextDelay mismatch") + } + if getRetryAfterDuration(&http.Response{Header: http.Header{"Retry-After": []string{"bad"}}}) != 0 { + t.Fatal("invalid retry-after should be zero") + } + if isp := IsISPBlocking(errors.New("connection reset by peer"), "https://example.com/x"); isp == nil || !strings.Contains(isp.Error(), "example.com") { + t.Fatalf("IsISPBlocking = %#v", isp) + } + if !CheckAndLogISPBlocking(errors.New("i/o timeout"), "https://timeout.example/x", "test") { + t.Fatal("expected logged ISP blocking") + } + if wrapped := WrapErrorWithISPCheck(errors.New("connection refused"), "https://refused.example/x", "test"); wrapped == nil || !strings.Contains(wrapped.Error(), "ISP blocking") { + t.Fatalf("WrapErrorWithISPCheck = %v", wrapped) + } + if WrapErrorWithISPCheck(nil, "", "test") != nil { + t.Fatal("nil wrap should stay nil") + } + if extractDomain("https://example.com/path") != "example.com" || extractDomain("bad://") != "unknown" || extractDomain("") != "unknown" { + t.Fatal("extractDomain mismatch") + } +} + +func TestRateLimiterHelpers(t *testing.T) { + limiter := NewRateLimiter(1, time.Hour) + if limiter.Available() != 1 { + t.Fatalf("available = %d", limiter.Available()) + } + if !limiter.TryAcquire() || limiter.TryAcquire() { + t.Fatal("TryAcquire mismatch") + } + if limiter.Available() != 0 { + t.Fatalf("available after acquire = %d", limiter.Available()) + } + if GetSongLinkRateLimiter() == nil { + t.Fatal("expected global limiter") + } +} + +func mustNewRequest(t *testing.T, rawURL string) *http.Request { + t.Helper() + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + t.Fatal(err) + } + return req +} + +func mustParseURL(t *testing.T, rawURL string) *url.URL { + t.Helper() + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatal(err) + } + return parsed +} diff --git a/go_backend/library_scan_supplement_test.go b/go_backend/library_scan_supplement_test.go new file mode 100644 index 00000000..be23a8ff --- /dev/null +++ b/go_backend/library_scan_supplement_test.go @@ -0,0 +1,150 @@ +package gobackend + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLibraryScanFullIncrementalAndMetadataFallbacks(t *testing.T) { + dir := t.TempDir() + albumDir := filepath.Join(dir, "Album") + if err := os.MkdirAll(albumDir, 0755); err != nil { + t.Fatal(err) + } + mp3Path := filepath.Join(albumDir, "Artist - Song.mp3") + if err := os.WriteFile(mp3Path, []byte("not really mp3"), 0600); err != nil { + t.Fatal(err) + } + numberedPath := filepath.Join(albumDir, "01 - Intro.ogg") + if err := os.WriteFile(numberedPath, []byte("not really ogg"), 0600); err != nil { + t.Fatal(err) + } + apePath := filepath.Join(albumDir, "tagged.ape") + if err := os.WriteFile(apePath, []byte("audio"), 0600); err != nil { + t.Fatal(err) + } + if err := WriteAPETags(apePath, &APETag{Items: AudioMetadataToAPEItems(&AudioMetadata{ + Title: "Tagged", + Artist: "APE Artist", + Album: "APE Album", + TrackNumber: 2, + TotalTracks: 3, + Date: "2026", + Genre: "Pop", + Composer: "Composer", + })}); err != nil { + t.Fatalf("write ape tags: %v", err) + } + cuePath, _ := writeExportCueFixture(t, albumDir) + if err := os.WriteFile(filepath.Join(albumDir, "ignored.txt"), []byte("ignore"), 0600); err != nil { + t.Fatal(err) + } + + files, err := collectLibraryAudioFiles(dir, make(chan struct{})) + if err != nil { + t.Fatalf("collectLibraryAudioFiles: %v", err) + } + if len(files) < 4 { + t.Fatalf("files = %#v", files) + } + cancelCh := make(chan struct{}) + close(cancelCh) + if _, err := collectLibraryAudioFiles(dir, cancelCh); err == nil { + t.Fatal("expected cancelled collect") + } + + jsonText, err := ScanLibraryFolder(dir) + if err != nil { + t.Fatalf("ScanLibraryFolder: %v", err) + } + var results []LibraryScanResult + if err := json.Unmarshal([]byte(jsonText), &results); err != nil { + t.Fatalf("decode scan results: %v", err) + } + if len(results) < 4 { + t.Fatalf("scan results = %#v", results) + } + foundTagged := false + for _, result := range results { + if result.FilePath == apePath { + foundTagged = result.TrackName == "Tagged" && result.ArtistName == "APE Artist" + } + } + if !foundTagged { + t.Fatalf("tagged APE not found in %#v", results) + } + if progress := GetLibraryScanProgress(); !strings.Contains(progress, `"IsComplete":true`) && !strings.Contains(progress, `"is_complete":true`) { + t.Fatalf("progress = %s", progress) + } + + metaJSON, err := ReadAudioMetadataWithDisplayName(mp3Path, "Display Artist - Display Song.mp3") + if err != nil { + t.Fatalf("ReadAudioMetadataWithDisplayName: %v", err) + } + if !strings.Contains(metaJSON, "Display Song") { + t.Fatalf("metadata json = %s", metaJSON) + } + noExtPath := filepath.Join(albumDir, "noext") + if err := os.WriteFile(noExtPath, []byte("audio"), 0600); err != nil { + t.Fatal(err) + } + noExtJSON, err := ReadAudioMetadataWithDisplayNameAndCoverCacheKey(noExtPath, "Artist - No Ext.mp3", "cache-key") + if err != nil { + t.Fatalf("ReadAudioMetadataWithDisplayNameAndCoverCacheKey: %v", err) + } + if !strings.Contains(noExtJSON, "No Ext") { + t.Fatalf("no ext metadata = %s", noExtJSON) + } + + existing := map[string]int64{} + for _, file := range files { + existing[file.path] = file.modTime + } + if info, err := os.Stat(cuePath); err == nil { + existing[cuePath+"#track01"] = info.ModTime().UnixMilli() + } + incJSON, err := scanLibraryFolderIncrementalWithExistingFiles(dir, existing) + if err != nil { + t.Fatalf("incremental existing: %v", err) + } + var inc IncrementalScanResult + if err := json.Unmarshal([]byte(incJSON), &inc); err != nil { + t.Fatalf("decode incremental: %v", err) + } + if inc.SkippedCount == 0 { + t.Fatalf("incremental = %#v", inc) + } + if _, err := ScanLibraryFolderIncremental("", "{}"); err == nil { + t.Fatal("expected empty incremental folder error") + } + if incJSON, err := ScanLibraryFolderIncremental(dir, `not-json`); err != nil || incJSON == "" { + t.Fatalf("incremental invalid existing JSON = %q/%v", incJSON, err) + } + + snapshot := filepath.Join(dir, "snapshot.txt") + if err := os.WriteFile(snapshot, []byte("bad\n123\t"+mp3Path+"\nnotint\tpath\n999\t"+filepath.Join(dir, "deleted.mp3")+"\n"), 0600); err != nil { + t.Fatal(err) + } + fromSnapshot, err := ScanLibraryFolderIncrementalFromSnapshot(dir, snapshot) + if err != nil { + t.Fatalf("snapshot incremental: %v", err) + } + if !strings.Contains(fromSnapshot, "deleted.mp3") { + t.Fatalf("snapshot result = %s", fromSnapshot) + } + if _, err := ScanLibraryFolder(""); err == nil { + t.Fatal("expected empty folder scan error") + } + fileInsteadOfFolder := filepath.Join(dir, "file.flac") + if err := os.WriteFile(fileInsteadOfFolder, []byte("audio"), 0600); err != nil { + t.Fatal(err) + } + if _, err := ScanLibraryFolder(fileInsteadOfFolder); err == nil { + t.Fatal("expected not folder error") + } + CancelLibraryScan() + SetLibraryCoverCacheDir("") +} diff --git a/go_backend/log_progress_timeout_supplement_test.go b/go_backend/log_progress_timeout_supplement_test.go new file mode 100644 index 00000000..7b0ac755 --- /dev/null +++ b/go_backend/log_progress_timeout_supplement_test.go @@ -0,0 +1,123 @@ +package gobackend + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/dop251/goja" +) + +func TestLogBufferExportedHelpersAndRedaction(t *testing.T) { + ClearLogs() + SetLoggingEnabled(false) + LogInfo("test", "ignored access_token=secret") + LogError("test", "Authorization: Bearer secret-token api_key=value") + if GetLogCount() != 1 { + t.Fatalf("disabled logging should keep errors only, got %d", GetLogCount()) + } + + SetLoggingEnabled(true) + defer SetLoggingEnabled(false) + LogDebug("debug", "client_secret=secret") + LogWarn("warn", "warning password=secret") + GoLog("[GoTag] success token=abc") + + var entries []LogEntry + if err := json.Unmarshal([]byte(GetLogs()), &entries); err != nil { + t.Fatalf("GetLogs JSON: %v", err) + } + if len(entries) < 4 { + t.Fatalf("expected log entries, got %#v", entries) + } + for _, entry := range entries { + if strings.Contains(entry.Message, "secret-token") || strings.Contains(entry.Message, "api_key=value") || strings.Contains(entry.Message, "password=secret") { + t.Fatalf("log was not redacted: %#v", entry) + } + } + + sinceJSON := GetLogsSince(1) + if !strings.Contains(sinceJSON, `"next_index"`) || !strings.Contains(sinceJSON, `"logs"`) { + t.Fatalf("GetLogsSince = %q", sinceJSON) + } + if emptyJSON := GetLogsSince(999); !strings.Contains(emptyJSON, `"logs":[]`) { + t.Fatalf("GetLogsSince empty = %q", emptyJSON) + } + if negativeJSON := GetLogsSince(-5); !strings.Contains(negativeJSON, `"logs"`) { + t.Fatalf("GetLogsSince negative = %q", negativeJSON) + } + + ClearLogs() + if GetLogCount() != 0 || GetLogs() != "[]" { + t.Fatalf("logs were not cleared: count=%d logs=%s", GetLogCount(), GetLogs()) + } +} + +func TestProgressItemHelpersAndWriter(t *testing.T) { + ClearAllItemProgress() + itemID := "progress-writer" + StartItemProgress(itemID) + SetItemBytesTotal(itemID, int64(progressUpdateThreshold*2)) + SetItemBytesReceived(itemID, int64(progressUpdateThreshold)) + + progressJSON := GetItemProgress(itemID) + if !strings.Contains(progressJSON, `"bytes_received":131072`) || !strings.Contains(progressJSON, `"progress":0.5`) { + t.Fatalf("GetItemProgress = %q", progressJSON) + } + if missing := GetItemProgress("missing"); missing != "{}" { + t.Fatalf("missing progress = %q", missing) + } + + var out bytes.Buffer + writer := NewItemProgressWriter(&out, itemID) + payload := bytes.Repeat([]byte("x"), progressUpdateThreshold+1) + n, err := writer.Write(payload) + if err != nil || n != len(payload) { + t.Fatalf("progress writer = %d/%v", n, err) + } + if out.Len() != len(payload) { + t.Fatalf("writer output length = %d", out.Len()) + } + if progressJSON = GetItemProgress(itemID); !strings.Contains(progressJSON, `"bytes_received":131073`) { + t.Fatalf("progress after writer = %q", progressJSON) + } + + cancelDownload(itemID) + defer clearDownloadCancel(itemID) + n, err = writer.Write([]byte("cancelled")) + if n != 0 || !errors.Is(err, ErrDownloadCancelled) { + t.Fatalf("cancelled writer = %d/%v", n, err) + } + + ClearAllItemProgress() +} + +func TestRunWithTimeoutBranches(t *testing.T) { + if _, err := RunWithTimeout(nil, "1 + 1", time.Millisecond); err == nil { + t.Fatal("expected nil VM error") + } + + vm := goja.New() + value, err := RunWithTimeout(vm, "1 + 2", time.Second) + if err != nil || value.ToInteger() != 3 { + t.Fatalf("RunWithTimeout success = %v/%v", value, err) + } + + timeoutVM := goja.New() + _, err = RunWithTimeoutAndRecover(timeoutVM, "for (;;) {}", 10*time.Millisecond) + if err == nil { + t.Fatal("expected timeout error") + } + if !IsTimeoutError(&JSExecutionError{Message: "timeout", IsTimeout: true}) { + t.Fatal("JSExecutionError should be recognized as timeout") + } + if IsTimeoutError(errors.New("plain")) { + t.Fatal("plain error should not be timeout") + } + if (&JSExecutionError{Message: "boom"}).Error() != "boom" { + t.Fatal("JSExecutionError Error mismatch") + } +} diff --git a/go_backend/lyrics_supplement_test.go b/go_backend/lyrics_supplement_test.go new file mode 100644 index 00000000..2ab5ce78 --- /dev/null +++ b/go_backend/lyrics_supplement_test.go @@ -0,0 +1,236 @@ +package gobackend + +import ( + "io" + "net/http" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) { + SetAppVersion("4.5.0") + if ua := appUserAgent(); !strings.Contains(ua, "4.5.0") { + t.Fatalf("user agent = %q", ua) + } + SetLyricsProviderOrder([]string{"LRCLIB", "bad", "netease"}) + if providers := GetLyricsProviderOrder(); len(providers) != 2 || providers[0] != LyricsProviderLRCLIB { + t.Fatalf("providers = %#v", providers) + } + SetLyricsProviderOrder(nil) + SetLyricsFetchOptions(LyricsFetchOptions{MusixmatchLanguage: " EN_us!!too-long-value ", MultiPersonWordByWord: true}) + if opts := GetLyricsFetchOptions(); !strings.HasPrefix(opts.MusixmatchLanguage, "en_us") || len(opts.MusixmatchLanguage) > 16 { + t.Fatalf("options = %#v", opts) + } + + cache := &lyricsCache{cache: map[string]*lyricsCacheEntry{}} + response := &LyricsResponse{PlainLyrics: "Hello", Source: "test"} + cache.Set(" Artist ", " Song ", 184, response) + if got, ok := cache.Get("artist", "song", 180); !ok || got.PlainLyrics != "Hello" { + t.Fatalf("cache get = %#v/%v", got, ok) + } + cache.cache["expired"] = &lyricsCacheEntry{response: response, expiresAt: time.Now().Add(-time.Hour)} + if cleaned := cache.CleanExpired(); cleaned != 1 { + t.Fatalf("cleaned = %d", cleaned) + } + if cache.Size() != 1 || cache.ClearAll() != 1 || cache.Size() != 0 { + t.Fatalf("cache size after clear = %d", cache.Size()) + } + + lines := parseSyncedLyrics("[00:01.20]Hello\n[bg:Harmony]\n[00:02.300]World\n[00:03.00]\n") + if len(lines) != 2 || !strings.Contains(lines[0].Words, "[bg:Harmony]") || lines[0].EndTimeMs != lines[1].StartTimeMs { + t.Fatalf("synced lines = %#v", lines) + } + if plain := plainLyricsFromTimedLines(lines); !strings.Contains(plain, "Hello") { + t.Fatalf("plain = %q", plain) + } + if unsynced := plainTextLyricsLines("A\n\n B "); len(unsynced) != 2 { + t.Fatalf("unsynced = %#v", unsynced) + } + if !lyricsHasUsableText(&LyricsResponse{Instrumental: true}) || lyricsHasUsableText(&LyricsResponse{}) { + t.Fatal("unexpected usable lyrics result") + } + if msg, ok := detectLyricsErrorPayload(`{"success":false,"message":"nope"}`); !ok || msg != "nope" { + t.Fatalf("error payload = %q/%v", msg, ok) + } + if lrcTimestampToMs("01", "02", "345") != 62345 || msToLRCTimestamp(62340) != "[01:02.34]" { + t.Fatal("unexpected LRC timestamp conversion") + } + lrc := convertToLRCWithMetadata(&LyricsResponse{SyncType: "LINE_SYNCED", Lines: lines}, "Song", "Artist") + if !strings.Contains(lrc, "[ti:Song]") || !strings.Contains(lrc, "Hello") { + t.Fatalf("lrc = %q", lrc) + } + if got := simplifyTrackName("Song (feat. Guest) - 2020 Remaster"); got != "song" { + t.Fatalf("simplified = %q", got) + } + if got := normalizeArtistName("Artist feat. Guest"); got != "Artist" { + t.Fatalf("artist = %q", got) + } + if !isLikelyInstrumentalTrack("Song (Instrumental)") || isLikelyInstrumentalTrack("Song") { + t.Fatal("instrumental heuristic mismatch") + } + + dir := t.TempDir() + lrcPath, err := SaveLRCFile(filepath.Join(dir, "song.flac"), lrc) + if err != nil { + t.Fatalf("SaveLRCFile: %v", err) + } + if !strings.HasSuffix(lrcPath, ".lrc") { + t.Fatalf("lrc path = %q", lrcPath) + } + if _, err := SaveLRCFile(filepath.Join(dir, "empty.flac"), ""); err == nil { + t.Fatal("expected empty LRC error") + } + + client := &LyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.Path { + case "/api/get": + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"id":1,"trackName":"Song","artistName":"Artist","duration":180,"syncedLyrics":"[00:01.00]Hello"}`)), Request: req}, nil + case "/api/search": + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"id":2,"duration":180,"plainLyrics":"Plain\nLyric"},{"id":3,"duration":180,"syncedLyrics":"[00:02.00]Synced"}]`)), Request: req}, nil + default: + return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil + } + })}} + got, err := client.FetchLyricsWithMetadata("Artist", "Song") + if err != nil || got.SyncType != "LINE_SYNCED" || len(got.Lines) != 1 { + t.Fatalf("FetchLyricsWithMetadata = %#v/%v", got, err) + } + search, err := client.FetchLyricsFromLRCLibSearch("Artist Song", 180) + if err != nil || len(search.Lines) == 0 { + t.Fatalf("FetchLyricsFromLRCLibSearch = %#v/%v", search, err) + } + if best := client.findBestMatch([]LRCLibResponse{{Duration: 100, PlainLyrics: "A"}, {Duration: 180, SyncedLyrics: "[00:01.00]B"}}, 180); best == nil || best.SyncedLyrics == "" { + t.Fatalf("best = %#v", best) + } + if !client.durationMatches(181, 180) || client.durationMatches(300, 180) { + t.Fatal("duration match mismatch") + } + parsed := client.parseLRCLibResponse(&LRCLibResponse{PlainLyrics: "A\nB"}) + if parsed.SyncType != "UNSYNCED" || len(parsed.Lines) != 2 { + t.Fatalf("parsed plain = %#v", parsed) + } + + allSources := &LyricsClient{httpClient: client.httpClient} + SetLyricsProviderOrder([]string{LyricsProviderLRCLIB}) + globalLyricsCache.ClearAll() + all, err := allSources.FetchLyricsAllSources("", "Song (Instrumental)", "Artist", 180) + if err != nil || !all.Instrumental { + t.Fatalf("instrumental all sources = %#v/%v", all, err) + } + globalLyricsCache.ClearAll() + all, err = allSources.FetchLyricsAllSources("", "Song", "Artist", 180) + if err != nil || len(all.Lines) == 0 { + t.Fatalf("all sources = %#v/%v", all, err) + } + cached, err := allSources.FetchLyricsAllSources("", "Song", "Artist", 180) + if err != nil || !strings.Contains(cached.Source, "cached") { + t.Fatalf("cached all sources = %#v/%v", cached, err) + } +} + +func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) { + paxJSON := `{"type":"Syllable","content":[{"timestamp":1000,"oppositeTurn":true,"background":true,"text":[{"text":"Hel","part":true,"timestamp":1000},{"text":"lo","part":false,"timestamp":1200,"endtime":1500}],"backgroundText":[{"text":"bg","part":false,"timestamp":900}]}]}` + apple := &AppleMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch { + case strings.Contains(req.URL.Path, "/apple-music/search"): + if req.URL.Query().Get("q") == "bad" { + return &http.Response{StatusCode: 500, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`error`)), Request: req}, nil + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"id":"apple-2","songName":"Other","artistName":"Other","duration":1000},{"id":"apple-1","songName":"Song","artistName":"Artist","albumName":"Album","duration":180000}]`)), Request: req}, nil + case strings.Contains(req.URL.Path, "/apple-music/lyrics"): + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(paxJSON)), Request: req}, nil + default: + return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil + } + })}} + if best := selectBestAppleMusicSearchResult([]appleMusicSearchResult{{ID: "1", SongName: "Song", ArtistName: "Artist", Duration: 180000}}, "Song", "Artist", 180); best == nil || best.ID != "1" { + t.Fatalf("best apple result = %#v", best) + } + appleID, err := apple.SearchSong("Song", "Artist", 180) + if err != nil || appleID != "apple-1" { + t.Fatalf("apple SearchSong = %q/%v", appleID, err) + } + rawApple, err := apple.FetchLyricsByID(appleID) + if err != nil || !strings.Contains(rawApple, "Syllable") { + t.Fatalf("apple raw = %q/%v", rawApple, err) + } + appleLyrics, err := apple.FetchLyrics("Song", "Artist", 180, true) + if err != nil || appleLyrics.SyncType != "LINE_SYNCED" || appleLyrics.Provider != "Apple Music" { + t.Fatalf("apple lyrics = %#v/%v", appleLyrics, err) + } + if plain, err := formatPaxLyricsToLRC(`[{"timestamp":2000,"text":[{"text":"Plain","part":false}]}]`, false); err != nil || !strings.Contains(plain, "Plain") { + t.Fatalf("direct pax = %q/%v", plain, err) + } + if _, err := apple.SearchSong("", "", 0); err == nil { + t.Fatal("expected empty apple search error") + } + + musixmatch := &MusixmatchClient{ + httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + lyricsType := req.URL.Query().Get("type") + lang := req.URL.Query().Get("l") + if req.URL.Query().Get("t") == "bad" { + return &http.Response{StatusCode: 429, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":"rate limited"}`)), Request: req}, nil + } + if lyricsType == "translate" && lang == "id" { + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`"[00:01.00]Halo"`)), Request: req}, nil + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[00:01.00]Hello`)), Request: req}, nil + })}, + baseURL: "https://lyrics.paxsenix.org/musixmatch/lyrics", + } + if localized, err := musixmatch.FetchLyricsInLanguage("Song", "Artist", 180, "id"); err != nil || localized.Source != "Musixmatch (id)" { + t.Fatalf("localized musixmatch = %#v/%v", localized, err) + } + if normal, err := musixmatch.FetchLyrics("Song", "Artist", 180, "xx"); err != nil || normal.Provider != "Musixmatch" { + t.Fatalf("musixmatch = %#v/%v", normal, err) + } + if _, err := musixmatch.FetchLyricsInLanguage("Song", "Artist", 180, " "); err == nil { + t.Fatal("expected invalid language error") + } + if _, err := musixmatch.fetchLyricsPayload("bad", "Artist", 0, "word", ""); err == nil { + t.Fatal("expected musixmatch proxy error") + } + + netease := &NeteaseClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch { + case strings.Contains(req.URL.Path, "/netease/search"): + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"code":200,"result":{"songCount":1,"songs":[{"name":"Song","id":123,"artists":[{"name":"Artist"}]}]}}`)), Request: req}, nil + case strings.Contains(req.URL.Path, "/netease/lyrics"): + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"code":200,"lrc":{"lyric":"[00:01.00]Hello"},"tlyric":{"lyric":"[00:01.00]Halo"},"romalrc":{"lyric":"[00:01.00]Romaji"}}`)), Request: req}, nil + default: + return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil + } + })}} + songID, err := netease.SearchSong("Song", "Artist") + if err != nil || songID != 123 { + t.Fatalf("netease search = %d/%v", songID, err) + } + netLyrics, err := netease.FetchLyrics("Song", "Artist", 180, true, true) + if err != nil || netLyrics.SyncType != "LINE_SYNCED" { + t.Fatalf("netease lyrics = %#v/%v", netLyrics, err) + } + if _, err := netease.SearchSong("", ""); err == nil { + t.Fatal("expected empty netease search error") + } + + qq := &QQMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodPost { + t.Fatalf("unexpected QQ method %s", req.Method) + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"lyrics":[{"timestamp":1000,"text":[{"text":"QQ","part":false,"timestamp":1000}]}]}`)), Request: req}, nil + })}} + qqRaw, err := qq.fetchLyricsByMetadata("Song", "Artist", 180) + if err != nil || !strings.Contains(qqRaw, "lyrics") { + t.Fatalf("qq raw = %q/%v", qqRaw, err) + } + qqLyrics, err := qq.FetchLyrics("Song", "Artist", 180, false) + if err != nil || qqLyrics.Provider != "QQ Music" { + t.Fatalf("qq lyrics = %#v/%v", qqLyrics, err) + } + if _, err := formatQQLyricsMetadataToLRC(`{"lyrics":[]}`, false); err == nil { + t.Fatal("expected empty QQ metadata error") + } +} diff --git a/go_backend/misc_coverage_supplement_test.go b/go_backend/misc_coverage_supplement_test.go new file mode 100644 index 00000000..13000c6c --- /dev/null +++ b/go_backend/misc_coverage_supplement_test.go @@ -0,0 +1,305 @@ +package gobackend + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/dop251/goja" + "github.com/go-flac/flacvorbis/v2" +) + +func TestReadFileMetadataAndCueLibraryWrappers(t *testing.T) { + dir := t.TempDir() + mp3Path := filepath.Join(dir, "tagged.mp3") + tag := buildID3v23Tag( + id3TextFrame("TIT2", "Title"), + id3TextFrame("TPE1", "Artist"), + id3TextFrame("TALB", "Album"), + id3TextFrame("TRCK", "4/12"), + id3CommentFrame("USLT", "[00:00.00]Lyric"), + ) + if err := os.WriteFile(mp3Path, append(tag, []byte{0xFF, 0xFB, 0x90, 0x64, 0, 0, 0, 0}...), 0600); err != nil { + t.Fatal(err) + } + if jsonText, err := ReadFileMetadata(mp3Path); err != nil || !strings.Contains(jsonText, `"title":"Title"`) { + t.Fatalf("ReadFileMetadata mp3 = %q/%v", jsonText, err) + } + + m4aPath := filepath.Join(dir, "tagged.m4a") + ilst := buildM4ATextTag("\xa9nam", "M4A Title") + if err := os.WriteFile(m4aPath, buildM4AFileWithIlst(ilst, true), 0600); err != nil { + t.Fatal(err) + } + if jsonText, err := ReadFileMetadata(m4aPath); err != nil || !strings.Contains(jsonText, "M4A Title") { + t.Fatalf("ReadFileMetadata m4a = %q/%v", jsonText, err) + } + + cuePath, _ := writeExportCueFixture(t, dir) + results, err := ScanCueFileForLibrary(cuePath, time.Now().Format(time.RFC3339)) + if err != nil || len(results) != 1 || results[0].TrackName != "Song" { + t.Fatalf("ScanCueFileForLibrary = %#v/%v", results, err) + } + if _, err := ReadFileMetadata(filepath.Join(dir, "unsupported.txt")); err == nil { + t.Fatal("expected unsupported metadata format") + } +} + +func TestOutputFDFilePathBranches(t *testing.T) { + dir := t.TempDir() + outputPath := filepath.Join(dir, "out.bin") + file, err := openOutputForWrite(outputPath, 0) + if err != nil { + t.Fatalf("openOutputForWrite path: %v", err) + } + if _, err := file.Write([]byte("data")); err != nil { + t.Fatalf("write output: %v", err) + } + file.Close() + if !isFDOutput(1) || isFDOutput(0) { + t.Fatal("isFDOutput mismatch") + } + closeOwnedOutputFD(0) + if err := prepareDupFDForWrite(11, 10); err != nil { + t.Fatalf("prepareDupFDForWrite: %v", err) + } + closeOwnedOutputFD(11) + cleanupOutputOnError(outputPath, 0) + if _, err := os.Stat(outputPath); !os.IsNotExist(err) { + t.Fatalf("cleanup should remove output path, stat err=%v", err) + } + cleanupOutputOnError("", 0) + cleanupOutputOnError("/proc/self/fd/1", 0) + cleanupOutputOnError(filepath.Join(dir, "kept.bin"), 10) +} + +func TestMoreSmallConstructorsRuntimeAndMetadataHelpers(t *testing.T) { + if cfg := DefaultRetryConfig(); cfg.MaxRetries == 0 || cfg.BackoffFactor <= 1 { + t.Fatalf("DefaultRetryConfig = %#v", cfg) + } + if NewAppleMusicClient().httpClient == nil || NewNeteaseClient().httpClient == nil || NewMusixmatchClient().httpClient == nil || NewQQMusicClient().httpClient == nil { + t.Fatal("expected lyric provider HTTP clients") + } + if NewIDHSClient().client == nil { + t.Fatal("expected IDHS HTTP client") + } + ClearTrackCache() + + vm := goja.New() + runtime := &extensionRuntime{extensionID: "misc-runtime", vm: vm, settings: map[string]interface{}{}} + if parseExtensionTimeoutSeconds(" 42 ") != 42 || parseExtensionTimeoutSeconds("bad") != 0 || parseExtensionTimeoutSeconds(float64(7)) != 7 { + t.Fatal("parseExtensionTimeoutSeconds mismatch") + } + if (&RedirectBlockedError{Domain: "blocked.example"}).Error() == "" || (&RedirectBlockedError{IsPrivate: true}).Error() == "" { + t.Fatal("RedirectBlockedError Error mismatch") + } + runtime.SetSettings(map[string]interface{}{"quality": "lossless"}) + if runtime.settings["quality"] != "lossless" { + t.Fatal("SetSettings mismatch") + } + jar, _ := newSimpleCookieJar() + cookieURL, _ := url.Parse("https://example.test/") + jar.SetCookies(cookieURL, []*http.Cookie{{Name: "a", Value: "b"}}) + if cookies := jar.Cookies(cookieURL); len(cookies) != 1 || cookies[0].Value != "b" { + t.Fatalf("cookies = %#v", cookies) + } + + if result := runtime.ffmpegExecute(goja.FunctionCall{}).Export().(map[string]interface{}); result["success"] != false { + t.Fatalf("ffmpegExecute missing args = %#v", result) + } + if result := runtime.ffmpegGetInfo(goja.FunctionCall{}).Export().(map[string]interface{}); result["success"] != false { + t.Fatalf("ffmpegGetInfo missing args = %#v", result) + } + if result := runtime.ffmpegGetInfo(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("missing.flac")}}).Export().(map[string]interface{}); result["success"] != false { + t.Fatalf("ffmpegGetInfo missing file = %#v", result) + } + if result := runtime.ffmpegConvert(goja.FunctionCall{}).Export().(map[string]interface{}); result["success"] != false { + t.Fatalf("ffmpegConvert missing args = %#v", result) + } + + cmt := flacvorbis.New() + setComment(cmt, "TITLE", "Song") + setComment(cmt, "ARTIST", "Artist") + if getComment(cmt, "TITLE") != "Song" || getJoinedComment(cmt, "ARTIST") != "Artist" { + t.Fatalf("comments = %#v", cmt.Comments) + } + setOrClearComment(cmt, "TITLE", "") + if getComment(cmt, "TITLE") != "" { + t.Fatal("setOrClearComment should remove empty value") + } + setOrClearArtistComments(cmt, "ARTIST", "A; B", artistTagModeSplitVorbis) + if joined := getJoinedComment(cmt, "ARTIST"); !strings.Contains(joined, "A") || !strings.Contains(joined, "B") { + t.Fatalf("split artist comments = %q", joined) + } + removeCommentKey(cmt, "ARTIST") + if getComment(cmt, "ARTIST") != "" { + t.Fatal("removeCommentKey failed") + } + if fileExists(filepath.Join(t.TempDir(), "missing")) { + t.Fatal("missing file should not exist") + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("cover")) + })) + defer server.Close() + SetNetworkCompatibilityOptions(true, false) + defer SetNetworkCompatibilityOptions(false, false) + coverPath := filepath.Join(t.TempDir(), "cover.jpg") + if err := DownloadCoverToFile(server.URL+"/cover.jpg", coverPath, false); err != nil { + t.Fatalf("DownloadCoverToFile: %v", err) + } + if string(mustReadFile(t, coverPath)) != "cover" { + t.Fatal("downloaded cover mismatch") + } + + parallel := FetchCoverAndLyricsParallel(server.URL+"/cover.jpg", false, "spotify-1", "Song Instrumental", "Artist", true, 180000) + if string(parallel.CoverData) != "cover" || parallel.CoverErr != nil || parallel.LyricsErr == nil { + t.Fatalf("FetchCoverAndLyricsParallel = %#v", parallel) + } + emptyParallel := FetchCoverAndLyricsParallel("", false, "", "", "", false, 0) + if emptyParallel.CoverData != nil || emptyParallel.LyricsData != nil { + t.Fatalf("empty FetchCoverAndLyricsParallel = %#v", emptyParallel) + } +} + +func TestExtensionHealthInitializeVMAndCustomSearchWrappers(t *testing.T) { + dir := t.TempDir() + extDir := filepath.Join(dir, "ext") + if err := os.MkdirAll(extDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(extDir, "index.js"), []byte(testExtensionJS), 0600); err != nil { + t.Fatal(err) + } + ext := &loadedExtension{ + ID: "health-ext", + Manifest: &ExtensionManifest{ + Name: "health-ext", + DisplayName: "Health", + Version: "1.0.0", + Description: "Health extension", + Types: []ExtensionType{ExtensionTypeMetadataProvider}, + SearchBehavior: &SearchBehaviorConfig{ + Enabled: true, + Primary: true, + }, + ServiceHealth: []ExtensionHealthCheck{{ + ID: "bad", + URL: "http://health.example.test/status", + Required: true, + }}, + }, + Enabled: true, + SourceDir: extDir, + DataDir: filepath.Join(dir, "data"), + } + manager := getExtensionManager() + manager.mu.Lock() + if manager.extensions == nil { + manager.extensions = map[string]*loadedExtension{} + } + manager.extensions[ext.ID] = ext + manager.mu.Unlock() + defer func() { + manager.mu.Lock() + delete(manager.extensions, ext.ID) + manager.mu.Unlock() + }() + + if err := manager.initializeVM(ext); err != nil { + t.Fatalf("initializeVM: %v", err) + } + if ext.VM == nil { + t.Fatal("expected initialized VM") + } + provider := &extensionProviderWrapper{extension: ext} + if tracks, err := provider.CustomSearch("needle", map[string]interface{}{"type": "track"}); err != nil || len(tracks) == 0 { + t.Fatalf("CustomSearch = %#v/%v", tracks, err) + } + cancelMu.Lock() + delete(cancelMap, "custom-item-unique") + cancelMu.Unlock() + if tracks, err := provider.CustomSearchForItemID("needle", nil, "custom-item-unique"); err != nil || len(tracks) == 0 { + t.Fatalf("CustomSearchForItemID = %#v/%v", tracks, err) + } + if healthJSON, err := CheckExtensionHealthJSON(ext.ID); err != nil || !strings.Contains(healthJSON, `"status":"offline"`) { + t.Fatalf("CheckExtensionHealthJSON = %q/%v", healthJSON, err) + } + teardownVMLocked(ext) +} + +func TestManifestPerfMatchingAndTitleHelpers(t *testing.T) { + manifest := &ExtensionManifest{ + Name: "misc-ext", + DisplayName: "Misc", + Version: "1.0.0", + Description: "Misc extension", + Types: []ExtensionType{ExtensionTypeMetadataProvider}, + URLHandler: &URLHandlerConfig{Enabled: true, Patterns: []string{"example.test"}}, + PostProcessing: &PostProcessingConfig{Hooks: []PostProcessingHook{{ + ID: "hook", Name: "Hook", + }}}, + } + data, err := manifest.ToJSON() + if err != nil || !strings.Contains(string(data), "misc-ext") { + t.Fatalf("ToJSON = %q/%v", string(data), err) + } + if !manifest.HasURLHandler() || !manifest.MatchesURL("https://example.test/track") || len(manifest.GetPostProcessingHooks()) != 1 { + t.Fatal("manifest helpers mismatch") + } + if (&ManifestValidationError{Field: "name", Message: "required"}).Error() == "" { + t.Fatal("manifest validation error string empty") + } + + if extensionDurationMs(1500*time.Microsecond) != 1.5 { + t.Fatal("extensionDurationMs mismatch") + } + vm := goja.New() + value := vm.ToValue(map[string]interface{}{"tracks": []interface{}{1, 2, 3}}) + if countExtensionTopLevelItems(vm, value) != 3 { + t.Fatal("countExtensionTopLevelItems mismatch") + } + if countExtensionTopLevelItems(vm, goja.Undefined()) != 0 { + t.Fatal("empty top-level item count mismatch") + } + + if calculateStringSimilarity("", "") != 1 || calculateStringSimilarity("", "x") != 0 || levenshteinDistance("kitten", "sitting") != 3 { + t.Fatal("string similarity helpers mismatch") + } + var b strings.Builder + writeNormalizedArtistRune(&b, 'ß') + writeNormalizedArtistRune(&b, 'æ') + if b.String() != "ssae" { + t.Fatalf("writeNormalizedArtistRune = %q", b.String()) + } + if !artistsMatch("Artist feat Guest", "Guest") || !sameWordsUnordered("B A", "A B") || !titlesMatch("Song (Remastered)", "Song") { + t.Fatal("artist/title matching mismatch") + } + if len(splitArtists("A & B, C x D")) != 4 { + t.Fatal("splitArtists mismatch") + } + if isLatinScript("東京") || !isLatinScript("Beyonce") { + t.Fatal("isLatinScript mismatch") + } + + req := DownloadRequest{TrackName: "Song", ArtistName: "Artist", DurationMS: 180000} + if !trackMatchesRequest(req, resolvedTrackInfo{Title: "Song", ArtistName: "Artist", Duration: 181}, "test") { + t.Fatal("expected matching track") + } + if trackMatchesRequest(req, resolvedTrackInfo{Title: "Other", ArtistName: "Other", Duration: 240}, "test") { + t.Fatal("expected mismatching track") + } + + var decoded map[string]interface{} + if err := json.Unmarshal(data, &decoded); err != nil || decoded["name"] != "misc-ext" { + t.Fatalf("manifest JSON decode = %#v/%v", decoded, err) + } +} diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 62e8a409..bc42c359 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -78,6 +78,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( json['lyricsMultiPersonWordByWord'] as bool? ?? false, musixmatchLanguage: json['musixmatchLanguage'] as String? ?? '', lastSeenVersion: json['lastSeenVersion'] as String? ?? '', + deduplicateDownloads: json['deduplicateDownloads'] as bool? ?? true, ); Map _$AppSettingsToJson( @@ -140,4 +141,5 @@ Map _$AppSettingsToJson( 'lyricsMultiPersonWordByWord': instance.lyricsMultiPersonWordByWord, 'musixmatchLanguage': instance.musixmatchLanguage, 'lastSeenVersion': instance.lastSeenVersion, + 'deduplicateDownloads': instance.deduplicateDownloads, }; diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart new file mode 100644 index 00000000..c1429735 --- /dev/null +++ b/test/models_and_utils_test.dart @@ -0,0 +1,502 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/download_item.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/theme_settings.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/services/download_request_payload.dart'; +import 'package:spotiflac_android/utils/artist_utils.dart'; +import 'package:spotiflac_android/utils/mime_utils.dart'; +import 'package:spotiflac_android/utils/path_match_keys.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; + +void main() { + group('Track', () { + test('exposes collection, source, and quality flags', () { + const album = Track( + id: 'album-1', + name: 'Album', + artistName: 'Artist', + albumName: 'Album', + duration: 0, + itemType: 'album', + source: 'extension.example', + audioQuality: 'FLAC 1411kbps', + audioModes: 'STEREO,DOLBY_ATMOS', + ); + + expect(album.isAlbumItem, isTrue); + expect(album.isPlaylistItem, isFalse); + expect(album.isArtistItem, isFalse); + expect(album.isCollection, isTrue); + expect(album.isFromExtension, isTrue); + expect(album.hasAudioQuality, isTrue); + expect(album.isDolbyAtmos, isTrue); + }); + + test('detects singles and eps case-insensitively', () { + const single = Track( + id: 'track-1', + name: 'Song', + artistName: 'Artist', + albumName: 'Single', + duration: 210000, + albumType: 'SINGLE', + ); + const ep = Track( + id: 'track-2', + name: 'Song 2', + artistName: 'Artist', + albumName: 'EP', + duration: 180000, + albumType: 'ep', + ); + const album = Track( + id: 'track-3', + name: 'Song 3', + artistName: 'Artist', + albumName: 'Album', + duration: 240000, + albumType: 'album', + ); + + expect(single.isSingle, isTrue); + expect(ep.isSingle, isTrue); + expect(album.isSingle, isFalse); + }); + + test('round-trips json with service availability', () { + final track = Track.fromJson({ + 'id': 'spotify:track:1', + 'name': 'Song', + 'artistName': 'Artist', + 'albumName': 'Album', + 'duration': 123456, + 'availability': {'tidal': true, 'deezer': true, 'deezerId': '31337'}, + }); + + expect(track.availability?.tidal, isTrue); + expect(track.availability?.qobuz, isFalse); + expect(track.availability?.deezerId, '31337'); + expect(track.toJson()['id'], 'spotify:track:1'); + expect(track.availability!.toJson()['deezer'], isTrue); + }); + }); + + group('DownloadItem', () { + Track sampleTrack() => const Track( + id: 'track-1', + name: 'Song', + artistName: 'Artist', + albumName: 'Album', + duration: 1000, + ); + + test('uses defaults and preserves fields through copyWith', () { + final createdAt = DateTime.utc(2026, 5, 4, 10); + final item = DownloadItem( + id: 'download-1', + track: sampleTrack(), + service: 'tidal', + createdAt: createdAt, + ); + + final updated = item.copyWith( + status: DownloadStatus.downloading, + progress: 0.5, + speedMBps: 1.25, + bytesReceived: 512, + bytesTotal: 1024, + qualityOverride: 'HI_RES', + playlistName: 'Favorites', + ); + + expect(item.status, DownloadStatus.queued); + expect(item.progress, 0); + expect(updated.id, item.id); + expect(updated.track, item.track); + expect(updated.status, DownloadStatus.downloading); + expect(updated.progress, 0.5); + expect(updated.speedMBps, 1.25); + expect(updated.bytesReceived, 512); + expect(updated.bytesTotal, 1024); + expect(updated.qualityOverride, 'HI_RES'); + expect(updated.playlistName, 'Favorites'); + }); + + test('maps typed errors to user-facing messages', () { + final base = DownloadItem( + id: 'download-1', + track: sampleTrack(), + service: 'qobuz', + createdAt: DateTime.utc(2026), + error: 'raw backend failure', + ); + + expect(base.errorMessage, 'raw backend failure'); + expect( + base.copyWith(errorType: DownloadErrorType.notFound).errorMessage, + 'Song not found on any service', + ); + expect( + base.copyWith(errorType: DownloadErrorType.rateLimit).errorMessage, + 'Rate limit reached, try again later', + ); + expect( + base.copyWith(errorType: DownloadErrorType.network).errorMessage, + 'Connection failed, check your internet', + ); + expect( + base.copyWith(errorType: DownloadErrorType.permission).errorMessage, + 'Cannot write to folder, check storage permission', + ); + expect(base.copyWith(error: null).errorMessage, 'raw backend failure'); + }); + + test('decodes json defaults and enums', () { + final item = DownloadItem.fromJson({ + 'id': 'download-1', + 'track': { + 'id': 'track-1', + 'name': 'Song', + 'artistName': 'Artist', + 'albumName': 'Album', + 'duration': 1000, + }, + 'service': 'deezer', + 'status': 'failed', + 'errorType': 'network', + 'createdAt': '2026-05-04T10:00:00.000Z', + }); + + expect(item.status, DownloadStatus.failed); + expect(item.errorType, DownloadErrorType.network); + expect(item.progress, 0); + expect(item.bytesReceived, 0); + expect(item.toJson()['status'], 'failed'); + expect(item.toJson()['errorType'], 'network'); + }); + }); + + group('AppSettings', () { + test('provides stable defaults', () { + const settings = AppSettings(); + + expect(settings.audioQuality, 'LOSSLESS'); + expect(settings.filenameFormat, '{title} - {artist}'); + expect(settings.artistTagMode, artistTagModeJoined); + expect(settings.autoFallback, isTrue); + expect(settings.lyricsProviders, [ + 'lrclib', + 'musixmatch', + 'netease', + 'apple_music', + 'qqmusic', + ]); + expect(settings.deduplicateDownloads, isTrue); + }); + + test('copyWith updates values and can clear nullable provider fields', () { + const settings = AppSettings( + downloadFallbackExtensionIds: ['fallback.ext'], + searchProvider: 'search.ext', + homeFeedProvider: 'feed.ext', + ); + + final updated = settings.copyWith( + defaultService: 'tidal', + concurrentDownloads: 4, + embedReplayGain: true, + lyricsProviders: ['apple_music'], + deduplicateDownloads: false, + clearDownloadFallbackExtensionIds: true, + clearSearchProvider: true, + clearHomeFeedProvider: true, + ); + + expect(updated.defaultService, 'tidal'); + expect(updated.concurrentDownloads, 4); + expect(updated.embedReplayGain, isTrue); + expect(updated.lyricsProviders, ['apple_music']); + expect(updated.deduplicateDownloads, isFalse); + expect(updated.downloadFallbackExtensionIds, isNull); + expect(updated.searchProvider, isNull); + expect(updated.homeFeedProvider, isNull); + expect(updated.audioQuality, settings.audioQuality); + }); + + test('round-trips json including recently added settings', () { + const settings = AppSettings( + defaultService: 'qobuz', + storageMode: 'saf', + downloadTreeUri: 'content://tree/music', + downloadFallbackExtensionIds: ['ext.a', 'ext.b'], + searchProvider: 'search.ext', + homeFeedProvider: AppSettings.homeFeedProviderOff, + useAllFilesAccess: true, + networkCompatibilityMode: true, + songLinkRegion: 'ID', + localLibraryEnabled: true, + localLibraryPath: '/music', + hasCompletedTutorial: true, + musixmatchLanguage: 'id', + lastSeenVersion: '4.5.0', + deduplicateDownloads: false, + ); + + final decoded = AppSettings.fromJson(settings.toJson()); + + expect(decoded.defaultService, 'qobuz'); + expect(decoded.storageMode, 'saf'); + expect(decoded.downloadTreeUri, 'content://tree/music'); + expect(decoded.downloadFallbackExtensionIds, ['ext.a', 'ext.b']); + expect(decoded.searchProvider, 'search.ext'); + expect(decoded.homeFeedProvider, AppSettings.homeFeedProviderOff); + expect(decoded.useAllFilesAccess, isTrue); + expect(decoded.networkCompatibilityMode, isTrue); + expect(decoded.songLinkRegion, 'ID'); + expect(decoded.localLibraryEnabled, isTrue); + expect(decoded.localLibraryPath, '/music'); + expect(decoded.hasCompletedTutorial, isTrue); + expect(decoded.musixmatchLanguage, 'id'); + expect(decoded.lastSeenVersion, '4.5.0'); + expect(decoded.deduplicateDownloads, isFalse); + }); + }); + + group('ThemeSettings', () { + test('serializes, deserializes, copies, and compares values', () { + const settings = ThemeSettings( + themeMode: ThemeMode.dark, + useDynamicColor: false, + seedColorValue: 0xff123456, + useAmoled: true, + ); + + final decoded = ThemeSettings.fromJson(settings.toJson()); + final copied = decoded.copyWith(themeMode: ThemeMode.light); + + expect(decoded, settings); + expect(decoded.hashCode, settings.hashCode); + expect(decoded.seedColor, const Color(0xff123456)); + expect(copied.themeMode, ThemeMode.light); + expect(copied.useAmoled, isTrue); + expect( + ThemeSettings.fromJson({'theme_mode': 'invalid'}).themeMode, + ThemeMode.system, + ); + }); + }); + + group('DownloadRequestPayload', () { + test('serializes all backend field names', () { + const payload = DownloadRequestPayload( + isrc: 'ISRC123', + service: 'tidal', + spotifyId: 'spotify:track:1', + trackName: 'Song', + artistName: 'Artist', + albumName: 'Album', + albumArtist: 'Album Artist', + coverUrl: 'https://example.test/cover.jpg', + outputDir: '/downloads', + filenameFormat: '{artist} - {title}', + quality: 'HI_RES', + embedMetadata: false, + artistTagMode: artistTagModeSplitVorbis, + embedLyrics: false, + embedMaxQualityCover: false, + trackNumber: 7, + discNumber: 2, + totalTracks: 12, + totalDiscs: 2, + releaseDate: '2026-05-04', + itemId: 'item-1', + durationMs: 250000, + source: 'extension.example', + genre: 'Pop', + label: 'Label', + copyright: 'Copyright', + composer: 'Composer', + tidalId: 'tidal-1', + qobuzId: 'qobuz-1', + deezerId: 'deezer-1', + lyricsMode: 'sidecar', + useExtensions: true, + useFallback: true, + storageMode: 'saf', + safTreeUri: 'content://tree/music', + safRelativeDir: 'Album', + safFileName: 'Song.flac', + safOutputExt: 'flac', + songLinkRegion: 'ID', + ); + + expect(payload.toJson(), { + 'isrc': 'ISRC123', + 'service': 'tidal', + 'spotify_id': 'spotify:track:1', + 'track_name': 'Song', + 'artist_name': 'Artist', + 'album_name': 'Album', + 'album_artist': 'Album Artist', + 'cover_url': 'https://example.test/cover.jpg', + 'output_dir': '/downloads', + 'filename_format': '{artist} - {title}', + 'quality': 'HI_RES', + 'embed_metadata': false, + 'artist_tag_mode': artistTagModeSplitVorbis, + 'embed_lyrics': false, + 'embed_max_quality_cover': false, + 'track_number': 7, + 'disc_number': 2, + 'total_tracks': 12, + 'total_discs': 2, + 'release_date': '2026-05-04', + 'item_id': 'item-1', + 'duration_ms': 250000, + 'source': 'extension.example', + 'genre': 'Pop', + 'label': 'Label', + 'copyright': 'Copyright', + 'composer': 'Composer', + 'tidal_id': 'tidal-1', + 'qobuz_id': 'qobuz-1', + 'deezer_id': 'deezer-1', + 'lyrics_mode': 'sidecar', + 'use_extensions': true, + 'use_fallback': true, + 'storage_mode': 'saf', + 'saf_tree_uri': 'content://tree/music', + 'saf_relative_dir': 'Album', + 'saf_file_name': 'Song.flac', + 'saf_output_ext': 'flac', + 'songlink_region': 'ID', + }); + }); + + test('withStrategy only changes requested strategy flags', () { + const payload = DownloadRequestPayload( + trackName: 'Song', + artistName: 'Artist', + albumName: 'Album', + outputDir: '/downloads', + filenameFormat: '{title}', + useExtensions: false, + useFallback: true, + ); + + final updated = payload.withStrategy(useExtensions: true); + + expect(updated.useExtensions, isTrue); + expect(updated.useFallback, isTrue); + expect(updated.trackName, payload.trackName); + expect(updated.filenameFormat, payload.filenameFormat); + }); + }); + + group('artist utils', () { + test('splits common artist separators and removes duplicates for tags', () { + expect(splitArtistNames(' A, B & C feat. D x E with F '), [ + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + ]); + expect(splitArtistTagValues('A, a & B'), ['A', 'B']); + expect(splitArtistTagValues(' '), isEmpty); + expect(shouldSplitVorbisArtistTags(artistTagModeSplitVorbis), isTrue); + expect(shouldSplitVorbisArtistTags(artistTagModeJoined), isFalse); + }); + }); + + group('string utils', () { + test('normalizes optional strings and cover references', () { + expect(normalizeOptionalString(null), isNull); + expect(normalizeOptionalString(' null '), isNull); + expect(normalizeOptionalString(' value '), 'value'); + expect( + normalizeCoverReference('//cdn.example.test/a.jpg'), + 'https://cdn.example.test/a.jpg', + ); + expect( + normalizeCoverReference('https://example.test/a.jpg'), + 'https://example.test/a.jpg', + ); + expect( + normalizeCoverReference('/storage/music/a.jpg'), + '/storage/music/a.jpg', + ); + expect(normalizeCoverReference('relative/a.jpg'), isNull); + expect(normalizeRemoteHttpUrl('file:///tmp/a.jpg'), isNull); + expect( + normalizeRemoteHttpUrl('http://example.test/a.jpg'), + 'http://example.test/a.jpg', + ); + }); + + test('formats display audio quality from strongest available source', () { + expect( + buildDisplayAudioQuality( + bitrateKbps: 320, + format: 'mp3', + bitDepth: 24, + sampleRate: 96000, + storedQuality: 'LOSSLESS', + ), + 'MP3 320kbps', + ); + expect( + buildDisplayAudioQuality(bitDepth: 24, sampleRate: 96000), + '24-bit/96kHz', + ); + expect(formatSampleRateKHz(44100), '44.1kHz'); + expect(buildDisplayAudioQuality(storedQuality: ' Hi-Res '), 'Hi-Res'); + expect(isPlaceholderQualityLabel('lossless'), isTrue); + expect(isPlaceholderQualityLabel('FLAC 1411kbps'), isFalse); + }); + }); + + group('mime utils', () { + test('maps known audio extensions and falls back to wildcard', () { + expect(audioMimeTypeForPath('/music/song.FLAC'), 'audio/flac'); + expect(audioMimeTypeForPath('/music/song.m4a'), 'audio/mp4'); + expect(audioMimeTypeForPath('/music/song.mp3'), 'audio/mpeg'); + expect(audioMimeTypeForPath('/music/song.ogg'), 'audio/ogg'); + expect(audioMimeTypeForPath('/music/song.wav'), 'audio/wav'); + expect(audioMimeTypeForPath('/music/song.aac'), 'audio/aac'); + expect(audioMimeTypeForPath('/music/song'), 'audio/*'); + expect(audioMimeTypeForPath('/music/song.'), 'audio/*'); + expect(audioMimeTypeForPath('/music/song.txt'), 'audio/*'); + }); + }); + + group('path match keys', () { + test('builds normalized variants for local paths and file uris', () { + final keys = buildPathMatchKeys('EXISTS: /Music/A%20Song.FLAC '); + + expect(keys, contains('/Music/A%20Song.FLAC')); + expect(keys, contains('/music/a%20song.flac')); + expect(keys, contains('/Music/A Song.FLAC')); + expect(keys, contains('/music/a song.flac')); + expect(keys, contains('file:///Music/A%2520Song.FLAC')); + expect(keys, contains('/Music/A%20Song')); + expect( + identical(buildPathMatchKeys('/Music/A%20Song.FLAC'), keys), + isTrue, + ); + expect(buildPathMatchKeys(' '), isEmpty); + }); + + test('normalizes windows-style separators', () { + final keys = buildPathMatchKeys(r'C:\Music\Song.mp3'); + + expect(keys, contains(r'C:\Music\Song.mp3')); + expect(keys, contains('C:/Music/Song.mp3')); + expect(keys, contains('c:/music/song.mp3')); + expect(keys, contains('C:/Music/Song')); + }); + }); +}