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