feat: add extended metadata (genre, label, copyright) support

- Add genre, label, copyright fields to ExtTrackMetadata and DownloadResponse
- Add utils.randomUserAgent() for extensions to get random User-Agent strings
- Fix VM race condition panic by adding mutex locks to all provider methods
- Fix Tidal release date fallback when req.ReleaseDate is empty
- Display genre, label, copyright in track metadata screen
- Store extended metadata in download history for persistence
- Add trackGenre, trackLabel, trackCopyright localization strings
This commit is contained in:
zarzet
2026-01-19 21:11:57 +07:00
parent 9c35515d6f
commit 0119db094d
26 changed files with 509 additions and 41 deletions
+24
View File
@@ -1688,6 +1688,12 @@ abstract class AppLocalizations {
/// **'Found {count} tracks in CSV. Add them to download queue?'**
String dialogImportPlaylistMessage(int count);
/// Label shown in quality picker for CSV import
///
/// In en, this message translates to:
/// **'{count} tracks from CSV'**
String csvImportTracks(int count);
/// Snackbar - track added to download queue
///
/// In en, this message translates to:
@@ -2870,6 +2876,24 @@ abstract class AppLocalizations {
/// **'Release date'**
String get trackReleaseDate;
/// Metadata label - music genre
///
/// In en, this message translates to:
/// **'Genre'**
String get trackGenre;
/// Metadata label - record label
///
/// In en, this message translates to:
/// **'Label'**
String get trackLabel;
/// Metadata label - copyright information
///
/// In en, this message translates to:
/// **'Copyright'**
String get trackCopyright;
/// Metadata label - download date
///
/// In en, this message translates to:
+14
View File
@@ -910,6 +910,11 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1584,6 +1589,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
+14
View File
@@ -897,6 +897,11 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1571,6 +1576,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
+14
View File
@@ -897,6 +897,11 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1571,6 +1576,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
+14
View File
@@ -897,6 +897,11 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1571,6 +1576,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
+14
View File
@@ -897,6 +897,11 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1571,6 +1576,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
+14
View File
@@ -903,6 +903,11 @@ class AppLocalizationsId extends AppLocalizations {
return 'Ditemukan $count lagu di CSV. Tambahkan ke antrian unduhan?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Menambahkan \"$trackName\" ke antrian';
@@ -1581,6 +1586,15 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get trackReleaseDate => 'Tanggal rilis';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Diunduh';
+14
View File
@@ -897,6 +897,11 @@ class AppLocalizationsJa extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1571,6 +1576,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
+14
View File
@@ -897,6 +897,11 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1571,6 +1576,15 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
+14
View File
@@ -897,6 +897,11 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1571,6 +1576,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
+14
View File
@@ -897,6 +897,11 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1571,6 +1576,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
+14
View File
@@ -919,6 +919,11 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Найдено $count треков в CSV. Добавить их в очередь загрузки?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return '\"$trackName\" добавлен в очередь';
@@ -1603,6 +1608,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get trackReleaseDate => 'Дата выхода';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Скачано';
+14
View File
@@ -897,6 +897,11 @@ class AppLocalizationsZh extends AppLocalizations {
return 'Found $count tracks in CSV. Add them to download queue?';
}
@override
String csvImportTracks(int count) {
return '$count tracks from CSV';
}
@override
String snackbarAddedToQueue(String trackName) {
return 'Added \"$trackName\" to queue';
@@ -1571,6 +1576,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get trackReleaseDate => 'Release date';
@override
String get trackGenre => 'Genre';
@override
String get trackLabel => 'Label';
@override
String get trackCopyright => 'Copyright';
@override
String get trackDownloaded => 'Downloaded';
+13
View File
@@ -619,6 +619,13 @@
"dialogImportPlaylistTitle": "Import Playlist",
"@dialogImportPlaylistTitle": {"description": "Dialog title - import CSV playlist"},
"dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?",
"csvImportTracks": "{count} tracks from CSV",
"@csvImportTracks": {
"description": "Label shown in quality picker for CSV import",
"placeholders": {
"count": {"type": "int"}
}
},
"@dialogImportPlaylistMessage": {
"description": "Dialog message - import playlist confirmation",
"placeholders": {
@@ -1153,6 +1160,12 @@
"@trackAudioQuality": {"description": "Metadata label - audio quality"},
"trackReleaseDate": "Release date",
"@trackReleaseDate": {"description": "Metadata label - release date"},
"trackGenre": "Genre",
"@trackGenre": {"description": "Metadata label - music genre"},
"trackLabel": "Label",
"@trackLabel": {"description": "Metadata label - record label"},
"trackCopyright": "Copyright",
"@trackCopyright": {"description": "Metadata label - copyright information"},
"trackDownloaded": "Downloaded",
"@trackDownloaded": {"description": "Metadata label - download date"},
"trackCopyLyrics": "Copy lyrics",
+55 -2
View File
@@ -45,6 +45,9 @@ class DownloadHistoryItem {
final String? quality;
final int? bitDepth;
final int? sampleRate;
final String? genre;
final String? label;
final String? copyright;
const DownloadHistoryItem({
required this.id,
@@ -65,6 +68,9 @@ class DownloadHistoryItem {
this.quality,
this.bitDepth,
this.sampleRate,
this.genre,
this.label,
this.copyright,
});
Map<String, dynamic> toJson() => {
@@ -86,6 +92,9 @@ class DownloadHistoryItem {
'quality': quality,
'bitDepth': bitDepth,
'sampleRate': sampleRate,
'genre': genre,
'label': label,
'copyright': copyright,
};
factory DownloadHistoryItem.fromJson(Map<String, dynamic> json) =>
@@ -108,6 +117,9 @@ class DownloadHistoryItem {
quality: json['quality'] as String?,
bitDepth: json['bitDepth'] as int?,
sampleRate: json['sampleRate'] as int?,
genre: json['genre'] as String?,
label: json['label'] as String?,
copyright: json['copyright'] as String?,
);
}
@@ -1016,7 +1028,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
/// Embed metadata and cover to a FLAC file after M4A conversion
Future<void> _embedMetadataAndCover(String flacPath, Track track) async {
Future<void> _embedMetadataAndCover(
String flacPath,
Track track, {
String? genre,
String? label,
String? copyright,
}) async {
final settings = ref.read(settingsProvider);
String? coverPath;
@@ -1083,6 +1101,20 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
metadata['ISRC'] = track.isrc!;
}
// Extended metadata from enrichment (genre, label, copyright)
if (genre != null && genre.isNotEmpty) {
metadata['GENRE'] = genre;
_log.d('Adding GENRE: $genre');
}
if (label != null && label.isNotEmpty) {
metadata['ORGANIZATION'] = label;
_log.d('Adding ORGANIZATION (label): $label');
}
if (copyright != null && copyright.isNotEmpty) {
metadata['COPYRIGHT'] = copyright;
_log.d('Adding COPYRIGHT: $copyright');
}
_log.d('Metadata map content: $metadata');
try {
@@ -1809,7 +1841,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
}
await _embedMetadataAndCover(flacPath, finalTrack);
// Get extended metadata from backend response
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
if (backendGenre != null || backendLabel != null || backendCopyright != null) {
_log.d('Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright');
}
await _embedMetadataAndCover(
flacPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
_log.d('Metadata and cover embedded successfully');
} catch (e) {
_log.w('Warning: Failed to embed metadata/cover: $e');
@@ -1920,6 +1967,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendBitDepth = result['actual_bit_depth'] as int?;
final backendSampleRate = result['actual_sample_rate'] as int?;
final backendISRC = result['isrc'] as String?;
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
@@ -1970,6 +2020,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
quality: actualQuality,
bitDepth: historyBitDepth,
sampleRate: historySampleRate,
genre: backendGenre,
label: backendLabel,
copyright: backendCopyright,
),
);
+57 -30
View File
@@ -355,37 +355,64 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
// ignore: use_build_context_synchronously
final l10n = context.l10n;
final confirmed = await showDialog<bool>(
context: this.context,
builder: (dialogCtx) => AlertDialog(
title: Text(l10n.dialogImportPlaylistTitle),
content: Text(l10n.dialogImportPlaylistMessage(tracks.length)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogCtx, false),
child: Text(l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(dialogCtx, true),
child: Text(l10n.dialogImport),
),
],
),
);
if (confirmed == true) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
if (mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
SnackBar(
content: Text(l10n.snackbarAddedTracksToQueue(tracks.length)),
action: SnackBarAction(
label: l10n.snackbarViewQueue,
onPressed: () {
},
// Show quality picker if enabled in settings
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
this.context,
trackName: l10n.csvImportTracks(tracks.length),
artistName: l10n.dialogImportPlaylistTitle,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(
tracks,
service,
qualityOverride: quality,
);
if (mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
SnackBar(
content: Text(l10n.snackbarAddedTracksToQueue(tracks.length)),
action: SnackBarAction(
label: l10n.snackbarViewQueue,
onPressed: () {},
),
),
);
}
},
);
} else {
// Use default settings without quality picker
final confirmed = await showDialog<bool>(
context: this.context,
builder: (dialogCtx) => AlertDialog(
title: Text(l10n.dialogImportPlaylistTitle),
content: Text(l10n.dialogImportPlaylistMessage(tracks.length)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogCtx, false),
child: Text(l10n.dialogCancel),
),
),
);
FilledButton(
onPressed: () => Navigator.pop(dialogCtx, true),
child: Text(l10n.dialogImport),
),
],
),
);
if (confirmed == true) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
if (mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
SnackBar(
content: Text(l10n.snackbarAddedTracksToQueue(tracks.length)),
action: SnackBarAction(
label: l10n.snackbarViewQueue,
onPressed: () {},
),
),
);
}
}
}
}
+9
View File
@@ -119,6 +119,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
int? get discNumber => item.discNumber;
String? get releaseDate => item.releaseDate;
String? get isrc => item.isrc;
String? get genre => item.genre;
String? get label => item.label;
String? get copyright => item.copyright;
String get cleanFilePath {
final path = item.filePath;
@@ -519,6 +522,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_MetadataItem(context.l10n.trackAudioQuality, audioQualityStr),
if (releaseDate != null && releaseDate!.isNotEmpty)
_MetadataItem(context.l10n.trackReleaseDate, releaseDate!),
if (genre != null && genre!.isNotEmpty)
_MetadataItem(context.l10n.trackGenre, genre!),
if (label != null && label!.isNotEmpty)
_MetadataItem(context.l10n.trackLabel, label!),
if (copyright != null && copyright!.isNotEmpty)
_MetadataItem(context.l10n.trackCopyright, copyright!),
if (isrc != null && isrc!.isNotEmpty)
_MetadataItem('ISRC', isrc!),
];