feat: add Qobuz and Tidal metadata API, URL parsers, and full store support

This commit is contained in:
zarzet committed 2026-03-14 15:09:48 +07:00
1 parent d89850e8a9
commit ac9141f167
13 files changed
+2230 -133

No files matched your search

+19 -1
View File
@@ -3605,10 +3605,26 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? trackToDownload.discNumber!
: 1;
String payloadSpotifyId = trackToDownload.id;
String payloadQobuzId = '';
String payloadTidalId = '';
if (trackToDownload.id.startsWith('qobuz:')) {
payloadQobuzId = trackToDownload.id.substring(6);
if (item.service == 'qobuz') {
payloadSpotifyId = '';
}
}
if (trackToDownload.id.startsWith('tidal:')) {
payloadTidalId = trackToDownload.id.substring(6);
if (item.service == 'tidal') {
payloadSpotifyId = '';
}
}
final payload = DownloadRequestPayload(
isrc: trackToDownload.isrc ?? '',
service: item.service,
spotifyId: trackToDownload.id,
spotifyId: payloadSpotifyId,
trackName: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
@@ -3632,6 +3648,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
genre: genre ?? '',
label: label ?? '',
copyright: copyright ?? '',
qobuzId: payloadQobuzId,
tidalId: payloadTidalId,
deezerId: deezerTrackId ?? '',
lyricsMode: settings.lyricsMode,
storageMode: storageMode,
+119 -60
View File
@@ -18,7 +18,7 @@ class TrackState {
final String? artistName;
final String? coverUrl;
final String? headerImageUrl; // Artist header image for background
final int? monthlyListeners; // Artist monthly listeners
final int? monthlyListeners;
final List<ArtistAlbum>? artistAlbums; // For artist page
final List<Track>? artistTopTracks; // Artist's popular tracks
final List<SearchArtist>? searchArtists; // For search results
@@ -384,6 +384,72 @@ class TrackNotifier extends Notifier<TrackState> {
return;
}
if (url.contains('qobuz.com') || url.startsWith('qobuzapp://')) {
_log.i('Detected Qobuz URL, parsing...');
final parsed = await PlatformBridge.parseQobuzUrl(url);
if (!_isRequestValid(requestId)) return;
final type = parsed['type'] as String;
final id = parsed['id'] as String;
final metadata = await PlatformBridge.getQobuzMetadata(type, id);
if (!_isRequestValid(requestId)) return;
if (type == 'track') {
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
} else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
state = TrackState(
tracks: tracks,
isLoading: false,
albumId: 'qobuz:$id',
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
final playlistInfo =
metadata['playlist_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
state = TrackState(
tracks: tracks,
isLoading: false,
playlistName: owner?['name'] as String?,
coverUrl: owner?['images'] as String?,
);
_preWarmCacheForTracks(tracks);
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
state = TrackState(
tracks: [],
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?,
artistAlbums: albums,
);
}
return;
}
if (url.contains('tidal.com')) {
_log.i('Detected Tidal URL, parsing...');
final parsed = await PlatformBridge.parseTidalUrl(url);
@@ -392,68 +458,61 @@ class TrackNotifier extends Notifier<TrackState> {
final type = parsed['type'] as String;
final id = parsed['id'] as String;
_log.i('Tidal URL parsed: type=$type, id=$id');
final metadata = await PlatformBridge.getTidalMetadata(type, id);
if (!_isRequestValid(requestId)) return;
// 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<String, dynamic>;
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<String, dynamic>;
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');
}
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
} else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
state = TrackState(
tracks: tracks,
isLoading: false,
albumId: 'tidal:$id',
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
final playlistInfo =
metadata['playlist_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
state = TrackState(
tracks: tracks,
isLoading: false,
playlistName: owner?['name'] as String?,
coverUrl: owner?['images'] as String?,
);
_preWarmCacheForTracks(tracks);
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
state = TrackState(
tracks: [],
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?,
artistAlbums: albums,
);
}
// 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;
}
+16 -5
View File
@@ -84,7 +84,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
// Use extensionId if available, otherwise detect from albumId prefix
final providerId =
widget.extensionId ??
(widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify');
(() {
if (widget.albumId.startsWith('deezer:')) return 'deezer';
if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
if (widget.albumId.startsWith('tidal:')) return 'tidal';
return 'spotify';
})();
ref
.read(recentAccessProvider.notifier)
.recordAlbumAccess(
@@ -175,6 +180,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
'album',
deezerAlbumId,
);
} else if (widget.albumId.startsWith('qobuz:')) {
final qobuzAlbumId = widget.albumId.replaceFirst('qobuz:', '');
metadata = await PlatformBridge.getQobuzMetadata('album', qobuzAlbumId);
} else if (widget.albumId.startsWith('tidal:')) {
final tidalAlbumId = widget.albumId.replaceFirst('tidal:', '');
metadata = await PlatformBridge.getTidalMetadata('album', tidalAlbumId);
} else {
final url = 'https://open.spotify.com/album/${widget.albumId}';
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
@@ -619,7 +630,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
size: 22,
color: allLoved ? Colors.redAccent : Colors.white,
),
tooltip: allLoved ? context.l10n.trackOptionRemoveFromLoved : context.l10n.tooltipLoveAll,
tooltip: allLoved
? context.l10n.trackOptionRemoveFromLoved
: context.l10n.tooltipLoveAll,
padding: EdgeInsets.zero,
),
);
@@ -678,9 +691,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToLoved(addedCount),
),
content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)),
),
);
}
+48 -1
View File
@@ -153,7 +153,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
WidgetsBinding.instance.addPostFrameCallback((_) {
final providerId =
widget.extensionId ??
(widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify');
(() {
if (widget.artistId.startsWith('deezer:')) return 'deezer';
if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
if (widget.artistId.startsWith('tidal:')) return 'tidal';
return 'spotify';
})();
ref
.read(recentAccessProvider.notifier)
.recordArtistAccess(
@@ -230,6 +235,30 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
} else if (widget.artistId.startsWith('qobuz:')) {
final qobuzArtistId = widget.artistId.replaceFirst('qobuz:', '');
final metadata = await PlatformBridge.getQobuzMetadata(
'artist',
qobuzArtistId,
);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
final artistInfo = metadata['artist_info'] as Map<String, dynamic>?;
headerImage = artistInfo?['images'] as String?;
} else if (widget.artistId.startsWith('tidal:')) {
final tidalArtistId = widget.artistId.replaceFirst('tidal:', '');
final metadata = await PlatformBridge.getTidalMetadata(
'artist',
tidalArtistId,
);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
final artistInfo = metadata['artist_info'] as Map<String, dynamic>?;
headerImage = artistInfo?['images'] as String?;
} else {
final url = 'https://open.spotify.com/artist/${widget.artistId}';
final result = await PlatformBridge.handleURLWithExtension(url);
@@ -961,6 +990,24 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
.map((t) => _parseTrackFromDeezer(t as Map<String, dynamic>, album))
.toList();
}
} else if (album.id.startsWith('qobuz:')) {
final qobuzId = album.id.replaceFirst('qobuz:', '');
final metadata = await PlatformBridge.getQobuzMetadata('album', qobuzId);
if (metadata['track_list'] != null) {
final tracksList = metadata['track_list'] as List<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
} else if (album.id.startsWith('tidal:')) {
final tidalId = album.id.replaceFirst('tidal:', '');
final metadata = await PlatformBridge.getTidalMetadata('album', tidalId);
if (metadata['track_list'] != null) {
final tracksList = metadata['track_list'] as List<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
} else {
final url = 'https://open.spotify.com/album/${album.id}';
final result = await PlatformBridge.handleURLWithExtension(url);
+36 -14
View File
@@ -65,16 +65,20 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
});
try {
// Extract numeric ID from "deezer:123" format
String playlistId = widget.playlistId!;
late final Map<String, dynamic> result;
if (playlistId.startsWith('deezer:')) {
playlistId = playlistId.substring(7);
result = await PlatformBridge.getDeezerMetadata('playlist', playlistId);
} else if (playlistId.startsWith('qobuz:')) {
playlistId = playlistId.substring(6);
result = await PlatformBridge.getQobuzMetadata('playlist', playlistId);
} else if (playlistId.startsWith('tidal:')) {
playlistId = playlistId.substring(6);
result = await PlatformBridge.getTidalMetadata('playlist', playlistId);
} else {
result = await PlatformBridge.getDeezerMetadata('playlist', playlistId);
}
final result = await PlatformBridge.getDeezerMetadata(
'playlist',
playlistId,
);
if (!mounted) return;
// Go backend returns 'track_list' not 'tracks'
@@ -416,7 +420,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, service, qualityOverride: quality, playlistName: widget.playlistName);
.addToQueue(
track,
service,
qualityOverride: quality,
playlistName: widget.playlistName,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
@@ -427,7 +436,11 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
} else {
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, settings.defaultService, playlistName: widget.playlistName);
.addToQueue(
track,
settings.defaultService,
playlistName: widget.playlistName,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
@@ -482,7 +495,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
size: 22,
color: allLoved ? Colors.redAccent : Colors.white,
),
tooltip: allLoved ? context.l10n.trackOptionRemoveFromLoved : context.l10n.tooltipLoveAll,
tooltip: allLoved
? context.l10n.trackOptionRemoveFromLoved
: context.l10n.tooltipLoveAll,
padding: EdgeInsets.zero,
),
);
@@ -570,9 +585,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToLoved(addedCount),
),
content: Text(context.l10n.snackbarAddedTracksToLoved(addedCount)),
),
);
}
@@ -594,7 +607,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, service, qualityOverride: quality, playlistName: widget.playlistName);
.addMultipleToQueue(
tracks,
service,
qualityOverride: quality,
playlistName: widget.playlistName,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
@@ -607,7 +625,11 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
} else {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, settings.defaultService, playlistName: widget.playlistName);
.addMultipleToQueue(
tracks,
settings.defaultService,
playlistName: widget.playlistName,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
+40 -1
View File
@@ -535,11 +535,48 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> getQobuzMetadata(
String resourceType,
String resourceId,
) async {
final result = await _channel.invokeMethod('getQobuzMetadata', {
'resource_type': resourceType,
'resource_id': resourceId,
});
if (result == null) {
throw Exception(
'getQobuzMetadata returned null for $resourceType:$resourceId',
);
}
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> parseQobuzUrl(String url) async {
final result = await _channel.invokeMethod('parseQobuzUrl', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> parseTidalUrl(String url) async {
final result = await _channel.invokeMethod('parseTidalUrl', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> getTidalMetadata(
String resourceType,
String resourceId,
) async {
final result = await _channel.invokeMethod('getTidalMetadata', {
'resource_type': resourceType,
'resource_id': resourceId,
});
if (result == null) {
throw Exception(
'getTidalMetadata returned null for $resourceType:$resourceId',
);
}
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> convertTidalToSpotifyDeezer(
String tidalUrl,
) async {
@@ -1193,7 +1230,9 @@ class PlatformBridge {
static Future<void> setStoreRegistryUrl(String registryUrl) async {
_log.d('setStoreRegistryUrl: $registryUrl');
await _channel.invokeMethod('setStoreRegistryUrl', {'registry_url': registryUrl});
await _channel.invokeMethod('setStoreRegistryUrl', {
'registry_url': registryUrl,
});
}
static Future<String> getStoreRegistryUrl() async {