diff --git a/CHANGELOG.md b/CHANGELOG.md index ecf8a8cc..25d41bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,10 @@ ### Added +- **Deezer Download Service**: Deezer is now available as a built-in download service (FLAC CD Quality). - **Smarter YouTube Downloads**: If the YouTube Music extension is installed, the app now uses it first to find the correct song — more accurate than SongLink, especially for new releases. - **Songs-Only Search Filter**: YouTube Music extension search now filters results server-side, so you only get actual songs — no music videos or covers mixed in. +- **Qobuz Squid.wtf Fallback**: Added Squid.wtf as an additional Qobuz download provider. - **Qobuz Search Fallback**: If Qobuz API search returns nothing, the app now tries the Qobuz web store as a backup to find the track. - **Better ISRC Lookup**: Tracks can now be resolved via ISRC even without a Spotify ID, using Deezer as an intermediary. diff --git a/go_backend/deezer_download.go b/go_backend/deezer_download.go index fa613394..0c64b658 100644 --- a/go_backend/deezer_download.go +++ b/go_backend/deezer_download.go @@ -15,6 +15,7 @@ import ( ) const deezerYoinkifyURL = "https://yoinkify.lol/api/download" +const deezerMusicDLURL = "https://www.musicdl.me/api/download" type YoinkifyRequest struct { URL string `json:"url"` @@ -194,6 +195,195 @@ func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outpu return nil } +func resolveDeezerTrackURL(req DownloadRequest) (string, error) { + deezerID := strings.TrimSpace(req.DeezerID) + if deezerID == "" { + if prefixed, found := strings.CutPrefix(strings.TrimSpace(req.SpotifyID), "deezer:"); found { + deezerID = strings.TrimSpace(prefixed) + } + } + if deezerID != "" { + return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil + } + + // Try resolving Deezer ID from Spotify ID via SongLink + spotifyID := strings.TrimSpace(req.SpotifyID) + if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) { + songlink := NewSongLinkClient() + availability, err := songlink.CheckTrackAvailability(spotifyID, "") + if err == nil && availability.Deezer && availability.DeezerURL != "" { + return availability.DeezerURL, nil + } + } + + // Try resolving from ISRC + isrc := strings.TrimSpace(req.ISRC) + if isrc != "" { + ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout) + defer cancel() + track, err := GetDeezerClient().SearchByISRC(ctx, isrc) + if err == nil && track != nil { + deezerID = songLinkExtractDeezerTrackID(track) + if deezerID != "" { + return fmt.Sprintf("https://www.deezer.com/track/%s", deezerID), nil + } + } + } + + return "", fmt.Errorf("could not resolve Deezer track URL") +} + +type deezerMusicDLRequest struct { + Platform string `json:"platform"` + URL string `json:"url"` +} + +func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, error) { + payload := deezerMusicDLRequest{ + Platform: "deezer", + URL: deezerTrackURL, + } + jsonData, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to encode MusicDL request: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, deezerMusicDLURL, bytes.NewReader(jsonData)) + if err != nil { + 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) + if err != nil { + return "", fmt.Errorf("MusicDL request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if err != nil { + return "", fmt.Errorf("failed to read MusicDL response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("MusicDL returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + return "", fmt.Errorf("invalid MusicDL JSON: %w", err) + } + + if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" { + return "", fmt.Errorf("MusicDL error: %s", errMsg) + } + + // Try various response fields for download URL + for _, key := range []string{"download_url", "url", "link"} { + if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" { + return strings.TrimSpace(urlVal), nil + } + } + if data, ok := raw["data"].(map[string]any); ok { + for _, key := range []string{"download_url", "url", "link"} { + if urlVal, ok := data[key].(string); ok && strings.TrimSpace(urlVal) != "" { + return strings.TrimSpace(urlVal), nil + } + } + } + + return "", fmt.Errorf("no download URL found in MusicDL response") +} + +func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, outputFD int, itemID string) error { + GoLog("[Deezer] Resolving download URL via MusicDL for: %s\n", deezerTrackURL) + + downloadURL, err := c.GetMusicDLDownloadURL(deezerTrackURL) + if err != nil { + return err + } + GoLog("[Deezer] MusicDL returned download URL, starting download...\n") + + ctx := context.Background() + if itemID != "" { + StartItemProgress(itemID) + defer CompleteItemProgress(itemID) + ctx = initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) + } + + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } + + req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil) + if err != nil { + return fmt.Errorf("failed to create download request: %w", err) + } + req.Header.Set("User-Agent", getRandomUserAgent()) + + resp, err := c.httpClient.Do(req) + if err != nil { + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } + return fmt.Errorf("download request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download returned HTTP %d", resp.StatusCode) + } + + expectedSize := resp.ContentLength + if expectedSize > 0 && itemID != "" { + SetItemBytesTotal(itemID, expectedSize) + } + + out, err := openOutputForWrite(outputPath, outputFD) + if err != nil { + return err + } + + bufWriter := bufio.NewWriterSize(out, 256*1024) + var written int64 + if itemID != "" { + pw := NewItemProgressWriter(bufWriter, itemID) + written, err = io.Copy(pw, resp.Body) + } else { + written, err = io.Copy(bufWriter, resp.Body) + } + + flushErr := bufWriter.Flush() + closeErr := out.Close() + + if err != nil { + cleanupOutputOnError(outputPath, outputFD) + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } + return fmt.Errorf("download interrupted: %w", err) + } + if flushErr != nil { + cleanupOutputOnError(outputPath, outputFD) + return fmt.Errorf("failed to flush output: %w", flushErr) + } + if closeErr != nil { + cleanupOutputOnError(outputPath, outputFD) + return fmt.Errorf("failed to close output: %w", closeErr) + } + + if expectedSize > 0 && written != expectedSize { + cleanupOutputOnError(outputPath, outputFD) + return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) + } + + GoLog("[Deezer] Downloaded via MusicDL: %.2f MB\n", float64(written)/(1024*1024)) + return nil +} + func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) { deezerClient := GetDeezerClient() isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" @@ -254,11 +444,30 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) { ) }() - if err := deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID); err != nil { - if errors.Is(err, ErrDownloadCancelled) { - return DeezerDownloadResult{}, ErrDownloadCancelled + // Try MusicDL first (better quality), fallback to Yoinkify + var downloadErr error + deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req) + if deezerURLErr == nil { + GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL) + downloadErr = deezerClient.DownloadFromMusicDL(deezerTrackURL, outputPath, req.OutputFD, req.ItemID) + if downloadErr != nil { + if errors.Is(downloadErr, ErrDownloadCancelled) { + return DeezerDownloadResult{}, ErrDownloadCancelled + } + GoLog("[Deezer] MusicDL failed: %v, falling back to Yoinkify\n", downloadErr) + } + } else { + GoLog("[Deezer] Could not resolve Deezer URL: %v, using Yoinkify directly\n", deezerURLErr) + } + + if downloadErr != nil || deezerURLErr != nil { + downloadErr = deezerClient.DownloadFromYoinkify(spotifyURL, outputPath, req.OutputFD, req.ItemID) + if downloadErr != nil { + if errors.Is(downloadErr, ErrDownloadCancelled) { + return DeezerDownloadResult{}, ErrDownloadCancelled + } + return DeezerDownloadResult{}, fmt.Errorf("deezer download failed (MusicDL + Yoinkify): %w", downloadErr) } - return DeezerDownloadResult{}, fmt.Errorf("deezer yoinkify failed: %w", err) } <-parallelDone diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 1963e4cc..1b55f744 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -811,7 +811,7 @@ class ExtensionNotifier extends Notifier { } } - for (final provider in const ['tidal', 'qobuz', 'amazon']) { + for (final provider in const ['tidal', 'qobuz', 'amazon', 'deezer']) { if (!result.contains(provider)) { result.add(provider); } @@ -880,7 +880,7 @@ class ExtensionNotifier extends Notifier { } List getAllDownloadProviders() { - final providers = ['tidal', 'qobuz', 'amazon']; + final providers = ['tidal', 'qobuz', 'amazon', 'deezer']; for (final ext in state.extensions) { if (ext.enabled && ext.hasDownloadProvider) { providers.add(ext.id); diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 37918235..2e1e8e8c 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -23,7 +23,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget { } class _DownloadSettingsPageState extends ConsumerState { - static const _builtInServices = ['tidal', 'qobuz', 'amazon']; + static const _builtInServices = ['tidal', 'qobuz', 'amazon', 'deezer']; static const _songLinkRegions = [ 'AD', 'AE', @@ -2039,7 +2039,7 @@ class _ServiceSelector extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final extState = ref.watch(extensionProvider); - final builtInServiceIds = ['tidal', 'qobuz', 'amazon', 'youtube']; + final builtInServiceIds = ['tidal', 'qobuz', 'amazon', 'deezer', 'youtube']; final extensionProviders = extState.extensions .where((e) => e.enabled && e.hasDownloadProvider) diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 21b05b5e..881b6101 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -78,6 +78,17 @@ const _builtInServices = [ ), ], ), + BuiltInService( + id: 'deezer', + label: 'Deezer', + qualityOptions: [ + QualityOption( + id: 'FLAC', + label: 'FLAC Lossless', + description: '16-bit / 44.1kHz (CD Quality)', + ), + ], + ), BuiltInService( id: 'youtube', label: 'YouTube', diff --git a/pubspec.lock b/pubspec.lock index 0370159a..4fc7941f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -557,6 +557,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" json_annotation: dependency: "direct main" description: @@ -625,18 +633,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -1158,26 +1166,26 @@ packages: dependency: transitive description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.12" timezone: dependency: transitive description: