feat: auto-enrich metadata for extension downloads, fix artist/playlist parsing, and improve metadata screen

- Add metadata provider search (Deezer/Tidal/Qobuz) in download pipeline for extension tracks with missing album/date/ISRC, using the same mechanism as ReEnrichFile
- Always pass enriched metadata (album, release_date, ISRC, cover_url, track/disc number) back in DownloadResponse so Flutter can embed them
- Add Deezer ISRC lookup for genre/label during download enrichment
- Extend _buildTrackForMetadataEmbedding to use ISRC, cover_url, album_artist from backend response
- Add Releases section support in artist page (Go + Flutter)
- Fix Track ID parsing to prefer non-empty native ID over empty spotify_id
- Paginate popular tracks (5 per page with swipe + dot indicators)
- Fix metadata screen: duration getter checks _editedMetadata, read album/duration from file tags
- Make metadata screen ID labels and Open-in buttons source-aware (Amazon/Tidal/Qobuz/Deezer/Spotify)
- Copy enrichment fields (AlbumName, DurationMS, CoverURL, AlbumArtist, ID) back to download request
- Update README badge, add network_requests.txt to gitignore
This commit is contained in:
zarzet committed 2026-03-14 21:47:57 +07:00
1 parent aa9854fc0a
commit 134bf4375f
8 files changed
+408 -39

No files matched your search

+21 -7
View File
@@ -2373,11 +2373,25 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendAlbum = normalizeOptionalString(
backendResult['album'] as String?,
);
final backendIsrc = normalizeOptionalString(
backendResult['isrc'] as String?,
);
final backendCoverUrl = normalizeOptionalString(
backendResult['cover_url'] as String?,
);
final backendAlbumArtist = normalizeOptionalString(
backendResult['album_artist'] as String?,
);
if (backendTrackNum == null &&
backendDiscNum == null &&
backendYear == null &&
backendAlbum == null) {
final hasOverrides = backendTrackNum != null ||
backendDiscNum != null ||
backendYear != null ||
backendAlbum != null ||
backendIsrc != null ||
backendCoverUrl != null ||
backendAlbumArtist != null;
if (!hasOverrides) {
return baseTrack;
}
@@ -2386,12 +2400,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
name: baseTrack.name,
artistName: baseTrack.artistName,
albumName: backendAlbum ?? baseTrack.albumName,
albumArtist: resolvedAlbumArtist,
albumArtist: backendAlbumArtist ?? resolvedAlbumArtist,
artistId: baseTrack.artistId,
albumId: baseTrack.albumId,
coverUrl: baseTrack.coverUrl,
coverUrl: backendCoverUrl ?? baseTrack.coverUrl,
duration: baseTrack.duration,
isrc: baseTrack.isrc,
isrc: backendIsrc ?? baseTrack.isrc,
trackNumber: backendTrackNum ?? baseTrack.trackNumber,
discNumber: backendDiscNum ?? baseTrack.discNumber,
releaseDate: backendYear ?? baseTrack.releaseDate,
+3 -1
View File
@@ -933,8 +933,10 @@ class TrackNotifier extends Notifier<TrackState> {
Track _parseTrack(Map<String, dynamic> data) {
final durationMs = _extractDurationMs(data);
final spotifyId = (data['spotify_id'] ?? '').toString();
final nativeId = (data['id'] ?? '').toString();
return Track(
id: data['spotify_id'] as String? ?? '',
id: spotifyId.isNotEmpty ? spotifyId : nativeId,
name: data['name'] as String? ?? '',
artistName: data['artists'] as String? ?? '',
albumName: data['album_name'] as String? ?? '',
+137 -15
View File
@@ -38,12 +38,14 @@ class _ArtistCache {
static void set(
String artistId, {
required List<ArtistAlbum> albums,
List<ArtistAlbum>? releases,
List<Track>? topTracks,
String? headerImageUrl,
int? monthlyListeners,
}) {
_cache[artistId] = _CacheEntry(
albums: albums,
releases: releases,
topTracks: topTracks,
headerImageUrl: headerImageUrl,
monthlyListeners: monthlyListeners,
@@ -54,6 +56,7 @@ class _ArtistCache {
class _CacheEntry {
final List<ArtistAlbum> albums;
final List<ArtistAlbum>? releases;
final List<Track>? topTracks;
final String? headerImageUrl;
final int? monthlyListeners;
@@ -61,6 +64,7 @@ class _CacheEntry {
_CacheEntry({
required this.albums,
this.releases,
this.topTracks,
this.headerImageUrl,
this.monthlyListeners,
@@ -97,6 +101,7 @@ class ArtistScreen extends ConsumerStatefulWidget {
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
bool _isLoadingDiscography = false;
List<ArtistAlbum>? _albums;
List<ArtistAlbum>? _releases;
List<Track>? _topTracks;
String? _headerImageUrl;
int? _monthlyListeners;
@@ -104,6 +109,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
final PageController _popularPageController = PageController();
int _popularCurrentPage = 0;
bool _isSelectionMode = false;
final Set<String> _selectedAlbumIds = {};
@@ -174,6 +181,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
_topTracks = widget.topTracks;
_headerImageUrl = widget.headerImageUrl;
_monthlyListeners = widget.monthlyListeners;
if ((_albums == null || _albums!.isEmpty) ||
(_topTracks == null || _topTracks!.isEmpty)) {
_fetchDiscography();
}
return;
}
@@ -190,6 +202,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
} else if (cached != null) {
_albums = cached.albums;
_releases = cached.releases;
_topTracks = cached.topTracks;
_headerImageUrl = cached.headerImageUrl;
_monthlyListeners = cached.monthlyListeners;
@@ -214,6 +227,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_popularPageController.dispose();
super.dispose();
}
@@ -221,6 +235,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
setState(() => _isLoadingDiscography = true);
try {
List<ArtistAlbum> albums;
List<ArtistAlbum>? releases;
List<Track>? topTracks;
String? headerImage;
int? listeners;
@@ -259,6 +274,42 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
.toList();
final artistInfo = metadata['artist_info'] as Map<String, dynamic>?;
headerImage = artistInfo?['images'] as String?;
} else if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
final result = await PlatformBridge.getArtistWithExtension(
widget.extensionId!,
widget.artistId,
);
if (result == null) {
throw Exception('Failed to load artist from extension');
}
final artistData = result;
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
final releasesList = artistData['releases'] as List<dynamic>? ?? [];
if (releasesList.isNotEmpty) {
releases = releasesList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
}
final topTracksList =
artistData['top_tracks'] as List<dynamic>? ?? [];
if (topTracksList.isNotEmpty) {
topTracks = topTracksList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
}
headerImage =
artistData['header_image'] as String? ??
artistData['cover_url'] as String? ??
artistData['image_url'] as String?;
listeners = artistData['listeners'] as int?;
} else {
final url = 'https://open.spotify.com/artist/${widget.artistId}';
final result = await PlatformBridge.handleURLWithExtension(url);
@@ -299,6 +350,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
_ArtistCache.set(
widget.artistId,
albums: albums,
releases: releases,
topTracks: topTracks,
headerImageUrl: finalHeaderImage,
monthlyListeners: finalListeners,
@@ -307,6 +359,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (mounted) {
setState(() {
_albums = albums;
_releases = releases;
_topTracks = topTracks;
_headerImageUrl = finalHeaderImage;
_monthlyListeners = finalListeners;
@@ -332,8 +385,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
durationMs = durationValue.toInt();
}
final spotifyId = (data['spotify_id'] ?? '').toString();
final nativeId = (data['id'] ?? '').toString();
return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
id: spotifyId.isNotEmpty ? spotifyId : nativeId,
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
albumName: (data['album_name'] ?? data['album'] ?? album?.name ?? '')
@@ -352,20 +408,28 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
releaseDate: data['release_date']?.toString(),
albumType: data['album_type']?.toString() ?? album?.albumType,
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
source: data['provider_id']?.toString(),
source: data['provider_id']?.toString() ?? widget.extensionId,
);
}
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
final totalTracksValue = data['total_tracks'];
final totalTracks =
totalTracksValue is int
? totalTracksValue
: int.tryParse(totalTracksValue?.toString() ?? '') ?? 0;
return ArtistAlbum(
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
releaseDate: data['release_date'] as String? ?? '',
totalTracks: data['total_tracks'] as int? ?? 0,
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
albumType: data['album_type'] as String? ?? 'album',
artists: data['artists'] as String? ?? '',
providerId: data['provider_id']?.toString(),
name: (data['name'] ?? data['title'] ?? '').toString(),
releaseDate: (data['release_date'] ?? '').toString(),
totalTracks: totalTracks,
coverUrl: (data['cover_url'] ?? data['images'] ?? data['cover_art'])
?.toString(),
albumType: (data['album_type'] ?? data['type'] ?? 'album').toString(),
artists: (data['artists'] ?? data['artist'] ?? widget.artistName)
.toString(),
providerId: data['provider_id']?.toString() ?? widget.extensionId,
);
}
@@ -388,6 +452,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
final colorScheme = Theme.of(context).colorScheme;
final albums = _albums ?? [];
_ensureAlbumBuckets(albums);
final releases = _releases ?? const <ArtistAlbum>[];
final albumsOnly = _albumsOnlyBucket;
final singles = _singlesBucket;
final compilations = _compilationsBucket;
@@ -433,6 +498,14 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
SliverToBoxAdapter(
child: _buildPopularSection(colorScheme),
),
if (releases.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
'Releases',
releases,
colorScheme,
),
),
if (albumsOnly.isNotEmpty)
SliverToBoxAdapter(
child: _buildAlbumSection(
@@ -1258,7 +1331,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return const SizedBox.shrink();
}
final tracks = _topTracks!.take(5).toList();
final tracks = _topTracks!;
const tracksPerPage = 5;
final pageCount = (tracks.length / tracksPerPage).ceil();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1272,11 +1347,58 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
...tracks.asMap().entries.map((entry) {
final index = entry.key;
final track = entry.value;
return _buildPopularTrackItem(index + 1, track, colorScheme);
}),
SizedBox(
height: tracksPerPage * 64.0,
child: PageView.builder(
controller: _popularPageController,
itemCount: pageCount,
onPageChanged: (page) {
setState(() {
_popularCurrentPage = page;
});
},
itemBuilder: (context, pageIndex) {
final startIndex = pageIndex * tracksPerPage;
final endIndex =
(startIndex + tracksPerPage).clamp(0, tracks.length);
final pageTracks = tracks.sublist(startIndex, endIndex);
return Column(
children: pageTracks.asMap().entries.map((entry) {
final globalIndex = startIndex + entry.key;
return _buildPopularTrackItem(
globalIndex + 1,
entry.value,
colorScheme,
);
}).toList(),
);
},
),
),
if (pageCount > 1)
Center(
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(pageCount, (index) {
final isActive = _popularCurrentPage == index;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 3),
width: isActive ? 8 : 6,
height: isActive ? 8 : 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive
? colorScheme.primary
: colorScheme.onSurfaceVariant.withValues(alpha: 0.3),
),
);
}),
),
),
),
],
);
}
+80 -15
View File
@@ -298,11 +298,23 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final resolvedBitDepth = _readPositiveInt(metadata['bit_depth']);
final resolvedSampleRate = _readPositiveInt(metadata['sample_rate']);
final resolvedDuration = _readPositiveInt(metadata['duration']);
final resolvedAlbum = metadata['album']?.toString();
final resolvedQuality = buildDisplayAudioQuality(
bitDepth: resolvedBitDepth ?? bitDepth,
sampleRate: resolvedSampleRate ?? sampleRate,
storedQuality: _quality,
);
// Fill in album name from file tags if stored value is empty
final needsAlbum = resolvedAlbum != null &&
resolvedAlbum.isNotEmpty &&
(albumName.isEmpty);
// Fill in duration from file if stored value is missing/zero
final needsDuration = resolvedDuration != null &&
resolvedDuration > 0 &&
(duration == null || duration == 0);
final shouldPersistResolvedAudioMetadata =
resolvedBitDepth != null ||
resolvedSampleRate != null ||
@@ -310,6 +322,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if ((resolvedBitDepth != null ||
resolvedSampleRate != null ||
needsAlbum ||
needsDuration ||
isPlaceholderQualityLabel(_quality)) &&
mounted) {
setState(() {
@@ -317,6 +331,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
...?_editedMetadata,
if (resolvedBitDepth != null) 'bit_depth': resolvedBitDepth,
if (resolvedSampleRate != null) 'sample_rate': resolvedSampleRate,
if (needsAlbum) 'album': resolvedAlbum,
if (needsDuration) 'duration': resolvedDuration,
};
});
}
@@ -486,7 +502,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_editedMetadata?['copyright']?.toString() ??
(_isLocalItem ? null : _downloadItem!.copyright);
int? get duration =>
_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration;
_readPositiveInt(_editedMetadata?['duration']) ??
(_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration);
int? get bitDepth =>
_readPositiveInt(_editedMetadata?['bit_depth']) ??
(_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth);
@@ -1035,14 +1052,23 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Builder(
builder: (context) {
final isDeezer = _spotifyId!.contains('deezer');
final svc = _service.toLowerCase();
String buttonLabel;
if (isDeezer) {
buttonLabel = context.l10n.trackOpenInDeezer;
} else if (svc == 'amazon') {
buttonLabel = 'Open in Amazon Music';
} else if (svc == 'tidal') {
buttonLabel = 'Open in Tidal';
} else if (svc == 'qobuz') {
buttonLabel = 'Open in Qobuz';
} else {
buttonLabel = context.l10n.trackOpenInSpotify;
}
return OutlinedButton.icon(
onPressed: () => _openServiceUrl(context),
icon: const Icon(Icons.open_in_new, size: 18),
label: Text(
isDeezer
? context.l10n.trackOpenInDeezer
: context.l10n.trackOpenInSpotify,
),
label: Text(buttonLabel),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16,
@@ -1067,14 +1093,33 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final isDeezer = _spotifyId!.contains('deezer');
final rawId = _spotifyId!.replaceAll('deezer:', '');
final svc = _service.toLowerCase();
final webUrl = isDeezer
? 'https://www.deezer.com/track/$rawId'
: 'https://open.spotify.com/track/$rawId';
String webUrl;
Uri? appUri;
String serviceName;
final appUri = isDeezer
? Uri.parse('deezer://www.deezer.com/track/$rawId')
: Uri.parse('spotify:track:$rawId');
if (isDeezer) {
webUrl = 'https://www.deezer.com/track/$rawId';
appUri = Uri.parse('deezer://www.deezer.com/track/$rawId');
serviceName = 'Deezer';
} else if (svc == 'amazon') {
webUrl = 'https://music.amazon.com/search/$rawId';
appUri = Uri.parse('amznm://search/$rawId');
serviceName = 'Amazon Music';
} else if (svc == 'tidal') {
webUrl = 'https://listen.tidal.com/track/$rawId';
appUri = Uri.parse('tidal://track/$rawId');
serviceName = 'Tidal';
} else if (svc == 'qobuz') {
webUrl = 'https://play.qobuz.com/track/$rawId';
appUri = Uri.parse('qobuz://track/$rawId');
serviceName = 'Qobuz';
} else {
webUrl = 'https://open.spotify.com/track/$rawId';
appUri = Uri.parse('spotify:track:$rawId');
serviceName = 'Spotify';
}
try {
final launched = await launchUrl(
@@ -1100,7 +1145,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarUrlCopied(isDeezer ? 'Deezer' : 'Spotify'),
context.l10n.snackbarUrlCopied(serviceName),
),
),
);
@@ -1140,7 +1185,22 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (!_isLocalItem && _spotifyId != null && _spotifyId!.isNotEmpty) {
final isDeezer = _spotifyId!.contains('deezer');
final cleanId = _spotifyId!.replaceAll('deezer:', '');
items.add(_MetadataItem(isDeezer ? 'Deezer ID' : 'Spotify ID', cleanId));
String idLabel;
if (isDeezer) {
idLabel = 'Deezer ID';
} else {
switch (_service.toLowerCase()) {
case 'amazon':
idLabel = 'Amazon ASIN';
case 'tidal':
idLabel = 'Tidal ID';
case 'qobuz':
idLabel = 'Qobuz ID';
default:
idLabel = 'Spotify ID';
}
}
items.add(_MetadataItem(idLabel, cleanId));
}
items.add(
@@ -1153,7 +1213,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return Column(
children: items.map((metadata) {
final isCopyable =
metadata.label == 'ISRC' || metadata.label == 'Spotify ID';
metadata.label == 'ISRC' ||
metadata.label == 'Spotify ID' ||
metadata.label == 'Deezer ID' ||
metadata.label == 'Amazon ASIN' ||
metadata.label == 'Tidal ID' ||
metadata.label == 'Qobuz ID';
return InkWell(
onTap: isCopyable
? () => _copyToClipboard(context, metadata.value)