feat: add resolve API with SongLink fallback, fix multi-artist tags (#288), and cleanup

Resolve API (api.zarz.moe):
- Refactor songlink.go: Spotify URLs use resolve API, non-Spotify uses SongLink API
- Add SongLink fallback when resolve API fails for Spotify (two-layer resilience)
- Remove dead code: page parser, XOR-obfuscated keys, legacy helpers

Multi-artist tag fix (#288):
- Add RewriteSplitArtistTags() in Go to rewrite ARTIST/ALBUMARTIST as split Vorbis comments
- Wire method channel handler in Android (MainActivity.kt) and iOS (AppDelegate.swift)
- Add PlatformBridge.rewriteSplitArtistTags() in Dart
- Call native FLAC rewriter after FFmpeg embed when split_vorbis mode is active
- Extract deezerTrackArtistDisplay() helper to use Contributors in album/playlist tracks

Code cleanup:
- Remove unused imports, dead code, and redundant comments across Go and Dart
- Fix build: remove stale getQobuzDebugKey() reference in deezer_download.go
This commit is contained in:
zarzet
2026-04-01 02:45:19 +07:00
parent a1aa1319ce
commit f511f30ad0
63 changed files with 427 additions and 1046 deletions
@@ -2384,6 +2384,41 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"rewriteSplitArtistTags" -> {
val filePath = call.argument<String>("file_path") ?: ""
val artist = call.argument<String>("artist") ?: ""
val albumArtist = call.argument<String>("album_artist") ?: ""
val response = withContext(Dispatchers.IO) {
if (filePath.startsWith("content://")) {
val uri = Uri.parse(filePath)
val tempPath = copyUriToTemp(uri, ".flac")
?: return@withContext errorJson("Failed to copy SAF file to temp")
try {
val raw = Gobackend.rewriteSplitArtistTagsExport(tempPath, artist, albumArtist)
val obj = JSONObject(raw)
if (!obj.optBoolean("success", false)) {
return@withContext raw
}
if (!writeUriFromPath(uri, tempPath)) {
return@withContext errorJson("Failed to write rewritten tags back to SAF file")
}
obj.put("file_path", filePath)
obj.toString()
} catch (e: Exception) {
errorJson("Failed to rewrite split artist tags in SAF file: ${e.message}")
} finally {
try {
File(tempPath).delete()
} catch (_: Exception) {}
}
} else {
Gobackend.rewriteSplitArtistTagsExport(filePath, artist, albumArtist)
}
}
result.success(response)
}
"cleanupConnections" -> {
withContext(Dispatchers.IO) {
Gobackend.cleanupConnections()
+7 -46
View File
@@ -338,7 +338,6 @@ func readID3v1(file *os.File) (*AudioMetadata, error) {
Year: strings.TrimRight(string(tag[93:97]), " \x00"),
}
// ID3v1.1 track number (if byte 125 is 0 and byte 126 is not)
if tag[125] == 0 && tag[126] != 0 {
metadata.TrackNumber = int(tag[126])
}
@@ -373,27 +372,23 @@ func extractTextFrame(data []byte) string {
}
}
// extractCommentFrame parses an ID3v2 COMM frame.
// Format: encoding(1) + language(3) + description(null-terminated) + text
func extractCommentFrame(data []byte) string {
if len(data) < 5 {
return ""
}
encoding := data[0]
// skip 3-byte language code
rest := data[4:]
// find null terminator separating description from text
var text []byte
switch encoding {
case 1, 2: // UTF-16 variants use double-null terminator
case 1, 2:
for i := 0; i+1 < len(rest); i += 2 {
if rest[i] == 0 && rest[i+1] == 0 {
text = rest[i+2:]
break
}
}
default: // ISO-8859-1 or UTF-8
default:
idx := bytes.IndexByte(rest, 0)
if idx >= 0 && idx+1 < len(rest) {
text = rest[idx+1:]
@@ -406,33 +401,30 @@ func extractCommentFrame(data []byte) string {
return ""
}
// re-prepend encoding byte so extractTextFrame can decode properly
framed := make([]byte, 1+len(text))
framed[0] = encoding
copy(framed[1:], text)
return extractTextFrame(framed)
}
// extractLyricsFrame parses ID3 unsynchronized lyrics frames (USLT/ULT).
// Format: encoding(1) + language(3) + description(null-terminated) + lyrics text.
func extractLyricsFrame(data []byte) string {
if len(data) < 5 {
return ""
}
encoding := data[0]
rest := data[4:] // skip 3-byte language code
rest := data[4:]
var text []byte
switch encoding {
case 1, 2: // UTF-16 variants use double-null terminator
case 1, 2:
for i := 0; i+1 < len(rest); i += 2 {
if rest[i] == 0 && rest[i+1] == 0 {
text = rest[i+2:]
break
}
}
default: // ISO-8859-1 or UTF-8
default:
idx := bytes.IndexByte(rest, 0)
if idx >= 0 && idx+1 < len(rest) {
text = rest[idx+1:]
@@ -451,8 +443,6 @@ func extractLyricsFrame(data []byte) string {
return extractTextFrame(framed)
}
// extractUserTextFrame parses ID3 TXXX/TXX user text frame:
// encoding(1) + description + separator + value.
func extractUserTextFrame(data []byte) (string, string) {
if len(data) < 2 {
return "", ""
@@ -463,7 +453,7 @@ func extractUserTextFrame(data []byte) (string, string) {
var descRaw, valueRaw []byte
switch encoding {
case 1, 2: // UTF-16 variants
case 1, 2:
for i := 0; i+1 < len(payload); i += 2 {
if payload[i] == 0 && payload[i+1] == 0 {
descRaw = payload[:i]
@@ -471,7 +461,7 @@ func extractUserTextFrame(data []byte) (string, string) {
break
}
}
default: // ISO-8859-1 or UTF-8
default:
idx := bytes.IndexByte(payload, 0)
if idx >= 0 {
descRaw = payload[:idx]
@@ -665,7 +655,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
file.Seek(audioStart, io.SeekStart)
// Find first valid MP3 frame sync
frameHeader := make([]byte, 4)
var frameStart int64 = -1
for i := 0; i < 10000; i++ {
@@ -692,8 +681,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
channelMode := (frameHeader[3] >> 6) & 0x03
// Sample rate tables: [version][index]
// version: 0=MPEG2.5, 1=reserved, 2=MPEG2, 3=MPEG1
sampleRates := [][]int{
{11025, 12000, 8000},
{0, 0, 0},
@@ -704,15 +691,12 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
quality.SampleRate = sampleRates[version][sampleRateIdx]
}
// Bitrate tables for all MPEG versions and layers
// MPEG1 Layer III
if version == 3 && layer == 1 {
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
if bitrateIdx < 16 {
quality.Bitrate = bitrates[bitrateIdx] * 1000
}
}
// MPEG2/2.5 Layer III
if (version == 0 || version == 2) && layer == 1 {
bitrates := []int{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}
if bitrateIdx < 16 {
@@ -720,14 +704,11 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
}
}
// Determine samples per frame for duration calculation
samplesPerFrame := 1152 // MPEG1 Layer III
if version == 0 || version == 2 {
samplesPerFrame = 576 // MPEG2/2.5 Layer III
}
// Try to read Xing/VBRI header from the first frame for VBR info
// Xing header offset depends on MPEG version and channel mode
var xingOffset int
if version == 3 { // MPEG1
if channelMode == 3 { // Mono
@@ -743,7 +724,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
}
}
// Read enough of the first frame to find Xing/VBRI header
xingBuf := make([]byte, 200)
file.Seek(frameStart+4, io.SeekStart)
n, _ := io.ReadFull(file, xingBuf)
@@ -753,7 +733,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
vbrBytes := int64(0)
isVBR := false
// Check for Xing/Info header
if xingOffset+8 <= n {
tag := string(xingBuf[xingOffset : xingOffset+4])
if tag == "Xing" || tag == "Info" {
@@ -772,7 +751,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
}
}
// Check for VBRI header (always at offset 32 from frame start + 4)
if !isVBR && 36+26 <= n {
if string(xingBuf[32:36]) == "VBRI" {
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[36+6 : 36+10]))
@@ -784,11 +762,9 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
}
if isVBR && vbrFrames > 0 && quality.SampleRate > 0 {
// Accurate duration from total frames
totalSamples := int64(vbrFrames) * int64(samplesPerFrame)
quality.Duration = int(totalSamples / int64(quality.SampleRate))
// Accurate average bitrate
if vbrBytes > 0 && quality.Duration > 0 {
quality.Bitrate = int(vbrBytes * 8 / int64(quality.Duration))
} else if quality.Duration > 0 {
@@ -796,7 +772,6 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
quality.Bitrate = int(audioSize * 8 / int64(quality.Duration))
}
} else if quality.Bitrate > 0 {
// CBR fallback: estimate duration from file size and frame bitrate
audioSize := fileSize - audioStart - 128 // subtract possible ID3v1 tag
if audioSize > 0 {
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
@@ -983,7 +958,6 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
artistValues := make([]string, 0, 1)
albumArtistValues := make([]string, 0, 1)
// Read vendor string length
var vendorLen uint32
if err := binary.Read(reader, binary.LittleEndian, &vendorLen); err != nil {
return
@@ -1012,8 +986,6 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
if commentLen > remaining {
break
}
// Large comment entries are typically METADATA_BLOCK_PICTURE.
// Skip them so we can continue parsing normal text tags after/before.
if commentLen > 512*1024 {
reader.Seek(int64(commentLen), io.SeekCurrent)
continue
@@ -1123,7 +1095,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
}
}
// Read granule position from the last Ogg page for accurate duration
stat, err := file.Stat()
if err != nil {
return quality, nil
@@ -1133,7 +1104,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
granule := readLastOggGranulePosition(file, fileSize)
if granule > 0 {
if isOpus {
// Opus always uses 48kHz granule position internally
totalSamples := granule - int64(preSkip)
if totalSamples > 0 {
durationSec := float64(totalSamples) / 48000.0
@@ -1151,11 +1121,9 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
}
}
// Fallback bitrate estimate if duration exists but bitrate couldn't be derived.
if quality.Bitrate <= 0 && quality.Duration > 0 {
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
}
// Guard against obviously invalid values from corrupted/unreliable granule reads.
if quality.Duration > 24*60*60 {
quality.Duration = 0
quality.Bitrate = 0
@@ -1167,10 +1135,7 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
return quality, nil
}
// readLastOggGranulePosition seeks to the end of the file and scans backwards
// to find the last Ogg page, then reads its granule position (bytes 6-13).
func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
// Read the last chunk of the file to find the last OggS sync
searchSize := int64(65536)
if searchSize > fileSize {
searchSize = fileSize
@@ -1194,7 +1159,6 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
if i+27 > n {
continue
}
// Validate minimal header fields to avoid false positives inside payload bytes.
version := buf[i+4]
headerType := buf[i+5]
if version != 0 || headerType > 0x07 {
@@ -1212,7 +1176,6 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
if i+headerLen+payloadLen > n {
continue
}
// Granule position is at bytes 6-13 of the Ogg page header (little-endian int64).
return int64(binary.LittleEndian.Uint64(buf[i+6 : i+14]))
}
return 0
@@ -1272,7 +1235,6 @@ func extractMP3CoverArt(filePath string) ([]byte, string, error) {
return nil, "", err
}
// Parse frames looking for APIC (Attached Picture)
pos := 0
var frameIDLen, headerLen int
if majorVersion == 2 {
@@ -1303,7 +1265,6 @@ func extractMP3CoverArt(filePath string) ([]byte, string, error) {
break
}
// Check for APIC (ID3v2.3/2.4) or PIC (ID3v2.2)
if (frameIDLen == 4 && frameID == "APIC") || (frameIDLen == 3 && frameID == "PIC") {
frameData := tagData[pos+headerLen : pos+headerLen+frameSize]
imageData, mimeType := parseAPICFrame(frameData, majorVersion)
-47
View File
@@ -11,7 +11,6 @@ import (
"strings"
)
// CueSheet represents a parsed .cue file
type CueSheet struct {
Performer string `json:"performer"`
Title string `json:"title"`
@@ -24,7 +23,6 @@ type CueSheet struct {
Tracks []CueTrack `json:"tracks"`
}
// CueTrack represents a single track in a cue sheet
type CueTrack struct {
Number int `json:"number"`
Title string `json:"title"`
@@ -35,7 +33,6 @@ type CueTrack struct {
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
}
// CueSplitInfo represents the information needed to split a CUE+audio file
type CueSplitInfo struct {
CuePath string `json:"cue_path"`
AudioPath string `json:"audio_path"`
@@ -46,7 +43,6 @@ type CueSplitInfo struct {
Tracks []CueSplitTrack `json:"tracks"`
}
// CueSplitTrack has the FFmpeg split parameters for a single track
type CueSplitTrack struct {
Number int `json:"number"`
Title string `json:"title"`
@@ -62,7 +58,6 @@ var (
reQuoted = regexp.MustCompile(`"([^"]*)"`)
)
// ParseCueFile parses a .cue file and returns a CueSheet
func ParseCueFile(cuePath string) (*CueSheet, error) {
f, err := os.Open(cuePath)
if err != nil {
@@ -202,7 +197,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
return sheet, nil
}
// parseCueTimestamp converts MM:SS:FF (frames at 75fps) to seconds
func parseCueTimestamp(ts string) float64 {
parts := strings.Split(ts, ":")
if len(parts) != 3 {
@@ -216,7 +210,6 @@ func parseCueTimestamp(ts string) float64 {
return float64(minutes)*60 + float64(seconds) + float64(frames)/75.0
}
// formatCueTimestamp converts seconds to HH:MM:SS.mmm format for FFmpeg
func formatCueTimestamp(seconds float64) string {
if seconds < 0 {
return "0"
@@ -227,7 +220,6 @@ func formatCueTimestamp(seconds float64) string {
return fmt.Sprintf("%02d:%02d:%06.3f", hours, mins, secs)
}
// unquoteCue removes surrounding quotes from a CUE value
func unquoteCue(s string) string {
s = strings.TrimSpace(s)
if matches := reQuoted.FindStringSubmatch(s); len(matches) == 2 {
@@ -236,14 +228,12 @@ func unquoteCue(s string) string {
return s
}
// parseCueFileLine parses the FILE command's filename and type
func parseCueFileLine(rest string) (string, string) {
rest = strings.TrimSpace(rest)
var filename, ftype string
if strings.HasPrefix(rest, "\"") {
// Quoted filename
endQuote := strings.Index(rest[1:], "\"")
if endQuote >= 0 {
filename = rest[1 : endQuote+1]
@@ -253,7 +243,6 @@ func parseCueFileLine(rest string) (string, string) {
filename = rest
}
} else {
// Unquoted filename - last word is the type
parts := strings.Fields(rest)
if len(parts) >= 2 {
ftype = parts[len(parts)-1]
@@ -266,18 +255,14 @@ func parseCueFileLine(rest string) (string, string) {
return filename, strings.TrimSpace(ftype)
}
// ResolveCueAudioPath finds the actual audio file referenced by a .cue sheet.
// It checks relative to the cue file's directory.
func ResolveCueAudioPath(cuePath string, cueFileName string) string {
cueDir := filepath.Dir(cuePath)
// 1. Try the exact filename from the .cue
candidate := filepath.Join(cueDir, cueFileName)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
// 2. Try common case variations
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
for _, ext := range commonExts {
@@ -285,14 +270,12 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
if _, err := os.Stat(candidate); err == nil {
return candidate
}
// Try uppercase ext
candidate = filepath.Join(cueDir, baseName+strings.ToUpper(ext))
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
// 3. Try to find any audio file with the same base name as the .cue file
cueBase := strings.TrimSuffix(filepath.Base(cuePath), filepath.Ext(cuePath))
for _, ext := range commonExts {
candidate = filepath.Join(cueDir, cueBase+ext)
@@ -301,7 +284,6 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
}
}
// 4. If there's only one audio file in the directory, use that
entries, err := os.ReadDir(cueDir)
if err == nil {
audioExts := map[string]bool{
@@ -326,13 +308,9 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
return ""
}
// BuildCueSplitInfo creates the split information from a parsed CUE sheet.
// This is returned to the Dart side so FFmpeg can perform the splitting.
// audioDir, if non-empty, overrides the directory for audio file resolution.
func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSplitInfo, error) {
resolveDir := cuePath
if audioDir != "" {
// Create a virtual path in audioDir so ResolveCueAudioPath looks there
resolveDir = filepath.Join(audioDir, filepath.Base(cuePath))
}
audioPath := ResolveCueAudioPath(resolveDir, sheet.FileName)
@@ -360,11 +338,9 @@ func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSp
composer = sheet.Composer
}
// End time is the start of the next track, or -1 for the last track
endSec := float64(-1)
if i+1 < len(sheet.Tracks) {
nextTrack := sheet.Tracks[i+1]
// Use pre-gap of next track if available, otherwise its start time
if nextTrack.PreGap >= 0 {
endSec = nextTrack.PreGap
} else {
@@ -386,11 +362,6 @@ func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSp
return info, nil
}
// ParseCueFileJSON parses a .cue file and returns JSON with split info.
// This is the main entry point called from Dart via the platform bridge.
// audioDir, if non-empty, overrides the directory used for resolving the
// referenced audio file (useful when the .cue was copied to a temp dir
// but the audio still lives in the original location, e.g. SAF).
func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
sheet, err := ParseCueFile(cuePath)
if err != nil {
@@ -410,9 +381,6 @@ func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
return string(jsonBytes), nil
}
// ScanCueFileForLibrary parses a .cue file and returns multiple LibraryScanResult
// entries, one per track. This is used by the library scanner to populate the
// library with individual track entries from a single CUE+FLAC album.
func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) {
sheet, err := ParseCueFile(cuePath)
if err != nil {
@@ -425,13 +393,6 @@ func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult
return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, "", scanTime)
}
// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters
// for SAF (Storage Access Framework) scenarios:
// - audioDir: if non-empty, overrides the directory used to find the audio file
// - virtualPathPrefix: if non-empty, used instead of cuePath as the base for
// virtual file paths (e.g. a content:// URI). IDs are also based on this.
// - fileModTime: if > 0, used as the FileModTime for all results instead of
// stat-ing the cuePath on disk (useful when the real file lives behind SAF)
func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
return ScanCueFileForLibraryExtWithCoverCacheKey(
cuePath,
@@ -483,7 +444,6 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
return nil, fmt.Errorf("cue sheet is nil for %s", cuePath)
}
// Try to get quality info from the audio file
var bitDepth, sampleRate int
var totalDurationSec float64
audioExt := strings.ToLower(filepath.Ext(audioPath))
@@ -505,7 +465,6 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
}
}
// Extract cover from audio file for all tracks
var coverPath string
libraryCoverCacheMu.RLock()
coverCacheDir := libraryCoverCacheDir
@@ -522,13 +481,11 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
}
}
// Determine the base path for virtual paths and IDs
pathBase := cuePath
if virtualPathPrefix != "" {
pathBase = virtualPathPrefix
}
// Determine fileModTime
modTime := fileModTime
if modTime <= 0 {
if info, err := os.Stat(cuePath); err == nil {
@@ -556,7 +513,6 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
album = "Unknown Album"
}
// Calculate duration for this track
var duration int
if i+1 < len(sheet.Tracks) {
nextStart := sheet.Tracks[i+1].StartTime
@@ -570,9 +526,6 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number))
// Use a virtual file path that includes the track number to ensure
// uniqueness in the database (file_path has a UNIQUE constraint).
// Format: /path/to/album.cue#track01 or content://...album.cue#track01
virtualFilePath := fmt.Sprintf("%s#track%02d", pathBase, track.Number)
result := LibraryScanResult{
+12 -5
View File
@@ -196,15 +196,22 @@ type deezerAlbumSimple struct {
RecordType string `json:"record_type"`
}
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
artistName := track.Artist.Name
// deezerTrackArtistDisplay returns the display artist string for a track,
// preferring the Contributors list (comma-joined) when available, falling
// back to the primary Artist.Name.
func deezerTrackArtistDisplay(track deezerTrack) string {
if len(track.Contributors) > 0 {
names := make([]string, len(track.Contributors))
for i, a := range track.Contributors {
names[i] = a.Name
}
artistName = strings.Join(names, ", ")
return strings.Join(names, ", ")
}
return track.Artist.Name
}
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
artistName := deezerTrackArtistDisplay(track)
albumImage := track.Album.CoverXL
if albumImage == "" {
@@ -641,7 +648,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
Artists: track.Artist.Name,
Artists: deezerTrackArtistDisplay(track),
Name: track.Title,
AlbumName: album.Title,
AlbumArtist: artistName,
@@ -892,7 +899,7 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
tracks = append(tracks, AlbumTrackMetadata{
SpotifyID: fmt.Sprintf("deezer:%d", track.ID),
Artists: track.Artist.Name,
Artists: deezerTrackArtistDisplay(track),
Name: track.Title,
AlbumName: track.Album.Title,
AlbumArtist: track.Artist.Name,
+1 -2
View File
@@ -14,7 +14,7 @@ import (
"strings"
)
const deezerMusicDLURL = "https://www.musicdl.me/api/download"
const deezerMusicDLURL = "https://api.zarz.moe/v1/dzr"
type DeezerDownloadResult struct {
FilePath string
@@ -145,7 +145,6 @@ func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, err
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Debug-Key", getQobuzDebugKey())
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := c.httpClient.Do(req)
-3
View File
@@ -25,7 +25,6 @@ var (
)
func GetISRCIndex(outputDir string) *ISRCIndex {
// Fast path: check cache first
isrcIndexCacheMu.RLock()
idx, exists := isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
@@ -34,13 +33,11 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
return idx
}
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
mu := buildLock.(*sync.Mutex)
mu.Lock()
defer mu.Unlock()
// Double-check cache after acquiring lock (another goroutine may have built it)
isrcIndexCacheMu.RLock()
idx, exists = isrcIndexCache[outputDir]
isrcIndexCacheMu.RUnlock()
+19
View File
@@ -1442,6 +1442,25 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
return string(jsonBytes), nil
}
// RewriteSplitArtistTagsExport rewrites ARTIST and ALBUMARTIST Vorbis
// comments in a FLAC file as multiple separate entries (one per artist).
// Call this after FFmpeg metadata embedding to fix split artist tags,
// since FFmpeg deduplicates -metadata keys and only keeps the last value.
func RewriteSplitArtistTagsExport(filePath, artist, albumArtist string) (string, error) {
err := RewriteSplitArtistTags(filePath, artist, albumArtist)
if err != nil {
return errorResponse("Failed to rewrite artist tags: " + err.Error())
}
resp := map[string]interface{}{
"success": true,
"message": "Split artist tags written successfully",
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
var tracks []struct {
ISRC string `json:"isrc"`
-10
View File
@@ -1041,9 +1041,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
}
// If key metadata is still missing after extension enrichment, search
// configured metadata providers (Spotify/Deezer/Tidal/Qobuz) — same
// logic that ReEnrichFile uses.
if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) &&
req.TrackName != "" && req.ArtistName != "" &&
(req.AlbumName == "" || req.ReleaseDate == "" || req.ISRC == "") {
@@ -1091,7 +1088,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr)
}
// Try Deezer extended metadata if we have ISRC
if req.ISRC != "" &&
(req.Genre == "" || req.Label == "" || req.Copyright == "") {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -1205,8 +1201,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
}
}
// Always pass enriched metadata from req so Flutter can
// embed it — fills gaps from metadata provider search.
if req.AlbumName != "" && resp.Album == "" {
resp.Album = req.AlbumName
}
@@ -1609,7 +1603,6 @@ func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) stri
return buildOutputPath(req)
}
// SAF mode: use extension's data dir as writable temp location
tempDir := filepath.Join(ext.DataDir, "downloads")
os.MkdirAll(tempDir, 0755)
AddAllowedDownloadDir(tempDir)
@@ -2267,7 +2260,6 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
return nil, fmt.Errorf("failed to parse lyrics result: %w", err)
}
// Convert ExtLyricsResult to LyricsResponse
response := &LyricsResponse{
SyncType: extResult.SyncType,
Instrumental: extResult.Instrumental,
@@ -2288,7 +2280,6 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
})
}
// If the extension provided plainLyrics but no lines, parse them as unsynced
if len(response.Lines) == 0 && response.PlainLyrics != "" && !response.Instrumental {
response.SyncType = "UNSYNCED"
for _, line := range strings.Split(response.PlainLyrics, "\n") {
@@ -2316,7 +2307,6 @@ func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper {
}
}
// Keep a deterministic order so provider selection is stable across runs.
sort.Slice(providers, func(i, j int) bool {
return providers[i].extension.ID < providers[j].extension.ID
})
-4
View File
@@ -201,7 +201,6 @@ func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
return r.vm.ToValue(result)
}
// Length should be between 43-128 characters (RFC 7636)
func generatePKCEVerifier(length int) (string, error) {
if length < 43 {
length = 43
@@ -226,7 +225,6 @@ func generatePKCEVerifier(length int) (string, error) {
func generatePKCEChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier))
// Base64url encode without padding (RFC 7636)
return base64.RawURLEncoding.EncodeToString(hash[:])
}
@@ -283,7 +281,6 @@ func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
})
}
// config: { authUrl, clientId, redirectUri, scope, extraParams }
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -388,7 +385,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
})
}
// config: { tokenUrl, clientId, redirectUri, code, extraParams }
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -12,10 +12,6 @@ import (
"github.com/dop251/goja"
)
// These polyfills make porting browser/Node.js libraries easier
// without compromising sandbox security.
// Returns a Promise-like object with json(), text() methods.
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.createFetchError("URL is required")
@@ -38,7 +34,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
method = strings.ToUpper(m)
}
// Body - support string, object (auto-stringify), or nil
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
switch v := bodyArg.(type) {
case string:
@@ -197,7 +192,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
})
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
// Simplified implementation
if len(call.Arguments) < 2 {
return vm.ToValue(map[string]interface{}{"read": 0, "written": 0})
}
@@ -422,7 +416,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
})
}
// JSON is already built-in to Goja; this ensures a fallback exists.
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
jsonScript := `
if (typeof JSON === 'undefined') {
+1 -17
View File
@@ -145,7 +145,7 @@ func initExtensionStore(cacheDir string) *extensionStore {
if globalExtensionStore == nil {
globalExtensionStore = &extensionStore{
registryURL: "", // No default - user must provide a registry URL
registryURL: "",
cacheDir: cacheDir,
cacheTTL: cacheTTL,
}
@@ -154,8 +154,6 @@ func initExtensionStore(cacheDir string) *extensionStore {
return globalExtensionStore
}
// SetRegistryURL updates the registry URL and clears the in-memory cache
// so the next fetch will use the new URL. Disk cache is also cleared.
func (s *extensionStore) setRegistryURL(registryURL string) {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
@@ -168,7 +166,6 @@ func (s *extensionStore) setRegistryURL(registryURL string) {
s.cache = nil
s.cacheTime = time.Time{}
// Clear disk cache since it's from a different registry
if s.cacheDir != "" {
cachePath := filepath.Join(s.cacheDir, cacheFileName)
os.Remove(cachePath)
@@ -177,7 +174,6 @@ func (s *extensionStore) setRegistryURL(registryURL string) {
LogInfo("ExtensionStore", "Registry URL updated to: %s", registryURL)
}
// GetRegistryURL returns the currently configured registry URL.
func (s *extensionStore) getRegistryURL() string {
s.cacheMu.RLock()
defer s.cacheMu.RUnlock()
@@ -378,32 +374,22 @@ func (s *extensionStore) downloadExtension(extensionID string, destPath string)
return nil
}
// ResolveRegistryURL normalises a user-supplied URL into a direct registry.json URL.
//
// Accepted formats:
// - https://raw.githubusercontent.com/owner/repo/<branch>/registry.json → returned as-is
// - https://github.com/owner/repo (with optional trailing path / .git) → resolved via
// the GitHub API to discover the default branch, then converted to the raw URL
// - Any other HTTPS URL → returned as-is (assumed to be a direct link)
func resolveRegistryURL(input string) (string, error) {
input = strings.TrimSpace(input)
if input == "" {
return "", fmt.Errorf("registry URL is empty")
}
// Already a fully-qualified raw URL keep it.
if strings.Contains(input, "raw.githubusercontent.com") {
return input, nil
}
const ghPrefix = "https://github.com/"
if !strings.HasPrefix(input, ghPrefix) {
// Also accept http:// and upgrade silently.
const ghPrefixHTTP = "http://github.com/"
if strings.HasPrefix(input, ghPrefixHTTP) {
input = "https://github.com/" + input[len(ghPrefixHTTP):]
} else {
// Not a GitHub URL return as-is.
return input, nil
}
}
@@ -423,8 +409,6 @@ func resolveRegistryURL(input string) (string, error) {
return resolved, nil
}
// resolveGitHubDefaultBranch calls the GitHub API to discover the repository's
// default branch. Falls back to "main" on any error.
func resolveGitHubDefaultBranch(owner, repo string) string {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo)
client := NewHTTPClientWithTimeout(10 * time.Second)
-7
View File
@@ -66,9 +66,6 @@ var sharedTransport = &http.Transport{
DisableCompression: true,
}
// metadataTransport is a separate transport for metadata API calls (Deezer, Spotify, SongLink).
// Isolated from download traffic so that download failures cannot poison
// the connection pool used by metadata enrichment.
var metadataTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
@@ -104,8 +101,6 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
}
}
// NewMetadataHTTPClient creates an HTTP client using the isolated metadata transport.
// Use this for API calls that should not be affected by download traffic.
func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Transport: newCompatibilityTransport(metadataTransport),
@@ -229,7 +224,6 @@ func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request
return reqCopy, nil
}
// Also checks for ISP blocking on errors
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := client.Do(req)
@@ -239,7 +233,6 @@ func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Respo
return resp, err
}
// RetryConfig holds configuration for retry logic
type RetryConfig struct {
MaxRetries int
InitialDelay time.Duration
-7
View File
@@ -6,17 +6,10 @@ import (
"net/http"
)
// iOS version: uTLS is not supported on iOS due to cgo DNS resolver issues
// Fall back to standard HTTP client
// GetCloudflareBypassClient returns the standard HTTP client on iOS
// uTLS is not available on iOS due to cgo DNS resolver compatibility issues
func GetCloudflareBypassClient() *http.Client {
return sharedClient
}
// DoRequestWithCloudflareBypass on iOS just uses the standard client
// uTLS Chrome fingerprint bypass is not available on iOS
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := sharedClient.Do(req)
-8
View File
@@ -16,8 +16,6 @@ import (
"golang.org/x/net/http2"
)
// uTLS transport that mimics Chrome's TLS fingerprint to bypass Cloudflare
// Uses HTTP/2 for optimal performance as uTLS works best with HTTP/2
type utlsTransport struct {
dialer *net.Dialer
mu sync.Mutex
@@ -98,15 +96,10 @@ var cloudflareBypassClient = &http.Client{
Timeout: DefaultTimeout,
}
// GetCloudflareBypassClient returns an HTTP client that mimics Chrome's TLS fingerprint
// Use this when requests are blocked by Cloudflare (common when using VPN)
func GetCloudflareBypassClient() *http.Client {
return cloudflareBypassClient
}
// DoRequestWithCloudflareBypass attempts request with standard client first,
// then retries with uTLS Chrome fingerprint if Cloudflare blocks it.
// This is useful when using VPN as Cloudflare detects Go's default TLS fingerprint.
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", getRandomUserAgent())
@@ -142,7 +135,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
}
}
// Not Cloudflare, return original response (recreate body)
return &http.Response{
Status: resp.Status,
StatusCode: resp.StatusCode,
-4
View File
@@ -10,8 +10,6 @@ import (
"time"
)
// IDHSClient is a client for I Don't Have Spotify API
// Used as fallback when SongLink fails or is rate limited
type IDHSClient struct {
client *http.Client
}
@@ -55,7 +53,6 @@ func NewIDHSClient() *IDHSClient {
return globalIDHSClient
}
// Search converts a music link to links on other platforms
func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse, error) {
idhsRateLimiter.WaitForSlot()
@@ -109,7 +106,6 @@ func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse
return &result, nil
}
// GetAvailabilityFromSpotify checks track availability using IDHS as fallback
func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
-6
View File
@@ -170,11 +170,9 @@ func ScanLibraryFolder(folderPath string) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0
// Track audio files referenced by .cue sheets to avoid duplicates
cueReferencedAudioFiles := make(map[string]bool)
parsedCueFiles := make(map[string]scannedCueFileInfo)
// First pass: scan .cue files to collect referenced audio paths
for _, fileInfo := range audioFileInfos {
filePath := fileInfo.path
ext := strings.ToLower(filepath.Ext(filePath))
@@ -209,7 +207,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
ext := strings.ToLower(filepath.Ext(filePath))
// Handle .cue files: produce multiple track results
if ext == ".cue" {
var cueResults []LibraryScanResult
cueInfo, ok := parsedCueFiles[filePath]
@@ -827,9 +824,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
return string(jsonBytes), nil
}
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
// Only files that are new or have changed modification time will be scanned
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
existingFiles := make(map[string]int64)
if existingFilesJSON != "" && existingFilesJSON != "{}" {
-4
View File
@@ -145,13 +145,10 @@ func LogError(tag, format string, args ...interface{}) {
GetLogBuffer().Add("ERROR", tag, fmt.Sprintf(format, args...))
}
// GoLog is a drop-in replacement for fmt.Printf that also logs to buffer
// It parses the tag from the format string if it starts with [Tag]
func GoLog(format string, args ...interface{}) {
message := fmt.Sprintf(format, args...)
message = strings.TrimSuffix(message, "\n")
// Extract tag from message if present (e.g., "[Tidal] message")
tag := "Go"
level := "INFO"
@@ -163,7 +160,6 @@ func GoLog(format string, args ...interface{}) {
}
}
// Determine level from message content
msgLower := strings.ToLower(message)
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") {
level = "ERROR"
-16
View File
@@ -20,7 +20,6 @@ const (
durationToleranceSec = 10.0
)
// Lyrics provider names (used in settings and cascade ordering)
const (
LyricsProviderLRCLIB = "lrclib"
LyricsProviderNetease = "netease"
@@ -29,8 +28,6 @@ const (
LyricsProviderQQMusic = "qqmusic"
)
// DefaultLyricsProviders is the default cascade order for lyrics fetching.
// LRCLIB first (no proxy dependency), then the others.
var DefaultLyricsProviders = []string{
LyricsProviderLRCLIB,
LyricsProviderMusixmatch,
@@ -44,7 +41,6 @@ var (
lyricsProviders []string // ordered list of enabled providers
)
// LyricsFetchOptions controls optional provider-specific enhancements.
type LyricsFetchOptions struct {
IncludeTranslationNetease bool `json:"include_translation_netease"`
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
@@ -64,8 +60,6 @@ var (
lyricsFetchOptions = defaultLyricsFetchOptions
)
// SetLyricsProviderOrder sets the ordered list of lyrics providers to try.
// Providers not in the list are disabled. An empty list resets to defaults.
func SetLyricsProviderOrder(providers []string) {
lyricsProvidersMu.Lock()
defer lyricsProvidersMu.Unlock()
@@ -532,19 +526,16 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
return nil, fmt.Errorf("lyrics not found from any source")
}
// tryLRCLIB attempts all LRCLIB search strategies (exact match, simplified, search).
func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack string, durationSec float64) (*LyricsResponse, error) {
var lyrics *LyricsResponse
var err error
// 1. Exact match with primary artist
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, trackName)
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
lyrics.Source = "LRCLIB"
return lyrics, nil
}
// 2. Exact match with full artist name
if primaryArtist != artistName {
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
@@ -553,7 +544,6 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
}
}
// 3. Simplified track name
if simplifiedTrack != trackName {
lyrics, err = c.FetchLyricsWithMetadata(primaryArtist, simplifiedTrack)
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
@@ -562,7 +552,6 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
}
}
// 4. Search by query
query := primaryArtist + " " + trackName
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
if err == nil && lyrics != nil && (len(lyrics.Lines) > 0 || lyrics.Instrumental) {
@@ -570,7 +559,6 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
return lyrics, nil
}
// 5. Search with simplified track name
if simplifiedTrack != trackName {
query = primaryArtist + " " + simplifiedTrack
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
@@ -688,8 +676,6 @@ func lyricsHasUsableText(lyrics *LyricsResponse) bool {
return false
}
// detectLyricsErrorPayload extracts human-readable error messages from
// JSON payloads returned by lyrics proxies when no lyric is available.
func detectLyricsErrorPayload(raw string) (string, bool) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
@@ -814,8 +800,6 @@ func simplifyTrackName(name string) string {
return result
}
// Add a loose fallback form for provider queries where punctuation
// and separators differ (e.g. "/" vs "_" vs spaces).
if loose := normalizeLooseTitle(result); loose != "" {
return loose
}
-9
View File
@@ -11,8 +11,6 @@ import (
"time"
)
// AppleMusicClient fetches lyrics from Apple Music.
// Uses Paxsenix endpoints for search and lyrics.
type AppleMusicClient struct {
httpClient *http.Client
}
@@ -25,7 +23,6 @@ type appleMusicSearchResult struct {
Duration int `json:"duration"`
}
// PaxResponse represents the lyrics proxy response for word-by-word / line lyrics
type paxResponse struct {
Type string `json:"type"` // "Syllable" or "Line"
Content []paxLyrics `json:"content"` // List of lyric lines
@@ -103,7 +100,6 @@ func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackNam
return &results[bestIndex]
}
// SearchSong searches for a song on Apple Music and returns its ID.
func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
query := trackName + " " + artistName
if strings.TrimSpace(query) == "" {
@@ -144,7 +140,6 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec
return strings.TrimSpace(best.ID), nil
}
// FetchLyricsByID fetches lyrics from the paxsenix proxy using Apple Music song ID.
func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
lyricsURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/lyrics?id=%s", songID)
@@ -252,7 +247,6 @@ func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByW
return strings.TrimSpace(sb.String())
}
// FetchLyrics searches Apple Music and returns parsed LyricsResponse.
func (c *AppleMusicClient) FetchLyrics(
trackName,
artistName string,
@@ -272,10 +266,8 @@ func (c *AppleMusicClient) FetchLyrics(
return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg)
}
// Try to parse as pax format (word-by-word or line)
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
if err != nil {
// If pax parsing fails, try to parse as direct LRC text
lrcText = rawLyrics
}
@@ -289,7 +281,6 @@ func (c *AppleMusicClient) FetchLyrics(
}, nil
}
// Fall back to plain text if no timestamps found
resultLines := plainTextLyricsLines(lrcText)
if len(resultLines) > 0 {
-4
View File
@@ -11,8 +11,6 @@ import (
"time"
)
// MusixmatchClient fetches lyrics from Musixmatch via a proxy server.
// The proxy handles Musixmatch authentication internally.
type MusixmatchClient struct {
httpClient *http.Client
baseURL string
@@ -114,7 +112,6 @@ func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, dura
return "", fmt.Errorf("failed to decode musixmatch response")
}
// FetchLyricsInLanguage retrieves lyrics from Musixmatch for a specific language code.
func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, durationSec float64, language string) (*LyricsResponse, error) {
lang := strings.ToLower(strings.TrimSpace(language))
if lang == "" {
@@ -151,7 +148,6 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, d
return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang)
}
// FetchLyrics searches Musixmatch and returns parsed LyricsResponse.
func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) {
if preferred := strings.ToLower(strings.TrimSpace(preferredLanguage)); preferred != "" {
localized, localizedErr := c.FetchLyricsInLanguage(trackName, artistName, durationSec, preferred)
-5
View File
@@ -9,7 +9,6 @@ import (
"time"
)
// NeteaseClient fetches lyrics through Paxsenix's NetEase endpoints.
type NeteaseClient struct {
httpClient *http.Client
}
@@ -51,7 +50,6 @@ func NewNeteaseClient() *NeteaseClient {
}
}
// SearchSong searches for a song on Netease and returns the song ID.
func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) {
query := trackName + " " + artistName
if strings.TrimSpace(query) == "" {
@@ -96,7 +94,6 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
return searchResp.Result.Songs[0].ID, nil
}
// FetchLyricsByID fetches synced lyrics for a given Netease song ID.
func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includeRomanization bool) (string, error) {
lyricsURL := "https://lyrics.paxsenix.org/netease/lyrics"
params := url.Values{}
@@ -146,7 +143,6 @@ func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includ
return lyric, nil
}
// FetchLyrics searches for a track and returns parsed LyricsResponse.
func (c *NeteaseClient) FetchLyrics(
trackName,
artistName string,
@@ -166,7 +162,6 @@ func (c *NeteaseClient) FetchLyrics(
lines := parseSyncedLyrics(lrcText)
if len(lines) == 0 {
// May be plain text lyrics without timestamps
plainLines := strings.Split(lrcText, "\n")
for _, line := range plainLines {
trimmed := strings.TrimSpace(line)
-4
View File
@@ -10,8 +10,6 @@ import (
"time"
)
// QQMusicClient fetches lyrics from QQ Music.
// Uses Paxsenix metadata lookup for lyrics.
type QQMusicClient struct {
httpClient *http.Client
}
@@ -34,7 +32,6 @@ func NewQQMusicClient() *QQMusicClient {
}
}
// fetchLyricsByMetadata asks Paxsenix to resolve and return QQ lyrics using track metadata.
func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, durationSec float64) (string, error) {
payload := qqLyricsMetadataRequest{
Artist: []string{artistName},
@@ -93,7 +90,6 @@ func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (st
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil
}
// FetchLyrics searches QQ Music and returns parsed LyricsResponse.
func (c *QQMusicClient) FetchLyrics(
trackName,
artistName string,
+46
View File
@@ -431,6 +431,52 @@ func setArtistComments(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value, m
}
}
// RewriteSplitArtistTags opens a FLAC file and rewrites the ARTIST and
// ALBUMARTIST Vorbis comments as multiple separate entries (one per artist).
// This is needed because FFmpeg's -metadata flag deduplicates keys, so only
// the last value survives when multiple -metadata ARTIST=X flags are used.
// The native go-flac writer correctly handles multiple Vorbis comments.
func RewriteSplitArtistTags(filePath, artist, albumArtist string) error {
if !shouldSplitVorbisArtistTags(artistTagModeSplitVorbis) {
return nil
}
f, err := flac.ParseFile(filePath)
if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
var cmtIdx int = -1
var cmt *flacvorbis.MetaDataBlockVorbisComment
for idx, meta := range f.Meta {
if meta.Type == flac.VorbisComment {
cmtIdx = idx
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
if err != nil {
return fmt.Errorf("failed to parse vorbis comment: %w", err)
}
break
}
}
if cmt == nil {
cmt = flacvorbis.New()
}
setArtistComments(cmt, "ARTIST", artist, artistTagModeSplitVorbis)
setArtistComments(cmt, "ALBUMARTIST", albumArtist, artistTagModeSplitVorbis)
cmtMeta := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtMeta
} else {
f.Meta = append(f.Meta, &cmtMeta)
}
return f.Save(filePath)
}
func removeCommentKey(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) {
keyUpper := strings.ToUpper(key)
for i := len(cmt.Comments) - 1; i >= 0; i-- {
+1 -18
View File
@@ -53,26 +53,17 @@ const (
qobuzTrackOpenBaseURL = "https://open.qobuz.com/track/"
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
qobuzStoreBaseURL = "https://www.qobuz.com/us-en"
qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download"
qobuzDownloadAPIURL = "https://api.zarz.moe/v1/qbz"
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/"
qobuzSquidAPIURL = "https://qobuz.squid.wtf/api/download-music?country=US&track_id="
qobuzDebugKeyXORMask = byte(0x5A)
)
var qobuzStoreTrackIDRegex = regexp.MustCompile(`/v4/ajax/popin-add-cart/track/([0-9]+)`)
var qobuzArtistAlbumIDRegex = regexp.MustCompile(`data-itemtype="album"\s+data-itemId="([A-Za-z0-9]+)"`)
var qobuzLocaleSegmentRegex = regexp.MustCompile(`^[a-z]{2}-[a-z]{2}$`)
var qobuzDebugKeyObfuscated = []byte{
0x69, 0x3b, 0x38, 0x3e, 0x36, 0x37, 0x35, 0x2f, 0x36, 0x3b,
0x33, 0x29, 0x2e, 0x32, 0x3f, 0x3d, 0x35, 0x3b, 0x2e, 0x3b,
0x34, 0x3e, 0x34, 0x35, 0x35, 0x34, 0x3f, 0x39, 0x35, 0x37,
0x3f, 0x29, 0x3f, 0x2c, 0x3f, 0x34, 0x39, 0x36, 0x35, 0x29,
0x3f,
}
type QobuzTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
@@ -1216,14 +1207,6 @@ func mapQobuzQualityCodeToAPI(qualityCode string) string {
}
}
func getQobuzDebugKey() string {
decoded := make([]byte, len(qobuzDebugKeyObfuscated))
for i, b := range qobuzDebugKeyObfuscated {
decoded[i] = b ^ qobuzDebugKeyXORMask
}
return string(decoded)
}
func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
candidates, err := q.searchQobuzTracksWithFallback(isrc, 50)
if err != nil {
-12
View File
@@ -201,18 +201,6 @@ func TestNormalizeQobuzQualityCode(t *testing.T) {
}
}
func TestGetQobuzDebugKey(t *testing.T) {
got := getQobuzDebugKey()
if len(got) != len(qobuzDebugKeyObfuscated) {
t.Fatalf("unexpected debug key length: %d", len(got))
}
for i := range got {
if got[i]^qobuzDebugKeyXORMask != qobuzDebugKeyObfuscated[i] {
t.Fatalf("unexpected debug key reconstruction at index %d", i)
}
}
}
func TestBuildQobuzMusicDLPayloadUsesOpenTrackURL(t *testing.T) {
payloadBytes, err := buildQobuzMusicDLPayload(374610875, "7")
if err != nil {
+4 -17
View File
@@ -16,16 +16,13 @@ var hiraganaToRomaji = map[rune]string{
'や': "ya", 'ゆ': "yu", 'よ': "yo",
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
'わ': "wa", 'を': "wo", 'ん': "n",
// Dakuten (voiced)
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
// Handakuten (semi-voiced)
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
// Small characters
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
'っ': "", // Double consonant marker
'っ': "",
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
}
@@ -40,19 +37,15 @@ var katakanaToRomaji = map[rune]string{
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
// Dakuten (voiced)
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
// Handakuten (semi-voiced)
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
// Small characters
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
'ッ': "", // Double consonant marker
'ッ': "",
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
// Extended katakana
'ー': "", // Long vowel mark
'ー': "",
'ヴ': "vu",
}
@@ -82,7 +75,6 @@ var combinationKatakana = map[string]string{
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
// Extended combinations
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
@@ -120,7 +112,6 @@ func JapaneseToRomaji(text string) string {
i := 0
for i < len(runes) {
// Check for っ/ッ (double consonant)
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
nextRomaji := ""
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
@@ -129,13 +120,12 @@ func JapaneseToRomaji(text string) string {
nextRomaji = romaji
}
if len(nextRomaji) > 0 {
result.WriteByte(nextRomaji[0]) // Double the first consonant
result.WriteByte(nextRomaji[0])
}
i++
continue
}
// Check for two-character combinations
if i < len(runes)-1 {
combo := string(runes[i : i+2])
if romaji, ok := combinationHiragana[combo]; ok {
@@ -150,17 +140,14 @@ func JapaneseToRomaji(text string) string {
}
}
// Single character conversion
r := runes[i]
if romaji, ok := hiraganaToRomaji[r]; ok {
result.WriteString(romaji)
} else if romaji, ok := katakanaToRomaji[r]; ok {
result.WriteString(romaji)
} else if isKanji(r) {
// Keep kanji as-is (would need dictionary for proper conversion)
result.WriteRune(r)
} else {
// Keep other characters (punctuation, spaces, etc.)
result.WriteRune(r)
}
i++
+186 -442
View File
@@ -87,38 +87,184 @@ func GetSongLinkRegion() string {
return region
}
const resolveAPIURL = "https://api.zarz.moe/v1/resolve"
func songLinkBaseURL() string {
opts := GetNetworkCompatibilityOptions()
if opts.AllowHTTP {
return "http://api.song.link/v1-alpha.1/links"
}
return "https://api.song.link/v1-alpha.1/links"
}
func buildSongLinkURLFromTarget(targetURL string, userCountry string) string {
if userCountry == "" {
userCountry = GetSongLinkRegion()
// resolveTrackPlatforms resolves a music URL to all platforms.
// Spotify URLs use the resolve API; if that fails, falls back to SongLink.
// All other URLs go directly to SongLink.
func (s *SongLinkClient) resolveTrackPlatforms(inputURL string) (map[string]songLinkPlatformLink, error) {
if isSpotifyURL(inputURL) {
payload, err := json.Marshal(map[string]string{"url": inputURL})
if err != nil {
return nil, fmt.Errorf("failed to encode resolve request: %w", err)
}
links, err := s.doResolveRequest(payload)
if err == nil {
return links, nil
}
GoLog("[SongLink] Resolve proxy failed for %s: %v, falling back to SongLink", inputURL, err)
return s.songLinkByTargetURL(inputURL)
}
apiURL := fmt.Sprintf("%s?url=%s", songLinkBaseURL(), url.QueryEscape(targetURL))
if userCountry != "" {
apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry))
}
return apiURL
return s.songLinkByTargetURL(inputURL)
}
func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry string) string {
if userCountry == "" {
userCountry = GetSongLinkRegion()
// resolveTrackPlatformsByPlatform resolves using platform + type + id.
// Spotify uses the resolve API with SongLink fallback; all other platforms use SongLink directly.
func (s *SongLinkClient) resolveTrackPlatformsByPlatform(platform, entityType, entityID string) (map[string]songLinkPlatformLink, error) {
if strings.EqualFold(platform, "spotify") {
payload, err := json.Marshal(map[string]string{
"platform": platform,
"type": entityType,
"id": entityID,
})
if err != nil {
return nil, fmt.Errorf("failed to encode resolve request: %w", err)
}
links, err := s.doResolveRequest(payload)
if err == nil {
return links, nil
}
GoLog("[SongLink] Resolve proxy failed for %s/%s/%s: %v, falling back to SongLink", platform, entityType, entityID, err)
return s.songLinkByPlatform(platform, entityType, entityID)
}
apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s",
return s.songLinkByPlatform(platform, entityType, entityID)
}
func isSpotifyURL(u string) bool {
lower := strings.ToLower(u)
return strings.Contains(lower, "spotify.com/") || strings.Contains(lower, "spotify:")
}
// doResolveRequest sends a JSON payload to the resolve API (api.zarz.moe)
// and parses the response into a platform link map.
func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPlatformLink, error) {
req, err := http.NewRequest("POST", resolveAPIURL, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("failed to create resolve request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("resolve API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("resolve API returned status %d", resp.StatusCode)
}
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read resolve response: %w", err)
}
var resolveResp struct {
Success bool `json:"success"`
ISRC string `json:"isrc"`
SongUrls map[string]string `json:"songUrls"`
}
if err := json.Unmarshal(body, &resolveResp); err != nil {
return nil, fmt.Errorf("failed to decode resolve response: %w", err)
}
if !resolveResp.Success {
return nil, fmt.Errorf("resolve API returned success=false")
}
// Map resolve API keys to SongLink-compatible platform keys
keyMap := map[string]string{
"Spotify": "spotify",
"Deezer": "deezer",
"Tidal": "tidal",
"YouTubeMusic": "youtubeMusic",
"YouTube": "youtube",
"AmazonMusic": "amazonMusic",
"Qobuz": "qobuz",
"AppleMusic": "appleMusic",
}
links := make(map[string]songLinkPlatformLink)
for resolveKey, platformKey := range keyMap {
if u, ok := resolveResp.SongUrls[resolveKey]; ok && strings.TrimSpace(u) != "" {
links[platformKey] = songLinkPlatformLink{URL: strings.TrimSpace(u)}
}
}
if len(links) == 0 {
return nil, fmt.Errorf("resolve API returned no platform links")
}
return links, nil
}
// songLinkByTargetURL calls the SongLink API with a target URL (for non-Spotify URLs).
func (s *SongLinkClient) songLinkByTargetURL(targetURL string) (map[string]songLinkPlatformLink, error) {
songLinkRateLimiter.WaitForSlot()
apiURL := fmt.Sprintf("%s?url=%s&userCountry=%s",
songLinkBaseURL(),
url.QueryEscape(targetURL),
url.QueryEscape(GetSongLinkRegion()))
return s.doSongLinkRequest(apiURL)
}
// songLinkByPlatform calls the SongLink API with platform + type + id (for non-Spotify platforms).
func (s *SongLinkClient) songLinkByPlatform(platform, entityType, entityID string) (map[string]songLinkPlatformLink, error) {
songLinkRateLimiter.WaitForSlot()
apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s&userCountry=%s",
songLinkBaseURL(),
url.QueryEscape(platform),
url.QueryEscape(entityType),
url.QueryEscape(entityID))
if userCountry != "" {
apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry))
url.QueryEscape(entityID),
url.QueryEscape(GetSongLinkRegion()))
return s.doSongLinkRequest(apiURL)
}
// doSongLinkRequest calls the SongLink API and parses the response.
func (s *SongLinkClient) doSongLinkRequest(apiURL string) (map[string]songLinkPlatformLink, error) {
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create SongLink request: %w", err)
}
return apiURL
retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("SongLink request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 429 {
return nil, fmt.Errorf("SongLink rate limit exceeded")
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("SongLink returned status %d", resp.StatusCode)
}
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read SongLink response: %w", err)
}
var songLinkResp struct {
LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"`
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode SongLink response: %w", err)
}
if len(songLinkResp.LinksByPlatform) == 0 {
return nil, fmt.Errorf("SongLink returned no platform links")
}
return songLinkResp.LinksByPlatform, nil
}
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
@@ -136,145 +282,12 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
}
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
availability, pageErr := s.checkTrackAvailabilityFromSpotifyPage(spotifyTrackID)
if pageErr == nil {
return availability, nil
}
if !songLinkRateLimiter.TryAcquire() {
return nil, fmt.Errorf("song.link page lookup failed: %w (SongLink local rate limit exceeded)", pageErr)
}
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
req, err := http.NewRequest("GET", apiURL, nil)
links, err := s.resolveTrackPlatforms(spotifyURL)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
return nil, fmt.Errorf("resolve proxy failed for Spotify %s: %w", spotifyTrackID, err)
}
retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API lookup failed: %w", pageErr, err)
}
defer resp.Body.Close()
if resp.StatusCode == 400 {
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
}
if resp.StatusCode == 404 {
return nil, fmt.Errorf("track not found on any streaming platform")
}
if resp.StatusCode == 429 {
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API rate limit exceeded", pageErr)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API returned status %d", pageErr, resp.StatusCode)
}
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var songLinkResp struct {
LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"`
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
LogWarn("SongLink", "Spotify %s resolved via SongLink API after song.link page failure: %v", spotifyTrackID, pageErr)
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, songLinkResp.LinksByPlatform), nil
}
func (s *SongLinkClient) checkTrackAvailabilityFromSpotifyPage(spotifyTrackID string) (*TrackAvailability, error) {
pageURL := fmt.Sprintf("https://song.link/s/%s", spotifyTrackID)
req, err := http.NewRequest("GET", pageURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create song.link page request: %w", err)
}
req.Header.Set("Accept", "text/html,application/xhtml+xml")
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch song.link page: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return nil, fmt.Errorf("track not found on song.link page")
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("song.link page returned status %d", resp.StatusCode)
}
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read song.link page: %w", err)
}
nextDataJSON, err := extractSongLinkNextDataJSON(body)
if err != nil {
return nil, err
}
var pageData struct {
Props struct {
PageProps struct {
PageData struct {
Sections []struct {
Links []struct {
Platform string `json:"platform"`
URL string `json:"url"`
Show bool `json:"show"`
} `json:"links"`
} `json:"sections"`
} `json:"pageData"`
} `json:"pageProps"`
} `json:"props"`
}
if err := json.Unmarshal(nextDataJSON, &pageData); err != nil {
return nil, fmt.Errorf("failed to decode song.link page data: %w", err)
}
linksByPlatform := make(map[string]songLinkPlatformLink)
for _, section := range pageData.Props.PageProps.PageData.Sections {
for _, link := range section.Links {
if !link.Show || strings.TrimSpace(link.URL) == "" {
continue
}
linksByPlatform[link.Platform] = songLinkPlatformLink{URL: link.URL}
}
}
if len(linksByPlatform) == 0 {
return nil, fmt.Errorf("song.link page contained no usable platform links")
}
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, linksByPlatform), nil
}
func extractSongLinkNextDataJSON(body []byte) ([]byte, error) {
const startMarker = `<script id="__NEXT_DATA__" type="application/json">`
const endMarker = `</script>`
start := bytes.Index(body, []byte(startMarker))
if start < 0 {
return nil, fmt.Errorf("song.link page missing __NEXT_DATA__")
}
start += len(startMarker)
end := bytes.Index(body[start:], []byte(endMarker))
if end < 0 {
return nil, fmt.Errorf("song.link page has unterminated __NEXT_DATA__")
}
return body[start : start+end], nil
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, links), nil
}
func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) {
@@ -505,47 +518,17 @@ type AlbumAvailability struct {
}
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
songLinkRateLimiter.WaitForSlot()
spotifyURL := fmt.Sprintf("https://open.spotify.com/album/%s", spotifyAlbumID)
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
req, err := http.NewRequest("GET", apiURL, nil)
links, err := s.resolveTrackPlatforms(spotifyURL)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check album availability: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
return nil, fmt.Errorf("resolve proxy failed for album %s: %w", spotifyAlbumID, err)
}
availability := &AlbumAvailability{
SpotifyID: spotifyAlbumID,
}
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
if deezerLink, ok := links["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
@@ -588,101 +571,19 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
}
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot()
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
apiURL := buildSongLinkURLFromTarget(deezerURL, "")
req, err := http.NewRequest("GET", apiURL, nil)
links, err := s.resolveTrackPlatforms(deezerURL)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
return nil, fmt.Errorf("resolve failed for Deezer %s: %w", deezerTrackID, err)
}
retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check availability: %w", err)
availability := buildTrackAvailabilityFromSongLinkLinks("", links)
// Ensure Deezer is always marked available since we started from a Deezer URL
availability.Deezer = true
availability.DeezerID = deezerTrackID
if availability.DeezerURL == "" {
availability.DeezerURL = deezerURL
}
defer resp.Body.Close()
if resp.StatusCode == 400 {
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
}
if resp.StatusCode == 404 {
return nil, fmt.Errorf("track not found on any streaming platform")
}
if resp.StatusCode == 429 {
return nil, fmt.Errorf("SongLink rate limit exceeded")
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
}
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
EntitiesByUniqueId map[string]struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
ArtistName string `json:"artistName"`
} `json:"entitiesByUniqueId"`
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
availability := &TrackAvailability{
Deezer: true,
DeezerID: deezerTrackID,
}
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
}
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
}
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
availability.Qobuz = true
availability.QobuzURL = qobuzLink.URL
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
}
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.DeezerURL = deezerLink.URL
}
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
if !availability.YouTube {
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
}
return availability, nil
}
@@ -694,94 +595,12 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
return nil, fmt.Errorf("%s ID is empty", platform)
}
songLinkRateLimiter.WaitForSlot()
apiURL := buildSongLinkURLByPlatform(platform, entityType, entityID, "")
req, err := http.NewRequest("GET", apiURL, nil)
links, err := s.resolveTrackPlatformsByPlatform(platform, entityType, entityID)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
return nil, fmt.Errorf("resolve failed for %s %s: %w", platform, entityID, err)
}
retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check availability: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 400 {
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
}
if resp.StatusCode == 404 {
return nil, fmt.Errorf("track not found on any streaming platform")
}
if resp.StatusCode == 429 {
return nil, fmt.Errorf("SongLink rate limit exceeded")
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
}
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
availability := &TrackAvailability{}
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
}
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
}
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
availability.Qobuz = true
availability.QobuzURL = qobuzLink.URL
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
}
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
}
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
if !availability.YouTube {
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
}
return availability, nil
return buildTrackAvailabilityFromSongLinkLinks("", links), nil
}
func buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID string, links map[string]songLinkPlatformLink) *TrackAvailability {
@@ -894,85 +713,10 @@ func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string,
}
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot()
apiURL := buildSongLinkURLFromTarget(inputURL, "")
req, err := http.NewRequest("GET", apiURL, nil)
links, err := s.resolveTrackPlatforms(inputURL)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
return nil, fmt.Errorf("resolve failed for URL %s: %w", inputURL, err)
}
retryConfig := songLinkRetryConfig()
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
if err != nil {
return nil, fmt.Errorf("failed to check availability: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 400 || resp.StatusCode == 404 {
return nil, fmt.Errorf("track not found on SongLink")
}
if resp.StatusCode == 429 {
return nil, fmt.Errorf("SongLink rate limit exceeded")
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
}
body, err := ReadResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
EntityID string `json:"entityUniqueId"`
} `json:"linksByPlatform"`
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
availability := &TrackAvailability{}
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
}
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
}
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
availability.Qobuz = true
availability.QobuzURL = qobuzLink.URL
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
}
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
}
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
if !availability.YouTube {
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
}
return availability, nil
return buildTrackAvailabilityFromSongLinkLinks("", links), nil
}
+70 -39
View File
@@ -23,26 +23,24 @@ func TestGetRetryAfterDurationMissingHeaderReturnsZero(t *testing.T) {
}
}
func TestCheckTrackAvailabilityFromSpotifyPrefersSongLinkPage(t *testing.T) {
func TestCheckTrackAvailabilityFromSpotifyViaResolveAPI(t *testing.T) {
origRetryConfig := songLinkRetryConfig
defer func() { songLinkRetryConfig = origRetryConfig }()
client := &SongLinkClient{
client: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case req.URL.Host == "api.song.link":
t.Fatalf("api.song.link should not be called when song.link page succeeds")
return nil, nil
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
body := `<!DOCTYPE html><html><body><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"pageData":{"sections":[{"displayName":"Listen","links":[{"platform":"spotify","url":"https://open.spotify.com/track/testspotifyid","show":true},{"platform":"deezer","url":"https://www.deezer.com/track/908604612","show":true},{"platform":"amazonMusic","url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C","show":true},{"platform":"tidal","url":"https://listen.tidal.com/track/134858527","show":true},{"platform":"qobuz","url":"https://open.qobuz.com/track/195125822","show":true},{"platform":"youtubeMusic","url":"https://music.youtube.com/watch?v=testvideoid1","show":true}]}]}}}}</script></body></html>`
if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" && req.Method == "POST" {
body := `{"success":true,"isrc":"USRC12345678","songUrls":{"Spotify":"https://open.spotify.com/track/testspotifyid","Deezer":"https://www.deezer.com/track/908604612","AmazonMusic":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C","Tidal":"https://listen.tidal.com/track/134858527","Qobuz":"https://open.qobuz.com/track/195125822","YouTubeMusic":"https://music.youtube.com/watch?v=testvideoid1"}}`
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
default:
t.Fatalf("unexpected request: %s", req.URL.String())
return nil, nil
}
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
return nil, nil
}),
},
}
@@ -66,62 +64,95 @@ func TestCheckTrackAvailabilityFromSpotifyPrefersSongLinkPage(t *testing.T) {
}
}
func TestCheckTrackAvailabilityFromSpotifyFallsBackToAPIWhenPageFails(t *testing.T) {
func TestCheckTrackAvailabilityFromSpotifyResolveAPIFailure(t *testing.T) {
origRetryConfig := songLinkRetryConfig
songLinkRetryConfig = func() RetryConfig {
return RetryConfig{
MaxRetries: 0,
InitialDelay: 0,
MaxDelay: 0,
BackoffFactor: 1,
}
return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1}
}
defer func() {
songLinkRetryConfig = origRetryConfig
}()
defer func() { songLinkRetryConfig = origRetryConfig }()
var hitSongLink bool
client := &SongLinkClient{
client: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
// Resolve proxy returns 500
if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" {
return &http.Response{
StatusCode: 500,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("page failure")),
Body: io.NopCloser(strings.NewReader("internal error")),
Request: req,
}, nil
case req.URL.Host == "api.song.link":
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testspotifyid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"amazonMusic":{"url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C"},"tidal":{"url":"https://listen.tidal.com/track/134858527"},"qobuz":{"url":"https://open.qobuz.com/track/195125822"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=testvideoid1"}}}`
}
// SongLink fallback should be called
if req.URL.Host == "api.song.link" {
hitSongLink = true
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testspotifyid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"tidal":{"url":"https://listen.tidal.com/track/134858527"}}}`
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
default:
t.Fatalf("unexpected request: %s", req.URL.String())
return nil, nil
}
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
return nil, nil
}),
},
}
availability, err := client.CheckTrackAvailability("testspotifyid", "")
if err != nil {
t.Fatalf("CheckTrackAvailability() error = %v", err)
t.Fatalf("expected SongLink fallback to succeed, got error: %v", err)
}
if availability.SpotifyID != "testspotifyid" {
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
if !hitSongLink {
t.Fatal("expected fallback request to SongLink API, but it was never called")
}
if !availability.Deezer || availability.DeezerID != "908604612" {
t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
}
if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
}
if availability.YouTubeID != "testvideoid1" {
t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
t.Fatalf("Deezer availability via fallback = %+v, want DeezerID 908604612", availability)
}
}
func TestCheckAvailabilityFromDeezerUsesSongLink(t *testing.T) {
origRetryConfig := songLinkRetryConfig
songLinkRetryConfig = func() RetryConfig {
return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1}
}
defer func() { songLinkRetryConfig = origRetryConfig }()
client := &SongLinkClient{
client: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
// Non-Spotify should go to SongLink, not resolve API
if req.URL.Host == "api.zarz.moe" {
t.Fatalf("non-Spotify URL should not hit resolve API, got: %s", req.URL.String())
return nil, nil
}
if req.URL.Host == "api.song.link" {
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"tidal":{"url":"https://listen.tidal.com/track/134858527"},"qobuz":{"url":"https://open.qobuz.com/track/195125822"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=testvid"}}}`
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}, nil
}
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
return nil, nil
}),
},
}
availability, err := client.checkAvailabilityFromDeezerSongLink("908604612")
if err != nil {
t.Fatalf("checkAvailabilityFromDeezerSongLink() error = %v", err)
}
if !availability.Deezer || availability.DeezerID != "908604612" {
t.Fatalf("Deezer = %+v, want DeezerID 908604612", availability)
}
if availability.SpotifyID != "testid" {
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testid")
}
}
-17
View File
@@ -875,8 +875,6 @@ func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetad
return results, nil
}
// SearchAll searches Tidal for tracks, artists, and albums matching the query.
// Returns results in the same SearchAllResult format as Deezer's SearchAll.
func (t *TidalDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) {
GoLog("[Tidal] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter)
@@ -1165,7 +1163,6 @@ type tidalAPIResult struct {
duration time.Duration
}
// Mobile networks are more unstable, so we use longer timeouts
const (
tidalAPITimeoutMobile = 25 * time.Second
tidalMaxRetries = 2
@@ -1211,7 +1208,6 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t
continue
}
// 429 rate limit - wait and retry
if resp.StatusCode == 429 {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
@@ -1233,7 +1229,6 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t
continue
}
// Try V2 response format (with manifest)
var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
if v2Response.Data.AssetPresentation == "PREVIEW" {
@@ -1247,7 +1242,6 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t
}, nil
}
// Try V1 response format
var v1Responses []struct {
OriginalTrackURL string `json:"OriginalTrackUrl"`
}
@@ -1602,10 +1596,6 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
return nil
}
// For DASH format, determine correct M4A path
// If outputPath already ends with .m4a, use it directly.
// If outputPath ends with .flac, convert .flac to .m4a.
// Otherwise (e.g., SAF /proc/self/fd/*), use outputPath as-is.
var m4aPath string
if strings.HasSuffix(outputPath, ".m4a") {
m4aPath = outputPath
@@ -1879,8 +1869,6 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
}
}
// Emoji/symbol-only titles must be matched strictly to avoid false positives
// like mapping "🪐" to "Higher Power".
if (!hasAlphaNumericRunes(expectedTitle) || !hasAlphaNumericRunes(foundTitle)) &&
strings.TrimSpace(expectedTitle) != "" &&
strings.TrimSpace(foundTitle) != "" {
@@ -2111,7 +2099,6 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
}
}
// Prefer Deezer-based SongLink lookup when DeezerID is available.
if req.DeezerID != "" {
GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, req.DeezerID)
songlink := NewSongLinkClient()
@@ -2150,11 +2137,9 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
return nil, fmt.Errorf("failed to find tidal track id from request/cache/songlink")
}
// Verify the resolved track matches the request.
actualTrack, fetchErr := tidalGetPublicTrackFunc(downloader, strconv.FormatInt(trackID, 10))
if fetchErr != nil {
GoLog("[%s] Warning: could not fetch Tidal track %d for verification: %v\n", logPrefix, trackID, fetchErr)
// Continue without verification — better than failing entirely.
} else {
providerArtist := actualTrack.Artist.Name
if providerArtist == "" && len(actualTrack.Artists) > 0 {
@@ -2168,7 +2153,6 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
SkipNameVerification: resolvedViaSongLink,
}
if !trackMatchesRequest(req, resolved, logPrefix) {
// Invalidate the cached ID so future requests don't reuse it.
if req.ISRC != "" {
GetTrackIDCache().SetTidal(req.ISRC, 0)
}
@@ -2497,7 +2481,6 @@ func parseTidalURL(input string) (string, string, error) {
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
// Handle /browse/track/123 format
if len(parts) > 0 && parts[0] == "browse" {
parts = parts[1:]
}
-10
View File
@@ -22,8 +22,6 @@ func writeNormalizedArtistRune(b *strings.Builder, r rune) {
}
}
// normalizeLooseTitle collapses separators/punctuation so titles like
// "Doctor / Cops" and "Doctor _ Cops" can still match.
func normalizeLooseTitle(title string) string {
trimmed := strings.TrimSpace(strings.ToLower(title))
if trimmed == "" {
@@ -48,8 +46,6 @@ func normalizeLooseTitle(title string) string {
return strings.Join(strings.Fields(b.String()), " ")
}
// normalizeLooseArtistName folds diacritics and common separators so artist
// verification is resilient to variants like "Özkent" vs "Ozkent".
func normalizeLooseArtistName(name string) string {
trimmed := strings.TrimSpace(strings.ToLower(name))
if trimmed == "" {
@@ -87,9 +83,6 @@ func hasAlphaNumericRunes(value string) bool {
return false
}
// normalizeSymbolOnlyTitle keeps symbol/emoji runes while dropping letters,
// digits, spaces and punctuation. This is useful for emoji-only titles such as
// "🪐", "🌎" etc, so we can compare them strictly and avoid false matches.
func normalizeSymbolOnlyTitle(title string) string {
trimmed := strings.TrimSpace(strings.ToLower(title))
if trimmed == "" {
@@ -114,7 +107,6 @@ func normalizeSymbolOnlyTitle(title string) string {
return b.String()
}
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
type resolvedTrackInfo struct {
Title string
ArtistName string
@@ -123,8 +115,6 @@ type resolvedTrackInfo struct {
SkipNameVerification bool
}
// trackMatchesRequest checks whether a resolved track from a provider matches
// the original download request. Returns true if the track is a plausible match.
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
exactISRCMatch := req.ISRC != "" &&
resolved.ISRC != "" &&
+9
View File
@@ -296,6 +296,15 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "rewriteSplitArtistTags":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let artist = args["artist"] as! String
let albumArtist = args["album_artist"] as! String
let response = GobackendRewriteSplitArtistTagsExport(filePath, artist, albumArtist, &error)
if let error = error { throw error }
return response
case "cleanupConnections":
GobackendCleanupConnections()
return nil
-6
View File
@@ -82,7 +82,6 @@ class _RuntimeProfile {
});
}
/// Widget to eagerly initialize providers that need to load data on startup
class _EagerInitialization extends ConsumerStatefulWidget {
const _EagerInitialization({required this.child});
final Widget child;
@@ -170,10 +169,8 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
const Duration(milliseconds: 1600),
() {
ref.read(localLibraryProvider);
// Trigger auto-scan after initial warmup on first app launch.
if (!_autoScanTriggeredOnLaunch) {
_autoScanTriggeredOnLaunch = true;
// Give the provider a moment to load existing data before scanning.
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) _maybeAutoScanLocalLibrary();
});
@@ -182,8 +179,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
);
}
/// Checks whether an automatic incremental scan should be triggered based on
/// the user's auto-scan preference and the time since the last scan.
Future<void> _maybeAutoScanLocalLibrary() async {
if (!mounted) return;
@@ -204,7 +199,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
switch (settings.localLibraryAutoScan) {
case 'on_open':
// Cooldown of 10 minutes to prevent rapid re-scans.
if (elapsed.inMinutes < 10) return;
break;
case 'daily':
@@ -20,6 +20,7 @@ import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
final _log = AppLogger('DownloadQueue');
final _historyLog = AppLogger('DownloadHistory');
@@ -3010,6 +3011,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.w('FFmpeg metadata/cover embed failed');
}
// After FFmpeg embed, fix split artist tags using native FLAC writer.
// FFmpeg deduplicates repeated metadata keys, so multiple ARTIST entries
// collapse into one. The Go FLAC writer rewrites them properly.
if (settings.artistTagMode == artistTagModeSplitVorbis) {
try {
await PlatformBridge.rewriteSplitArtistTags(
flacPath,
track.artistName,
albumArtist,
);
_log.d('Split artist tags rewritten via native FLAC writer');
} catch (e) {
_log.w('Failed to rewrite split artist tags: $e');
}
}
if (coverPath != null) {
try {
final coverFile = File(coverPath);
-8
View File
@@ -146,7 +146,6 @@ class StoreState {
this.registryUrl = '',
});
/// Whether a registry URL has been configured by the user.
bool get hasRegistryUrl => registryUrl.isNotEmpty;
StoreState copyWith({
@@ -218,7 +217,6 @@ class StoreNotifier extends Notifier<StoreState> {
Future<void> initialize(String cacheDir) async {
if (state.isInitialized) return;
// Load saved registry URL early to avoid UI flash (empty setup screen)
final prefs = await SharedPreferences.getInstance();
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
@@ -246,8 +244,6 @@ class StoreNotifier extends Notifier<StoreState> {
}
}
/// Sets the registry URL, saves it, and refreshes the store.
/// The Go backend handles URL normalisation (GitHub repo raw URL, branch detection).
Future<void> setRegistryUrl(String url) async {
final trimmed = url.trim();
if (trimmed.isEmpty) {
@@ -258,10 +254,8 @@ class StoreNotifier extends Notifier<StoreState> {
state = state.copyWith(isLoading: true, clearError: true);
try {
// Go backend resolves GitHub URLs (detects default branch) and validates HTTPS.
await PlatformBridge.setStoreRegistryUrl(trimmed);
// Read back the resolved URL (may differ from input after normalisation).
final resolvedUrl = await PlatformBridge.getStoreRegistryUrl();
final prefs = await SharedPreferences.getInstance();
@@ -280,13 +274,11 @@ class StoreNotifier extends Notifier<StoreState> {
}
}
/// Removes the saved registry URL and fully detaches the repo from backend.
Future<void> removeRegistryUrl() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_registryUrlPrefKey);
// Reset the URL in Go backend memory AND clear its cache
await PlatformBridge.clearStoreRegistryUrl();
state = state.copyWith(
-3
View File
@@ -138,14 +138,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
}
/// Upgrade cover URL to a higher resolution for full-screen display.
String? _highResCoverUrl(String? url) {
if (url == null) return null;
// Spotify CDN: upgrade 300 640 only (no intermediate between 640 and 2000)
if (url.contains('ab67616d00001e02')) {
return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273');
}
// Deezer CDN: upgrade to 1000x1000
final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$');
if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) {
return url.replaceAllMapped(
-3
View File
@@ -228,7 +228,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
void _onScroll() {
// Show title when scrolled past the header (280px trigger)
final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
@@ -2013,7 +2012,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
}
/// Option tile for discography download bottom sheet
class _DiscographyOptionTile extends StatelessWidget {
final IconData icon;
final String title;
@@ -2051,7 +2049,6 @@ class _DiscographyOptionTile extends StatelessWidget {
}
}
/// Progress dialog shown while fetching album tracks
class _FetchingProgressDialog extends StatefulWidget {
final int totalAlbums;
final VoidCallback onCancel;
-5
View File
@@ -95,7 +95,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
}
/// Upgrade cover URL to a reasonable resolution for full-screen display.
String? _highResCoverUrl(String? url) {
if (url == null) return null;
if (url.contains('ab67616d00001e02')) {
@@ -111,7 +110,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return url;
}
/// Get tracks for this album from history provider (reactive)
List<DownloadHistoryItem> _getAlbumTracks(
List<DownloadHistoryItem> allItems,
) {
@@ -641,7 +639,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
// Info is now displayed in the full-screen cover overlay
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
@@ -848,7 +845,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
);
}
/// Share selected tracks via system share sheet
Future<void> _shareSelected(List<DownloadHistoryItem> allTracks) async {
final tracksById = {for (final t in allTracks) t.id: t};
final safUris = <String>[];
@@ -1091,7 +1087,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
for (final id in _selectedIds) {
final item = tracksById[id];
if (item == null) continue;
// For SAF items, use safFileName to detect format (filePath is content:// URI)
final nameToCheck =
(item.safFileName != null && item.safFileName!.isNotEmpty)
? item.safFileName!.toLowerCase()
-2
View File
@@ -2412,8 +2412,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
}
// Search result sorting
String _sortOptionLabel(_SearchSortOption option) {
switch (option) {
case _SearchSortOption.defaultOrder:
@@ -597,7 +597,6 @@ class _LibraryTracksFolderScreenState
final customCoverPath = playlist?.coverImagePath;
final isLovedMode = widget.mode == LibraryTracksFolderMode.loved;
final isPlaylistMode = widget.mode == LibraryTracksFolderMode.playlist;
// Loved always shows the heart icon (like Spotify's Liked Songs)
final coverUrl = isLovedMode ? null : _firstCoverUrl(entries, localState);
final hasCustomCover =
customCoverPath != null && customCoverPath.isNotEmpty;
@@ -667,7 +666,6 @@ class _LibraryTracksFolderScreenState
background: Stack(
fit: StackFit.expand,
children: [
// Cover background: custom > first track URL > icon
if (hasCustomCover)
Image.file(
File(customCoverPath),
@@ -1364,15 +1362,12 @@ class _CollectionTrackTile extends ConsumerWidget {
final track = entry.track;
final historyState = ref.read(downloadHistoryProvider);
// 1. Download history by Spotify ID
var historyItem = historyState.getBySpotifyId(track.id);
// 2. Download history by ISRC
if (historyItem == null && track.isrc != null && track.isrc!.isNotEmpty) {
historyItem = historyState.getByIsrc(track.isrc!);
}
// 3. Download history by track name + artist (handles ID/ISRC mismatch)
historyItem ??= historyState.findByTrackAndArtist(
track.name,
track.artistName,
@@ -1385,14 +1380,12 @@ class _CollectionTrackTile extends ConsumerWidget {
return;
}
// 4. Local library by ISRC
final localState = ref.read(localLibraryProvider);
LocalLibraryItem? localItem;
if (track.isrc != null && track.isrc!.isNotEmpty) {
localItem = localState.getByIsrc(track.isrc!);
}
// 5. Local library by track name + artist
localItem ??= localState.findByTrackAndArtist(track.name, track.artistName);
if (localItem != null) {
@@ -1402,7 +1395,6 @@ class _CollectionTrackTile extends ConsumerWidget {
return;
}
// 6. Not found anywhere offer to download
_downloadTrack(context, ref);
}
}
-2
View File
@@ -526,7 +526,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
) {
// Info is now displayed in the full-screen cover overlay
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
@@ -1057,7 +1056,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return;
}
// Temporarily hide selection bar so it doesn't overlap the bottom sheet.
// The bar uses AnimatedPositioned (250ms), so wait for the slide-out.
setState(() => _isSelectionMode = false);
await Future<void>.delayed(const Duration(milliseconds: 300));
-2
View File
@@ -348,7 +348,6 @@ class _MainShellState extends ConsumerState<MainShell>
trackState.isShowingRecentAccess &&
!trackState.isLoading &&
(trackState.hasSearchText || trackState.hasContent)) {
// Has recent access AND search content clear everything at once
_log.i(
'Back: step 3a - dismiss recent access + clear search/content '
'(hasSearchText=${trackState.hasSearchText}, hasContent=${trackState.hasContent})',
@@ -360,7 +359,6 @@ class _MainShellState extends ConsumerState<MainShell>
}
if (_currentIndex == 0 && trackState.isShowingRecentAccess) {
// Recent access overlay only (no search content) just dismiss it
_log.i('Back: step 3b - dismiss recent access only');
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
FocusManager.instance.primaryFocus?.unfocus();
-5
View File
@@ -117,7 +117,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
final playlistInfo = result['playlist_info'] as Map<String, dynamic>?;
final owner = playlistInfo?['owner'] as Map<String, dynamic>?;
// Go backend returns 'track_list' not 'tracks'
final trackList = result['track_list'] as List<dynamic>? ?? [];
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
@@ -182,14 +181,11 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
}
/// Upgrade cover URL to a reasonable resolution for full-screen display.
String? _highResCoverUrl(String? url) {
if (url == null) return null;
// Spotify CDN: upgrade 300 640 only
if (url.contains('ab67616d00001e02')) {
return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273');
}
// Deezer CDN: upgrade to 1000x1000
final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$');
if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) {
return url.replaceAllMapped(
@@ -729,7 +725,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
}
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
class _PlaylistTrackItem extends ConsumerWidget {
final Track track;
final VoidCallback onDownload;
-24
View File
@@ -156,7 +156,6 @@ class UnifiedLibraryItem {
return 'builtin:$id';
}
/// Convert to a [Track] for adding to collections/playlists.
Track toTrack() {
if (historyItem != null) {
final h = historyItem!;
@@ -2101,7 +2100,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
}
// Reload local library if we deleted any local items
if (allItems.any(
(i) =>
_selectedIds.contains(i.id) && i.source == LibraryItemSource.local,
@@ -2121,7 +2119,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
}
/// Strip EXISTS: prefix from file path (legacy history items)
String _cleanFilePath(String? filePath) {
return DownloadedEmbeddedCoverResolver.cleanFilePath(filePath);
}
@@ -2971,7 +2968,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
}
/// Navigate with unfocus pattern unfocuses search before and after navigation.
void _navigateWithUnfocus(Route<dynamic> route) {
_searchFocusNode.unfocus();
Navigator.of(context).push(route).then((_) => _searchFocusNode.unfocus());
@@ -3201,14 +3197,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}) async {
final notifier = ref.read(libraryCollectionsProvider.notifier);
// If in selection mode and the dragged item is selected, add ALL selected
if (_isSelectionMode &&
_selectedIds.isNotEmpty &&
_selectedIds.contains(item.id)) {
final selectedItems = allItems
.where((e) => _selectedIds.contains(e.id))
.toList();
// Fallback: if allItems is empty or no match, at least add the dragged item
if (selectedItems.isEmpty) {
selectedItems.add(item);
}
@@ -3247,8 +3241,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
/// Build a compact floating feedback widget shown while dragging a track.
/// Shows the count when multiple tracks are selected and being dragged.
Widget _buildDragFeedback(
BuildContext context,
UnifiedLibraryItem item,
@@ -3777,7 +3769,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
/// Build a Spotify-style collection list item (Wishlist, Loved, Playlists)
Widget _buildCollectionListItem({
required BuildContext context,
required ColorScheme colorScheme,
@@ -3854,7 +3845,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
/// Build a collection grid item for grid view mode
Widget _buildCollectionGridItem({
required BuildContext context,
required ColorScheme colorScheme,
@@ -3937,7 +3927,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return entries;
}
/// Build a collection item for the unified "All" tab grid view.
Widget _buildAllTabGridCollectionItem({
required BuildContext context,
required ColorScheme colorScheme,
@@ -4055,7 +4044,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
}
/// Build a collection item for the unified "All" tab list view.
Widget _buildAllTabListCollectionItem({
required BuildContext context,
required ColorScheme colorScheme,
@@ -4208,8 +4196,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
// Collection folders as list items (Spotify-style) in "All" tab
// are now rendered inline with tracks below (unified sliver)
if ((filteredGroupedAlbums.isNotEmpty ||
filteredGroupedLocalAlbums.isNotEmpty) &&
filterMode == 'albums')
@@ -4696,7 +4682,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
/// Album grid item for local library albums
Widget _buildLocalAlbumGridItem(
BuildContext context,
_GroupedLocalAlbum album,
@@ -5275,7 +5260,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
return;
}
// Share SAF content URIs via native intent
if (safUris.isNotEmpty) {
try {
if (safUris.length == 1) {
@@ -5286,7 +5270,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
} catch (_) {}
}
// Share regular files via SharePlus
if (filesToShare.isNotEmpty) {
await SharePlus.instance.share(ShareParams(files: filesToShare));
}
@@ -6400,7 +6383,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
}
/// Reusable filter button with badge showing active filter count.
Widget _buildFilterButton(
BuildContext context,
List<UnifiedLibraryItem> unifiedItems,
@@ -6495,7 +6477,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
}
// Network URL cover (downloaded items)
if (item.coverUrl != null) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
@@ -6513,7 +6494,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
// Local file cover (from library scan)
if (item.localCoverPath != null && item.localCoverPath!.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
@@ -6530,7 +6510,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
// Placeholder (no cover)
if (size != null) {
return buildPlaceholder();
}
@@ -6540,7 +6519,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
/// Build a unified library item (merged downloaded + local)
Widget _buildUnifiedLibraryItem(
BuildContext context,
UnifiedLibraryItem item,
@@ -6748,7 +6726,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
);
}
/// Build unified grid item for grid view mode
Widget _buildUnifiedGridItem(
BuildContext context,
UnifiedLibraryItem item,
@@ -7038,7 +7015,6 @@ class _FilterChip extends StatelessWidget {
}
}
/// Reusable action button for selection mode bottom bar
class _SelectionActionButton extends StatelessWidget {
final IconData icon;
final String label;
@@ -53,10 +53,8 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
if (mounted) {
setState(() {
_androidSdkVersion = sdkVersion;
// SAF doesn't need storage permission on Android 10+
_hasStoragePermission = sdkVersion >= 29 ? true : false;
});
// For older Android, check legacy storage permission
if (sdkVersion < 29) {
final hasPermission = await Permission.storage.isGranted;
if (mounted) {
@@ -65,7 +63,6 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
}
}
} else if (Platform.isIOS) {
// iOS doesn't need explicit storage permission for app documents
setState(() => _hasStoragePermission = true);
} else {
setState(() => _hasStoragePermission = true);
@@ -74,7 +71,6 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
Future<bool> _requestStoragePermission() async {
if (!Platform.isAndroid) return true;
// SAF on Android 10+ doesn't need MANAGE_EXTERNAL_STORAGE
if (_androidSdkVersion >= 29) return true;
final status = await Permission.storage.request();
@@ -125,12 +121,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
final granted = await _requestStoragePermission();
if (!granted) return;
}
// Fallback for older devices
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
if (Platform.isIOS) {
// On iOS, create a security-scoped bookmark so we can access
// this folder across app restarts and from the Go backend.
final bookmark = await PlatformBridge.createIosBookmarkFromPath(
result,
);
@@ -139,8 +132,6 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
.read(settingsProvider.notifier)
.setLocalLibraryPathAndBookmark(result, bookmark);
} else {
// Bookmark creation failed; save path anyway (works for
// app-internal folders like Documents/).
ref.read(settingsProvider.notifier).setLocalLibraryPath(result);
}
} else {
@@ -162,13 +153,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
return;
}
// On iOS with a bookmark, try resolving the bookmark first to validate
// access instead of checking the path directly (which may fail outside
// the app sandbox).
if (Platform.isIOS && iosBookmark.isNotEmpty) {
// Bookmark will be resolved inside startScan; skip Directory.exists
// check since security-scoped paths are not accessible without the
// bookmark being activated.
} else if (!libraryPath.startsWith('content://') &&
!await Directory(libraryPath).exists()) {
if (mounted) {
@@ -467,7 +452,6 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
),
),
// Scan Actions Section
if (settings.localLibraryEnabled) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.libraryActions),
-4
View File
@@ -707,10 +707,6 @@ class _TutorialPage extends StatelessWidget {
final contentGap = (56 * scale) + ((textScale - 1) * 10);
final bottomGap = (32 * scale).clamp(20.0, 32.0);
// Parallax effect logic (simplified for StatelessWidget)
// In a real advanced implementation we'd pass the Controller's listenable
// But for now, let's use entrance animations based on currentIndex == index
final isActive = currentIndex == index;
return SingleChildScrollView(
+3 -9
View File
@@ -21,7 +21,6 @@ class CoverCacheManager {
static CacheManager get instance {
if (!_initialized || _instance == null) {
// Fallback to default cache manager if not initialized
debugPrint('CoverCacheManager: Not initialized, using DefaultCacheManager');
return DefaultCacheManager();
}
@@ -36,13 +35,13 @@ class CoverCacheManager {
try {
final appDir = await getApplicationSupportDirectory();
_cachePath = p.join(appDir.path, 'cover_cache');
await Directory(_cachePath!).create(recursive: true);
debugPrint('CoverCacheManager: Initializing at $_cachePath');
_instance = _createManager(_cachePath!);
_initialized = true;
debugPrint('CoverCacheManager: Initialized successfully');
} catch (e) {
@@ -60,22 +59,18 @@ class CoverCacheManager {
if (instance == null || cachePath == null) return;
// Ask cache manager to clear indexed entries first.
try {
await instance.emptyCache();
} catch (e) {
debugPrint('CoverCacheManager: emptyCache failed, fallback to wipe: $e');
}
// Then wipe the directory to remove orphaned files/metadata leftovers.
await _wipeDirectory(cachePath);
// Clear in-memory image cache so cleared covers are not retained in RAM.
final imageCache = PaintingBinding.instance.imageCache;
imageCache.clear();
imageCache.clearLiveImages();
// Reset manager memory/index state after on-disk wipe.
instance.store.emptyMemoryCache();
_instance = _createManager(cachePath);
_initialized = true;
@@ -124,7 +119,6 @@ class CoverCacheManager {
_cacheKey,
stalePeriod: _maxCacheAge,
maxNrOfCacheObjects: _maxCacheObjects,
// Use path only (not databaseName) to store database in persistent directory
repo: JsonCacheInfoRepository(path: cachePath),
fileSystem: IOFileSystem(cachePath),
fileService: HttpFileService(),
-3
View File
@@ -1350,7 +1350,6 @@ class FFmpegService {
return null;
}
// Lossless targets: dedicated single-pass methods
if (format == 'alac') {
return _convertToAlac(
inputPath: inputPath,
@@ -1369,7 +1368,6 @@ class FFmpegService {
);
}
// Lossy targets: MP3 / Opus
final extension = format == 'opus' ? '.opus' : '.mp3';
final outputPath = _buildOutputPath(inputPath, extension);
@@ -1966,7 +1964,6 @@ class FFmpegService {
}
}
/// Track info for CUE splitting, passed from the CUE parser
class CueSplitTrackInfo {
final int number;
final String title;
-31
View File
@@ -9,10 +9,8 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('HistoryDatabase');
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
/// Cached current iOS container path for path normalization
String? _currentContainerPath;
/// Provides O(1) lookups by spotify_id and isrc with proper indexing
class HistoryDatabase {
static final HistoryDatabase instance = HistoryDatabase._init();
static Database? _database;
@@ -102,21 +100,16 @@ class HistoryDatabase {
}
}
/// Pattern to match iOS container paths
/// Example: /var/mobile/Containers/Data/Application/UUID-HERE/Documents/...
static final _iosContainerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/',
caseSensitive: false,
);
/// Initialize and cache the current iOS container path
Future<void> _initContainerPath() async {
if (!Platform.isIOS || _currentContainerPath != null) return;
try {
final docDir = await getApplicationDocumentsDirectory();
// Extract container path up to and including the UUID folder
// e.g., /var/mobile/Containers/Data/Application/UUID/
final match = _iosContainerPattern.firstMatch(docDir.path);
if (match != null) {
_currentContainerPath = match.group(0);
@@ -127,13 +120,10 @@ class HistoryDatabase {
}
}
/// Normalize iOS file path by replacing old container UUID with current one
/// This fixes the issue where iOS changes container UUID after app updates
String _normalizeIosPath(String? filePath) {
if (filePath == null || filePath.isEmpty) return filePath ?? '';
if (!Platform.isIOS || _currentContainerPath == null) return filePath;
// Check if path contains an iOS container path
if (_iosContainerPattern.hasMatch(filePath)) {
final normalized = filePath.replaceFirst(
_iosContainerPattern,
@@ -148,8 +138,6 @@ class HistoryDatabase {
return filePath;
}
/// Migrate iOS paths in database to use current container UUID
/// This is called once after app update if container changed
Future<bool> migrateIosContainerPaths() async {
if (!Platform.isIOS) return false;
@@ -205,8 +193,6 @@ class HistoryDatabase {
}
}
/// Migrate data from SharedPreferences to SQLite
/// Returns true if migration was performed, false if already migrated
Future<bool> migrateFromSharedPreferences() async {
final prefs = await _prefs;
final migrationKey = 'history_migrated_to_sqlite';
@@ -243,7 +229,6 @@ class HistoryDatabase {
await batch.commit(noResult: true);
// Mark as migrated but keep old data for safety
await prefs.setBool(migrationKey, true);
_log.i('Migration complete: ${jsonList.length} items');
@@ -254,7 +239,6 @@ class HistoryDatabase {
}
}
/// Convert JSON format (camelCase) to DB row (snake_case)
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
return {
'id': json['id'],
@@ -286,8 +270,6 @@ class HistoryDatabase {
};
}
/// Convert DB row (snake_case) to JSON format (camelCase)
/// Also normalizes iOS paths if container UUID changed
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
return {
'id': row['id'],
@@ -342,7 +324,6 @@ class HistoryDatabase {
await batch.commit(noResult: true);
}
/// Get all history items ordered by download date (newest first)
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
final db = await database;
final rows = await db.query(
@@ -366,7 +347,6 @@ class HistoryDatabase {
return _dbRowToJson(rows.first);
}
/// Get item by Spotify ID - O(1) with index
Future<Map<String, dynamic>?> getBySpotifyId(String spotifyId) async {
final db = await database;
final rows = await db.query(
@@ -379,7 +359,6 @@ class HistoryDatabase {
return _dbRowToJson(rows.first);
}
/// Get item by ISRC - O(1) with index
Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
final db = await database;
final rows = await db.query(
@@ -392,7 +371,6 @@ class HistoryDatabase {
return _dbRowToJson(rows.first);
}
/// Check if spotify_id exists - O(1) with index
Future<bool> existsBySpotifyId(String spotifyId) async {
final db = await database;
final result = await db.rawQuery(
@@ -402,7 +380,6 @@ class HistoryDatabase {
return result.isNotEmpty;
}
/// Get all spotify_ids as Set for fast in-memory lookup
Future<Set<String>> getAllSpotifyIds() async {
final db = await database;
final rows = await db.rawQuery(
@@ -433,7 +410,6 @@ class HistoryDatabase {
return Sqflite.firstIntValue(result) ?? 0;
}
/// Find existing item by spotify_id or isrc (for deduplication)
Future<Map<String, dynamic>?> findExisting({
String? spotifyId,
String? isrc,
@@ -442,7 +418,6 @@ class HistoryDatabase {
final bySpotify = await getBySpotifyId(spotifyId);
if (bySpotify != null) return bySpotify;
// Check for deezer: prefix matching
if (spotifyId.startsWith('deezer:')) {
final deezerId = spotifyId.substring(7);
final db = await database;
@@ -469,7 +444,6 @@ class HistoryDatabase {
_database = null;
}
/// Update file path for a history entry (e.g. after format conversion)
Future<void> updateFilePath(
String id,
String newFilePath, {
@@ -524,8 +498,6 @@ class HistoryDatabase {
await db.update('history', values, where: 'id = ?', whereArgs: [id]);
}
/// Get all file paths from download history
/// Used to exclude downloaded files from local library scan
Future<Set<String>> getAllFilePaths() async {
final db = await database;
final rows = await db.rawQuery(
@@ -534,8 +506,6 @@ class HistoryDatabase {
return rows.map((r) => r['file_path'] as String).toSet();
}
/// Get all entries with file paths for orphan detection
/// Returns list of (id, file_path, storage_mode, download_tree_uri, saf_relative_dir, saf_file_name)
Future<List<Map<String, dynamic>>> getAllEntriesWithPaths() async {
final db = await database;
final rows = await db.rawQuery('''
@@ -569,7 +539,6 @@ class HistoryDatabase {
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
}
/// Delete multiple entries by IDs
Future<int> deleteByIds(List<String> ids) async {
if (ids.isEmpty) return 0;
-9
View File
@@ -96,7 +96,6 @@ class LocalLibraryItem {
format: json['format'] as String?,
);
/// Create a unique key for matching tracks
String get matchKey =>
'${trackName.toLowerCase()}|${artistName.toLowerCase()}';
String get albumKey =>
@@ -183,13 +182,11 @@ class LibraryDatabase {
}
if (oldVersion < 3) {
// Add file_mod_time column for incremental scanning
await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER');
_log.i('Added file_mod_time column for incremental scanning');
}
if (oldVersion < 4) {
// Add bitrate column for lossy format quality info
await db.execute('ALTER TABLE library ADD COLUMN bitrate INTEGER');
_log.i('Added bitrate column for lossy format quality');
}
@@ -475,8 +472,6 @@ class LibraryDatabase {
_database = null;
}
/// Get all file paths with their modification times for incremental scanning
/// Returns a map of filePath -> fileModTime (unix timestamp in milliseconds)
Future<Map<String, int>> getFileModTimes() async {
final db = await database;
final rows = await db.rawQuery(
@@ -491,8 +486,6 @@ class LibraryDatabase {
return result;
}
/// Export file modification times to a compact line-based snapshot that
/// native code can read without receiving a large method-channel payload.
Future<String> writeFileModTimesSnapshot() async {
final db = await database;
final rows = await db.rawQuery(
@@ -519,7 +512,6 @@ class LibraryDatabase {
return file.path;
}
/// Update file_mod_time for existing rows using file_path as key.
Future<void> updateFileModTimes(Map<String, int> fileModTimes) async {
if (fileModTimes.isEmpty) return;
final db = await database;
@@ -535,7 +527,6 @@ class LibraryDatabase {
await batch.commit(noResult: true);
}
/// Get all file paths in the library (for detecting deleted files)
Future<Set<String>> getAllFilePaths() async {
final db = await database;
final rows = await db.rawQuery('SELECT file_path FROM library');
+15
View File
@@ -422,6 +422,21 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Rewrites ARTIST/ALBUMARTIST Vorbis comments as multiple split entries
/// using the native Go FLAC writer, fixing FFmpeg's tag deduplication.
static Future<Map<String, dynamic>> rewriteSplitArtistTags(
String filePath,
String artist,
String albumArtist,
) async {
final result = await _channel.invokeMethod('rewriteSplitArtistTags', {
'file_path': filePath,
'artist': artist,
'album_artist': albumArtist,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<bool> writeTempToSaf(String tempPath, String safUri) async {
final result = await _channel.invokeMethod('writeTempToSaf', {
'temp_path': tempPath,
-6
View File
@@ -10,7 +10,6 @@ class ShareIntentService {
factory ShareIntentService() => _instance;
ShareIntentService._internal();
// Spotify patterns
static final RegExp _spotifyUriPattern = RegExp(
r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+',
);
@@ -18,7 +17,6 @@ class ShareIntentService {
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
);
// Deezer patterns
static final RegExp _deezerUrlPattern = RegExp(
r'https?://(www\.)?deezer\.com/(track|album|playlist|artist)/\d+(\?[^\s]*)?',
);
@@ -26,17 +24,14 @@ class ShareIntentService {
r'https?://deezer\.page\.link/[a-zA-Z0-9]+',
);
// Tidal patterns
static final RegExp _tidalUrlPattern = RegExp(
r'https?://(listen\.)?tidal\.com/(track|album|playlist|artist)/[a-zA-Z0-9-]+(\?[^\s]*)?',
);
// YouTube Music patterns
static final RegExp _ytMusicUrlPattern = RegExp(
r'https?://music\.youtube\.com/(watch\?v=|playlist\?list=|channel/|browse/)[a-zA-Z0-9_-]+([?&][^\s]*)?',
);
// Standard YouTube patterns (youtu.be short links and www.youtube.com/watch)
static final RegExp _youtubeUrlPattern = RegExp(
r'https?://(youtu\.be/[a-zA-Z0-9_-]+|www\.youtube\.com/watch\?v=[a-zA-Z0-9_-]+)([?&][^\s]*)?',
);
@@ -117,7 +112,6 @@ class ShareIntentService {
final match = pattern.firstMatch(text);
if (match != null) {
final fullUrl = match.group(0)!;
// Keep query params for YouTube URLs (needed for ?v=, ?list=, etc.)
if (pattern == _ytMusicUrlPattern || pattern == _youtubeUrlPattern) {
return fullUrl;
}
-3
View File
@@ -3,7 +3,6 @@ import 'package:flutter/services.dart';
import 'package:spotiflac_android/models/theme_settings.dart';
class AppTheme {
/// Default seed color (Spotify green)
static const Color defaultSeedColor = Color(kDefaultSeedColor);
static ThemeData light({ColorScheme? dynamicScheme, Color? seedColor}) {
@@ -87,12 +86,10 @@ class AppTheme {
fontWeight: FontWeight.w500,
),
systemOverlayStyle: SystemUiOverlayStyle(
// Status bar
statusBarColor: Colors.transparent,
statusBarIconBrightness: scheme.brightness == Brightness.dark
? Brightness.light
: Brightness.dark,
// System navigation bar match the in-app NavigationBar color
systemNavigationBarColor: isAmoled
? Colors.black
: scheme.surfaceContainer,
-21
View File
@@ -11,11 +11,6 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('ClickableMetadata');
/// Navigate to an artist screen by searching Deezer for the artist ID.
///
/// If [artistId] is provided and valid, navigates directly.
/// Otherwise, searches Deezer by [artistName] to resolve the ID first.
/// For extension-based content, pass [extensionId] to use ExtensionArtistScreen.
Future<void> navigateToArtist(
BuildContext context, {
required String artistName,
@@ -95,11 +90,6 @@ Future<void> navigateToArtist(
}
}
/// Navigate to an album screen by searching Deezer for the album ID.
///
/// If [albumId] is provided and valid, navigates directly.
/// Otherwise, searches Deezer by [albumName] (optionally with [artistName]) to resolve the ID.
/// For extension-based content, pass [extensionId] to use ExtensionAlbumScreen.
Future<void> navigateToAlbum(
BuildContext context, {
required String albumName,
@@ -217,9 +207,6 @@ void _pushAlbumScreen(
String? coverUrl,
String? extensionId,
}) {
// Built-in providers (tidal, qobuz, deezer) use AlbumScreen which
// detects the provider from the album ID prefix. Only true JS extensions
// should use ExtensionAlbumScreen.
const builtInProviders = {'tidal', 'qobuz', 'deezer'};
final isExtension =
extensionId != null && !builtInProviders.contains(extensionId);
@@ -297,10 +284,6 @@ void _showUnavailable(BuildContext context, String type) {
).showSnackBar(SnackBar(content: Text('$type information not available')));
}
/// A reusable widget that makes text tappable to navigate to an artist screen.
///
/// Wraps the text in a GestureDetector that, when tapped, looks up the artist
/// via Deezer search and navigates to the ArtistScreen.
class ClickableArtistName extends StatefulWidget {
final String artistName;
final String? artistId;
@@ -526,10 +509,6 @@ bool _canNavigateArtistDirectly({
final RegExp _spotifyArtistIdPattern = RegExp(r'^[A-Za-z0-9]{22}$');
/// A reusable widget that makes text tappable to navigate to an album screen.
///
/// Wraps the text in a GestureDetector that, when tapped, looks up the album
/// via Deezer search and navigates to the AlbumScreen.
class ClickableAlbumName extends StatelessWidget {
final String albumName;
final String? albumId;
-17
View File
@@ -47,26 +47,20 @@ bool isValidIosWritablePath(String path) {
if (path.isEmpty) return false;
if (!path.startsWith('/')) return false;
// Check if it's the container root (without Documents/, tmp/, etc.)
if (_iosContainerRootPattern.hasMatch(path)) {
return false;
}
// Check for iCloud Drive paths
if (path.contains('Mobile Documents') ||
path.contains('CloudDocs') ||
path.contains('com~apple~CloudDocs')) {
return false;
}
// Reject stale paths where an old sandbox container path has been embedded
// inside the current Documents directory.
if (_iosNestedLegacyDocumentsPattern.hasMatch(path)) {
return false;
}
// Ensure path contains a valid subdirectory (Documents, tmp, Library, etc.)
// This handles cases where FilePicker returns container root
final containerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
caseSensitive: false,
@@ -74,7 +68,6 @@ bool isValidIosWritablePath(String path) {
final match = containerPattern.firstMatch(path);
if (match != null) {
final remainingPath = path.substring(match.end);
// Valid paths should have something after the UUID
if (remainingPath.isEmpty || remainingPath == '/') {
return false;
}
@@ -111,13 +104,10 @@ Future<String> validateOrFixIosPath(
candidates.add(trimmed);
}
// Some pickers can return absolute iOS paths without the leading slash.
if (_iosContainerPathWithoutLeadingSlashPattern.hasMatch(trimmed)) {
candidates.add('/$trimmed');
}
// Recover legacy relative iOS path format:
// Data/Application/<UUID>/Documents/<subdir>
final legacyRelativeMatch = _iosLegacyRelativeDocumentsPattern.firstMatch(
trimmed,
);
@@ -127,7 +117,6 @@ Future<String> validateOrFixIosPath(
);
}
// Generic salvage for relative paths containing `Documents/...`.
if (!trimmed.startsWith('/')) {
final documentsMarker = 'Documents/';
final index = trimmed.indexOf(documentsMarker);
@@ -143,7 +132,6 @@ Future<String> validateOrFixIosPath(
}
}
// Fall back to app Documents directory
final musicDir = Directory('${docDir.path}/$subfolder');
if (!await musicDir.exists()) {
await musicDir.create(recursive: true);
@@ -185,7 +173,6 @@ IosPathValidationResult validateIosPath(String path) {
);
}
// Check if it's the container root
if (_iosContainerRootPattern.hasMatch(path)) {
return const IosPathValidationResult(
isValid: false,
@@ -194,7 +181,6 @@ IosPathValidationResult validateIosPath(String path) {
);
}
// Check for iCloud Drive paths
if (path.contains('Mobile Documents') ||
path.contains('CloudDocs') ||
path.contains('com~apple~CloudDocs')) {
@@ -213,7 +199,6 @@ IosPathValidationResult validateIosPath(String path) {
);
}
// Check for container root without subdirectory
final containerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
caseSensitive: false,
@@ -263,7 +248,6 @@ String stripCueTrackSuffix(String path) {
Future<bool> fileExists(String? path) async {
if (path == null || path.isEmpty) return false;
// For CUE virtual paths, check if the base .cue file exists
final realPath = isCueVirtualPath(path) ? stripCueTrackSuffix(path) : path;
if (isContentUri(realPath)) {
return PlatformBridge.safExists(realPath);
@@ -288,7 +272,6 @@ Future<void> deleteFile(String? path) async {
Future<FileAccessStat?> fileStat(String? path) async {
if (path == null || path.isEmpty) return null;
// For CUE virtual paths, stat the base .cue file
final realPath = isCueVirtualPath(path) ? stripCueTrackSuffix(path) : path;
if (isContentUri(realPath)) {
final stat = await PlatformBridge.safStat(realPath);
-5
View File
@@ -25,9 +25,6 @@ const _audioExtensions = <String>[
const _maxPathMatchKeyCacheSize = 6000;
final Map<String, Set<String>> _pathMatchKeyCache = <String, Set<String>>{};
/// Strips a trailing audio extension from [path] if present.
/// Returns the path without extension, or `null` if no known audio extension
/// was found.
String? _stripAudioExtension(String path) {
final lower = path.toLowerCase();
for (final ext in _audioExtensions) {
@@ -115,8 +112,6 @@ Set<String> buildPathMatchKeys(String? filePath) {
addNormalized(cleaned);
// Add extension-stripped variants so that a file converted from one audio
// format to another (e.g. Song.flac Song.opus) still matches.
final extensionStrippedKeys = <String>{};
for (final key in keys) {
final stripped = _stripAudioExtension(key);
-36
View File
@@ -1,9 +1,5 @@
import 'package:flutter/material.dart';
//
// 1. Staggered List Item fade + slide-up entrance with index-based delay
//
/// Wraps a child in a staggered fade-in + slide-up animation.
///
/// [index] controls the stagger delay (each item delayed by [staggerDelay]).
@@ -31,7 +27,6 @@ class StaggeredListItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (!animate || index >= maxAnimatedItems) return child;
// Cap the delay so very long lists don't have absurd wait times.
final cappedIndex = index.clamp(0, maxAnimatedItems - 1);
final delay = staggerDelay * cappedIndex;
final totalDuration = duration + delay;
@@ -42,7 +37,6 @@ class StaggeredListItem extends StatelessWidget {
duration: totalDuration,
curve: Curves.easeOutCubic,
builder: (context, value, child) {
// Compute the effective progress after the stagger delay.
final delayFraction = totalDuration.inMilliseconds > 0
? delay.inMilliseconds / totalDuration.inMilliseconds
: 0.0;
@@ -62,10 +56,6 @@ class StaggeredListItem extends StatelessWidget {
}
}
//
// 2. Animated State Switcher crossfade between loading / content / empty / error
//
/// A convenience wrapper around [AnimatedSwitcher] that crossfades between
/// different widget states (loading, content, empty, error).
///
@@ -94,10 +84,6 @@ class AnimatedStateSwitcher extends StatelessWidget {
}
}
//
// 3. Shared Page Route consistent slide-from-right transition
//
/// Creates a platform-aware material route.
///
/// This intentionally defers route transitions to Flutter's material route and
@@ -107,10 +93,6 @@ Route<T> slidePageRoute<T>({required Widget page}) {
return MaterialPageRoute<T>(builder: (context) => page);
}
//
// 4. Shimmer / Skeleton Loading Widget
//
/// A shimmer effect widget that can wrap skeleton placeholders.
class ShimmerLoading extends StatefulWidget {
final Widget child;
@@ -682,10 +664,6 @@ class HomeSearchSkeleton extends StatelessWidget {
}
}
//
// 5. Animated Selection Checkbox scales in when entering selection mode
//
/// An animated selection indicator that scales in/out and crossfades the
/// checked/unchecked state.
class AnimatedSelectionCheckbox extends StatelessWidget {
@@ -746,10 +724,6 @@ class AnimatedSelectionCheckbox extends StatelessWidget {
}
}
//
// 6. Download Success Animation green flash + checkmark
//
/// A widget that briefly flashes a success color behind its child and shows
/// an animated checkmark when [showSuccess] transitions to true.
class DownloadSuccessOverlay extends StatefulWidget {
@@ -775,8 +749,6 @@ class _DownloadSuccessOverlayState extends State<DownloadSuccessOverlay>
@override
void initState() {
super.initState();
// Initialise from the current widget value so items that are already
// completed when first built do not play the flash animation.
_wasSuccess = widget.showSuccess;
_controller = AnimationController(
vsync: this,
@@ -821,10 +793,6 @@ class _DownloadSuccessOverlayState extends State<DownloadSuccessOverlay>
}
}
//
// 7. Badge Bump Animation scales the badge when count changes
//
/// Wraps a [Badge] child and plays a brief scale-bump whenever [count] changes.
class AnimatedBadge extends StatefulWidget {
final int count;
@@ -877,10 +845,6 @@ class _AnimatedBadgeState extends State<AnimatedBadge>
}
}
//
// 8. Animated Removal Item fade + slide out when removed from a list
//
/// Build a removal animation for [AnimatedList] items.
/// Use as the `builder` callback in [AnimatedListState.removeItem].
Widget buildRemovalAnimation(Widget child, Animation<double> animation) {
-7
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
/// Progress state communicated from caller to dialog via [ValueNotifier].
class _BatchProgress {
final int current;
final String? detail;
@@ -54,11 +53,8 @@ class BatchProgressDialog extends StatefulWidget {
required ValueNotifier<_BatchProgress> progressNotifier,
}) : _progressNotifier = progressNotifier;
// Static bookkeeping
static ValueNotifier<_BatchProgress>? _activeNotifier;
/// Show the dialog. Call [update] to push progress, [dismiss] to close.
static void show({
required BuildContext context,
required String title,
@@ -82,13 +78,10 @@ class BatchProgressDialog extends StatefulWidget {
);
}
/// Update the progress of the currently visible dialog.
/// No [BuildContext] needed communicates via [ValueNotifier].
static void update({required int current, String? detail}) {
_activeNotifier?.value = _BatchProgress(current: current, detail: detail);
}
/// Dismiss the dialog and clean up.
static void dismiss(BuildContext context) {
_activeNotifier = null;
Navigator.of(context, rootNavigator: true).pop();
-4
View File
@@ -1,8 +1,6 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
/// A collapsing header widget
/// Title collapses from large to small when scrolling
class CollapsingHeader extends StatelessWidget {
final String title;
final bool showBackButton;
@@ -100,7 +98,6 @@ class CollapsingHeader extends StatelessWidget {
}
}
/// Section header for settings
class SettingsSection extends StatelessWidget {
final String title;
const SettingsSection({super.key, required this.title});
@@ -120,7 +117,6 @@ class SettingsSection extends StatelessWidget {
}
}
/// Info card widget (like version info)
class InfoCard extends StatelessWidget {
final IconData icon;
final String title;
-8
View File
@@ -1,7 +1,5 @@
import 'package:flutter/material.dart';
/// Custom painted icons for donate platforms
class KofiIcon extends StatelessWidget {
final double size;
final Color color;
@@ -46,7 +44,6 @@ class _KofiPainter extends CustomPainter {
..quadraticBezierTo(s * 0.92, s * 0.68, s * 0.70, s * 0.68);
canvas.drawPath(handlePath, handlePaint);
// Heart on cup
final heartPaint = Paint()
..color = const Color(0xFFFF5E5B)
..style = PaintingStyle.fill;
@@ -62,7 +59,6 @@ class _KofiPainter extends CustomPainter {
..close();
canvas.drawPath(heart, heartPaint);
// Steam lines
final steamPaint = Paint()
..color = color.withValues(alpha: 0.6)
..style = PaintingStyle.stroke
@@ -108,12 +104,9 @@ class _GitHubPainter extends CustomPainter {
..color = color
..style = PaintingStyle.fill;
// GitHub octocat silhouette (simplified mark)
// Based on the GitHub logo path, scaled to fit
final scale = s / 24.0;
final path = Path();
// Outer circle/head shape
path.moveTo(12 * scale, 0.5 * scale);
path.cubicTo(
5.37 * scale, 0.5 * scale,
@@ -135,7 +128,6 @@ class _GitHubPainter extends CustomPainter {
9.01 * scale, 22.01 * scale,
9.01 * scale, 21.01 * scale,
);
// Left arm
path.cubicTo(
5.67 * scale, 21.71 * scale,
4.97 * scale, 19.56 * scale,
-7
View File
@@ -5,7 +5,6 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
/// Built-in service info with quality options
class BuiltInService {
final String id;
final String label;
@@ -22,7 +21,6 @@ class BuiltInService {
});
}
/// Default quality options for each built-in service
const _builtInServices = [
BuiltInService(
id: 'tidal',
@@ -99,7 +97,6 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
ConsumerState<DownloadServicePicker> createState() =>
_DownloadServicePickerState();
/// Show the download service picker as a modal bottom sheet
static void show(
BuildContext context, {
String? trackName,
@@ -135,7 +132,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
@override
void initState() {
super.initState();
// Default to recommended service if available, otherwise use default
final recommended = widget.recommendedService;
if (recommended != null && recommended.isNotEmpty) {
_selectedService = recommended;
@@ -144,7 +140,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
}
}
/// Get quality options for the selected service
List<QualityOption> _getQualityOptions() {
final builtIn = _builtInServices
.where((s) => s.id == _selectedService)
@@ -161,8 +156,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
return ext.qualityOptions;
}
// Extensions without quality options use Tidal's options as default
// since the download will fall back to built-in providers anyway.
return _builtInServices.firstWhere((s) => s.id == 'tidal').qualityOptions;
}
-9
View File
@@ -29,10 +29,6 @@ class ReEnrichFieldSelection {
bool get isAll => fields.length == ReEnrichFields.all.length;
}
/// Shows a bottom sheet that lets the user pick which metadata fields to update
/// during a bulk re-enrich operation.
///
/// Returns `null` when cancelled, or a [ReEnrichFieldSelection] when confirmed.
Future<ReEnrichFieldSelection?> showReEnrichFieldDialog(
BuildContext context, {
required int selectedCount,
@@ -128,7 +124,6 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 4),
child: Text(
@@ -138,7 +133,6 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
),
),
),
// Subtitle
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 4),
child: Text(
@@ -158,7 +152,6 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
),
),
const Divider(height: 1),
// Select All
CheckboxListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
title: Text(
@@ -171,7 +164,6 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
controlAffinity: ListTileControlAffinity.leading,
),
const Divider(height: 1, indent: 16, endIndent: 16),
// Individual fields
for (final field in ReEnrichFields.all)
CheckboxListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
@@ -182,7 +174,6 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
controlAffinity: ListTileControlAffinity.leading,
),
const SizedBox(height: 8),
// Confirm button
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: SizedBox(
+1 -10
View File
@@ -1,7 +1,5 @@
import 'package:flutter/material.dart';
/// A grouped settings card that connects items together like Android Settings
/// Items are connected with no gap between them, only separated when changing groups
class SettingsGroup extends StatelessWidget {
final List<Widget> children;
final EdgeInsetsGeometry? margin;
@@ -17,14 +15,10 @@ class SettingsGroup extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
// Use a more contrasting color for cards
// In dark mode with dynamic color, surfaceContainerHighest can be too similar to surface
// So we add a slight white overlay to make it more visible
// In light mode with dynamic color, we add a slight black overlay for the same reason
final cardColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
: Color.alphaBlend(Colors.black.withValues(alpha: 0.04), colorScheme.surface);
return Container(
margin: margin ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
@@ -44,7 +38,6 @@ class SettingsGroup extends StatelessWidget {
}
class SettingsItem extends StatelessWidget {
final IconData? icon;
final String title;
final String? subtitle;
@@ -126,7 +119,6 @@ class SettingsItem extends StatelessWidget {
}
class SettingsSwitchItem extends StatelessWidget {
final IconData? icon;
final String title;
final String? subtitle;
@@ -214,7 +206,6 @@ class SettingsSwitchItem extends StatelessWidget {
}
class SettingsSectionHeader extends StatelessWidget {
final String title;
const SettingsSectionHeader({super.key, required this.title});
@@ -74,7 +74,6 @@ class _TrackOptionsSheet extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header with drag handle + track info (matches _TrackInfoHeader)
Column(
children: [
const SizedBox(height: 8),
@@ -163,7 +162,6 @@ class _TrackOptionsSheet extends ConsumerWidget {
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
// Action items (matches _QualityOption style)
_OptionTile(
icon: isLoved ? Icons.favorite : Icons.favorite_border,
iconColor: isLoved ? colorScheme.error : null,
@@ -234,7 +232,6 @@ class _TrackOptionsSheet extends ConsumerWidget {
}
}
/// Styled like _QualityOption in download_service_picker.dart
class _OptionTile extends StatelessWidget {
final IconData icon;
final Color? iconColor;