mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 11:48:00 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51b2ad5c77 |
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [2.0.5] - 2026-01-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Large Playlist Support**: Playlists with up to 1000 tracks are now fully fetched (was limited to 100)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Wrong Track Download**: Fixed issue where tracks with same ISRC but different versions (e.g., short/instrumental vs full version) would download the wrong track. Now verifies duration matches before downloading (30 second tolerance).
|
||||||
|
|
||||||
## [2.0.4] - 2026-01-04
|
## [2.0.4] - 2026-01-04
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -132,7 +132,8 @@ type DownloadRequest struct {
|
|||||||
DiscNumber int `json:"disc_number"`
|
DiscNumber int `json:"disc_number"`
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
||||||
|
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadResponse represents the result of a download
|
// DownloadResponse represents the result of a download
|
||||||
|
|||||||
+148
-10
@@ -112,8 +112,96 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
|||||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchTrackByISRCWithTitle searches for a track by ISRC with duration verification
|
||||||
|
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
|
||||||
|
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
|
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", searchURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Tracks struct {
|
||||||
|
Items []QobuzTrack `json:"items"`
|
||||||
|
} `json:"tracks"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find ISRC matches
|
||||||
|
var isrcMatches []*QobuzTrack
|
||||||
|
for i := range result.Tracks.Items {
|
||||||
|
if result.Tracks.Items[i].ISRC == isrc {
|
||||||
|
isrcMatches = append(isrcMatches, &result.Tracks.Items[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(isrcMatches) > 0 {
|
||||||
|
// Verify duration if provided
|
||||||
|
if expectedDurationSec > 0 {
|
||||||
|
var durationVerifiedMatches []*QobuzTrack
|
||||||
|
for _, track := range isrcMatches {
|
||||||
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
// Allow 30 seconds tolerance
|
||||||
|
if durationDiff <= 30 {
|
||||||
|
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(durationVerifiedMatches) > 0 {
|
||||||
|
fmt.Printf("[Qobuz] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||||
|
durationVerifiedMatches[0].Title, expectedDurationSec, durationVerifiedMatches[0].Duration)
|
||||||
|
return durationVerifiedMatches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISRC matches but duration doesn't
|
||||||
|
fmt.Printf("[Qobuz] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||||
|
isrc, expectedDurationSec, isrcMatches[0].Duration)
|
||||||
|
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version)",
|
||||||
|
expectedDurationSec, isrcMatches[0].Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No duration to verify, return first match
|
||||||
|
fmt.Printf("[Qobuz] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||||
|
return isrcMatches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Tracks.Items) == 0 {
|
||||||
|
return nil, fmt.Errorf("no tracks found for ISRC: %s", isrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchTrackByISRCWithTitle is deprecated, use SearchTrackByISRCWithDuration instead
|
||||||
|
func (q *QobuzDownloader) SearchTrackByISRCWithTitle(isrc, expectedTitle string) (*QobuzTrack, error) {
|
||||||
|
return q.SearchTrackByISRCWithDuration(isrc, 0)
|
||||||
|
}
|
||||||
|
|
||||||
// SearchTrackByMetadata searches for a track using artist name and track name
|
// SearchTrackByMetadata searches for a track using artist name and track name
|
||||||
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*QobuzTrack, error) {
|
||||||
|
return q.SearchTrackByMetadataWithDuration(trackName, artistName, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchTrackByMetadataWithDuration searches for a track with duration verification
|
||||||
|
func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
|
|
||||||
// Try multiple search strategies
|
// Try multiple search strategies
|
||||||
@@ -129,6 +217,8 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
queries = append(queries, trackName)
|
queries = append(queries, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var allTracks []QobuzTrack
|
||||||
|
|
||||||
for _, query := range queries {
|
for _, query := range queries {
|
||||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(query), q.appID)
|
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(query), q.appID)
|
||||||
|
|
||||||
@@ -159,19 +249,50 @@ func (q *QobuzDownloader) SearchTrackByMetadata(trackName, artistName string) (*
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
|
|
||||||
if len(result.Tracks.Items) > 0 {
|
if len(result.Tracks.Items) > 0 {
|
||||||
// Return first result with best quality
|
allTracks = append(allTracks, result.Tracks.Items...)
|
||||||
for i := range result.Tracks.Items {
|
}
|
||||||
track := &result.Tracks.Items[i]
|
}
|
||||||
|
|
||||||
|
if len(allTracks) == 0 {
|
||||||
|
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If duration verification is requested
|
||||||
|
if expectedDurationSec > 0 {
|
||||||
|
var durationMatches []*QobuzTrack
|
||||||
|
for i := range allTracks {
|
||||||
|
track := &allTracks[i]
|
||||||
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
if durationDiff <= 30 {
|
||||||
|
durationMatches = append(durationMatches, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(durationMatches) > 0 {
|
||||||
|
// Return best quality among duration matches
|
||||||
|
for _, track := range durationMatches {
|
||||||
if track.MaximumBitDepth >= 24 {
|
if track.MaximumBitDepth >= 24 {
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Return first result if no hi-res found
|
return durationMatches[0], nil
|
||||||
return &result.Tracks.Items[0], nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No duration match found
|
||||||
|
return nil, fmt.Errorf("no tracks found with matching duration (expected %ds)", expectedDurationSec)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("no tracks found for: %s - %s", artistName, trackName)
|
// No duration verification, return best quality
|
||||||
|
for i := range allTracks {
|
||||||
|
track := &allTracks[i]
|
||||||
|
if track.MaximumBitDepth >= 24 {
|
||||||
|
return track, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &allTracks[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
|
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
|
||||||
@@ -321,17 +442,20 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert expected duration from ms to seconds
|
||||||
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
|
|
||||||
var track *QobuzTrack
|
var track *QobuzTrack
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Strategy 1: Search by ISRC
|
// Strategy 1: Search by ISRC with duration verification
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
track, err = downloader.SearchTrackByISRC(req.ISRC)
|
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Search by metadata
|
// Strategy 2: Search by metadata with duration verification
|
||||||
if track == nil {
|
if track == nil {
|
||||||
track, err = downloader.SearchTrackByMetadata(req.TrackName, req.ArtistName)
|
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
|
||||||
}
|
}
|
||||||
|
|
||||||
if track == nil {
|
if track == nil {
|
||||||
@@ -342,6 +466,20 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// Build filename
|
// Build filename
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
|
|||||||
+56
-2
@@ -567,6 +567,7 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string) (*PlaylistResponsePayload, error) {
|
||||||
|
// First request to get playlist info and first batch of tracks
|
||||||
var data struct {
|
var data struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Images []image `json:"images"`
|
Images []image `json:"images"`
|
||||||
@@ -577,7 +578,8 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
Items []struct {
|
Items []struct {
|
||||||
Track *trackFull `json:"track"`
|
Track *trackFull `json:"track"`
|
||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
|
Next string `json:"next"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,7 +593,10 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
info.Owner.Name = data.Name
|
info.Owner.Name = data.Name
|
||||||
info.Owner.Images = firstImageURL(data.Images)
|
info.Owner.Images = firstImageURL(data.Images)
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(data.Tracks.Items))
|
// Pre-allocate with expected capacity
|
||||||
|
tracks := make([]AlbumTrackMetadata, 0, data.Tracks.Total)
|
||||||
|
|
||||||
|
// Add first batch of tracks
|
||||||
for _, item := range data.Tracks.Items {
|
for _, item := range data.Tracks.Items {
|
||||||
if item.Track == nil {
|
if item.Track == nil {
|
||||||
continue
|
continue
|
||||||
@@ -615,6 +620,55 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch remaining tracks using pagination (up to 1000 tracks max)
|
||||||
|
nextURL := data.Tracks.Next
|
||||||
|
maxTracks := 1000
|
||||||
|
|
||||||
|
for nextURL != "" && len(tracks) < maxTracks {
|
||||||
|
var pageData struct {
|
||||||
|
Items []struct {
|
||||||
|
Track *trackFull `json:"track"`
|
||||||
|
} `json:"items"`
|
||||||
|
Next string `json:"next"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.getJSON(ctx, nextURL, token, &pageData); err != nil {
|
||||||
|
// Log error but return what we have so far
|
||||||
|
fmt.Printf("[Spotify] Warning: failed to fetch page, returning %d tracks: %v\n", len(tracks), err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range pageData.Items {
|
||||||
|
if item.Track == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(tracks) >= maxTracks {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tracks = append(tracks, AlbumTrackMetadata{
|
||||||
|
SpotifyID: item.Track.ID,
|
||||||
|
Artists: joinArtists(item.Track.Artists),
|
||||||
|
Name: item.Track.Name,
|
||||||
|
AlbumName: item.Track.Album.Name,
|
||||||
|
AlbumArtist: joinArtists(item.Track.Album.Artists),
|
||||||
|
DurationMS: item.Track.DurationMS,
|
||||||
|
Images: firstImageURL(item.Track.Album.Images),
|
||||||
|
ReleaseDate: item.Track.Album.ReleaseDate,
|
||||||
|
TrackNumber: item.Track.TrackNumber,
|
||||||
|
TotalTracks: item.Track.Album.TotalTracks,
|
||||||
|
DiscNumber: item.Track.DiscNumber,
|
||||||
|
ExternalURL: item.Track.ExternalURL.Spotify,
|
||||||
|
ISRC: item.Track.ExternalID.ISRC,
|
||||||
|
AlbumID: item.Track.Album.ID,
|
||||||
|
AlbumURL: item.Track.Album.ExternalURL.Spotify,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
nextURL = pageData.Next
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[Spotify] Fetched %d tracks from playlist (total: %d)\n", len(tracks), data.Tracks.Total)
|
||||||
|
|
||||||
return &PlaylistResponsePayload{
|
return &PlaylistResponsePayload{
|
||||||
PlaylistInfo: info,
|
PlaylistInfo: info,
|
||||||
TrackList: tracks,
|
TrackList: tracks,
|
||||||
|
|||||||
+93
-5
@@ -315,6 +315,28 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) {
|
|||||||
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
return nil, fmt.Errorf("no exact ISRC match found for: %s", isrc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeTitle normalizes a track title for comparison (kept for potential future use)
|
||||||
|
func normalizeTitle(title string) string {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(title))
|
||||||
|
|
||||||
|
// Remove common suffixes in parentheses or brackets
|
||||||
|
suffixPatterns := []string{
|
||||||
|
" (remaster)", " (remastered)", " (deluxe)", " (deluxe edition)",
|
||||||
|
" (bonus track)", " (single)", " (album version)", " (radio edit)",
|
||||||
|
" [remaster]", " [remastered]", " [deluxe]", " [bonus track]",
|
||||||
|
}
|
||||||
|
for _, suffix := range suffixPatterns {
|
||||||
|
normalized = strings.TrimSuffix(normalized, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove multiple spaces
|
||||||
|
for strings.Contains(normalized, " ") {
|
||||||
|
normalized = strings.ReplaceAll(normalized, " ", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
|
// SearchTrackByMetadataWithISRC searches for a track with ISRC matching priority
|
||||||
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) {
|
||||||
token, err := t.GetAccessToken()
|
token, err := t.GetAccessToken()
|
||||||
@@ -390,14 +412,50 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
return nil, fmt.Errorf("no tracks found for any search query")
|
return nil, fmt.Errorf("no tracks found for any search query")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 1: Match by ISRC (exact match)
|
// Priority 1: Match by ISRC (exact match) WITH title verification
|
||||||
if spotifyISRC != "" {
|
if spotifyISRC != "" {
|
||||||
|
var isrcMatches []*TidalTrack
|
||||||
for i := range allTracks {
|
for i := range allTracks {
|
||||||
track := &allTracks[i]
|
track := &allTracks[i]
|
||||||
if track.ISRC == spotifyISRC {
|
if track.ISRC == spotifyISRC {
|
||||||
return track, nil
|
isrcMatches = append(isrcMatches, track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(isrcMatches) > 0 {
|
||||||
|
// Verify duration first (most important check)
|
||||||
|
if expectedDuration > 0 {
|
||||||
|
var durationVerifiedMatches []*TidalTrack
|
||||||
|
for _, track := range isrcMatches {
|
||||||
|
durationDiff := track.Duration - expectedDuration
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
// Allow 30 seconds tolerance for duration
|
||||||
|
if durationDiff <= 30 {
|
||||||
|
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(durationVerifiedMatches) > 0 {
|
||||||
|
// Return first duration-verified match
|
||||||
|
fmt.Printf("[Tidal] ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||||
|
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
|
||||||
|
return durationVerifiedMatches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISRC matches but duration doesn't - this is likely wrong version
|
||||||
|
fmt.Printf("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||||
|
spotifyISRC, expectedDuration, isrcMatches[0].Duration)
|
||||||
|
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)",
|
||||||
|
expectedDuration, isrcMatches[0].Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No duration to verify, just return first ISRC match
|
||||||
|
fmt.Printf("[Tidal] ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||||
|
return isrcMatches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
// If ISRC was provided but no match found, return error
|
// If ISRC was provided but no match found, return error
|
||||||
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
|
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
|
||||||
}
|
}
|
||||||
@@ -820,6 +878,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert expected duration from ms to seconds
|
||||||
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
|
|
||||||
var track *TidalTrack
|
var track *TidalTrack
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
@@ -831,18 +892,31 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
||||||
if idErr == nil {
|
if idErr == nil {
|
||||||
track, err = downloader.GetTrackInfoByID(trackID)
|
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
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Search by ISRC with multi-strategy fallback
|
// Strategy 2: Search by ISRC with duration verification
|
||||||
if track == nil && req.ISRC != "" {
|
if track == nil && req.ISRC != "" {
|
||||||
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, 0)
|
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Search by metadata only (no ISRC requirement)
|
// Strategy 3: Search by metadata only (no ISRC requirement)
|
||||||
if track == nil {
|
if track == nil {
|
||||||
track, err = downloader.SearchTrackByMetadata(req.TrackName, req.ArtistName)
|
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
|
||||||
}
|
}
|
||||||
|
|
||||||
if track == nil {
|
if track == nil {
|
||||||
@@ -853,6 +927,20 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
|
return TidalDownloadResult{}, fmt.Errorf("tidal search failed: %s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final duration verification
|
||||||
|
if expectedDurationSec > 0 {
|
||||||
|
durationDiff := track.Duration - expectedDurationSec
|
||||||
|
if durationDiff < 0 {
|
||||||
|
durationDiff = -durationDiff
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// Build filename
|
// Build filename
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '2.0.4';
|
static const String version = '2.0.5';
|
||||||
static const String buildNumber = '34';
|
static const String buildNumber = '35';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1007,6 +1007,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
releaseDate: item.track.releaseDate,
|
releaseDate: item.track.releaseDate,
|
||||||
preferredService: item.service,
|
preferredService: item.service,
|
||||||
itemId: item.id, // Pass item ID for progress tracking
|
itemId: item.id, // Pass item ID for progress tracking
|
||||||
|
durationMs: item.track.duration, // Duration in ms for verification
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
result = await PlatformBridge.downloadTrack(
|
result = await PlatformBridge.downloadTrack(
|
||||||
@@ -1025,6 +1026,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
discNumber: item.track.discNumber ?? 1,
|
discNumber: item.track.discNumber ?? 1,
|
||||||
releaseDate: item.track.releaseDate,
|
releaseDate: item.track.releaseDate,
|
||||||
itemId: item.id, // Pass item ID for progress tracking
|
itemId: item.id, // Pass item ID for progress tracking
|
||||||
|
durationMs: item.track.duration, // Duration in ms for verification
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class PlatformBridge {
|
|||||||
int totalTracks = 1,
|
int totalTracks = 1,
|
||||||
String? releaseDate,
|
String? releaseDate,
|
||||||
String? itemId,
|
String? itemId,
|
||||||
|
int durationMs = 0,
|
||||||
}) async {
|
}) async {
|
||||||
final request = jsonEncode({
|
final request = jsonEncode({
|
||||||
'isrc': isrc,
|
'isrc': isrc,
|
||||||
@@ -85,6 +86,7 @@ class PlatformBridge {
|
|||||||
'total_tracks': totalTracks,
|
'total_tracks': totalTracks,
|
||||||
'release_date': releaseDate ?? '',
|
'release_date': releaseDate ?? '',
|
||||||
'item_id': itemId ?? '',
|
'item_id': itemId ?? '',
|
||||||
|
'duration_ms': durationMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
final result = await _channel.invokeMethod('downloadTrack', request);
|
final result = await _channel.invokeMethod('downloadTrack', request);
|
||||||
@@ -111,6 +113,7 @@ class PlatformBridge {
|
|||||||
String? releaseDate,
|
String? releaseDate,
|
||||||
String preferredService = 'tidal',
|
String preferredService = 'tidal',
|
||||||
String? itemId,
|
String? itemId,
|
||||||
|
int durationMs = 0,
|
||||||
}) async {
|
}) async {
|
||||||
final request = jsonEncode({
|
final request = jsonEncode({
|
||||||
'isrc': isrc,
|
'isrc': isrc,
|
||||||
@@ -131,6 +134,7 @@ class PlatformBridge {
|
|||||||
'total_tracks': totalTracks,
|
'total_tracks': totalTracks,
|
||||||
'release_date': releaseDate ?? '',
|
'release_date': releaseDate ?? '',
|
||||||
'item_id': itemId ?? '',
|
'item_id': itemId ?? '',
|
||||||
|
'duration_ms': durationMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
final result = await _channel.invokeMethod('downloadWithFallback', request);
|
final result = await _channel.invokeMethod('downloadWithFallback', request);
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.0.4+34
|
version: 2.0.5+35
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
Reference in New Issue
Block a user