From b33ae905a288383477734e71e876ca2fe557ea54 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 4 Feb 2026 11:09:07 +0700 Subject: [PATCH] feat: add support for Deezer, Tidal, and YT Music links --- CHANGELOG.md | 4 + android/app/src/main/AndroidManifest.xml | 29 +++- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 14 ++ go_backend/exports.go | 45 ++++++ go_backend/songlink.go | 70 ++++++++++ go_backend/tidal.go | 37 +++++ ios/Runner/AppDelegate.swift | 14 ++ ios/Runner/Info.plist | 24 ++++ lib/providers/track_provider.dart | 128 +++++++++++++++++- lib/services/platform_bridge.dart | 10 ++ lib/services/share_intent_service.dart | 55 ++++++-- 11 files changed, 417 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f394f019..b5e89193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ - Cover art extraction from embedded tags (FLAC, MP3, Opus/Ogg) - "Already in Library" notification when downloading existing tracks - Spotify secrets now stored in secure storage (`flutter_secure_storage`) +- **Multi-Service Link Support**: Share links from Deezer, Tidal, and YouTube Music (in addition to Spotify) + - Deezer: Full support for track, album, playlist, artist links + - Tidal: Track links converted via SongLink to Spotify/Deezer for metadata + - YouTube Music: Handled via ytmusic extension URL handler ### Changed diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ac696ec9..0de21e00 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -42,7 +42,7 @@ - + @@ -56,6 +56,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index ea35abe0..b701685d 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -431,6 +431,20 @@ class MainActivity: FlutterActivity() { } result.success(response) } + "parseTidalUrl" -> { + val url = call.argument("url") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.parseTidalURLExport(url) + } + result.success(response) + } + "convertTidalToSpotifyDeezer" -> { + val url = call.argument("url") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.convertTidalToSpotifyDeezer(url) + } + result.success(response) + } "searchDeezerByISRC" -> { val isrc = call.argument("isrc") ?: "" val response = withContext(Dispatchers.IO) { diff --git a/go_backend/exports.go b/go_backend/exports.go index 5cc4b9f8..9b6e5585 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -787,6 +787,51 @@ func ParseDeezerURLExport(url string) (string, error) { return string(jsonBytes), nil } +func ParseTidalURLExport(url string) (string, error) { + resourceType, resourceID, err := parseTidalURL(url) + if err != nil { + return "", err + } + + result := map[string]string{ + "type": resourceType, + "id": resourceID, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func ConvertTidalToSpotifyDeezer(tidalURL string) (string, error) { + client := NewSongLinkClient() + availability, err := client.CheckAvailabilityFromURL(tidalURL) + if err != nil { + return "", err + } + + result := map[string]string{ + "spotify_id": availability.SpotifyID, + "deezer_id": availability.DeezerID, + "deezer_url": availability.DeezerURL, + "spotify_url": "", + } + + if availability.SpotifyID != "" { + result["spotify_url"] = "https://open.spotify.com/track/" + availability.SpotifyID + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + func GetDeezerExtendedMetadata(trackID string) (string, error) { if trackID == "" { return "", fmt.Errorf("empty track ID") diff --git a/go_backend/songlink.go b/go_backend/songlink.go index e9f40773..e7e0fe93 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -499,3 +499,73 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e return availability.AmazonURL, nil } + +func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) { + songLinkRateLimiter.WaitForSlot() + + apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==") + apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(inputURL)) + + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + retryConfig := DefaultRetryConfig() + 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 + } + 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 + } + if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { + availability.Deezer = true + availability.DeezerURL = deezerLink.URL + availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) + } + + return availability, nil +} diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 2343c0ee..464971d8 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1720,3 +1720,40 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { LyricsLRC: lyricsLRC, }, nil } + +func parseTidalURL(input string) (string, string, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "", "", fmt.Errorf("empty URL") + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return "", "", err + } + + if parsed.Host != "tidal.com" && parsed.Host != "listen.tidal.com" && parsed.Host != "www.tidal.com" { + return "", "", fmt.Errorf("not a Tidal URL") + } + + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + + // Handle /browse/track/123 format + if len(parts) > 0 && parts[0] == "browse" { + parts = parts[1:] + } + + if len(parts) < 2 { + return "", "", fmt.Errorf("invalid Tidal URL format") + } + + resourceType := parts[0] + resourceID := parts[1] + + switch resourceType { + case "track", "album", "artist", "playlist": + return resourceType, resourceID, nil + default: + return "", "", fmt.Errorf("unsupported Tidal resource type: %s", resourceType) + } +} diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 5a840ee3..418a18bf 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -242,6 +242,20 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + case "parseTidalUrl": + let args = call.arguments as! [String: Any] + let url = args["url"] as! String + let response = GobackendParseTidalURLExport(url, &error) + if let error = error { throw error } + return response + + case "convertTidalToSpotifyDeezer": + let args = call.arguments as! [String: Any] + let url = args["url"] as! String + let response = GobackendConvertTidalToSpotifyDeezer(url, &error) + if let error = error { throw error } + return response + case "searchDeezerByISRC": let args = call.arguments as! [String: Any] let isrc = args["isrc"] as! String diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index b18c57c5..4439fd20 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -81,5 +81,29 @@ NSPhotoLibraryUsageDescription SpotiFLAC needs access to save album artwork + + + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLName + com.zarz.spotiflac + CFBundleURLSchemes + + spotiflac + + + + + + LSApplicationQueriesSchemes + + spotify + deezer + tidal + youtube-music + diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index c98274d0..b088bb48 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -193,6 +193,7 @@ class TrackNotifier extends Notifier { state = TrackState(isLoading: true, hasSearchText: state.hasSearchText); try { + // Step 1: Check for extension URL handlers first (handles YT Music, etc.) final extensionHandler = await PlatformBridge.findURLHandler(url); if (extensionHandler != null) { _log.i('Found extension URL handler: $extensionHandler for URL: $url'); @@ -251,8 +252,131 @@ class TrackNotifier extends Notifier { } } + // Step 2: Try Deezer URL parsing + if (url.contains('deezer.com') || url.contains('deezer.page.link')) { + _log.i('Detected Deezer URL, parsing...'); + final parsed = await PlatformBridge.parseDeezerUrl(url); + if (!_isRequestValid(requestId)) return; + + final type = parsed['type'] as String; + final id = parsed['id'] as String; + + final metadata = await PlatformBridge.getDeezerMetadata(type, id); + if (!_isRequestValid(requestId)) return; + + if (type == 'track') { + final trackData = metadata['track'] as Map; + final track = _parseTrack(trackData); + state = TrackState( + tracks: [track], + isLoading: false, + coverUrl: track.coverUrl, + ); + } else if (type == 'album') { + final albumInfo = metadata['album_info'] as Map; + final trackList = metadata['track_list'] as List; + final tracks = trackList.map((t) => _parseTrack(t as Map)).toList(); + state = TrackState( + tracks: tracks, + isLoading: false, + albumId: id, + albumName: albumInfo['name'] as String?, + coverUrl: albumInfo['images'] as String?, + ); + _preWarmCacheForTracks(tracks); + } else if (type == 'playlist') { + final playlistInfo = metadata['playlist_info'] as Map; + final trackList = metadata['track_list'] as List; + final tracks = trackList.map((t) => _parseTrack(t as Map)).toList(); + state = TrackState( + tracks: tracks, + isLoading: false, + playlistName: playlistInfo['name'] as String?, + coverUrl: playlistInfo['images'] as String?, + ); + _preWarmCacheForTracks(tracks); + } else if (type == 'artist') { + final artistInfo = metadata['artist_info'] as Map; + final albumsList = metadata['albums'] as List; + final albums = albumsList.map((a) => _parseArtistAlbum(a as Map)).toList(); + state = TrackState( + tracks: [], + isLoading: false, + artistId: artistInfo['id'] as String?, + artistName: artistInfo['name'] as String?, + coverUrl: artistInfo['images'] as String?, + artistAlbums: albums, + ); + } + return; + } + + // Step 3: Try Tidal URL parsing + if (url.contains('tidal.com')) { + _log.i('Detected Tidal URL, parsing...'); + final parsed = await PlatformBridge.parseTidalUrl(url); + if (!_isRequestValid(requestId)) return; + + final type = parsed['type'] as String; + final id = parsed['id'] as String; + + _log.i('Tidal URL parsed: type=$type, id=$id'); + + // For track URLs, convert to Spotify/Deezer and fetch metadata from there + if (type == 'track') { + try { + _log.i('Converting Tidal track to Spotify/Deezer via SongLink...'); + final conversion = await PlatformBridge.convertTidalToSpotifyDeezer(url); + if (!_isRequestValid(requestId)) return; + + final spotifyUrl = conversion['spotify_url'] as String?; + final deezerUrl = conversion['deezer_url'] as String?; + + if (spotifyUrl != null && spotifyUrl.isNotEmpty) { + _log.i('Found Spotify URL: $spotifyUrl, fetching metadata...'); + final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(spotifyUrl); + if (!_isRequestValid(requestId)) return; + + final trackData = metadata['track'] as Map; + final track = _parseTrack(trackData); + state = TrackState( + tracks: [track], + isLoading: false, + coverUrl: track.coverUrl, + ); + return; + } else if (deezerUrl != null && deezerUrl.isNotEmpty) { + _log.i('Found Deezer URL: $deezerUrl, fetching metadata...'); + final deezerParsed = await PlatformBridge.parseDeezerUrl(deezerUrl); + final metadata = await PlatformBridge.getDeezerMetadata('track', deezerParsed['id'] as String); + if (!_isRequestValid(requestId)) return; + + final trackData = metadata['track'] as Map; + final track = _parseTrack(trackData); + state = TrackState( + tracks: [track], + isLoading: false, + coverUrl: track.coverUrl, + ); + return; + } + } catch (e) { + _log.w('Failed to convert Tidal URL via SongLink: $e'); + } + } + + // For album/artist/playlist, not yet supported + state = TrackState( + isLoading: false, + error: 'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.', + hasSearchText: state.hasSearchText, + ); + return; + } + + // Step 4: Fall back to Spotify parsing final parsed = await PlatformBridge.parseSpotifyUrl(url); - if (!_isRequestValid(requestId)) return; // Request cancelled + if (!_isRequestValid(requestId)) return; final type = parsed['type'] as String; @@ -264,7 +388,7 @@ class TrackNotifier extends Notifier { rethrow; } - if (!_isRequestValid(requestId)) return; // Request cancelled + if (!_isRequestValid(requestId)) return; if (type == 'track') { final trackData = metadata['track'] as Map; diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 88861adf..265801cb 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -368,6 +368,16 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Future> parseTidalUrl(String url) async { + final result = await _channel.invokeMethod('parseTidalUrl', {'url': url}); + return jsonDecode(result as String) as Map; + } + + static Future> convertTidalToSpotifyDeezer(String tidalUrl) async { + final result = await _channel.invokeMethod('convertTidalToSpotifyDeezer', {'url': tidalUrl}); + return jsonDecode(result as String) as Map; + } + static Future> searchDeezerByISRC(String isrc) async { final result = await _channel.invokeMethod('searchDeezerByISRC', {'isrc': isrc}); return jsonDecode(result as String) as Map; diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart index fac3a9a6..2ba79660 100644 --- a/lib/services/share_intent_service.dart +++ b/lib/services/share_intent_service.dart @@ -9,16 +9,35 @@ class ShareIntentService { factory ShareIntentService() => _instance; ShareIntentService._internal(); + // Spotify patterns static final RegExp _spotifyUriPattern = RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+'); static final RegExp _spotifyUrlPattern = RegExp( 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]*)?', + ); + static final RegExp _deezerShortLinkPattern = RegExp( + 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/)[a-zA-Z0-9_-]+(\&[^\s]*)?', + ); + final _sharedUrlController = StreamController.broadcast(); StreamSubscription>? _mediaSubscription; bool _initialized = false; - String? _pendingUrl; // Store URL received before listener is ready + String? _pendingUrl; Stream get sharedUrlStream => _sharedUrlController.stream; @@ -48,31 +67,47 @@ class ShareIntentService { for (final file in files) { final textToCheck = file.path; - final url = _extractSpotifyUrl(textToCheck); + final url = _extractMusicUrl(textToCheck); if (url != null) { - _log.i('Received Spotify URL: $url (initial: $isInitial)'); + _log.i('Received music URL: $url (initial: $isInitial)'); if (isInitial) { _pendingUrl = url; } _sharedUrlController.add(url); - return; // Only process first valid URL + return; } } } - String? _extractSpotifyUrl(String text) { + String? _extractMusicUrl(String text) { if (text.isEmpty) return null; + // Try Spotify URI first final uriMatch = _spotifyUriPattern.firstMatch(text); if (uriMatch != null) { return uriMatch.group(0); } - final urlMatch = _spotifyUrlPattern.firstMatch(text); - if (urlMatch != null) { - final fullUrl = urlMatch.group(0)!; - final queryIndex = fullUrl.indexOf('?'); - return queryIndex > 0 ? fullUrl.substring(0, queryIndex) : fullUrl; + // Try all URL patterns + final patterns = [ + _spotifyUrlPattern, + _deezerUrlPattern, + _deezerShortLinkPattern, + _tidalUrlPattern, + _ytMusicUrlPattern, + ]; + + for (final pattern in patterns) { + final match = pattern.firstMatch(text); + if (match != null) { + final fullUrl = match.group(0)!; + // Remove query params for cleaner URL (except for YT Music which needs them) + if (pattern == _ytMusicUrlPattern) { + return fullUrl; + } + final queryIndex = fullUrl.indexOf('?'); + return queryIndex > 0 ? fullUrl.substring(0, queryIndex) : fullUrl; + } } return null;