From f511f30ad0972f0ea7a7965304e38af436b7e5c9 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 1 Apr 2026 02:45:19 +0700 Subject: [PATCH] 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 --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 35 + go_backend/audio_metadata.go | 53 +- go_backend/cue_parser.go | 47 -- go_backend/deezer.go | 17 +- go_backend/deezer_download.go | 3 +- go_backend/duplicate.go | 3 - go_backend/exports.go | 19 + go_backend/extension_providers.go | 10 - go_backend/extension_runtime_auth.go | 4 - go_backend/extension_runtime_polyfills.go | 7 - go_backend/extension_store.go | 18 +- go_backend/httputil.go | 7 - go_backend/httputil_ios.go | 7 - go_backend/httputil_utls.go | 8 - go_backend/idhs.go | 4 - go_backend/library_scan.go | 6 - go_backend/logbuffer.go | 4 - go_backend/lyrics.go | 16 - go_backend/lyrics_apple.go | 9 - go_backend/lyrics_musixmatch.go | 4 - go_backend/lyrics_netease.go | 5 - go_backend/lyrics_qqmusic.go | 4 - go_backend/metadata.go | 46 ++ go_backend/qobuz.go | 19 +- go_backend/qobuz_test.go | 12 - go_backend/romaji.go | 21 +- go_backend/songlink.go | 628 ++++++------------ go_backend/songlink_test.go | 109 +-- go_backend/tidal.go | 17 - go_backend/title_match_utils.go | 10 - ios/Runner/AppDelegate.swift | 9 + lib/main.dart | 6 - lib/providers/download_queue_provider.dart | 17 + lib/providers/store_provider.dart | 8 - lib/screens/album_screen.dart | 3 - lib/screens/artist_screen.dart | 3 - lib/screens/downloaded_album_screen.dart | 5 - lib/screens/home_tab.dart | 2 - lib/screens/library_tracks_folder_screen.dart | 8 - lib/screens/local_album_screen.dart | 2 - lib/screens/main_shell.dart | 2 - lib/screens/playlist_screen.dart | 5 - lib/screens/queue_tab.dart | 24 - .../settings/library_settings_page.dart | 16 - lib/screens/tutorial_screen.dart | 4 - lib/services/cover_cache_manager.dart | 12 +- lib/services/ffmpeg_service.dart | 3 - lib/services/history_database.dart | 31 - lib/services/library_database.dart | 9 - lib/services/platform_bridge.dart | 15 + lib/services/share_intent_service.dart | 6 - lib/theme/app_theme.dart | 3 - lib/utils/clickable_metadata.dart | 21 - lib/utils/file_access.dart | 17 - lib/utils/path_match_keys.dart | 5 - lib/widgets/animation_utils.dart | 36 - lib/widgets/batch_progress_dialog.dart | 7 - lib/widgets/collapsing_header.dart | 4 - lib/widgets/donate_icons.dart | 8 - lib/widgets/download_service_picker.dart | 7 - lib/widgets/re_enrich_field_dialog.dart | 9 - lib/widgets/settings_group.dart | 11 +- .../track_collection_quick_actions.dart | 3 - 63 files changed, 427 insertions(+), 1046 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 33d1c568..e49c65dd 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -2384,6 +2384,41 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "rewriteSplitArtistTags" -> { + val filePath = call.argument("file_path") ?: "" + val artist = call.argument("artist") ?: "" + val albumArtist = call.argument("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() diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index 3d8fe652..8532ddaf 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -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) diff --git a/go_backend/cue_parser.go b/go_backend/cue_parser.go index 480ea51a..4d54420d 100644 --- a/go_backend/cue_parser.go +++ b/go_backend/cue_parser.go @@ -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{ diff --git a/go_backend/deezer.go b/go_backend/deezer.go index 3d76bcbf..846f243b 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -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, diff --git a/go_backend/deezer_download.go b/go_backend/deezer_download.go index 2db32c39..2df4bc0e 100644 --- a/go_backend/deezer_download.go +++ b/go_backend/deezer_download.go @@ -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) diff --git a/go_backend/duplicate.go b/go_backend/duplicate.go index 30b68551..35bb2953 100644 --- a/go_backend/duplicate.go +++ b/go_backend/duplicate.go @@ -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() diff --git a/go_backend/exports.go b/go_backend/exports.go index 26502e8e..cf61bebe 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -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"` diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index df7467f9..04ed4085 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -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 }) diff --git a/go_backend/extension_runtime_auth.go b/go_backend/extension_runtime_auth.go index e17e0d4d..d334c8b6 100644 --- a/go_backend/extension_runtime_auth.go +++ b/go_backend/extension_runtime_auth.go @@ -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{}{ diff --git a/go_backend/extension_runtime_polyfills.go b/go_backend/extension_runtime_polyfills.go index a5334e06..74270101 100644 --- a/go_backend/extension_runtime_polyfills.go +++ b/go_backend/extension_runtime_polyfills.go @@ -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') { diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go index 3a2c479b..7cf86bbc 100644 --- a/go_backend/extension_store.go +++ b/go_backend/extension_store.go @@ -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//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) diff --git a/go_backend/httputil.go b/go_backend/httputil.go index b3a4c752..f4badcf6 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -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 diff --git a/go_backend/httputil_ios.go b/go_backend/httputil_ios.go index 3302ad0d..5edf55e8 100644 --- a/go_backend/httputil_ios.go +++ b/go_backend/httputil_ios.go @@ -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) diff --git a/go_backend/httputil_utls.go b/go_backend/httputil_utls.go index c3a90670..79191d39 100644 --- a/go_backend/httputil_utls.go +++ b/go_backend/httputil_utls.go @@ -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, diff --git a/go_backend/idhs.go b/go_backend/idhs.go index 3b339ed0..31093a21 100644 --- a/go_backend/idhs.go +++ b/go_backend/idhs.go @@ -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) diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index 085d8f69..dbb3d828 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -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 != "{}" { diff --git a/go_backend/logbuffer.go b/go_backend/logbuffer.go index bae68c06..76d46762 100644 --- a/go_backend/logbuffer.go +++ b/go_backend/logbuffer.go @@ -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" diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 03d7757a..a00849b7 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -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 } diff --git a/go_backend/lyrics_apple.go b/go_backend/lyrics_apple.go index 8fe350ad..63370255 100644 --- a/go_backend/lyrics_apple.go +++ b/go_backend/lyrics_apple.go @@ -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 { diff --git a/go_backend/lyrics_musixmatch.go b/go_backend/lyrics_musixmatch.go index 37dfbea7..5cdcf6fe 100644 --- a/go_backend/lyrics_musixmatch.go +++ b/go_backend/lyrics_musixmatch.go @@ -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) diff --git a/go_backend/lyrics_netease.go b/go_backend/lyrics_netease.go index c6741e19..ff09ff9d 100644 --- a/go_backend/lyrics_netease.go +++ b/go_backend/lyrics_netease.go @@ -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) diff --git a/go_backend/lyrics_qqmusic.go b/go_backend/lyrics_qqmusic.go index 95461031..9a034619 100644 --- a/go_backend/lyrics_qqmusic.go +++ b/go_backend/lyrics_qqmusic.go @@ -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, diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 62b48aa4..ef8f25d0 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -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-- { diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index b5f449d2..bd97d0e5 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -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 { diff --git a/go_backend/qobuz_test.go b/go_backend/qobuz_test.go index 2fc43912..d8881166 100644 --- a/go_backend/qobuz_test.go +++ b/go_backend/qobuz_test.go @@ -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 { diff --git a/go_backend/romaji.go b/go_backend/romaji.go index 3c45d2d9..fce9524a 100644 --- a/go_backend/romaji.go +++ b/go_backend/romaji.go @@ -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++ diff --git a/go_backend/songlink.go b/go_backend/songlink.go index 2a6515b8..e464614c 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -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 = `` - - 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 } diff --git a/go_backend/songlink_test.go b/go_backend/songlink_test.go index ac085c1b..2bbe8a81 100644 --- a/go_backend/songlink_test.go +++ b/go_backend/songlink_test.go @@ -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 := `` + 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") } } diff --git a/go_backend/tidal.go b/go_backend/tidal.go index de360698..41e639b2 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -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:] } diff --git a/go_backend/title_match_utils.go b/go_backend/title_match_utils.go index 662d62b4..3976d6f0 100644 --- a/go_backend/title_match_utils.go +++ b/go_backend/title_match_utils.go @@ -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 != "" && diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index bb45446c..af667c3b 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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 diff --git a/lib/main.dart b/lib/main.dart index 56c94ae2..0eaf8b0c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 _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': diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index d685a890..ae892af9 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -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 { _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); diff --git a/lib/providers/store_provider.dart b/lib/providers/store_provider.dart index e6fc5eec..ed96e875 100644 --- a/lib/providers/store_provider.dart +++ b/lib/providers/store_provider.dart @@ -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 { Future 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 { } } - /// Sets the registry URL, saves it, and refreshes the store. - /// The Go backend handles URL normalisation (GitHub repo → raw URL, branch detection). Future setRegistryUrl(String url) async { final trimmed = url.trim(); if (trimmed.isEmpty) { @@ -258,10 +254,8 @@ class StoreNotifier extends Notifier { 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 { } } - /// Removes the saved registry URL and fully detaches the repo from backend. Future 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( diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 20bc79ac..1d55a3d6 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -138,14 +138,11 @@ class _AlbumScreenState extends ConsumerState { 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( diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index a1070de1..b30e303c 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -228,7 +228,6 @@ class _ArtistScreenState extends ConsumerState { } 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 { } } -/// 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; diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index adcbca1a..d44f5453 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -95,7 +95,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { 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 { return url; } - /// Get tracks for this album from history provider (reactive) List _getAlbumTracks( List allItems, ) { @@ -641,7 +639,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { ColorScheme colorScheme, List 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 { ); } - /// Share selected tracks via system share sheet Future _shareSelected(List allTracks) async { final tracksById = {for (final t in allTracks) t.id: t}; final safUris = []; @@ -1091,7 +1087,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { 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() diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index ca6c18ae..a9219add 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -2412,8 +2412,6 @@ class _HomeTabState extends ConsumerState ); } - // ── Search result sorting ────────────────────────────────────────────── - String _sortOptionLabel(_SearchSortOption option) { switch (option) { case _SearchSortOption.defaultOrder: diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 61e5eac7..549b77f9 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -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); } } diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 9efc624f..e6b07663 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -526,7 +526,6 @@ class _LocalAlbumScreenState extends ConsumerState { ColorScheme colorScheme, List 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 { 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.delayed(const Duration(milliseconds: 300)); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index e1f7555f..79a186cc 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -348,7 +348,6 @@ class _MainShellState extends ConsumerState 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 } 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(); diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 364324a0..8646b268 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -117,7 +117,6 @@ class _PlaylistScreenState extends ConsumerState { final playlistInfo = result['playlist_info'] as Map?; final owner = playlistInfo?['owner'] as Map?; - // Go backend returns 'track_list' not 'tracks' final trackList = result['track_list'] as List? ?? []; final tracks = trackList .map((t) => _parseTrack(t as Map)) @@ -182,14 +181,11 @@ class _PlaylistScreenState extends ConsumerState { 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 { } } -/// 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; diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 4e0156a6..923fc494 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -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 { } } - // 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 { } } - /// 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 { } } - /// Navigate with unfocus pattern — unfocuses search before and after navigation. void _navigateWithUnfocus(Route route) { _searchFocusNode.unfocus(); Navigator.of(context).push(route).then((_) => _searchFocusNode.unfocus()); @@ -3201,14 +3197,12 @@ class _QueueTabState extends ConsumerState { }) 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 { ); } - /// 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 { ); } - /// 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 { ); } - /// 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 { 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 { } } - /// 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 { ), ), - // 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 { ); } - /// Album grid item for local library albums Widget _buildLocalAlbumGridItem( BuildContext context, _GroupedLocalAlbum album, @@ -5275,7 +5260,6 @@ class _QueueTabState extends ConsumerState { return; } - // Share SAF content URIs via native intent if (safUris.isNotEmpty) { try { if (safUris.length == 1) { @@ -5286,7 +5270,6 @@ class _QueueTabState extends ConsumerState { } catch (_) {} } - // Share regular files via SharePlus if (filesToShare.isNotEmpty) { await SharePlus.instance.share(ShareParams(files: filesToShare)); } @@ -6400,7 +6383,6 @@ class _QueueTabState extends ConsumerState { } } - /// Reusable filter button with badge showing active filter count. Widget _buildFilterButton( BuildContext context, List unifiedItems, @@ -6495,7 +6477,6 @@ class _QueueTabState extends ConsumerState { } } - // Network URL cover (downloaded items) if (item.coverUrl != null) { return ClipRRect( borderRadius: BorderRadius.circular(8), @@ -6513,7 +6494,6 @@ class _QueueTabState extends ConsumerState { ); } - // 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 { ); } - // Placeholder (no cover) if (size != null) { return buildPlaceholder(); } @@ -6540,7 +6519,6 @@ class _QueueTabState extends ConsumerState { ); } - /// Build a unified library item (merged downloaded + local) Widget _buildUnifiedLibraryItem( BuildContext context, UnifiedLibraryItem item, @@ -6748,7 +6726,6 @@ class _QueueTabState extends ConsumerState { ); } - /// 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; diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index bd474989..2db292ab 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -53,10 +53,8 @@ class _LibrarySettingsPageState extends ConsumerState { 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 { } } } 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 { Future _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 { 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 { .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 { 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 { ), ), - // Scan Actions Section if (settings.localLibraryEnabled) ...[ SliverToBoxAdapter( child: SettingsSectionHeader(title: context.l10n.libraryActions), diff --git a/lib/screens/tutorial_screen.dart b/lib/screens/tutorial_screen.dart index f9156e99..2094b383 100644 --- a/lib/screens/tutorial_screen.dart +++ b/lib/screens/tutorial_screen.dart @@ -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( diff --git a/lib/services/cover_cache_manager.dart b/lib/services/cover_cache_manager.dart index 0af65464..92033e96 100644 --- a/lib/services/cover_cache_manager.dart +++ b/lib/services/cover_cache_manager.dart @@ -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(), diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 00987ebd..52e11ea0 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -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; diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 4c851305..b6ba580f 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -9,10 +9,8 @@ import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('HistoryDatabase'); final Future _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 _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 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 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 _jsonToDbRow(Map 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 _dbRowToJson(Map 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>> 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?> 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?> 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 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> 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?> 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 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> 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>> getAllEntriesWithPaths() async { final db = await database; final rows = await db.rawQuery(''' @@ -569,7 +539,6 @@ class HistoryDatabase { return rows.map((r) => Map.from(r)).toList(); } - /// Delete multiple entries by IDs Future deleteByIds(List ids) async { if (ids.isEmpty) return 0; diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index a7eb9701..b3cb306b 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -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> 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 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 updateFileModTimes(Map 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> getAllFilePaths() async { final db = await database; final rows = await db.rawQuery('SELECT file_path FROM library'); diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 2df81351..e897727d 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -422,6 +422,21 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + /// Rewrites ARTIST/ALBUMARTIST Vorbis comments as multiple split entries + /// using the native Go FLAC writer, fixing FFmpeg's tag deduplication. + static Future> 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; + } + static Future writeTempToSaf(String tempPath, String safUri) async { final result = await _channel.invokeMethod('writeTempToSaf', { 'temp_path': tempPath, diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart index c5f174d0..b3a3fc96 100644 --- a/lib/services/share_intent_service.dart +++ b/lib/services/share_intent_service.dart @@ -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; } diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 1331770f..92e41ff0 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -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, diff --git a/lib/utils/clickable_metadata.dart b/lib/utils/clickable_metadata.dart index b39f1bd1..1ec5879a 100644 --- a/lib/utils/clickable_metadata.dart +++ b/lib/utils/clickable_metadata.dart @@ -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 navigateToArtist( BuildContext context, { required String artistName, @@ -95,11 +90,6 @@ Future 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 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; diff --git a/lib/utils/file_access.dart b/lib/utils/file_access.dart index f686bc46..1562a35d 100644 --- a/lib/utils/file_access.dart +++ b/lib/utils/file_access.dart @@ -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 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//Documents/ final legacyRelativeMatch = _iosLegacyRelativeDocumentsPattern.firstMatch( trimmed, ); @@ -127,7 +117,6 @@ Future validateOrFixIosPath( ); } - // Generic salvage for relative paths containing `Documents/...`. if (!trimmed.startsWith('/')) { final documentsMarker = 'Documents/'; final index = trimmed.indexOf(documentsMarker); @@ -143,7 +132,6 @@ Future 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 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 deleteFile(String? path) async { Future 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); diff --git a/lib/utils/path_match_keys.dart b/lib/utils/path_match_keys.dart index 3c6969ec..b9e02ca0 100644 --- a/lib/utils/path_match_keys.dart +++ b/lib/utils/path_match_keys.dart @@ -25,9 +25,6 @@ const _audioExtensions = [ const _maxPathMatchKeyCacheSize = 6000; final Map> _pathMatchKeyCache = >{}; -/// 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 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 = {}; for (final key in keys) { final stripped = _stripAudioExtension(key); diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart index 14c3d978..4494fa37 100644 --- a/lib/widgets/animation_utils.dart +++ b/lib/widgets/animation_utils.dart @@ -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 slidePageRoute({required Widget page}) { return MaterialPageRoute(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 @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 } } -// ───────────────────────────────────────────────────────────────────────────── -// 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 } } -// ───────────────────────────────────────────────────────────────────────────── -// 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 animation) { diff --git a/lib/widgets/batch_progress_dialog.dart b/lib/widgets/batch_progress_dialog.dart index 457c2bef..673b17aa 100644 --- a/lib/widgets/batch_progress_dialog.dart +++ b/lib/widgets/batch_progress_dialog.dart @@ -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(); diff --git a/lib/widgets/collapsing_header.dart b/lib/widgets/collapsing_header.dart index 04ee5213..790d4ce2 100644 --- a/lib/widgets/collapsing_header.dart +++ b/lib/widgets/collapsing_header.dart @@ -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; diff --git a/lib/widgets/donate_icons.dart b/lib/widgets/donate_icons.dart index fa34125a..dfea548d 100644 --- a/lib/widgets/donate_icons.dart +++ b/lib/widgets/donate_icons.dart @@ -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, diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 77349eab..5845d64a 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -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 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 { @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 { } } - /// Get quality options for the selected service List _getQualityOptions() { final builtIn = _builtInServices .where((s) => s.id == _selectedService) @@ -161,8 +156,6 @@ class _DownloadServicePickerState extends ConsumerState { 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; } diff --git a/lib/widgets/re_enrich_field_dialog.dart b/lib/widgets/re_enrich_field_dialog.dart index b8f2bd8e..989cefca 100644 --- a/lib/widgets/re_enrich_field_dialog.dart +++ b/lib/widgets/re_enrich_field_dialog.dart @@ -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 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( diff --git a/lib/widgets/settings_group.dart b/lib/widgets/settings_group.dart index 3db24a9d..b24ebecd 100644 --- a/lib/widgets/settings_group.dart +++ b/lib/widgets/settings_group.dart @@ -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 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}); diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart index 5fe9d3d4..2db506c9 100644 --- a/lib/widgets/track_collection_quick_actions.dart +++ b/lib/widgets/track_collection_quick_actions.dart @@ -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;