mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-24 00:34:07 +02:00
feat: add resolve API with SongLink fallback, fix multi-artist tags (#288), and cleanup
Resolve API (api.zarz.moe): - Refactor songlink.go: Spotify URLs use resolve API, non-Spotify uses SongLink API - Add SongLink fallback when resolve API fails for Spotify (two-layer resilience) - Remove dead code: page parser, XOR-obfuscated keys, legacy helpers Multi-artist tag fix (#288): - Add RewriteSplitArtistTags() in Go to rewrite ARTIST/ALBUMARTIST as split Vorbis comments - Wire method channel handler in Android (MainActivity.kt) and iOS (AppDelegate.swift) - Add PlatformBridge.rewriteSplitArtistTags() in Dart - Call native FLAC rewriter after FFmpeg embed when split_vorbis mode is active - Extract deezerTrackArtistDisplay() helper to use Contributors in album/playlist tracks Code cleanup: - Remove unused imports, dead code, and redundant comments across Go and Dart - Fix build: remove stale getQobuzDebugKey() reference in deezer_download.go
This commit is contained in:
@@ -2384,6 +2384,41 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"rewriteSplitArtistTags" -> {
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
val artist = call.argument<String>("artist") ?: ""
|
||||
val albumArtist = call.argument<String>("album_artist") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
if (filePath.startsWith("content://")) {
|
||||
val uri = Uri.parse(filePath)
|
||||
val tempPath = copyUriToTemp(uri, ".flac")
|
||||
?: return@withContext errorJson("Failed to copy SAF file to temp")
|
||||
try {
|
||||
val raw = Gobackend.rewriteSplitArtistTagsExport(tempPath, artist, albumArtist)
|
||||
val obj = JSONObject(raw)
|
||||
if (!obj.optBoolean("success", false)) {
|
||||
return@withContext raw
|
||||
}
|
||||
|
||||
if (!writeUriFromPath(uri, tempPath)) {
|
||||
return@withContext errorJson("Failed to write rewritten tags back to SAF file")
|
||||
}
|
||||
|
||||
obj.put("file_path", filePath)
|
||||
obj.toString()
|
||||
} catch (e: Exception) {
|
||||
errorJson("Failed to rewrite split artist tags in SAF file: ${e.message}")
|
||||
} finally {
|
||||
try {
|
||||
File(tempPath).delete()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
} else {
|
||||
Gobackend.rewriteSplitArtistTagsExport(filePath, artist, albumArtist)
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"cleanupConnections" -> {
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.cleanupConnections()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CueSheet represents a parsed .cue file
|
||||
type CueSheet struct {
|
||||
Performer string `json:"performer"`
|
||||
Title string `json:"title"`
|
||||
@@ -24,7 +23,6 @@ type CueSheet struct {
|
||||
Tracks []CueTrack `json:"tracks"`
|
||||
}
|
||||
|
||||
// CueTrack represents a single track in a cue sheet
|
||||
type CueTrack struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
@@ -35,7 +33,6 @@ type CueTrack struct {
|
||||
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
|
||||
}
|
||||
|
||||
// CueSplitInfo represents the information needed to split a CUE+audio file
|
||||
type CueSplitInfo struct {
|
||||
CuePath string `json:"cue_path"`
|
||||
AudioPath string `json:"audio_path"`
|
||||
@@ -46,7 +43,6 @@ type CueSplitInfo struct {
|
||||
Tracks []CueSplitTrack `json:"tracks"`
|
||||
}
|
||||
|
||||
// CueSplitTrack has the FFmpeg split parameters for a single track
|
||||
type CueSplitTrack struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
@@ -62,7 +58,6 @@ var (
|
||||
reQuoted = regexp.MustCompile(`"([^"]*)"`)
|
||||
)
|
||||
|
||||
// ParseCueFile parses a .cue file and returns a CueSheet
|
||||
func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
f, err := os.Open(cuePath)
|
||||
if err != nil {
|
||||
@@ -202,7 +197,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
return sheet, nil
|
||||
}
|
||||
|
||||
// parseCueTimestamp converts MM:SS:FF (frames at 75fps) to seconds
|
||||
func parseCueTimestamp(ts string) float64 {
|
||||
parts := strings.Split(ts, ":")
|
||||
if len(parts) != 3 {
|
||||
@@ -216,7 +210,6 @@ func parseCueTimestamp(ts string) float64 {
|
||||
return float64(minutes)*60 + float64(seconds) + float64(frames)/75.0
|
||||
}
|
||||
|
||||
// formatCueTimestamp converts seconds to HH:MM:SS.mmm format for FFmpeg
|
||||
func formatCueTimestamp(seconds float64) string {
|
||||
if seconds < 0 {
|
||||
return "0"
|
||||
@@ -227,7 +220,6 @@ func formatCueTimestamp(seconds float64) string {
|
||||
return fmt.Sprintf("%02d:%02d:%06.3f", hours, mins, secs)
|
||||
}
|
||||
|
||||
// unquoteCue removes surrounding quotes from a CUE value
|
||||
func unquoteCue(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if matches := reQuoted.FindStringSubmatch(s); len(matches) == 2 {
|
||||
@@ -236,14 +228,12 @@ func unquoteCue(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// parseCueFileLine parses the FILE command's filename and type
|
||||
func parseCueFileLine(rest string) (string, string) {
|
||||
rest = strings.TrimSpace(rest)
|
||||
|
||||
var filename, ftype string
|
||||
|
||||
if strings.HasPrefix(rest, "\"") {
|
||||
// Quoted filename
|
||||
endQuote := strings.Index(rest[1:], "\"")
|
||||
if endQuote >= 0 {
|
||||
filename = rest[1 : endQuote+1]
|
||||
@@ -253,7 +243,6 @@ func parseCueFileLine(rest string) (string, string) {
|
||||
filename = rest
|
||||
}
|
||||
} else {
|
||||
// Unquoted filename - last word is the type
|
||||
parts := strings.Fields(rest)
|
||||
if len(parts) >= 2 {
|
||||
ftype = parts[len(parts)-1]
|
||||
@@ -266,18 +255,14 @@ func parseCueFileLine(rest string) (string, string) {
|
||||
return filename, strings.TrimSpace(ftype)
|
||||
}
|
||||
|
||||
// ResolveCueAudioPath finds the actual audio file referenced by a .cue sheet.
|
||||
// It checks relative to the cue file's directory.
|
||||
func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
||||
cueDir := filepath.Dir(cuePath)
|
||||
|
||||
// 1. Try the exact filename from the .cue
|
||||
candidate := filepath.Join(cueDir, cueFileName)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
|
||||
// 2. Try common case variations
|
||||
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
|
||||
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
|
||||
for _, ext := range commonExts {
|
||||
@@ -285,14 +270,12 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
// Try uppercase ext
|
||||
candidate = filepath.Join(cueDir, baseName+strings.ToUpper(ext))
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try to find any audio file with the same base name as the .cue file
|
||||
cueBase := strings.TrimSuffix(filepath.Base(cuePath), filepath.Ext(cuePath))
|
||||
for _, ext := range commonExts {
|
||||
candidate = filepath.Join(cueDir, cueBase+ext)
|
||||
@@ -301,7 +284,6 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. If there's only one audio file in the directory, use that
|
||||
entries, err := os.ReadDir(cueDir)
|
||||
if err == nil {
|
||||
audioExts := map[string]bool{
|
||||
@@ -326,13 +308,9 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// BuildCueSplitInfo creates the split information from a parsed CUE sheet.
|
||||
// This is returned to the Dart side so FFmpeg can perform the splitting.
|
||||
// audioDir, if non-empty, overrides the directory for audio file resolution.
|
||||
func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSplitInfo, error) {
|
||||
resolveDir := cuePath
|
||||
if audioDir != "" {
|
||||
// Create a virtual path in audioDir so ResolveCueAudioPath looks there
|
||||
resolveDir = filepath.Join(audioDir, filepath.Base(cuePath))
|
||||
}
|
||||
audioPath := ResolveCueAudioPath(resolveDir, sheet.FileName)
|
||||
@@ -360,11 +338,9 @@ func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSp
|
||||
composer = sheet.Composer
|
||||
}
|
||||
|
||||
// End time is the start of the next track, or -1 for the last track
|
||||
endSec := float64(-1)
|
||||
if i+1 < len(sheet.Tracks) {
|
||||
nextTrack := sheet.Tracks[i+1]
|
||||
// Use pre-gap of next track if available, otherwise its start time
|
||||
if nextTrack.PreGap >= 0 {
|
||||
endSec = nextTrack.PreGap
|
||||
} else {
|
||||
@@ -386,11 +362,6 @@ func BuildCueSplitInfo(cuePath string, sheet *CueSheet, audioDir string) (*CueSp
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ParseCueFileJSON parses a .cue file and returns JSON with split info.
|
||||
// This is the main entry point called from Dart via the platform bridge.
|
||||
// audioDir, if non-empty, overrides the directory used for resolving the
|
||||
// referenced audio file (useful when the .cue was copied to a temp dir
|
||||
// but the audio still lives in the original location, e.g. SAF).
|
||||
func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
|
||||
sheet, err := ParseCueFile(cuePath)
|
||||
if err != nil {
|
||||
@@ -410,9 +381,6 @@ func ParseCueFileJSON(cuePath string, audioDir string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// ScanCueFileForLibrary parses a .cue file and returns multiple LibraryScanResult
|
||||
// entries, one per track. This is used by the library scanner to populate the
|
||||
// library with individual track entries from a single CUE+FLAC album.
|
||||
func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult, error) {
|
||||
sheet, err := ParseCueFile(cuePath)
|
||||
if err != nil {
|
||||
@@ -425,13 +393,6 @@ func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult
|
||||
return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, "", scanTime)
|
||||
}
|
||||
|
||||
// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters
|
||||
// for SAF (Storage Access Framework) scenarios:
|
||||
// - audioDir: if non-empty, overrides the directory used to find the audio file
|
||||
// - virtualPathPrefix: if non-empty, used instead of cuePath as the base for
|
||||
// virtual file paths (e.g. a content:// URI). IDs are also based on this.
|
||||
// - fileModTime: if > 0, used as the FileModTime for all results instead of
|
||||
// stat-ing the cuePath on disk (useful when the real file lives behind SAF)
|
||||
func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
|
||||
return ScanCueFileForLibraryExtWithCoverCacheKey(
|
||||
cuePath,
|
||||
@@ -483,7 +444,6 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
||||
return nil, fmt.Errorf("cue sheet is nil for %s", cuePath)
|
||||
}
|
||||
|
||||
// Try to get quality info from the audio file
|
||||
var bitDepth, sampleRate int
|
||||
var totalDurationSec float64
|
||||
audioExt := strings.ToLower(filepath.Ext(audioPath))
|
||||
@@ -505,7 +465,6 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
||||
}
|
||||
}
|
||||
|
||||
// Extract cover from audio file for all tracks
|
||||
var coverPath string
|
||||
libraryCoverCacheMu.RLock()
|
||||
coverCacheDir := libraryCoverCacheDir
|
||||
@@ -522,13 +481,11 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the base path for virtual paths and IDs
|
||||
pathBase := cuePath
|
||||
if virtualPathPrefix != "" {
|
||||
pathBase = virtualPathPrefix
|
||||
}
|
||||
|
||||
// Determine fileModTime
|
||||
modTime := fileModTime
|
||||
if modTime <= 0 {
|
||||
if info, err := os.Stat(cuePath); err == nil {
|
||||
@@ -556,7 +513,6 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
||||
album = "Unknown Album"
|
||||
}
|
||||
|
||||
// Calculate duration for this track
|
||||
var duration int
|
||||
if i+1 < len(sheet.Tracks) {
|
||||
nextStart := sheet.Tracks[i+1].StartTime
|
||||
@@ -570,9 +526,6 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
||||
|
||||
id := generateLibraryID(fmt.Sprintf("%s#track%d", pathBase, track.Number))
|
||||
|
||||
// Use a virtual file path that includes the track number to ensure
|
||||
// uniqueness in the database (file_path has a UNIQUE constraint).
|
||||
// Format: /path/to/album.cue#track01 or content://...album.cue#track01
|
||||
virtualFilePath := fmt.Sprintf("%s#track%02d", pathBase, track.Number)
|
||||
|
||||
result := LibraryScanResult{
|
||||
|
||||
+12
-5
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -201,7 +201,6 @@ func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
// Length should be between 43-128 characters (RFC 7636)
|
||||
func generatePKCEVerifier(length int) (string, error) {
|
||||
if length < 43 {
|
||||
length = 43
|
||||
@@ -226,7 +225,6 @@ func generatePKCEVerifier(length int) (string, error) {
|
||||
|
||||
func generatePKCEChallenge(verifier string) string {
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
// Base64url encode without padding (RFC 7636)
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
@@ -283,7 +281,6 @@ func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// config: { authUrl, clientId, redirectUri, scope, extraParams }
|
||||
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -388,7 +385,6 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
})
|
||||
}
|
||||
|
||||
// config: { tokenUrl, clientId, redirectUri, code, extraParams }
|
||||
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
|
||||
@@ -12,10 +12,6 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// These polyfills make porting browser/Node.js libraries easier
|
||||
// without compromising sandbox security.
|
||||
|
||||
// Returns a Promise-like object with json(), text() methods.
|
||||
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.createFetchError("URL is required")
|
||||
@@ -38,7 +34,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
method = strings.ToUpper(m)
|
||||
}
|
||||
|
||||
// Body - support string, object (auto-stringify), or nil
|
||||
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
@@ -197,7 +192,6 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
})
|
||||
|
||||
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
|
||||
// Simplified implementation
|
||||
if len(call.Arguments) < 2 {
|
||||
return vm.ToValue(map[string]interface{}{"read": 0, "written": 0})
|
||||
}
|
||||
@@ -422,7 +416,6 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
})
|
||||
}
|
||||
|
||||
// JSON is already built-in to Goja; this ensures a fallback exists.
|
||||
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
||||
jsonScript := `
|
||||
if (typeof JSON === 'undefined') {
|
||||
|
||||
@@ -145,7 +145,7 @@ func initExtensionStore(cacheDir string) *extensionStore {
|
||||
|
||||
if globalExtensionStore == nil {
|
||||
globalExtensionStore = &extensionStore{
|
||||
registryURL: "", // No default - user must provide a registry URL
|
||||
registryURL: "",
|
||||
cacheDir: cacheDir,
|
||||
cacheTTL: cacheTTL,
|
||||
}
|
||||
@@ -154,8 +154,6 @@ func initExtensionStore(cacheDir string) *extensionStore {
|
||||
return globalExtensionStore
|
||||
}
|
||||
|
||||
// SetRegistryURL updates the registry URL and clears the in-memory cache
|
||||
// so the next fetch will use the new URL. Disk cache is also cleared.
|
||||
func (s *extensionStore) setRegistryURL(registryURL string) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
@@ -168,7 +166,6 @@ func (s *extensionStore) setRegistryURL(registryURL string) {
|
||||
s.cache = nil
|
||||
s.cacheTime = time.Time{}
|
||||
|
||||
// Clear disk cache since it's from a different registry
|
||||
if s.cacheDir != "" {
|
||||
cachePath := filepath.Join(s.cacheDir, cacheFileName)
|
||||
os.Remove(cachePath)
|
||||
@@ -177,7 +174,6 @@ func (s *extensionStore) setRegistryURL(registryURL string) {
|
||||
LogInfo("ExtensionStore", "Registry URL updated to: %s", registryURL)
|
||||
}
|
||||
|
||||
// GetRegistryURL returns the currently configured registry URL.
|
||||
func (s *extensionStore) getRegistryURL() string {
|
||||
s.cacheMu.RLock()
|
||||
defer s.cacheMu.RUnlock()
|
||||
@@ -378,32 +374,22 @@ func (s *extensionStore) downloadExtension(extensionID string, destPath string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveRegistryURL normalises a user-supplied URL into a direct registry.json URL.
|
||||
//
|
||||
// Accepted formats:
|
||||
// - https://raw.githubusercontent.com/owner/repo/<branch>/registry.json → returned as-is
|
||||
// - https://github.com/owner/repo (with optional trailing path / .git) → resolved via
|
||||
// the GitHub API to discover the default branch, then converted to the raw URL
|
||||
// - Any other HTTPS URL → returned as-is (assumed to be a direct link)
|
||||
func resolveRegistryURL(input string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", fmt.Errorf("registry URL is empty")
|
||||
}
|
||||
|
||||
// Already a fully-qualified raw URL – keep it.
|
||||
if strings.Contains(input, "raw.githubusercontent.com") {
|
||||
return input, nil
|
||||
}
|
||||
|
||||
const ghPrefix = "https://github.com/"
|
||||
if !strings.HasPrefix(input, ghPrefix) {
|
||||
// Also accept http:// and upgrade silently.
|
||||
const ghPrefixHTTP = "http://github.com/"
|
||||
if strings.HasPrefix(input, ghPrefixHTTP) {
|
||||
input = "https://github.com/" + input[len(ghPrefixHTTP):]
|
||||
} else {
|
||||
// Not a GitHub URL – return as-is.
|
||||
return input, nil
|
||||
}
|
||||
}
|
||||
@@ -423,8 +409,6 @@ func resolveRegistryURL(input string) (string, error) {
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// resolveGitHubDefaultBranch calls the GitHub API to discover the repository's
|
||||
// default branch. Falls back to "main" on any error.
|
||||
func resolveGitHubDefaultBranch(owner, repo string) string {
|
||||
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", owner, repo)
|
||||
client := NewHTTPClientWithTimeout(10 * time.Second)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 != "{}" {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -431,6 +431,52 @@ func setArtistComments(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value, m
|
||||
}
|
||||
}
|
||||
|
||||
// RewriteSplitArtistTags opens a FLAC file and rewrites the ARTIST and
|
||||
// ALBUMARTIST Vorbis comments as multiple separate entries (one per artist).
|
||||
// This is needed because FFmpeg's -metadata flag deduplicates keys, so only
|
||||
// the last value survives when multiple -metadata ARTIST=X flags are used.
|
||||
// The native go-flac writer correctly handles multiple Vorbis comments.
|
||||
func RewriteSplitArtistTags(filePath, artist, albumArtist string) error {
|
||||
if !shouldSplitVorbisArtistTags(artistTagModeSplitVorbis) {
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := flac.ParseFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||
}
|
||||
|
||||
var cmtIdx int = -1
|
||||
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
||||
|
||||
for idx, meta := range f.Meta {
|
||||
if meta.Type == flac.VorbisComment {
|
||||
cmtIdx = idx
|
||||
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse vorbis comment: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if cmt == nil {
|
||||
cmt = flacvorbis.New()
|
||||
}
|
||||
|
||||
setArtistComments(cmt, "ARTIST", artist, artistTagModeSplitVorbis)
|
||||
setArtistComments(cmt, "ALBUMARTIST", albumArtist, artistTagModeSplitVorbis)
|
||||
|
||||
cmtMeta := cmt.Marshal()
|
||||
if cmtIdx >= 0 {
|
||||
f.Meta[cmtIdx] = &cmtMeta
|
||||
} else {
|
||||
f.Meta = append(f.Meta, &cmtMeta)
|
||||
}
|
||||
|
||||
return f.Save(filePath)
|
||||
}
|
||||
|
||||
func removeCommentKey(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) {
|
||||
keyUpper := strings.ToUpper(key)
|
||||
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
||||
|
||||
+1
-18
@@ -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 {
|
||||
|
||||
@@ -201,18 +201,6 @@ func TestNormalizeQobuzQualityCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetQobuzDebugKey(t *testing.T) {
|
||||
got := getQobuzDebugKey()
|
||||
if len(got) != len(qobuzDebugKeyObfuscated) {
|
||||
t.Fatalf("unexpected debug key length: %d", len(got))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i]^qobuzDebugKeyXORMask != qobuzDebugKeyObfuscated[i] {
|
||||
t.Fatalf("unexpected debug key reconstruction at index %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQobuzMusicDLPayloadUsesOpenTrackURL(t *testing.T) {
|
||||
payloadBytes, err := buildQobuzMusicDLPayload(374610875, "7")
|
||||
if err != nil {
|
||||
|
||||
+4
-17
@@ -16,16 +16,13 @@ var hiraganaToRomaji = map[rune]string{
|
||||
'や': "ya", 'ゆ': "yu", 'よ': "yo",
|
||||
'ら': "ra", 'り': "ri", 'る': "ru", 'れ': "re", 'ろ': "ro",
|
||||
'わ': "wa", 'を': "wo", 'ん': "n",
|
||||
// Dakuten (voiced)
|
||||
'が': "ga", 'ぎ': "gi", 'ぐ': "gu", 'げ': "ge", 'ご': "go",
|
||||
'ざ': "za", 'じ': "ji", 'ず': "zu", 'ぜ': "ze", 'ぞ': "zo",
|
||||
'だ': "da", 'ぢ': "ji", 'づ': "zu", 'で': "de", 'ど': "do",
|
||||
'ば': "ba", 'び': "bi", 'ぶ': "bu", 'べ': "be", 'ぼ': "bo",
|
||||
// Handakuten (semi-voiced)
|
||||
'ぱ': "pa", 'ぴ': "pi", 'ぷ': "pu", 'ぺ': "pe", 'ぽ': "po",
|
||||
// Small characters
|
||||
'ゃ': "ya", 'ゅ': "yu", 'ょ': "yo",
|
||||
'っ': "", // Double consonant marker
|
||||
'っ': "",
|
||||
'ぁ': "a", 'ぃ': "i", 'ぅ': "u", 'ぇ': "e", 'ぉ': "o",
|
||||
}
|
||||
|
||||
@@ -40,19 +37,15 @@ var katakanaToRomaji = map[rune]string{
|
||||
'ヤ': "ya", 'ユ': "yu", 'ヨ': "yo",
|
||||
'ラ': "ra", 'リ': "ri", 'ル': "ru", 'レ': "re", 'ロ': "ro",
|
||||
'ワ': "wa", 'ヲ': "wo", 'ン': "n",
|
||||
// Dakuten (voiced)
|
||||
'ガ': "ga", 'ギ': "gi", 'グ': "gu", 'ゲ': "ge", 'ゴ': "go",
|
||||
'ザ': "za", 'ジ': "ji", 'ズ': "zu", 'ゼ': "ze", 'ゾ': "zo",
|
||||
'ダ': "da", 'ヂ': "ji", 'ヅ': "zu", 'デ': "de", 'ド': "do",
|
||||
'バ': "ba", 'ビ': "bi", 'ブ': "bu", 'ベ': "be", 'ボ': "bo",
|
||||
// Handakuten (semi-voiced)
|
||||
'パ': "pa", 'ピ': "pi", 'プ': "pu", 'ペ': "pe", 'ポ': "po",
|
||||
// Small characters
|
||||
'ャ': "ya", 'ュ': "yu", 'ョ': "yo",
|
||||
'ッ': "", // Double consonant marker
|
||||
'ッ': "",
|
||||
'ァ': "a", 'ィ': "i", 'ゥ': "u", 'ェ': "e", 'ォ': "o",
|
||||
// Extended katakana
|
||||
'ー': "", // Long vowel mark
|
||||
'ー': "",
|
||||
'ヴ': "vu",
|
||||
}
|
||||
|
||||
@@ -82,7 +75,6 @@ var combinationKatakana = map[string]string{
|
||||
"ジャ": "ja", "ジュ": "ju", "ジョ": "jo",
|
||||
"ビャ": "bya", "ビュ": "byu", "ビョ": "byo",
|
||||
"ピャ": "pya", "ピュ": "pyu", "ピョ": "pyo",
|
||||
// Extended combinations
|
||||
"ティ": "ti", "ディ": "di", "トゥ": "tu", "ドゥ": "du",
|
||||
"ファ": "fa", "フィ": "fi", "フェ": "fe", "フォ": "fo",
|
||||
"ウィ": "wi", "ウェ": "we", "ウォ": "wo",
|
||||
@@ -120,7 +112,6 @@ func JapaneseToRomaji(text string) string {
|
||||
i := 0
|
||||
|
||||
for i < len(runes) {
|
||||
// Check for っ/ッ (double consonant)
|
||||
if i < len(runes)-1 && (runes[i] == 'っ' || runes[i] == 'ッ') {
|
||||
nextRomaji := ""
|
||||
if romaji, ok := hiraganaToRomaji[runes[i+1]]; ok {
|
||||
@@ -129,13 +120,12 @@ func JapaneseToRomaji(text string) string {
|
||||
nextRomaji = romaji
|
||||
}
|
||||
if len(nextRomaji) > 0 {
|
||||
result.WriteByte(nextRomaji[0]) // Double the first consonant
|
||||
result.WriteByte(nextRomaji[0])
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for two-character combinations
|
||||
if i < len(runes)-1 {
|
||||
combo := string(runes[i : i+2])
|
||||
if romaji, ok := combinationHiragana[combo]; ok {
|
||||
@@ -150,17 +140,14 @@ func JapaneseToRomaji(text string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Single character conversion
|
||||
r := runes[i]
|
||||
if romaji, ok := hiraganaToRomaji[r]; ok {
|
||||
result.WriteString(romaji)
|
||||
} else if romaji, ok := katakanaToRomaji[r]; ok {
|
||||
result.WriteString(romaji)
|
||||
} else if isKanji(r) {
|
||||
// Keep kanji as-is (would need dictionary for proper conversion)
|
||||
result.WriteRune(r)
|
||||
} else {
|
||||
// Keep other characters (punctuation, spaces, etc.)
|
||||
result.WriteRune(r)
|
||||
}
|
||||
i++
|
||||
|
||||
+186
-442
@@ -87,38 +87,184 @@ func GetSongLinkRegion() string {
|
||||
return region
|
||||
}
|
||||
|
||||
const resolveAPIURL = "https://api.zarz.moe/v1/resolve"
|
||||
|
||||
func songLinkBaseURL() string {
|
||||
opts := GetNetworkCompatibilityOptions()
|
||||
if opts.AllowHTTP {
|
||||
return "http://api.song.link/v1-alpha.1/links"
|
||||
}
|
||||
return "https://api.song.link/v1-alpha.1/links"
|
||||
}
|
||||
|
||||
func buildSongLinkURLFromTarget(targetURL string, userCountry string) string {
|
||||
if userCountry == "" {
|
||||
userCountry = GetSongLinkRegion()
|
||||
// resolveTrackPlatforms resolves a music URL to all platforms.
|
||||
// Spotify URLs use the resolve API; if that fails, falls back to SongLink.
|
||||
// All other URLs go directly to SongLink.
|
||||
func (s *SongLinkClient) resolveTrackPlatforms(inputURL string) (map[string]songLinkPlatformLink, error) {
|
||||
if isSpotifyURL(inputURL) {
|
||||
payload, err := json.Marshal(map[string]string{"url": inputURL})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode resolve request: %w", err)
|
||||
}
|
||||
links, err := s.doResolveRequest(payload)
|
||||
if err == nil {
|
||||
return links, nil
|
||||
}
|
||||
GoLog("[SongLink] Resolve proxy failed for %s: %v, falling back to SongLink", inputURL, err)
|
||||
return s.songLinkByTargetURL(inputURL)
|
||||
}
|
||||
apiURL := fmt.Sprintf("%s?url=%s", songLinkBaseURL(), url.QueryEscape(targetURL))
|
||||
if userCountry != "" {
|
||||
apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry))
|
||||
}
|
||||
return apiURL
|
||||
return s.songLinkByTargetURL(inputURL)
|
||||
}
|
||||
|
||||
func buildSongLinkURLByPlatform(platform, entityType, entityID, userCountry string) string {
|
||||
if userCountry == "" {
|
||||
userCountry = GetSongLinkRegion()
|
||||
// resolveTrackPlatformsByPlatform resolves using platform + type + id.
|
||||
// Spotify uses the resolve API with SongLink fallback; all other platforms use SongLink directly.
|
||||
func (s *SongLinkClient) resolveTrackPlatformsByPlatform(platform, entityType, entityID string) (map[string]songLinkPlatformLink, error) {
|
||||
if strings.EqualFold(platform, "spotify") {
|
||||
payload, err := json.Marshal(map[string]string{
|
||||
"platform": platform,
|
||||
"type": entityType,
|
||||
"id": entityID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode resolve request: %w", err)
|
||||
}
|
||||
links, err := s.doResolveRequest(payload)
|
||||
if err == nil {
|
||||
return links, nil
|
||||
}
|
||||
GoLog("[SongLink] Resolve proxy failed for %s/%s/%s: %v, falling back to SongLink", platform, entityType, entityID, err)
|
||||
return s.songLinkByPlatform(platform, entityType, entityID)
|
||||
}
|
||||
apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s",
|
||||
return s.songLinkByPlatform(platform, entityType, entityID)
|
||||
}
|
||||
|
||||
func isSpotifyURL(u string) bool {
|
||||
lower := strings.ToLower(u)
|
||||
return strings.Contains(lower, "spotify.com/") || strings.Contains(lower, "spotify:")
|
||||
}
|
||||
|
||||
// doResolveRequest sends a JSON payload to the resolve API (api.zarz.moe)
|
||||
// and parses the response into a platform link map.
|
||||
func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPlatformLink, error) {
|
||||
req, err := http.NewRequest("POST", resolveAPIURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create resolve request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("resolve API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read resolve response: %w", err)
|
||||
}
|
||||
|
||||
var resolveResp struct {
|
||||
Success bool `json:"success"`
|
||||
ISRC string `json:"isrc"`
|
||||
SongUrls map[string]string `json:"songUrls"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resolveResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode resolve response: %w", err)
|
||||
}
|
||||
if !resolveResp.Success {
|
||||
return nil, fmt.Errorf("resolve API returned success=false")
|
||||
}
|
||||
|
||||
// Map resolve API keys to SongLink-compatible platform keys
|
||||
keyMap := map[string]string{
|
||||
"Spotify": "spotify",
|
||||
"Deezer": "deezer",
|
||||
"Tidal": "tidal",
|
||||
"YouTubeMusic": "youtubeMusic",
|
||||
"YouTube": "youtube",
|
||||
"AmazonMusic": "amazonMusic",
|
||||
"Qobuz": "qobuz",
|
||||
"AppleMusic": "appleMusic",
|
||||
}
|
||||
|
||||
links := make(map[string]songLinkPlatformLink)
|
||||
for resolveKey, platformKey := range keyMap {
|
||||
if u, ok := resolveResp.SongUrls[resolveKey]; ok && strings.TrimSpace(u) != "" {
|
||||
links[platformKey] = songLinkPlatformLink{URL: strings.TrimSpace(u)}
|
||||
}
|
||||
}
|
||||
|
||||
if len(links) == 0 {
|
||||
return nil, fmt.Errorf("resolve API returned no platform links")
|
||||
}
|
||||
|
||||
return links, nil
|
||||
}
|
||||
|
||||
// songLinkByTargetURL calls the SongLink API with a target URL (for non-Spotify URLs).
|
||||
func (s *SongLinkClient) songLinkByTargetURL(targetURL string) (map[string]songLinkPlatformLink, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
apiURL := fmt.Sprintf("%s?url=%s&userCountry=%s",
|
||||
songLinkBaseURL(),
|
||||
url.QueryEscape(targetURL),
|
||||
url.QueryEscape(GetSongLinkRegion()))
|
||||
|
||||
return s.doSongLinkRequest(apiURL)
|
||||
}
|
||||
|
||||
// songLinkByPlatform calls the SongLink API with platform + type + id (for non-Spotify platforms).
|
||||
func (s *SongLinkClient) songLinkByPlatform(platform, entityType, entityID string) (map[string]songLinkPlatformLink, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s&userCountry=%s",
|
||||
songLinkBaseURL(),
|
||||
url.QueryEscape(platform),
|
||||
url.QueryEscape(entityType),
|
||||
url.QueryEscape(entityID))
|
||||
if userCountry != "" {
|
||||
apiURL = fmt.Sprintf("%s&userCountry=%s", apiURL, url.QueryEscape(userCountry))
|
||||
url.QueryEscape(entityID),
|
||||
url.QueryEscape(GetSongLinkRegion()))
|
||||
|
||||
return s.doSongLinkRequest(apiURL)
|
||||
}
|
||||
|
||||
// doSongLinkRequest calls the SongLink API and parses the response.
|
||||
func (s *SongLinkClient) doSongLinkRequest(apiURL string) (map[string]songLinkPlatformLink, error) {
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SongLink request: %w", err)
|
||||
}
|
||||
return apiURL
|
||||
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SongLink request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("SongLink returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read SongLink response: %w", err)
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode SongLink response: %w", err)
|
||||
}
|
||||
|
||||
if len(songLinkResp.LinksByPlatform) == 0 {
|
||||
return nil, fmt.Errorf("SongLink returned no platform links")
|
||||
}
|
||||
|
||||
return songLinkResp.LinksByPlatform, nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||
@@ -136,145 +282,12 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) {
|
||||
availability, pageErr := s.checkTrackAvailabilityFromSpotifyPage(spotifyTrackID)
|
||||
if pageErr == nil {
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
if !songLinkRateLimiter.TryAcquire() {
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w (SongLink local rate limit exceeded)", pageErr)
|
||||
}
|
||||
|
||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID)
|
||||
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
links, err := s.resolveTrackPlatforms(spotifyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
return nil, fmt.Errorf("resolve proxy failed for Spotify %s: %w", spotifyTrackID, err)
|
||||
}
|
||||
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API lookup failed: %w", pageErr, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 400 {
|
||||
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
|
||||
}
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API rate limit exceeded", pageErr)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("song.link page lookup failed: %w; SongLink API returned status %d", pageErr, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
LogWarn("SongLink", "Spotify %s resolved via SongLink API after song.link page failure: %v", spotifyTrackID, pageErr)
|
||||
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, songLinkResp.LinksByPlatform), nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) checkTrackAvailabilityFromSpotifyPage(spotifyTrackID string) (*TrackAvailability, error) {
|
||||
pageURL := fmt.Sprintf("https://song.link/s/%s", spotifyTrackID)
|
||||
req, err := http.NewRequest("GET", pageURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create song.link page request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch song.link page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("track not found on song.link page")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("song.link page returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read song.link page: %w", err)
|
||||
}
|
||||
|
||||
nextDataJSON, err := extractSongLinkNextDataJSON(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var pageData struct {
|
||||
Props struct {
|
||||
PageProps struct {
|
||||
PageData struct {
|
||||
Sections []struct {
|
||||
Links []struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
Show bool `json:"show"`
|
||||
} `json:"links"`
|
||||
} `json:"sections"`
|
||||
} `json:"pageData"`
|
||||
} `json:"pageProps"`
|
||||
} `json:"props"`
|
||||
}
|
||||
if err := json.Unmarshal(nextDataJSON, &pageData); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode song.link page data: %w", err)
|
||||
}
|
||||
|
||||
linksByPlatform := make(map[string]songLinkPlatformLink)
|
||||
for _, section := range pageData.Props.PageProps.PageData.Sections {
|
||||
for _, link := range section.Links {
|
||||
if !link.Show || strings.TrimSpace(link.URL) == "" {
|
||||
continue
|
||||
}
|
||||
linksByPlatform[link.Platform] = songLinkPlatformLink{URL: link.URL}
|
||||
}
|
||||
}
|
||||
|
||||
if len(linksByPlatform) == 0 {
|
||||
return nil, fmt.Errorf("song.link page contained no usable platform links")
|
||||
}
|
||||
|
||||
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, linksByPlatform), nil
|
||||
}
|
||||
|
||||
func extractSongLinkNextDataJSON(body []byte) ([]byte, error) {
|
||||
const startMarker = `<script id="__NEXT_DATA__" type="application/json">`
|
||||
const endMarker = `</script>`
|
||||
|
||||
start := bytes.Index(body, []byte(startMarker))
|
||||
if start < 0 {
|
||||
return nil, fmt.Errorf("song.link page missing __NEXT_DATA__")
|
||||
}
|
||||
start += len(startMarker)
|
||||
|
||||
end := bytes.Index(body[start:], []byte(endMarker))
|
||||
if end < 0 {
|
||||
return nil, fmt.Errorf("song.link page has unterminated __NEXT_DATA__")
|
||||
}
|
||||
|
||||
return body[start : start+end], nil
|
||||
return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, links), nil
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) checkTrackAvailabilityFromISRC(isrc string) (*TrackAvailability, error) {
|
||||
@@ -505,47 +518,17 @@ type AlbumAvailability struct {
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
spotifyURL := fmt.Sprintf("https://open.spotify.com/album/%s", spotifyAlbumID)
|
||||
apiURL := buildSongLinkURLFromTarget(spotifyURL, "")
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
links, err := s.resolveTrackPlatforms(spotifyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check album availability: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, fmt.Errorf("resolve proxy failed for album %s: %w", spotifyAlbumID, err)
|
||||
}
|
||||
|
||||
availability := &AlbumAvailability{
|
||||
SpotifyID: spotifyAlbumID,
|
||||
}
|
||||
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
if deezerLink, ok := links["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
@@ -588,101 +571,19 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||
apiURL := buildSongLinkURLFromTarget(deezerURL, "")
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
links, err := s.resolveTrackPlatforms(deezerURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
return nil, fmt.Errorf("resolve failed for Deezer %s: %w", deezerTrackID, err)
|
||||
}
|
||||
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
availability := buildTrackAvailabilityFromSongLinkLinks("", links)
|
||||
// Ensure Deezer is always marked available since we started from a Deezer URL
|
||||
availability.Deezer = true
|
||||
availability.DeezerID = deezerTrackID
|
||||
if availability.DeezerURL == "" {
|
||||
availability.DeezerURL = deezerURL
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 400 {
|
||||
return nil, fmt.Errorf("track not found on SongLink (invalid Deezer ID)")
|
||||
}
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
EntitiesByUniqueId map[string]struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
ArtistName string `json:"artistName"`
|
||||
} `json:"entitiesByUniqueId"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{
|
||||
Deezer: true,
|
||||
DeezerID: deezerTrackID,
|
||||
}
|
||||
|
||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||
}
|
||||
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
}
|
||||
|
||||
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
if !availability.YouTube {
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
}
|
||||
|
||||
@@ -694,94 +595,12 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
||||
return nil, fmt.Errorf("%s ID is empty", platform)
|
||||
}
|
||||
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
apiURL := buildSongLinkURLByPlatform(platform, entityType, entityID, "")
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
links, err := s.resolveTrackPlatformsByPlatform(platform, entityType, entityID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
return nil, fmt.Errorf("resolve failed for %s %s: %w", platform, entityID, err)
|
||||
}
|
||||
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 400 {
|
||||
return nil, fmt.Errorf("track not found on SongLink (invalid %s ID)", platform)
|
||||
}
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("track not found on any streaming platform")
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{}
|
||||
|
||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||
}
|
||||
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
|
||||
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
if !availability.YouTube {
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
return buildTrackAvailabilityFromSongLinkLinks("", links), nil
|
||||
}
|
||||
|
||||
func buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID string, links map[string]songLinkPlatformLink) *TrackAvailability {
|
||||
@@ -894,85 +713,10 @@ func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string,
|
||||
}
|
||||
|
||||
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
|
||||
songLinkRateLimiter.WaitForSlot()
|
||||
|
||||
apiURL := buildSongLinkURLFromTarget(inputURL, "")
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
links, err := s.resolveTrackPlatforms(inputURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
return nil, fmt.Errorf("resolve failed for URL %s: %w", inputURL, err)
|
||||
}
|
||||
|
||||
retryConfig := songLinkRetryConfig()
|
||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check availability: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 400 || resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("track not found on SongLink")
|
||||
}
|
||||
if resp.StatusCode == 429 {
|
||||
return nil, fmt.Errorf("SongLink rate limit exceeded")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("SongLink API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var songLinkResp struct {
|
||||
LinksByPlatform map[string]struct {
|
||||
URL string `json:"url"`
|
||||
EntityID string `json:"entityUniqueId"`
|
||||
} `json:"linksByPlatform"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &songLinkResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
availability := &TrackAvailability{}
|
||||
|
||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||
}
|
||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||
availability.Tidal = true
|
||||
availability.TidalURL = tidalLink.URL
|
||||
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
|
||||
}
|
||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||
availability.Amazon = true
|
||||
availability.AmazonURL = amazonLink.URL
|
||||
}
|
||||
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
|
||||
availability.Qobuz = true
|
||||
availability.QobuzURL = qobuzLink.URL
|
||||
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
|
||||
}
|
||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||
availability.Deezer = true
|
||||
availability.DeezerURL = deezerLink.URL
|
||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||
}
|
||||
// Prefer youtubeMusic URLs — they are usually closer to music catalog matches.
|
||||
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = ytMusicLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
|
||||
}
|
||||
if !availability.YouTube {
|
||||
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
|
||||
availability.YouTube = true
|
||||
availability.YouTubeURL = youtubeLink.URL
|
||||
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
|
||||
}
|
||||
}
|
||||
|
||||
return availability, nil
|
||||
return buildTrackAvailabilityFromSongLinkLinks("", links), nil
|
||||
}
|
||||
|
||||
+70
-39
@@ -23,26 +23,24 @@ func TestGetRetryAfterDurationMissingHeaderReturnsZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckTrackAvailabilityFromSpotifyPrefersSongLinkPage(t *testing.T) {
|
||||
func TestCheckTrackAvailabilityFromSpotifyViaResolveAPI(t *testing.T) {
|
||||
origRetryConfig := songLinkRetryConfig
|
||||
defer func() { songLinkRetryConfig = origRetryConfig }()
|
||||
|
||||
client := &SongLinkClient{
|
||||
client: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case req.URL.Host == "api.song.link":
|
||||
t.Fatalf("api.song.link should not be called when song.link page succeeds")
|
||||
return nil, nil
|
||||
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
|
||||
body := `<!DOCTYPE html><html><body><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"pageData":{"sections":[{"displayName":"Listen","links":[{"platform":"spotify","url":"https://open.spotify.com/track/testspotifyid","show":true},{"platform":"deezer","url":"https://www.deezer.com/track/908604612","show":true},{"platform":"amazonMusic","url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C","show":true},{"platform":"tidal","url":"https://listen.tidal.com/track/134858527","show":true},{"platform":"qobuz","url":"https://open.qobuz.com/track/195125822","show":true},{"platform":"youtubeMusic","url":"https://music.youtube.com/watch?v=testvideoid1","show":true}]}]}}}}</script></body></html>`
|
||||
if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" && req.Method == "POST" {
|
||||
body := `{"success":true,"isrc":"USRC12345678","songUrls":{"Spotify":"https://open.spotify.com/track/testspotifyid","Deezer":"https://www.deezer.com/track/908604612","AmazonMusic":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C","Tidal":"https://listen.tidal.com/track/134858527","Qobuz":"https://open.qobuz.com/track/195125822","YouTubeMusic":"https://music.youtube.com/watch?v=testvideoid1"}}`
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s", req.URL.String())
|
||||
return nil, nil
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
|
||||
return nil, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
@@ -66,62 +64,95 @@ func TestCheckTrackAvailabilityFromSpotifyPrefersSongLinkPage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckTrackAvailabilityFromSpotifyFallsBackToAPIWhenPageFails(t *testing.T) {
|
||||
func TestCheckTrackAvailabilityFromSpotifyResolveAPIFailure(t *testing.T) {
|
||||
origRetryConfig := songLinkRetryConfig
|
||||
songLinkRetryConfig = func() RetryConfig {
|
||||
return RetryConfig{
|
||||
MaxRetries: 0,
|
||||
InitialDelay: 0,
|
||||
MaxDelay: 0,
|
||||
BackoffFactor: 1,
|
||||
}
|
||||
return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1}
|
||||
}
|
||||
defer func() {
|
||||
songLinkRetryConfig = origRetryConfig
|
||||
}()
|
||||
defer func() { songLinkRetryConfig = origRetryConfig }()
|
||||
|
||||
var hitSongLink bool
|
||||
|
||||
client := &SongLinkClient{
|
||||
client: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case req.URL.Host == "song.link" && req.URL.Path == "/s/testspotifyid":
|
||||
// Resolve proxy returns 500
|
||||
if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" {
|
||||
return &http.Response{
|
||||
StatusCode: 500,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("page failure")),
|
||||
Body: io.NopCloser(strings.NewReader("internal error")),
|
||||
Request: req,
|
||||
}, nil
|
||||
case req.URL.Host == "api.song.link":
|
||||
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testspotifyid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"amazonMusic":{"url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C"},"tidal":{"url":"https://listen.tidal.com/track/134858527"},"qobuz":{"url":"https://open.qobuz.com/track/195125822"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=testvideoid1"}}}`
|
||||
}
|
||||
// SongLink fallback should be called
|
||||
if req.URL.Host == "api.song.link" {
|
||||
hitSongLink = true
|
||||
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testspotifyid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"tidal":{"url":"https://listen.tidal.com/track/134858527"}}}`
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s", req.URL.String())
|
||||
return nil, nil
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
|
||||
return nil, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
availability, err := client.CheckTrackAvailability("testspotifyid", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckTrackAvailability() error = %v", err)
|
||||
t.Fatalf("expected SongLink fallback to succeed, got error: %v", err)
|
||||
}
|
||||
|
||||
if availability.SpotifyID != "testspotifyid" {
|
||||
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid")
|
||||
if !hitSongLink {
|
||||
t.Fatal("expected fallback request to SongLink API, but it was never called")
|
||||
}
|
||||
if !availability.Deezer || availability.DeezerID != "908604612" {
|
||||
t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability)
|
||||
}
|
||||
if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube {
|
||||
t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability)
|
||||
}
|
||||
if availability.YouTubeID != "testvideoid1" {
|
||||
t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1")
|
||||
t.Fatalf("Deezer availability via fallback = %+v, want DeezerID 908604612", availability)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAvailabilityFromDeezerUsesSongLink(t *testing.T) {
|
||||
origRetryConfig := songLinkRetryConfig
|
||||
songLinkRetryConfig = func() RetryConfig {
|
||||
return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1}
|
||||
}
|
||||
defer func() { songLinkRetryConfig = origRetryConfig }()
|
||||
|
||||
client := &SongLinkClient{
|
||||
client: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
// Non-Spotify should go to SongLink, not resolve API
|
||||
if req.URL.Host == "api.zarz.moe" {
|
||||
t.Fatalf("non-Spotify URL should not hit resolve API, got: %s", req.URL.String())
|
||||
return nil, nil
|
||||
}
|
||||
if req.URL.Host == "api.song.link" {
|
||||
body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"tidal":{"url":"https://listen.tidal.com/track/134858527"},"qobuz":{"url":"https://open.qobuz.com/track/195125822"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=testvid"}}}`
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String())
|
||||
return nil, nil
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
availability, err := client.checkAvailabilityFromDeezerSongLink("908604612")
|
||||
if err != nil {
|
||||
t.Fatalf("checkAvailabilityFromDeezerSongLink() error = %v", err)
|
||||
}
|
||||
|
||||
if !availability.Deezer || availability.DeezerID != "908604612" {
|
||||
t.Fatalf("Deezer = %+v, want DeezerID 908604612", availability)
|
||||
}
|
||||
if availability.SpotifyID != "testid" {
|
||||
t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testid")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:]
|
||||
}
|
||||
|
||||
@@ -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 != "" &&
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -82,7 +82,6 @@ class _RuntimeProfile {
|
||||
});
|
||||
}
|
||||
|
||||
/// Widget to eagerly initialize providers that need to load data on startup
|
||||
class _EagerInitialization extends ConsumerStatefulWidget {
|
||||
const _EagerInitialization({required this.child});
|
||||
final Widget child;
|
||||
@@ -170,10 +169,8 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
||||
const Duration(milliseconds: 1600),
|
||||
() {
|
||||
ref.read(localLibraryProvider);
|
||||
// Trigger auto-scan after initial warmup on first app launch.
|
||||
if (!_autoScanTriggeredOnLaunch) {
|
||||
_autoScanTriggeredOnLaunch = true;
|
||||
// Give the provider a moment to load existing data before scanning.
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) _maybeAutoScanLocalLibrary();
|
||||
});
|
||||
@@ -182,8 +179,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
||||
);
|
||||
}
|
||||
|
||||
/// Checks whether an automatic incremental scan should be triggered based on
|
||||
/// the user's auto-scan preference and the time since the last scan.
|
||||
Future<void> _maybeAutoScanLocalLibrary() async {
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -204,7 +199,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
|
||||
|
||||
switch (settings.localLibraryAutoScan) {
|
||||
case 'on_open':
|
||||
// Cooldown of 10 minutes to prevent rapid re-scans.
|
||||
if (elapsed.inMinutes < 10) return;
|
||||
break;
|
||||
case 'daily':
|
||||
|
||||
@@ -20,6 +20,7 @@ import 'package:spotiflac_android/services/history_database.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
|
||||
final _log = AppLogger('DownloadQueue');
|
||||
final _historyLog = AppLogger('DownloadHistory');
|
||||
@@ -3010,6 +3011,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.w('FFmpeg metadata/cover embed failed');
|
||||
}
|
||||
|
||||
// After FFmpeg embed, fix split artist tags using native FLAC writer.
|
||||
// FFmpeg deduplicates repeated metadata keys, so multiple ARTIST entries
|
||||
// collapse into one. The Go FLAC writer rewrites them properly.
|
||||
if (settings.artistTagMode == artistTagModeSplitVorbis) {
|
||||
try {
|
||||
await PlatformBridge.rewriteSplitArtistTags(
|
||||
flacPath,
|
||||
track.artistName,
|
||||
albumArtist,
|
||||
);
|
||||
_log.d('Split artist tags rewritten via native FLAC writer');
|
||||
} catch (e) {
|
||||
_log.w('Failed to rewrite split artist tags: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (coverPath != null) {
|
||||
try {
|
||||
final coverFile = File(coverPath);
|
||||
|
||||
@@ -146,7 +146,6 @@ class StoreState {
|
||||
this.registryUrl = '',
|
||||
});
|
||||
|
||||
/// Whether a registry URL has been configured by the user.
|
||||
bool get hasRegistryUrl => registryUrl.isNotEmpty;
|
||||
|
||||
StoreState copyWith({
|
||||
@@ -218,7 +217,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
Future<void> initialize(String cacheDir) async {
|
||||
if (state.isInitialized) return;
|
||||
|
||||
// Load saved registry URL early to avoid UI flash (empty → setup screen)
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
|
||||
|
||||
@@ -246,8 +244,6 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the registry URL, saves it, and refreshes the store.
|
||||
/// The Go backend handles URL normalisation (GitHub repo → raw URL, branch detection).
|
||||
Future<void> setRegistryUrl(String url) async {
|
||||
final trimmed = url.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
@@ -258,10 +254,8 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
|
||||
try {
|
||||
// Go backend resolves GitHub URLs (detects default branch) and validates HTTPS.
|
||||
await PlatformBridge.setStoreRegistryUrl(trimmed);
|
||||
|
||||
// Read back the resolved URL (may differ from input after normalisation).
|
||||
final resolvedUrl = await PlatformBridge.getStoreRegistryUrl();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -280,13 +274,11 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the saved registry URL and fully detaches the repo from backend.
|
||||
Future<void> removeRegistryUrl() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_registryUrlPrefKey);
|
||||
|
||||
// Reset the URL in Go backend memory AND clear its cache
|
||||
await PlatformBridge.clearStoreRegistryUrl();
|
||||
|
||||
state = state.copyWith(
|
||||
|
||||
@@ -138,14 +138,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
||||
}
|
||||
|
||||
/// Upgrade cover URL to a higher resolution for full-screen display.
|
||||
String? _highResCoverUrl(String? url) {
|
||||
if (url == null) return null;
|
||||
// Spotify CDN: upgrade 300 → 640 only (no intermediate between 640 and 2000)
|
||||
if (url.contains('ab67616d00001e02')) {
|
||||
return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273');
|
||||
}
|
||||
// Deezer CDN: upgrade to 1000x1000
|
||||
final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$');
|
||||
if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) {
|
||||
return url.replaceAllMapped(
|
||||
|
||||
@@ -228,7 +228,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
// Show title when scrolled past the header (280px trigger)
|
||||
final shouldShow = _scrollController.offset > 280;
|
||||
if (shouldShow != _showTitleInAppBar) {
|
||||
setState(() => _showTitleInAppBar = shouldShow);
|
||||
@@ -2013,7 +2012,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Option tile for discography download bottom sheet
|
||||
class _DiscographyOptionTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
@@ -2051,7 +2049,6 @@ class _DiscographyOptionTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress dialog shown while fetching album tracks
|
||||
class _FetchingProgressDialog extends StatefulWidget {
|
||||
final int totalAlbums;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
@@ -95,7 +95,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
||||
}
|
||||
|
||||
/// Upgrade cover URL to a reasonable resolution for full-screen display.
|
||||
String? _highResCoverUrl(String? url) {
|
||||
if (url == null) return null;
|
||||
if (url.contains('ab67616d00001e02')) {
|
||||
@@ -111,7 +110,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
return url;
|
||||
}
|
||||
|
||||
/// Get tracks for this album from history provider (reactive)
|
||||
List<DownloadHistoryItem> _getAlbumTracks(
|
||||
List<DownloadHistoryItem> allItems,
|
||||
) {
|
||||
@@ -641,7 +639,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
ColorScheme colorScheme,
|
||||
List<DownloadHistoryItem> tracks,
|
||||
) {
|
||||
// Info is now displayed in the full-screen cover overlay
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
@@ -848,7 +845,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Share selected tracks via system share sheet
|
||||
Future<void> _shareSelected(List<DownloadHistoryItem> allTracks) async {
|
||||
final tracksById = {for (final t in allTracks) t.id: t};
|
||||
final safUris = <String>[];
|
||||
@@ -1091,7 +1087,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
for (final id in _selectedIds) {
|
||||
final item = tracksById[id];
|
||||
if (item == null) continue;
|
||||
// For SAF items, use safFileName to detect format (filePath is content:// URI)
|
||||
final nameToCheck =
|
||||
(item.safFileName != null && item.safFileName!.isNotEmpty)
|
||||
? item.safFileName!.toLowerCase()
|
||||
|
||||
@@ -2412,8 +2412,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Search result sorting ──────────────────────────────────────────────
|
||||
|
||||
String _sortOptionLabel(_SearchSortOption option) {
|
||||
switch (option) {
|
||||
case _SearchSortOption.defaultOrder:
|
||||
|
||||
@@ -597,7 +597,6 @@ class _LibraryTracksFolderScreenState
|
||||
final customCoverPath = playlist?.coverImagePath;
|
||||
final isLovedMode = widget.mode == LibraryTracksFolderMode.loved;
|
||||
final isPlaylistMode = widget.mode == LibraryTracksFolderMode.playlist;
|
||||
// Loved always shows the heart icon (like Spotify's Liked Songs)
|
||||
final coverUrl = isLovedMode ? null : _firstCoverUrl(entries, localState);
|
||||
final hasCustomCover =
|
||||
customCoverPath != null && customCoverPath.isNotEmpty;
|
||||
@@ -667,7 +666,6 @@ class _LibraryTracksFolderScreenState
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Cover background: custom > first track URL > icon
|
||||
if (hasCustomCover)
|
||||
Image.file(
|
||||
File(customCoverPath),
|
||||
@@ -1364,15 +1362,12 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
final track = entry.track;
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
|
||||
// 1. Download history by Spotify ID
|
||||
var historyItem = historyState.getBySpotifyId(track.id);
|
||||
|
||||
// 2. Download history by ISRC
|
||||
if (historyItem == null && track.isrc != null && track.isrc!.isNotEmpty) {
|
||||
historyItem = historyState.getByIsrc(track.isrc!);
|
||||
}
|
||||
|
||||
// 3. Download history by track name + artist (handles ID/ISRC mismatch)
|
||||
historyItem ??= historyState.findByTrackAndArtist(
|
||||
track.name,
|
||||
track.artistName,
|
||||
@@ -1385,14 +1380,12 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Local library by ISRC
|
||||
final localState = ref.read(localLibraryProvider);
|
||||
LocalLibraryItem? localItem;
|
||||
if (track.isrc != null && track.isrc!.isNotEmpty) {
|
||||
localItem = localState.getByIsrc(track.isrc!);
|
||||
}
|
||||
|
||||
// 5. Local library by track name + artist
|
||||
localItem ??= localState.findByTrackAndArtist(track.name, track.artistName);
|
||||
|
||||
if (localItem != null) {
|
||||
@@ -1402,7 +1395,6 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. Not found anywhere — offer to download
|
||||
_downloadTrack(context, ref);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,7 +526,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
ColorScheme colorScheme,
|
||||
List<LocalLibraryItem> tracks,
|
||||
) {
|
||||
// Info is now displayed in the full-screen cover overlay
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
@@ -1057,7 +1056,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Temporarily hide selection bar so it doesn't overlap the bottom sheet.
|
||||
// The bar uses AnimatedPositioned (250ms), so wait for the slide-out.
|
||||
setState(() => _isSelectionMode = false);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
@@ -348,7 +348,6 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
trackState.isShowingRecentAccess &&
|
||||
!trackState.isLoading &&
|
||||
(trackState.hasSearchText || trackState.hasContent)) {
|
||||
// Has recent access AND search content — clear everything at once
|
||||
_log.i(
|
||||
'Back: step 3a - dismiss recent access + clear search/content '
|
||||
'(hasSearchText=${trackState.hasSearchText}, hasContent=${trackState.hasContent})',
|
||||
@@ -360,7 +359,6 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
}
|
||||
|
||||
if (_currentIndex == 0 && trackState.isShowingRecentAccess) {
|
||||
// Recent access overlay only (no search content) — just dismiss it
|
||||
_log.i('Back: step 3b - dismiss recent access only');
|
||||
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
|
||||
@@ -117,7 +117,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
final playlistInfo = result['playlist_info'] as Map<String, dynamic>?;
|
||||
final owner = playlistInfo?['owner'] as Map<String, dynamic>?;
|
||||
|
||||
// Go backend returns 'track_list' not 'tracks'
|
||||
final trackList = result['track_list'] as List<dynamic>? ?? [];
|
||||
final tracks = trackList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
@@ -182,14 +181,11 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
return (mediaSize.height * 0.55).clamp(360.0, 520.0);
|
||||
}
|
||||
|
||||
/// Upgrade cover URL to a reasonable resolution for full-screen display.
|
||||
String? _highResCoverUrl(String? url) {
|
||||
if (url == null) return null;
|
||||
// Spotify CDN: upgrade 300 → 640 only
|
||||
if (url.contains('ab67616d00001e02')) {
|
||||
return url.replaceAll('ab67616d00001e02', 'ab67616d0000b273');
|
||||
}
|
||||
// Deezer CDN: upgrade to 1000x1000
|
||||
final deezerRegex = RegExp(r'/(\d+)x(\d+)-(\d+)-(\d+)-(\d+)-(\d+)\.jpg$');
|
||||
if (url.contains('cdn-images.dzcdn.net') && deezerRegex.hasMatch(url)) {
|
||||
return url.replaceAllMapped(
|
||||
@@ -729,7 +725,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate Consumer widget for each track - only rebuilds when this specific track's status changes
|
||||
class _PlaylistTrackItem extends ConsumerWidget {
|
||||
final Track track;
|
||||
final VoidCallback onDownload;
|
||||
|
||||
@@ -156,7 +156,6 @@ class UnifiedLibraryItem {
|
||||
return 'builtin:$id';
|
||||
}
|
||||
|
||||
/// Convert to a [Track] for adding to collections/playlists.
|
||||
Track toTrack() {
|
||||
if (historyItem != null) {
|
||||
final h = historyItem!;
|
||||
@@ -2101,7 +2100,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
|
||||
// Reload local library if we deleted any local items
|
||||
if (allItems.any(
|
||||
(i) =>
|
||||
_selectedIds.contains(i.id) && i.source == LibraryItemSource.local,
|
||||
@@ -2121,7 +2119,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip EXISTS: prefix from file path (legacy history items)
|
||||
String _cleanFilePath(String? filePath) {
|
||||
return DownloadedEmbeddedCoverResolver.cleanFilePath(filePath);
|
||||
}
|
||||
@@ -2971,7 +2968,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate with unfocus pattern — unfocuses search before and after navigation.
|
||||
void _navigateWithUnfocus(Route<dynamic> route) {
|
||||
_searchFocusNode.unfocus();
|
||||
Navigator.of(context).push(route).then((_) => _searchFocusNode.unfocus());
|
||||
@@ -3201,14 +3197,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}) async {
|
||||
final notifier = ref.read(libraryCollectionsProvider.notifier);
|
||||
|
||||
// If in selection mode and the dragged item is selected, add ALL selected
|
||||
if (_isSelectionMode &&
|
||||
_selectedIds.isNotEmpty &&
|
||||
_selectedIds.contains(item.id)) {
|
||||
final selectedItems = allItems
|
||||
.where((e) => _selectedIds.contains(e.id))
|
||||
.toList();
|
||||
// Fallback: if allItems is empty or no match, at least add the dragged item
|
||||
if (selectedItems.isEmpty) {
|
||||
selectedItems.add(item);
|
||||
}
|
||||
@@ -3247,8 +3241,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a compact floating feedback widget shown while dragging a track.
|
||||
/// Shows the count when multiple tracks are selected and being dragged.
|
||||
Widget _buildDragFeedback(
|
||||
BuildContext context,
|
||||
UnifiedLibraryItem item,
|
||||
@@ -3777,7 +3769,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a Spotify-style collection list item (Wishlist, Loved, Playlists)
|
||||
Widget _buildCollectionListItem({
|
||||
required BuildContext context,
|
||||
required ColorScheme colorScheme,
|
||||
@@ -3854,7 +3845,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a collection grid item for grid view mode
|
||||
Widget _buildCollectionGridItem({
|
||||
required BuildContext context,
|
||||
required ColorScheme colorScheme,
|
||||
@@ -3937,7 +3927,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// Build a collection item for the unified "All" tab grid view.
|
||||
Widget _buildAllTabGridCollectionItem({
|
||||
required BuildContext context,
|
||||
required ColorScheme colorScheme,
|
||||
@@ -4055,7 +4044,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a collection item for the unified "All" tab list view.
|
||||
Widget _buildAllTabListCollectionItem({
|
||||
required BuildContext context,
|
||||
required ColorScheme colorScheme,
|
||||
@@ -4208,8 +4196,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
|
||||
// Collection folders as list items (Spotify-style) in "All" tab
|
||||
// are now rendered inline with tracks below (unified sliver)
|
||||
if ((filteredGroupedAlbums.isNotEmpty ||
|
||||
filteredGroupedLocalAlbums.isNotEmpty) &&
|
||||
filterMode == 'albums')
|
||||
@@ -4696,7 +4682,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Album grid item for local library albums
|
||||
Widget _buildLocalAlbumGridItem(
|
||||
BuildContext context,
|
||||
_GroupedLocalAlbum album,
|
||||
@@ -5275,7 +5260,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Share SAF content URIs via native intent
|
||||
if (safUris.isNotEmpty) {
|
||||
try {
|
||||
if (safUris.length == 1) {
|
||||
@@ -5286,7 +5270,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Share regular files via SharePlus
|
||||
if (filesToShare.isNotEmpty) {
|
||||
await SharePlus.instance.share(ShareParams(files: filesToShare));
|
||||
}
|
||||
@@ -6400,7 +6383,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable filter button with badge showing active filter count.
|
||||
Widget _buildFilterButton(
|
||||
BuildContext context,
|
||||
List<UnifiedLibraryItem> unifiedItems,
|
||||
@@ -6495,7 +6477,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
|
||||
// Network URL cover (downloaded items)
|
||||
if (item.coverUrl != null) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -6513,7 +6494,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
// Local file cover (from library scan)
|
||||
if (item.localCoverPath != null && item.localCoverPath!.isNotEmpty) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -6530,7 +6510,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
// Placeholder (no cover)
|
||||
if (size != null) {
|
||||
return buildPlaceholder();
|
||||
}
|
||||
@@ -6540,7 +6519,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a unified library item (merged downloaded + local)
|
||||
Widget _buildUnifiedLibraryItem(
|
||||
BuildContext context,
|
||||
UnifiedLibraryItem item,
|
||||
@@ -6748,7 +6726,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build unified grid item for grid view mode
|
||||
Widget _buildUnifiedGridItem(
|
||||
BuildContext context,
|
||||
UnifiedLibraryItem item,
|
||||
@@ -7038,7 +7015,6 @@ class _FilterChip extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable action button for selection mode bottom bar
|
||||
class _SelectionActionButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@@ -53,10 +53,8 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_androidSdkVersion = sdkVersion;
|
||||
// SAF doesn't need storage permission on Android 10+
|
||||
_hasStoragePermission = sdkVersion >= 29 ? true : false;
|
||||
});
|
||||
// For older Android, check legacy storage permission
|
||||
if (sdkVersion < 29) {
|
||||
final hasPermission = await Permission.storage.isGranted;
|
||||
if (mounted) {
|
||||
@@ -65,7 +63,6 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
}
|
||||
}
|
||||
} else if (Platform.isIOS) {
|
||||
// iOS doesn't need explicit storage permission for app documents
|
||||
setState(() => _hasStoragePermission = true);
|
||||
} else {
|
||||
setState(() => _hasStoragePermission = true);
|
||||
@@ -74,7 +71,6 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
|
||||
Future<bool> _requestStoragePermission() async {
|
||||
if (!Platform.isAndroid) return true;
|
||||
// SAF on Android 10+ doesn't need MANAGE_EXTERNAL_STORAGE
|
||||
if (_androidSdkVersion >= 29) return true;
|
||||
|
||||
final status = await Permission.storage.request();
|
||||
@@ -125,12 +121,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
final granted = await _requestStoragePermission();
|
||||
if (!granted) return;
|
||||
}
|
||||
// Fallback for older devices
|
||||
final result = await FilePicker.platform.getDirectoryPath();
|
||||
if (result != null) {
|
||||
if (Platform.isIOS) {
|
||||
// On iOS, create a security-scoped bookmark so we can access
|
||||
// this folder across app restarts and from the Go backend.
|
||||
final bookmark = await PlatformBridge.createIosBookmarkFromPath(
|
||||
result,
|
||||
);
|
||||
@@ -139,8 +132,6 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
.read(settingsProvider.notifier)
|
||||
.setLocalLibraryPathAndBookmark(result, bookmark);
|
||||
} else {
|
||||
// Bookmark creation failed; save path anyway (works for
|
||||
// app-internal folders like Documents/).
|
||||
ref.read(settingsProvider.notifier).setLocalLibraryPath(result);
|
||||
}
|
||||
} else {
|
||||
@@ -162,13 +153,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
// On iOS with a bookmark, try resolving the bookmark first to validate
|
||||
// access instead of checking the path directly (which may fail outside
|
||||
// the app sandbox).
|
||||
if (Platform.isIOS && iosBookmark.isNotEmpty) {
|
||||
// Bookmark will be resolved inside startScan; skip Directory.exists
|
||||
// check since security-scoped paths are not accessible without the
|
||||
// bookmark being activated.
|
||||
} else if (!libraryPath.startsWith('content://') &&
|
||||
!await Directory(libraryPath).exists()) {
|
||||
if (mounted) {
|
||||
@@ -467,7 +452,6 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
),
|
||||
),
|
||||
|
||||
// Scan Actions Section
|
||||
if (settings.localLibraryEnabled) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.libraryActions),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,10 +9,8 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
final _log = AppLogger('HistoryDatabase');
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
|
||||
/// Cached current iOS container path for path normalization
|
||||
String? _currentContainerPath;
|
||||
|
||||
/// Provides O(1) lookups by spotify_id and isrc with proper indexing
|
||||
class HistoryDatabase {
|
||||
static final HistoryDatabase instance = HistoryDatabase._init();
|
||||
static Database? _database;
|
||||
@@ -102,21 +100,16 @@ class HistoryDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pattern to match iOS container paths
|
||||
/// Example: /var/mobile/Containers/Data/Application/UUID-HERE/Documents/...
|
||||
static final _iosContainerPattern = RegExp(
|
||||
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
/// Initialize and cache the current iOS container path
|
||||
Future<void> _initContainerPath() async {
|
||||
if (!Platform.isIOS || _currentContainerPath != null) return;
|
||||
|
||||
try {
|
||||
final docDir = await getApplicationDocumentsDirectory();
|
||||
// Extract container path up to and including the UUID folder
|
||||
// e.g., /var/mobile/Containers/Data/Application/UUID/
|
||||
final match = _iosContainerPattern.firstMatch(docDir.path);
|
||||
if (match != null) {
|
||||
_currentContainerPath = match.group(0);
|
||||
@@ -127,13 +120,10 @@ class HistoryDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize iOS file path by replacing old container UUID with current one
|
||||
/// This fixes the issue where iOS changes container UUID after app updates
|
||||
String _normalizeIosPath(String? filePath) {
|
||||
if (filePath == null || filePath.isEmpty) return filePath ?? '';
|
||||
if (!Platform.isIOS || _currentContainerPath == null) return filePath;
|
||||
|
||||
// Check if path contains an iOS container path
|
||||
if (_iosContainerPattern.hasMatch(filePath)) {
|
||||
final normalized = filePath.replaceFirst(
|
||||
_iosContainerPattern,
|
||||
@@ -148,8 +138,6 @@ class HistoryDatabase {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/// Migrate iOS paths in database to use current container UUID
|
||||
/// This is called once after app update if container changed
|
||||
Future<bool> migrateIosContainerPaths() async {
|
||||
if (!Platform.isIOS) return false;
|
||||
|
||||
@@ -205,8 +193,6 @@ class HistoryDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrate data from SharedPreferences to SQLite
|
||||
/// Returns true if migration was performed, false if already migrated
|
||||
Future<bool> migrateFromSharedPreferences() async {
|
||||
final prefs = await _prefs;
|
||||
final migrationKey = 'history_migrated_to_sqlite';
|
||||
@@ -243,7 +229,6 @@ class HistoryDatabase {
|
||||
|
||||
await batch.commit(noResult: true);
|
||||
|
||||
// Mark as migrated but keep old data for safety
|
||||
await prefs.setBool(migrationKey, true);
|
||||
_log.i('Migration complete: ${jsonList.length} items');
|
||||
|
||||
@@ -254,7 +239,6 @@ class HistoryDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert JSON format (camelCase) to DB row (snake_case)
|
||||
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
|
||||
return {
|
||||
'id': json['id'],
|
||||
@@ -286,8 +270,6 @@ class HistoryDatabase {
|
||||
};
|
||||
}
|
||||
|
||||
/// Convert DB row (snake_case) to JSON format (camelCase)
|
||||
/// Also normalizes iOS paths if container UUID changed
|
||||
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
|
||||
return {
|
||||
'id': row['id'],
|
||||
@@ -342,7 +324,6 @@ class HistoryDatabase {
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
/// Get all history items ordered by download date (newest first)
|
||||
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
@@ -366,7 +347,6 @@ class HistoryDatabase {
|
||||
return _dbRowToJson(rows.first);
|
||||
}
|
||||
|
||||
/// Get item by Spotify ID - O(1) with index
|
||||
Future<Map<String, dynamic>?> getBySpotifyId(String spotifyId) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
@@ -379,7 +359,6 @@ class HistoryDatabase {
|
||||
return _dbRowToJson(rows.first);
|
||||
}
|
||||
|
||||
/// Get item by ISRC - O(1) with index
|
||||
Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
@@ -392,7 +371,6 @@ class HistoryDatabase {
|
||||
return _dbRowToJson(rows.first);
|
||||
}
|
||||
|
||||
/// Check if spotify_id exists - O(1) with index
|
||||
Future<bool> existsBySpotifyId(String spotifyId) async {
|
||||
final db = await database;
|
||||
final result = await db.rawQuery(
|
||||
@@ -402,7 +380,6 @@ class HistoryDatabase {
|
||||
return result.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Get all spotify_ids as Set for fast in-memory lookup
|
||||
Future<Set<String>> getAllSpotifyIds() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery(
|
||||
@@ -433,7 +410,6 @@ class HistoryDatabase {
|
||||
return Sqflite.firstIntValue(result) ?? 0;
|
||||
}
|
||||
|
||||
/// Find existing item by spotify_id or isrc (for deduplication)
|
||||
Future<Map<String, dynamic>?> findExisting({
|
||||
String? spotifyId,
|
||||
String? isrc,
|
||||
@@ -442,7 +418,6 @@ class HistoryDatabase {
|
||||
final bySpotify = await getBySpotifyId(spotifyId);
|
||||
if (bySpotify != null) return bySpotify;
|
||||
|
||||
// Check for deezer: prefix matching
|
||||
if (spotifyId.startsWith('deezer:')) {
|
||||
final deezerId = spotifyId.substring(7);
|
||||
final db = await database;
|
||||
@@ -469,7 +444,6 @@ class HistoryDatabase {
|
||||
_database = null;
|
||||
}
|
||||
|
||||
/// Update file path for a history entry (e.g. after format conversion)
|
||||
Future<void> updateFilePath(
|
||||
String id,
|
||||
String newFilePath, {
|
||||
@@ -524,8 +498,6 @@ class HistoryDatabase {
|
||||
await db.update('history', values, where: 'id = ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
/// Get all file paths from download history
|
||||
/// Used to exclude downloaded files from local library scan
|
||||
Future<Set<String>> getAllFilePaths() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery(
|
||||
@@ -534,8 +506,6 @@ class HistoryDatabase {
|
||||
return rows.map((r) => r['file_path'] as String).toSet();
|
||||
}
|
||||
|
||||
/// Get all entries with file paths for orphan detection
|
||||
/// Returns list of (id, file_path, storage_mode, download_tree_uri, saf_relative_dir, saf_file_name)
|
||||
Future<List<Map<String, dynamic>>> getAllEntriesWithPaths() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery('''
|
||||
@@ -569,7 +539,6 @@ class HistoryDatabase {
|
||||
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||
}
|
||||
|
||||
/// Delete multiple entries by IDs
|
||||
Future<int> deleteByIds(List<String> ids) async {
|
||||
if (ids.isEmpty) return 0;
|
||||
|
||||
|
||||
@@ -96,7 +96,6 @@ class LocalLibraryItem {
|
||||
format: json['format'] as String?,
|
||||
);
|
||||
|
||||
/// Create a unique key for matching tracks
|
||||
String get matchKey =>
|
||||
'${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||
String get albumKey =>
|
||||
@@ -183,13 +182,11 @@ class LibraryDatabase {
|
||||
}
|
||||
|
||||
if (oldVersion < 3) {
|
||||
// Add file_mod_time column for incremental scanning
|
||||
await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER');
|
||||
_log.i('Added file_mod_time column for incremental scanning');
|
||||
}
|
||||
|
||||
if (oldVersion < 4) {
|
||||
// Add bitrate column for lossy format quality info
|
||||
await db.execute('ALTER TABLE library ADD COLUMN bitrate INTEGER');
|
||||
_log.i('Added bitrate column for lossy format quality');
|
||||
}
|
||||
@@ -475,8 +472,6 @@ class LibraryDatabase {
|
||||
_database = null;
|
||||
}
|
||||
|
||||
/// Get all file paths with their modification times for incremental scanning
|
||||
/// Returns a map of filePath -> fileModTime (unix timestamp in milliseconds)
|
||||
Future<Map<String, int>> getFileModTimes() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery(
|
||||
@@ -491,8 +486,6 @@ class LibraryDatabase {
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Export file modification times to a compact line-based snapshot that
|
||||
/// native code can read without receiving a large method-channel payload.
|
||||
Future<String> writeFileModTimesSnapshot() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery(
|
||||
@@ -519,7 +512,6 @@ class LibraryDatabase {
|
||||
return file.path;
|
||||
}
|
||||
|
||||
/// Update file_mod_time for existing rows using file_path as key.
|
||||
Future<void> updateFileModTimes(Map<String, int> fileModTimes) async {
|
||||
if (fileModTimes.isEmpty) return;
|
||||
final db = await database;
|
||||
@@ -535,7 +527,6 @@ class LibraryDatabase {
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
/// Get all file paths in the library (for detecting deleted files)
|
||||
Future<Set<String>> getAllFilePaths() async {
|
||||
final db = await database;
|
||||
final rows = await db.rawQuery('SELECT file_path FROM library');
|
||||
|
||||
@@ -422,6 +422,21 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Rewrites ARTIST/ALBUMARTIST Vorbis comments as multiple split entries
|
||||
/// using the native Go FLAC writer, fixing FFmpeg's tag deduplication.
|
||||
static Future<Map<String, dynamic>> rewriteSplitArtistTags(
|
||||
String filePath,
|
||||
String artist,
|
||||
String albumArtist,
|
||||
) async {
|
||||
final result = await _channel.invokeMethod('rewriteSplitArtistTags', {
|
||||
'file_path': filePath,
|
||||
'artist': artist,
|
||||
'album_artist': albumArtist,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
static Future<bool> writeTempToSaf(String tempPath, String safUri) async {
|
||||
final result = await _channel.invokeMethod('writeTempToSaf', {
|
||||
'temp_path': tempPath,
|
||||
|
||||
@@ -10,7 +10,6 @@ class ShareIntentService {
|
||||
factory ShareIntentService() => _instance;
|
||||
ShareIntentService._internal();
|
||||
|
||||
// Spotify patterns
|
||||
static final RegExp _spotifyUriPattern = RegExp(
|
||||
r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+',
|
||||
);
|
||||
@@ -18,7 +17,6 @@ class ShareIntentService {
|
||||
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
|
||||
);
|
||||
|
||||
// Deezer patterns
|
||||
static final RegExp _deezerUrlPattern = RegExp(
|
||||
r'https?://(www\.)?deezer\.com/(track|album|playlist|artist)/\d+(\?[^\s]*)?',
|
||||
);
|
||||
@@ -26,17 +24,14 @@ class ShareIntentService {
|
||||
r'https?://deezer\.page\.link/[a-zA-Z0-9]+',
|
||||
);
|
||||
|
||||
// Tidal patterns
|
||||
static final RegExp _tidalUrlPattern = RegExp(
|
||||
r'https?://(listen\.)?tidal\.com/(track|album|playlist|artist)/[a-zA-Z0-9-]+(\?[^\s]*)?',
|
||||
);
|
||||
|
||||
// YouTube Music patterns
|
||||
static final RegExp _ytMusicUrlPattern = RegExp(
|
||||
r'https?://music\.youtube\.com/(watch\?v=|playlist\?list=|channel/|browse/)[a-zA-Z0-9_-]+([?&][^\s]*)?',
|
||||
);
|
||||
|
||||
// Standard YouTube patterns (youtu.be short links and www.youtube.com/watch)
|
||||
static final RegExp _youtubeUrlPattern = RegExp(
|
||||
r'https?://(youtu\.be/[a-zA-Z0-9_-]+|www\.youtube\.com/watch\?v=[a-zA-Z0-9_-]+)([?&][^\s]*)?',
|
||||
);
|
||||
@@ -117,7 +112,6 @@ class ShareIntentService {
|
||||
final match = pattern.firstMatch(text);
|
||||
if (match != null) {
|
||||
final fullUrl = match.group(0)!;
|
||||
// Keep query params for YouTube URLs (needed for ?v=, ?list=, etc.)
|
||||
if (pattern == _ytMusicUrlPattern || pattern == _youtubeUrlPattern) {
|
||||
return fullUrl;
|
||||
}
|
||||
|
||||
@@ -3,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,
|
||||
|
||||
@@ -11,11 +11,6 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('ClickableMetadata');
|
||||
|
||||
/// Navigate to an artist screen by searching Deezer for the artist ID.
|
||||
///
|
||||
/// If [artistId] is provided and valid, navigates directly.
|
||||
/// Otherwise, searches Deezer by [artistName] to resolve the ID first.
|
||||
/// For extension-based content, pass [extensionId] to use ExtensionArtistScreen.
|
||||
Future<void> navigateToArtist(
|
||||
BuildContext context, {
|
||||
required String artistName,
|
||||
@@ -95,11 +90,6 @@ Future<void> navigateToArtist(
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to an album screen by searching Deezer for the album ID.
|
||||
///
|
||||
/// If [albumId] is provided and valid, navigates directly.
|
||||
/// Otherwise, searches Deezer by [albumName] (optionally with [artistName]) to resolve the ID.
|
||||
/// For extension-based content, pass [extensionId] to use ExtensionAlbumScreen.
|
||||
Future<void> navigateToAlbum(
|
||||
BuildContext context, {
|
||||
required String albumName,
|
||||
@@ -217,9 +207,6 @@ void _pushAlbumScreen(
|
||||
String? coverUrl,
|
||||
String? extensionId,
|
||||
}) {
|
||||
// Built-in providers (tidal, qobuz, deezer) use AlbumScreen which
|
||||
// detects the provider from the album ID prefix. Only true JS extensions
|
||||
// should use ExtensionAlbumScreen.
|
||||
const builtInProviders = {'tidal', 'qobuz', 'deezer'};
|
||||
final isExtension =
|
||||
extensionId != null && !builtInProviders.contains(extensionId);
|
||||
@@ -297,10 +284,6 @@ void _showUnavailable(BuildContext context, String type) {
|
||||
).showSnackBar(SnackBar(content: Text('$type information not available')));
|
||||
}
|
||||
|
||||
/// A reusable widget that makes text tappable to navigate to an artist screen.
|
||||
///
|
||||
/// Wraps the text in a GestureDetector that, when tapped, looks up the artist
|
||||
/// via Deezer search and navigates to the ArtistScreen.
|
||||
class ClickableArtistName extends StatefulWidget {
|
||||
final String artistName;
|
||||
final String? artistId;
|
||||
@@ -526,10 +509,6 @@ bool _canNavigateArtistDirectly({
|
||||
|
||||
final RegExp _spotifyArtistIdPattern = RegExp(r'^[A-Za-z0-9]{22}$');
|
||||
|
||||
/// A reusable widget that makes text tappable to navigate to an album screen.
|
||||
///
|
||||
/// Wraps the text in a GestureDetector that, when tapped, looks up the album
|
||||
/// via Deezer search and navigates to the AlbumScreen.
|
||||
class ClickableAlbumName extends StatelessWidget {
|
||||
final String albumName;
|
||||
final String? albumId;
|
||||
|
||||
@@ -47,26 +47,20 @@ bool isValidIosWritablePath(String path) {
|
||||
if (path.isEmpty) return false;
|
||||
if (!path.startsWith('/')) return false;
|
||||
|
||||
// Check if it's the container root (without Documents/, tmp/, etc.)
|
||||
if (_iosContainerRootPattern.hasMatch(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for iCloud Drive paths
|
||||
if (path.contains('Mobile Documents') ||
|
||||
path.contains('CloudDocs') ||
|
||||
path.contains('com~apple~CloudDocs')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reject stale paths where an old sandbox container path has been embedded
|
||||
// inside the current Documents directory.
|
||||
if (_iosNestedLegacyDocumentsPattern.hasMatch(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure path contains a valid subdirectory (Documents, tmp, Library, etc.)
|
||||
// This handles cases where FilePicker returns container root
|
||||
final containerPattern = RegExp(
|
||||
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
|
||||
caseSensitive: false,
|
||||
@@ -74,7 +68,6 @@ bool isValidIosWritablePath(String path) {
|
||||
final match = containerPattern.firstMatch(path);
|
||||
if (match != null) {
|
||||
final remainingPath = path.substring(match.end);
|
||||
// Valid paths should have something after the UUID
|
||||
if (remainingPath.isEmpty || remainingPath == '/') {
|
||||
return false;
|
||||
}
|
||||
@@ -111,13 +104,10 @@ Future<String> validateOrFixIosPath(
|
||||
candidates.add(trimmed);
|
||||
}
|
||||
|
||||
// Some pickers can return absolute iOS paths without the leading slash.
|
||||
if (_iosContainerPathWithoutLeadingSlashPattern.hasMatch(trimmed)) {
|
||||
candidates.add('/$trimmed');
|
||||
}
|
||||
|
||||
// Recover legacy relative iOS path format:
|
||||
// Data/Application/<UUID>/Documents/<subdir>
|
||||
final legacyRelativeMatch = _iosLegacyRelativeDocumentsPattern.firstMatch(
|
||||
trimmed,
|
||||
);
|
||||
@@ -127,7 +117,6 @@ Future<String> validateOrFixIosPath(
|
||||
);
|
||||
}
|
||||
|
||||
// Generic salvage for relative paths containing `Documents/...`.
|
||||
if (!trimmed.startsWith('/')) {
|
||||
final documentsMarker = 'Documents/';
|
||||
final index = trimmed.indexOf(documentsMarker);
|
||||
@@ -143,7 +132,6 @@ Future<String> validateOrFixIosPath(
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to app Documents directory
|
||||
final musicDir = Directory('${docDir.path}/$subfolder');
|
||||
if (!await musicDir.exists()) {
|
||||
await musicDir.create(recursive: true);
|
||||
@@ -185,7 +173,6 @@ IosPathValidationResult validateIosPath(String path) {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if it's the container root
|
||||
if (_iosContainerRootPattern.hasMatch(path)) {
|
||||
return const IosPathValidationResult(
|
||||
isValid: false,
|
||||
@@ -194,7 +181,6 @@ IosPathValidationResult validateIosPath(String path) {
|
||||
);
|
||||
}
|
||||
|
||||
// Check for iCloud Drive paths
|
||||
if (path.contains('Mobile Documents') ||
|
||||
path.contains('CloudDocs') ||
|
||||
path.contains('com~apple~CloudDocs')) {
|
||||
@@ -213,7 +199,6 @@ IosPathValidationResult validateIosPath(String path) {
|
||||
);
|
||||
}
|
||||
|
||||
// Check for container root without subdirectory
|
||||
final containerPattern = RegExp(
|
||||
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
|
||||
caseSensitive: false,
|
||||
@@ -263,7 +248,6 @@ String stripCueTrackSuffix(String path) {
|
||||
|
||||
Future<bool> fileExists(String? path) async {
|
||||
if (path == null || path.isEmpty) return false;
|
||||
// For CUE virtual paths, check if the base .cue file exists
|
||||
final realPath = isCueVirtualPath(path) ? stripCueTrackSuffix(path) : path;
|
||||
if (isContentUri(realPath)) {
|
||||
return PlatformBridge.safExists(realPath);
|
||||
@@ -288,7 +272,6 @@ Future<void> deleteFile(String? path) async {
|
||||
|
||||
Future<FileAccessStat?> fileStat(String? path) async {
|
||||
if (path == null || path.isEmpty) return null;
|
||||
// For CUE virtual paths, stat the base .cue file
|
||||
final realPath = isCueVirtualPath(path) ? stripCueTrackSuffix(path) : path;
|
||||
if (isContentUri(realPath)) {
|
||||
final stat = await PlatformBridge.safStat(realPath);
|
||||
|
||||
@@ -25,9 +25,6 @@ const _audioExtensions = <String>[
|
||||
const _maxPathMatchKeyCacheSize = 6000;
|
||||
final Map<String, Set<String>> _pathMatchKeyCache = <String, Set<String>>{};
|
||||
|
||||
/// Strips a trailing audio extension from [path] if present.
|
||||
/// Returns the path without extension, or `null` if no known audio extension
|
||||
/// was found.
|
||||
String? _stripAudioExtension(String path) {
|
||||
final lower = path.toLowerCase();
|
||||
for (final ext in _audioExtensions) {
|
||||
@@ -115,8 +112,6 @@ Set<String> buildPathMatchKeys(String? filePath) {
|
||||
|
||||
addNormalized(cleaned);
|
||||
|
||||
// Add extension-stripped variants so that a file converted from one audio
|
||||
// format to another (e.g. Song.flac → Song.opus) still matches.
|
||||
final extensionStrippedKeys = <String>{};
|
||||
for (final key in keys) {
|
||||
final stripped = _stripAudioExtension(key);
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. Staggered List Item – fade + slide-up entrance with index-based delay
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Wraps a child in a staggered fade-in + slide-up animation.
|
||||
///
|
||||
/// [index] controls the stagger delay (each item delayed by [staggerDelay]).
|
||||
@@ -31,7 +27,6 @@ class StaggeredListItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!animate || index >= maxAnimatedItems) return child;
|
||||
// Cap the delay so very long lists don't have absurd wait times.
|
||||
final cappedIndex = index.clamp(0, maxAnimatedItems - 1);
|
||||
final delay = staggerDelay * cappedIndex;
|
||||
final totalDuration = duration + delay;
|
||||
@@ -42,7 +37,6 @@ class StaggeredListItem extends StatelessWidget {
|
||||
duration: totalDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (context, value, child) {
|
||||
// Compute the effective progress after the stagger delay.
|
||||
final delayFraction = totalDuration.inMilliseconds > 0
|
||||
? delay.inMilliseconds / totalDuration.inMilliseconds
|
||||
: 0.0;
|
||||
@@ -62,10 +56,6 @@ class StaggeredListItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 2. Animated State Switcher – crossfade between loading / content / empty / error
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A convenience wrapper around [AnimatedSwitcher] that crossfades between
|
||||
/// different widget states (loading, content, empty, error).
|
||||
///
|
||||
@@ -94,10 +84,6 @@ class AnimatedStateSwitcher extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 3. Shared Page Route – consistent slide-from-right transition
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Creates a platform-aware material route.
|
||||
///
|
||||
/// This intentionally defers route transitions to Flutter's material route and
|
||||
@@ -107,10 +93,6 @@ Route<T> slidePageRoute<T>({required Widget page}) {
|
||||
return MaterialPageRoute<T>(builder: (context) => page);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 4. Shimmer / Skeleton Loading Widget
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A shimmer effect widget that can wrap skeleton placeholders.
|
||||
class ShimmerLoading extends StatefulWidget {
|
||||
final Widget child;
|
||||
@@ -682,10 +664,6 @@ class HomeSearchSkeleton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 5. Animated Selection Checkbox – scales in when entering selection mode
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// An animated selection indicator that scales in/out and crossfades the
|
||||
/// checked/unchecked state.
|
||||
class AnimatedSelectionCheckbox extends StatelessWidget {
|
||||
@@ -746,10 +724,6 @@ class AnimatedSelectionCheckbox extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 6. Download Success Animation – green flash + checkmark
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A widget that briefly flashes a success color behind its child and shows
|
||||
/// an animated checkmark when [showSuccess] transitions to true.
|
||||
class DownloadSuccessOverlay extends StatefulWidget {
|
||||
@@ -775,8 +749,6 @@ class _DownloadSuccessOverlayState extends State<DownloadSuccessOverlay>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialise from the current widget value so items that are already
|
||||
// completed when first built do not play the flash animation.
|
||||
_wasSuccess = widget.showSuccess;
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
@@ -821,10 +793,6 @@ class _DownloadSuccessOverlayState extends State<DownloadSuccessOverlay>
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 7. Badge Bump Animation – scales the badge when count changes
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Wraps a [Badge] child and plays a brief scale-bump whenever [count] changes.
|
||||
class AnimatedBadge extends StatefulWidget {
|
||||
final int count;
|
||||
@@ -877,10 +845,6 @@ class _AnimatedBadgeState extends State<AnimatedBadge>
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 8. Animated Removal Item – fade + slide out when removed from a list
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a removal animation for [AnimatedList] items.
|
||||
/// Use as the `builder` callback in [AnimatedListState.removeItem].
|
||||
Widget buildRemovalAnimation(Widget child, Animation<double> animation) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
|
||||
/// Built-in service info with quality options
|
||||
class BuiltInService {
|
||||
final String id;
|
||||
final String label;
|
||||
@@ -22,7 +21,6 @@ class BuiltInService {
|
||||
});
|
||||
}
|
||||
|
||||
/// Default quality options for each built-in service
|
||||
const _builtInServices = [
|
||||
BuiltInService(
|
||||
id: 'tidal',
|
||||
@@ -99,7 +97,6 @@ class DownloadServicePicker extends ConsumerStatefulWidget {
|
||||
ConsumerState<DownloadServicePicker> createState() =>
|
||||
_DownloadServicePickerState();
|
||||
|
||||
/// Show the download service picker as a modal bottom sheet
|
||||
static void show(
|
||||
BuildContext context, {
|
||||
String? trackName,
|
||||
@@ -135,7 +132,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Default to recommended service if available, otherwise use default
|
||||
final recommended = widget.recommendedService;
|
||||
if (recommended != null && recommended.isNotEmpty) {
|
||||
_selectedService = recommended;
|
||||
@@ -144,7 +140,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get quality options for the selected service
|
||||
List<QualityOption> _getQualityOptions() {
|
||||
final builtIn = _builtInServices
|
||||
.where((s) => s.id == _selectedService)
|
||||
@@ -161,8 +156,6 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
return ext.qualityOptions;
|
||||
}
|
||||
|
||||
// Extensions without quality options use Tidal's options as default
|
||||
// since the download will fall back to built-in providers anyway.
|
||||
return _builtInServices.firstWhere((s) => s.id == 'tidal').qualityOptions;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,10 +29,6 @@ class ReEnrichFieldSelection {
|
||||
bool get isAll => fields.length == ReEnrichFields.all.length;
|
||||
}
|
||||
|
||||
/// Shows a bottom sheet that lets the user pick which metadata fields to update
|
||||
/// during a bulk re-enrich operation.
|
||||
///
|
||||
/// Returns `null` when cancelled, or a [ReEnrichFieldSelection] when confirmed.
|
||||
Future<ReEnrichFieldSelection?> showReEnrichFieldDialog(
|
||||
BuildContext context, {
|
||||
required int selectedCount,
|
||||
@@ -128,7 +124,6 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Title
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 4),
|
||||
child: Text(
|
||||
@@ -138,7 +133,6 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Subtitle
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 4),
|
||||
child: Text(
|
||||
@@ -158,7 +152,6 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// Select All
|
||||
CheckboxListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
title: Text(
|
||||
@@ -171,7 +164,6 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
// Individual fields
|
||||
for (final field in ReEnrichFields.all)
|
||||
CheckboxListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
@@ -182,7 +174,6 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> {
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Confirm button
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: SizedBox(
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A grouped settings card that connects items together like Android Settings
|
||||
/// Items are connected with no gap between them, only separated when changing groups
|
||||
class SettingsGroup extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
@@ -17,14 +15,10 @@ class SettingsGroup extends StatelessWidget {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
// Use a more contrasting color for cards
|
||||
// In dark mode with dynamic color, surfaceContainerHighest can be too similar to surface
|
||||
// So we add a slight white overlay to make it more visible
|
||||
// In light mode with dynamic color, we add a slight black overlay for the same reason
|
||||
final cardColor = isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.04), colorScheme.surface);
|
||||
|
||||
|
||||
return Container(
|
||||
margin: margin ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
@@ -44,7 +38,6 @@ class SettingsGroup extends StatelessWidget {
|
||||
}
|
||||
|
||||
class SettingsItem extends StatelessWidget {
|
||||
|
||||
final IconData? icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
@@ -126,7 +119,6 @@ class SettingsItem extends StatelessWidget {
|
||||
}
|
||||
|
||||
class SettingsSwitchItem extends StatelessWidget {
|
||||
|
||||
final IconData? icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
@@ -214,7 +206,6 @@ class SettingsSwitchItem extends StatelessWidget {
|
||||
}
|
||||
|
||||
class SettingsSectionHeader extends StatelessWidget {
|
||||
|
||||
final String title;
|
||||
|
||||
const SettingsSectionHeader({super.key, required this.title});
|
||||
|
||||
@@ -74,7 +74,6 @@ class _TrackOptionsSheet extends ConsumerWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header with drag handle + track info (matches _TrackInfoHeader)
|
||||
Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
@@ -163,7 +162,6 @@ class _TrackOptionsSheet extends ConsumerWidget {
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
|
||||
// Action items (matches _QualityOption style)
|
||||
_OptionTile(
|
||||
icon: isLoved ? Icons.favorite : Icons.favorite_border,
|
||||
iconColor: isLoved ? colorScheme.error : null,
|
||||
@@ -234,7 +232,6 @@ class _TrackOptionsSheet extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Styled like _QualityOption in download_service_picker.dart
|
||||
class _OptionTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color? iconColor;
|
||||
|
||||
Reference in New Issue
Block a user