feat(metadata): manual MusicBrainz lookup in the tag editor

The Go MusicBrainz genre and album-artist ISRC lookups only ran
automatically at download time. The tag editor's auto-fill section
gains a Fetch from MusicBrainz button that bridges both exports in one
channel call (sparing the 1 req/s budget) and fills the fields as
editable suggestions; nothing is written until Save.
This commit is contained in:
zarzet
2026-07-26 19:21:32 +07:00
parent fa7ef84b39
commit 97b87472fe
17 changed files with 298 additions and 0 deletions
@@ -3387,6 +3387,27 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"fetchMusicBrainzTags" -> {
val isrc = call.argument<String>("isrc") ?: ""
val albumName = call.argument<String>("album_name") ?: ""
val response = withContext(Dispatchers.IO) {
val genre = try {
Gobackend.fetchMusicBrainzGenreByISRC(isrc)
} catch (_: Exception) {
""
}
val albumArtist = try {
Gobackend.fetchMusicBrainzAlbumArtistByISRC(isrc, albumName)
} catch (_: Exception) {
""
}
JSONObject()
.put("genre", genre)
.put("album_artist", albumArtist)
.toString()
}
result.success(response)
}
"runPostProcessingV2" -> {
val inputJson = call.argument<String>("input") ?: ""
val metadataJson = call.argument<String>("metadata") ?: ""
+15
View File
@@ -959,6 +959,21 @@ import Gobackend
let response = GobackendGetTrackPlatformLinksJSON(spotifyId, isrc, &error)
if let error = error { throw error }
return response
case "fetchMusicBrainzTags":
let args = call.arguments as! [String: Any]
let isrc = args["isrc"] as? String ?? ""
let albumName = args["album_name"] as? String ?? ""
var genreError: NSError?
let genre = GobackendFetchMusicBrainzGenreByISRC(isrc, &genreError)
var artistError: NSError?
let albumArtist = GobackendFetchMusicBrainzAlbumArtistByISRC(isrc, albumName, &artistError)
let payload: [String: Any] = [
"genre": genreError == nil ? genre : "",
"album_artist": artistError == nil ? albumArtist : "",
]
let data = try JSONSerialization.data(withJSONObject: payload)
return String(data: data, encoding: .utf8) ?? "{}"
case "runPostProcessingV2":
let args = call.arguments as! [String: Any]
+24
View File
@@ -6294,6 +6294,30 @@ abstract class AppLocalizations {
/// **'Download next'**
String get queueDownloadNext;
/// Tag editor button that fills genre and album artist from MusicBrainz by ISRC
///
/// In en, this message translates to:
/// **'Fetch from MusicBrainz'**
String get editMetadataMusicBrainzButton;
/// Snackbar after MusicBrainz suggestions were applied to the tag editor fields
///
/// In en, this message translates to:
/// **'Updated from MusicBrainz'**
String get editMetadataMusicBrainzFilled;
/// Snackbar when the MusicBrainz lookup returns no data
///
/// In en, this message translates to:
/// **'Nothing found on MusicBrainz'**
String get editMetadataMusicBrainzNothing;
/// Snackbar when the MusicBrainz lookup is tapped without an ISRC
///
/// In en, this message translates to:
/// **'Requires an ISRC tag'**
String get editMetadataMusicBrainzNeedsIsrc;
/// Repeat toggle tooltip when repeat is disabled
///
/// In en, this message translates to:
+12
View File
@@ -3834,6 +3834,18 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get queueDownloadNext => 'Download next';
@override
String get editMetadataMusicBrainzButton => 'Fetch from MusicBrainz';
@override
String get editMetadataMusicBrainzFilled => 'Updated from MusicBrainz';
@override
String get editMetadataMusicBrainzNothing => 'Nothing found on MusicBrainz';
@override
String get editMetadataMusicBrainzNeedsIsrc => 'Requires an ISRC tag';
@override
String get nowPlayingRepeatOff => 'Repeat off';
+12
View File
@@ -3788,6 +3788,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get queueDownloadNext => 'Download next';
@override
String get editMetadataMusicBrainzButton => 'Fetch from MusicBrainz';
@override
String get editMetadataMusicBrainzFilled => 'Updated from MusicBrainz';
@override
String get editMetadataMusicBrainzNothing => 'Nothing found on MusicBrainz';
@override
String get editMetadataMusicBrainzNeedsIsrc => 'Requires an ISRC tag';
@override
String get nowPlayingRepeatOff => 'Repeat off';
+12
View File
@@ -3782,6 +3782,18 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get queueDownloadNext => 'Download next';
@override
String get editMetadataMusicBrainzButton => 'Fetch from MusicBrainz';
@override
String get editMetadataMusicBrainzFilled => 'Updated from MusicBrainz';
@override
String get editMetadataMusicBrainzNothing => 'Nothing found on MusicBrainz';
@override
String get editMetadataMusicBrainzNeedsIsrc => 'Requires an ISRC tag';
@override
String get nowPlayingRepeatOff => 'Repeat off';
+12
View File
@@ -3889,6 +3889,18 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get queueDownloadNext => 'Download next';
@override
String get editMetadataMusicBrainzButton => 'Fetch from MusicBrainz';
@override
String get editMetadataMusicBrainzFilled => 'Updated from MusicBrainz';
@override
String get editMetadataMusicBrainzNothing => 'Nothing found on MusicBrainz';
@override
String get editMetadataMusicBrainzNeedsIsrc => 'Requires an ISRC tag';
@override
String get nowPlayingRepeatOff => 'Repeat off';
+12
View File
@@ -3791,6 +3791,18 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get queueDownloadNext => 'Download next';
@override
String get editMetadataMusicBrainzButton => 'Fetch from MusicBrainz';
@override
String get editMetadataMusicBrainzFilled => 'Updated from MusicBrainz';
@override
String get editMetadataMusicBrainzNothing => 'Nothing found on MusicBrainz';
@override
String get editMetadataMusicBrainzNeedsIsrc => 'Requires an ISRC tag';
@override
String get nowPlayingRepeatOff => 'Repeat off';
+12
View File
@@ -3777,6 +3777,18 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get queueDownloadNext => 'Download next';
@override
String get editMetadataMusicBrainzButton => 'Fetch from MusicBrainz';
@override
String get editMetadataMusicBrainzFilled => 'Updated from MusicBrainz';
@override
String get editMetadataMusicBrainzNothing => 'Nothing found on MusicBrainz';
@override
String get editMetadataMusicBrainzNeedsIsrc => 'Requires an ISRC tag';
@override
String get nowPlayingRepeatOff => 'Repeat off';
+12
View File
@@ -3678,6 +3678,18 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get queueDownloadNext => 'Download next';
@override
String get editMetadataMusicBrainzButton => 'Fetch from MusicBrainz';
@override
String get editMetadataMusicBrainzFilled => 'Updated from MusicBrainz';
@override
String get editMetadataMusicBrainzNothing => 'Nothing found on MusicBrainz';
@override
String get editMetadataMusicBrainzNeedsIsrc => 'Requires an ISRC tag';
@override
String get nowPlayingRepeatOff => 'Repeat off';
+12
View File
@@ -3782,6 +3782,18 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get queueDownloadNext => 'Download next';
@override
String get editMetadataMusicBrainzButton => 'Fetch from MusicBrainz';
@override
String get editMetadataMusicBrainzFilled => 'Updated from MusicBrainz';
@override
String get editMetadataMusicBrainzNothing => 'Nothing found on MusicBrainz';
@override
String get editMetadataMusicBrainzNeedsIsrc => 'Requires an ISRC tag';
@override
String get nowPlayingRepeatOff => 'Repeat off';
+12
View File
@@ -3819,6 +3819,18 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get queueDownloadNext => 'Download next';
@override
String get editMetadataMusicBrainzButton => 'Fetch from MusicBrainz';
@override
String get editMetadataMusicBrainzFilled => 'Updated from MusicBrainz';
@override
String get editMetadataMusicBrainzNothing => 'Nothing found on MusicBrainz';
@override
String get editMetadataMusicBrainzNeedsIsrc => 'Requires an ISRC tag';
@override
String get nowPlayingRepeatOff => 'Repeat off';
+12
View File
@@ -3818,6 +3818,18 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get queueDownloadNext => 'Download next';
@override
String get editMetadataMusicBrainzButton => 'Fetch from MusicBrainz';
@override
String get editMetadataMusicBrainzFilled => 'Updated from MusicBrainz';
@override
String get editMetadataMusicBrainzNothing => 'Nothing found on MusicBrainz';
@override
String get editMetadataMusicBrainzNeedsIsrc => 'Requires an ISRC tag';
@override
String get nowPlayingRepeatOff => 'Repeat off';
+12
View File
@@ -3836,6 +3836,18 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get queueDownloadNext => 'Download next';
@override
String get editMetadataMusicBrainzButton => 'Fetch from MusicBrainz';
@override
String get editMetadataMusicBrainzFilled => 'Updated from MusicBrainz';
@override
String get editMetadataMusicBrainzNothing => 'Nothing found on MusicBrainz';
@override
String get editMetadataMusicBrainzNeedsIsrc => 'Requires an ISRC tag';
@override
String get nowPlayingRepeatOff => 'Repeat off';
+16
View File
@@ -4922,6 +4922,22 @@
"@queueDownloadNext": {
"description": "Tooltip on a queued download row; moves the item to the front of the queue so the next free slot downloads it"
},
"editMetadataMusicBrainzButton": "Fetch from MusicBrainz",
"@editMetadataMusicBrainzButton": {
"description": "Tag editor button that fills genre and album artist from MusicBrainz by ISRC"
},
"editMetadataMusicBrainzFilled": "Updated from MusicBrainz",
"@editMetadataMusicBrainzFilled": {
"description": "Snackbar after MusicBrainz suggestions were applied to the tag editor fields"
},
"editMetadataMusicBrainzNothing": "Nothing found on MusicBrainz",
"@editMetadataMusicBrainzNothing": {
"description": "Snackbar when the MusicBrainz lookup returns no data"
},
"editMetadataMusicBrainzNeedsIsrc": "Requires an ISRC tag",
"@editMetadataMusicBrainzNeedsIsrc": {
"description": "Snackbar when the MusicBrainz lookup is tapped without an ISRC"
},
"nowPlayingRepeatOff": "Repeat off",
"@nowPlayingRepeatOff": {
"description": "Repeat toggle tooltip when repeat is disabled"
@@ -84,6 +84,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
late final TextEditingController _copyrightCtrl;
late final TextEditingController _composerCtrl;
late final TextEditingController _commentCtrl;
bool _fetchingMusicBrainz = false;
bool _hasValue(String? value) => value != null && value.trim().isNotEmpty;
@@ -1518,11 +1519,82 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: (_fetching || _fetchingMusicBrainz || _saving)
? null
: _fetchFromMusicBrainz,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
icon: _fetchingMusicBrainz
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.library_music_outlined),
label: Text(context.l10n.editMetadataMusicBrainzButton),
),
),
const SizedBox(height: 8),
],
],
);
}
/// Fills genre and album artist from MusicBrainz (keyed by ISRC) as
/// editable suggestions; nothing is saved until the user taps Save.
Future<void> _fetchFromMusicBrainz() async {
final isrc = _isrcCtrl.text.trim();
if (isrc.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.editMetadataMusicBrainzNeedsIsrc)),
);
return;
}
setState(() => _fetchingMusicBrainz = true);
try {
final tags = await PlatformBridge.fetchMusicBrainzTags(
isrc: isrc,
albumName: _albumCtrl.text.trim(),
);
if (!mounted) return;
var filled = 0;
if (tags.genre.isNotEmpty) {
_genreCtrl.text = tags.genre;
filled++;
}
if (tags.albumArtist.isNotEmpty) {
_albumArtistCtrl.text = tags.albumArtist;
filled++;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
filled > 0
? context.l10n.editMetadataMusicBrainzFilled
: context.l10n.editMetadataMusicBrainzNothing,
),
),
);
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.editMetadataMusicBrainzNothing),
),
);
}
} finally {
if (mounted) setState(() => _fetchingMusicBrainz = false);
}
}
Widget _quickSelectButton({
required String label,
required VoidCallback onTap,
+18
View File
@@ -1645,6 +1645,24 @@ class PlatformBridge {
return result as String;
}
/// MusicBrainz genre and album artist for an ISRC; empty strings when a
/// lookup finds nothing. One channel call covers both fields to spare
/// MusicBrainz's 1 req/s budget.
static Future<({String genre, String albumArtist})> fetchMusicBrainzTags({
required String isrc,
String albumName = '',
}) async {
final result = await _channel.invokeMethod('fetchMusicBrainzTags', {
'isrc': isrc,
'album_name': albumName,
});
final decoded = _decodeMapResult(result);
return (
genre: decoded['genre']?.toString() ?? '',
albumArtist: decoded['album_artist']?.toString() ?? '',
);
}
/// Streaming-platform links for a track, keyed by song.link platform ID.
/// Served from the Go-side memory cache; throws when the lookup fails.
static Future<Map<String, String>> getTrackPlatformLinks({