mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-24 03:42:32 +02:00
fix: duration display, audio quality from file, artist verification, metadata case-sensitivity, settings navigation freeze
- Fix duration showing incorrect values (ms to seconds conversion) - Read audio quality from FLAC file instead of trusting API - Add artist verification for Tidal/Qobuz/Amazon downloads - Fix FLAC metadata case-insensitive replacement - Fix settings navigation freeze on Android 14+ (PopScope handling)
This commit is contained in:
@@ -36,6 +36,63 @@ type DoubleDoubleStatusResponse struct {
|
||||
} `json:"current"`
|
||||
}
|
||||
|
||||
// amazonArtistsMatch checks if the artist names are similar enough
|
||||
func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||
|
||||
// Exact match
|
||||
if normExpected == normFound {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if one contains the other
|
||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check first artist (before comma or feat)
|
||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||
|
||||
foundFirst := strings.Split(normFound, ",")[0]
|
||||
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||
foundFirst = strings.TrimSpace(foundFirst)
|
||||
|
||||
if expectedFirst == foundFirst {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if first artist is contained in the other
|
||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||
// assume they're the same artist with different transliteration
|
||||
expectedASCII := amazonIsASCIIString(expectedArtist)
|
||||
foundASCII := amazonIsASCIIString(foundArtist)
|
||||
if expectedASCII != foundASCII {
|
||||
fmt.Printf("[Amazon] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// amazonIsASCIIString checks if a string contains only ASCII characters
|
||||
func amazonIsASCIIString(s string) bool {
|
||||
for _, r := range s {
|
||||
if r > 127 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service
|
||||
func NewAmazonDownloader() *AmazonDownloader {
|
||||
return &AmazonDownloader{
|
||||
@@ -295,6 +352,15 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
// Verify artist matches
|
||||
if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) {
|
||||
fmt.Printf("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName)
|
||||
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
||||
}
|
||||
|
||||
// Log match found
|
||||
fmt.Printf("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
||||
|
||||
// Build filename using Spotify metadata (more accurate)
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
|
||||
+49
-10
@@ -5,6 +5,7 @@ package gobackend
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -216,17 +217,36 @@ func DownloadTrack(requestJSON string) (string, error) {
|
||||
|
||||
// Check if file already exists
|
||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||
actualPath := result.FilePath[7:]
|
||||
// Read actual quality from existing file
|
||||
quality, qErr := GetAudioQuality(actualPath)
|
||||
if qErr == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
}
|
||||
resp := DownloadResponse{
|
||||
Success: true,
|
||||
Message: "File already exists",
|
||||
FilePath: result.FilePath[7:],
|
||||
AlreadyExists: true,
|
||||
Service: req.Service,
|
||||
Success: true,
|
||||
Message: "File already exists",
|
||||
FilePath: actualPath,
|
||||
AlreadyExists: true,
|
||||
ActualBitDepth: result.BitDepth,
|
||||
ActualSampleRate: result.SampleRate,
|
||||
Service: req.Service,
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// Read actual quality from downloaded file (more accurate than API)
|
||||
quality, qErr := GetAudioQuality(result.FilePath)
|
||||
if qErr == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
} else {
|
||||
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
|
||||
}
|
||||
|
||||
resp := DownloadResponse{
|
||||
Success: true,
|
||||
Message: "Download complete",
|
||||
@@ -314,17 +334,36 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
if err == nil {
|
||||
// Check if file already exists
|
||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||
actualPath := result.FilePath[7:]
|
||||
// Read actual quality from existing file
|
||||
quality, qErr := GetAudioQuality(actualPath)
|
||||
if qErr == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
}
|
||||
resp := DownloadResponse{
|
||||
Success: true,
|
||||
Message: "File already exists",
|
||||
FilePath: result.FilePath[7:],
|
||||
AlreadyExists: true,
|
||||
Service: service,
|
||||
Success: true,
|
||||
Message: "File already exists",
|
||||
FilePath: actualPath,
|
||||
AlreadyExists: true,
|
||||
ActualBitDepth: result.BitDepth,
|
||||
ActualSampleRate: result.SampleRate,
|
||||
Service: service,
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// Read actual quality from downloaded file (more accurate than API)
|
||||
quality, qErr := GetAudioQuality(result.FilePath)
|
||||
if qErr == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
fmt.Printf("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
} else {
|
||||
fmt.Printf("[Download] Could not read quality from file: %v\n", qErr)
|
||||
}
|
||||
|
||||
resp := DownloadResponse{
|
||||
Success: true,
|
||||
Message: "Downloaded from " + service,
|
||||
|
||||
+10
-3
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-flac/flacpicture"
|
||||
"github.com/go-flac/flacvorbis"
|
||||
@@ -273,10 +274,16 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
// Remove existing
|
||||
// Remove existing (case-insensitive comparison for Vorbis comments)
|
||||
keyUpper := strings.ToUpper(key)
|
||||
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
||||
if len(cmt.Comments[i]) > len(key)+1 && cmt.Comments[i][:len(key)+1] == key+"=" {
|
||||
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
|
||||
comment := cmt.Comments[i]
|
||||
eqIdx := strings.Index(comment, "=")
|
||||
if eqIdx > 0 {
|
||||
existingKey := strings.ToUpper(comment[:eqIdx])
|
||||
if existingKey == keyUpper {
|
||||
cmt.Comments = append(cmt.Comments[:i], cmt.Comments[i+1:]...)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add new
|
||||
|
||||
+73
-14
@@ -9,6 +9,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// QobuzDownloader handles Qobuz downloads
|
||||
@@ -39,6 +40,63 @@ type QobuzTrack struct {
|
||||
} `json:"performer"`
|
||||
}
|
||||
|
||||
// qobuzArtistsMatch checks if the artist names are similar enough
|
||||
func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||
|
||||
// Exact match
|
||||
if normExpected == normFound {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if one contains the other
|
||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check first artist (before comma or feat)
|
||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||
|
||||
foundFirst := strings.Split(normFound, ",")[0]
|
||||
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||
foundFirst = strings.TrimSpace(foundFirst)
|
||||
|
||||
if expectedFirst == foundFirst {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if first artist is contained in the other
|
||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||
// assume they're the same artist with different transliteration
|
||||
expectedASCII := qobuzIsASCIIString(expectedArtist)
|
||||
foundASCII := qobuzIsASCIIString(foundArtist)
|
||||
if expectedASCII != foundASCII {
|
||||
fmt.Printf("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// qobuzIsASCIIString checks if a string contains only ASCII characters
|
||||
func qobuzIsASCIIString(s string) bool {
|
||||
for _, r := range s {
|
||||
if r > 127 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// NewQobuzDownloader creates a new Qobuz downloader
|
||||
func NewQobuzDownloader() *QobuzDownloader {
|
||||
return &QobuzDownloader{
|
||||
@@ -451,34 +509,35 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
// Strategy 1: Search by ISRC with duration verification
|
||||
if req.ISRC != "" {
|
||||
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
||||
// Verify artist
|
||||
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||
fmt.Printf("[Qobuz] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, track.Performer.Name)
|
||||
track = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Search by metadata with duration verification
|
||||
if track == nil {
|
||||
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
||||
// Verify artist
|
||||
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
|
||||
fmt.Printf("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, track.Performer.Name)
|
||||
track = nil
|
||||
}
|
||||
}
|
||||
|
||||
if track == nil {
|
||||
errMsg := "could not find track on Qobuz"
|
||||
errMsg := "could not find matching track on Qobuz (artist/duration mismatch)"
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
||||
}
|
||||
|
||||
// Final duration verification
|
||||
if expectedDurationSec > 0 {
|
||||
durationDiff := track.Duration - expectedDurationSec
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
}
|
||||
if durationDiff > 30 {
|
||||
return QobuzDownloadResult{}, fmt.Errorf("duration mismatch: expected %ds, found %ds (diff: %ds). Track may be wrong version",
|
||||
expectedDurationSec, track.Duration, durationDiff)
|
||||
}
|
||||
fmt.Printf("[Qobuz] Duration verified: expected %ds, found %ds (diff: %ds)\n",
|
||||
expectedDurationSec, track.Duration, durationDiff)
|
||||
}
|
||||
// Log match found
|
||||
fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
|
||||
|
||||
// Build filename
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
|
||||
+128
-22
@@ -869,6 +869,64 @@ type TidalDownloadResult struct {
|
||||
SampleRate int
|
||||
}
|
||||
|
||||
// artistsMatch checks if the artist names are similar enough
|
||||
func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||
normSpotify := strings.ToLower(strings.TrimSpace(spotifyArtist))
|
||||
normTidal := strings.ToLower(strings.TrimSpace(tidalArtist))
|
||||
|
||||
// Exact match
|
||||
if normSpotify == normTidal {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if one contains the other (for cases like "Artist" vs "Artist feat. Someone")
|
||||
if strings.Contains(normSpotify, normTidal) || strings.Contains(normTidal, normSpotify) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check first artist (before comma or feat)
|
||||
spotifyFirst := strings.Split(normSpotify, ",")[0]
|
||||
spotifyFirst = strings.Split(spotifyFirst, " feat")[0]
|
||||
spotifyFirst = strings.Split(spotifyFirst, " ft.")[0]
|
||||
spotifyFirst = strings.TrimSpace(spotifyFirst)
|
||||
|
||||
tidalFirst := strings.Split(normTidal, ",")[0]
|
||||
tidalFirst = strings.Split(tidalFirst, " feat")[0]
|
||||
tidalFirst = strings.Split(tidalFirst, " ft.")[0]
|
||||
tidalFirst = strings.TrimSpace(tidalFirst)
|
||||
|
||||
if spotifyFirst == tidalFirst {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if first artist is contained in the other
|
||||
if strings.Contains(spotifyFirst, tidalFirst) || strings.Contains(tidalFirst, spotifyFirst) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
||||
// assume they're the same artist with different transliteration
|
||||
// This handles cases like "鈴木雅之" vs "Masayuki Suzuki"
|
||||
spotifyASCII := isASCIIString(spotifyArtist)
|
||||
tidalASCII := isASCIIString(tidalArtist)
|
||||
if spotifyASCII != tidalASCII {
|
||||
fmt.Printf("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isASCIIString checks if a string contains only ASCII characters
|
||||
func isASCIIString(s string) bool {
|
||||
for _, r := range s {
|
||||
if r > 127 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// downloadFromTidal downloads a track using the request parameters
|
||||
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
downloader := NewTidalDownloader()
|
||||
@@ -892,17 +950,36 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
||||
if idErr == nil {
|
||||
track, err = downloader.GetTrackInfoByID(trackID)
|
||||
// Verify duration if we have expected duration
|
||||
if track != nil && expectedDurationSec > 0 {
|
||||
durationDiff := track.Duration - expectedDurationSec
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
if track != nil {
|
||||
// Get artist name from track
|
||||
tidalArtist := track.Artist.Name
|
||||
if len(track.Artists) > 0 {
|
||||
var artistNames []string
|
||||
for _, a := range track.Artists {
|
||||
artistNames = append(artistNames, a.Name)
|
||||
}
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
}
|
||||
// Allow 30 seconds tolerance
|
||||
if durationDiff > 30 {
|
||||
fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
||||
expectedDurationSec, track.Duration)
|
||||
track = nil // Reject this match
|
||||
|
||||
// Verify artist matches
|
||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||
fmt.Printf("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, tidalArtist)
|
||||
track = nil
|
||||
}
|
||||
|
||||
// Verify duration if we have expected duration
|
||||
if track != nil && expectedDurationSec > 0 {
|
||||
durationDiff := track.Duration - expectedDurationSec
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
}
|
||||
// Allow 30 seconds tolerance
|
||||
if durationDiff > 30 {
|
||||
fmt.Printf("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
|
||||
expectedDurationSec, track.Duration)
|
||||
track = nil // Reject this match
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -912,34 +989,63 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
// Strategy 2: Search by ISRC with duration verification
|
||||
if track == nil && req.ISRC != "" {
|
||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
|
||||
// Verify artist for ISRC match too
|
||||
if track != nil {
|
||||
tidalArtist := track.Artist.Name
|
||||
if len(track.Artists) > 0 {
|
||||
var artistNames []string
|
||||
for _, a := range track.Artists {
|
||||
artistNames = append(artistNames, a.Name)
|
||||
}
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
}
|
||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||
fmt.Printf("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, tidalArtist)
|
||||
track = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Search by metadata only (no ISRC requirement)
|
||||
if track == nil {
|
||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
|
||||
// Verify artist for metadata search too
|
||||
if track != nil {
|
||||
tidalArtist := track.Artist.Name
|
||||
if len(track.Artists) > 0 {
|
||||
var artistNames []string
|
||||
for _, a := range track.Artists {
|
||||
artistNames = append(artistNames, a.Name)
|
||||
}
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
}
|
||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||
fmt.Printf("[Tidal] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||
req.ArtistName, tidalArtist)
|
||||
track = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if track == nil {
|
||||
errMsg := "could not find track on Tidal"
|
||||
errMsg := "could not find matching track on Tidal (artist/duration mismatch)"
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
|
||||
}
|
||||
|
||||
// Final duration verification
|
||||
if expectedDurationSec > 0 {
|
||||
durationDiff := track.Duration - expectedDurationSec
|
||||
if durationDiff < 0 {
|
||||
durationDiff = -durationDiff
|
||||
// Final verification logging
|
||||
tidalArtist := track.Artist.Name
|
||||
if len(track.Artists) > 0 {
|
||||
var artistNames []string
|
||||
for _, a := range track.Artists {
|
||||
artistNames = append(artistNames, a.Name)
|
||||
}
|
||||
if durationDiff > 30 {
|
||||
return TidalDownloadResult{}, fmt.Errorf("duration mismatch: expected %ds, found %ds (diff: %ds). Track may be wrong version",
|
||||
expectedDurationSec, track.Duration, durationDiff)
|
||||
}
|
||||
fmt.Printf("[Tidal] Duration verified: expected %ds, found %ds (diff: %ds)\n",
|
||||
expectedDurationSec, track.Duration, durationDiff)
|
||||
tidalArtist = strings.Join(artistNames, ", ")
|
||||
}
|
||||
fmt.Printf("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
|
||||
|
||||
// Build filename
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
|
||||
Reference in New Issue
Block a user