mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-13 20:42:10 +02:00
test: add comprehensive Go backend and Dart model test suites
- Add 16 Go supplement test files covering extension runtime, providers, health checks, lyrics, metadata, HTTP utils, library scan, and more - Add Dart model/utils test suite (test/models_and_utils_test.dart) - Update settings.g.dart with deduplicateDownloads serialization
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
json['lyricsMultiPersonWordByWord'] as bool? ?? false,
|
||||
musixmatchLanguage: json['musixmatchLanguage'] as String? ?? '',
|
||||
lastSeenVersion: json['lastSeenVersion'] as String? ?? '',
|
||||
deduplicateDownloads: json['deduplicateDownloads'] as bool? ?? true,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(
|
||||
@@ -140,4 +141,5 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'lyricsMultiPersonWordByWord': instance.lyricsMultiPersonWordByWord,
|
||||
'musixmatchLanguage': instance.musixmatchLanguage,
|
||||
'lastSeenVersion': instance.lastSeenVersion,
|
||||
'deduplicateDownloads': instance.deduplicateDownloads,
|
||||
};
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user