mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-15 05:10:28 +02:00
bb06ab7e12
- Remove unused Go functions: buildRawAPEItem, loadCredentials, scanAudioFile, scanAudioFileWithKnownModTimeAndDisplayName, readM4AIndexValue, musixmatchSearchResponse/LyricsResponse structs - Remove unused Go fields: downloadDir, utlsTransport.mu/h2Transports - Lowercase Go error messages per convention (golint/ST1005) - Simplify LyricsLine conversion and artistsMatch return - Add Dart analysis rules: always_declare_return_types, avoid_types_as_parameter_names, strict_top_level_inference, type_annotate_public_apis - Suppress SA1019 lint for required blowfish import
402 lines
13 KiB
Go
402 lines
13 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|