From fb538e9517300a1f2b35a15cc571d67e02646a61 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 28 Jul 2026 15:18:10 +0700 Subject: [PATCH] feat(metadata): choose extension for online autofill --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 20 + go_backend/exports_extensions.go | 10 + go_backend/exports_supplement_test.go | 34 ++ go_backend/extension_providers.go | 40 ++ ios/Runner/AppDelegate.swift | 14 + lib/l10n/app_localizations.dart | 44 +- lib/l10n/app_localizations_de.dart | 32 ++ lib/l10n/app_localizations_en.dart | 34 +- lib/l10n/app_localizations_es.dart | 32 ++ lib/l10n/app_localizations_fr.dart | 32 ++ lib/l10n/app_localizations_id.dart | 28 +- lib/l10n/app_localizations_ja.dart | 32 ++ lib/l10n/app_localizations_ko.dart | 32 ++ lib/l10n/app_localizations_pt.dart | 32 ++ lib/l10n/app_localizations_ru.dart | 32 ++ lib/l10n/app_localizations_tr.dart | 32 ++ lib/l10n/app_localizations_uk.dart | 32 ++ lib/l10n/arb/app_en.arb | 43 +- lib/l10n/arb/app_id.arb | 43 +- lib/screens/track_metadata_edit_sheet.dart | 444 +++++++++++++++--- lib/screens/track_metadata_screen.dart | 1 + lib/services/platform_bridge.dart | 13 + test/platform_bridge_metadata_test.dart | 29 ++ 23 files changed, 1006 insertions(+), 79 deletions(-) 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 74fbcd59..7c10899c 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1853,6 +1853,26 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "searchTracksWithMetadataProvider" -> { + val extensionId = call.argument("extension_id") ?: "" + val query = call.argument("query") ?: "" + val limit = call.argument("limit") ?: 20 + val response = withContext(Dispatchers.IO) { + val method = Gobackend::class.java.getMethod( + "searchTracksWithMetadataProviderJSON", + String::class.java, + String::class.java, + java.lang.Long.TYPE + ) + method.invoke( + null, + extensionId, + query, + limit.toLong() + ) as? String ?: "[]" + } + result.success(response) + } "findCollectionAcrossExtensions" -> { val requestJson = call.arguments as? String ?: "{}" val response: String = withContext(Dispatchers.IO) { diff --git a/go_backend/exports_extensions.go b/go_backend/exports_extensions.go index d5e67ccf..528c8c6e 100644 --- a/go_backend/exports_extensions.go +++ b/go_backend/exports_extensions.go @@ -442,6 +442,16 @@ func SearchTracksWithMetadataProvidersJSON(query string, limit int, includeExten return marshalJSONString(tracks) } +func SearchTracksWithMetadataProviderJSON(providerID, query string, limit int) (string, error) { + manager := getExtensionManager() + tracks, err := manager.SearchTracksWithMetadataProvider(providerID, query, limit) + if err != nil { + return "", err + } + + return marshalJSONString(tracks) +} + func preflightExtensionDownloadSession(extensionID string) (bool, error) { extensionID = strings.TrimSpace(extensionID) if extensionID == "" { diff --git a/go_backend/exports_supplement_test.go b/go_backend/exports_supplement_test.go index ecb88fc6..00bfc760 100644 --- a/go_backend/exports_supplement_test.go +++ b/go_backend/exports_supplement_test.go @@ -104,6 +104,40 @@ func TestExtensionTrackExportsPreserveExplicitFlag(t *testing.T) { assertExplicit("URL handler", jsonText, err) } +func TestSearchTracksWithMetadataProviderUsesOnlySelectedExtension(t *testing.T) { + dir := t.TempDir() + if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil { + t.Fatalf("InitExtensionSystem: %v", err) + } + + selected := newTestLoadedExtension(t, ExtensionTypeMetadataProvider) + selected.ID = "selected-metadata" + selected.Manifest.Name = selected.ID + other := newTestLoadedExtension(t, ExtensionTypeMetadataProvider) + other.ID = "other-metadata" + other.Manifest.Name = other.ID + + manager := getExtensionManager() + manager.mu.Lock() + manager.extensions = map[string]*loadedExtension{ + selected.ID: selected, + other.ID: other, + } + manager.mu.Unlock() + defer CleanupExtensions() + + jsonText, err := SearchTracksWithMetadataProviderJSON(selected.ID, "needle", 5) + if err != nil { + t.Fatalf("SearchTracksWithMetadataProviderJSON: %v", err) + } + if !strings.Contains(jsonText, `"provider_id":"selected-metadata"`) { + t.Fatalf("expected selected provider attribution, got %s", jsonText) + } + if strings.Contains(jsonText, `"provider_id":"other-metadata"`) { + t.Fatalf("unexpected fallback to another provider: %s", jsonText) + } +} + func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) { dir := t.TempDir() dataDir := filepath.Join(dir, "data") diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index c375af63..c5465806 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -51,6 +51,46 @@ func (m *extensionManager) SearchTracksWithMetadataProviders(query string, limit return m.SearchTracksWithMetadataProvidersForItemID(query, limit, includeExtensions, "") } +// SearchTracksWithMetadataProvider searches one explicitly selected metadata +// provider. Unlike the priority-based search, this never falls through to a +// different extension, so callers can reliably attribute the returned fields +// to the provider selected by the user. +func (m *extensionManager) SearchTracksWithMetadataProvider(providerID, query string, limit int) ([]ExtTrackMetadata, error) { + providerID = strings.TrimSpace(providerID) + if providerID == "" { + return nil, fmt.Errorf("metadata provider ID is required") + } + if limit <= 0 { + limit = 20 + } + + ext, err := m.GetExtension(providerID) + if err != nil { + return nil, err + } + if ext == nil || ext.Manifest == nil || !ext.Manifest.IsMetadataProvider() { + return nil, fmt.Errorf("extension '%s' is not a metadata provider", providerID) + } + if !ext.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", providerID) + } + if ext.Error != "" { + return nil, fmt.Errorf("extension '%s' is unavailable: %s", providerID, ext.Error) + } + + result, err := newExtensionProviderWrapper(ext).SearchTracks(query, limit) + if err != nil { + return nil, err + } + if result == nil || len(result.Tracks) <= limit { + if result == nil { + return []ExtTrackMetadata{}, nil + } + return result.Tracks, nil + } + return result.Tracks[:limit], nil +} + func (m *extensionManager) SearchTracksWithMetadataProvidersForItemID(query string, limit int, includeExtensions bool, itemID string) ([]ExtTrackMetadata, error) { priority := GetMetadataProviderPriority() if limit <= 0 { diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 24d7ee94..5e54992d 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -808,6 +808,20 @@ import Gobackend ) if let error = error { throw error } return response + + case "searchTracksWithMetadataProvider": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as? String ?? "" + let query = args["query"] as? String ?? "" + let limit = args["limit"] as? Int ?? 20 + let response = GobackendSearchTracksWithMetadataProviderJSON( + extensionId, + query, + Int(limit), + &error + ) + if let error = error { throw error } + return response case "enrichTrackWithExtension": let args = call.arguments as! [String: Any] diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 67bc23a8..4cb2a1c3 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5555,9 +5555,51 @@ abstract class AppLocalizations { /// Description for the auto-fill section /// /// In en, this message translates to: - /// **'Select fields to fill automatically from online metadata'** + /// **'Choose a metadata extension, select fields, then review its data before applying'** String get editMetadataAutoFillDesc; + /// Label for the metadata extension selector in online auto-fill + /// + /// In en, this message translates to: + /// **'Metadata source'** + String get editMetadataAutoFillSource; + + /// Automatic source option that follows configured metadata provider priority + /// + /// In en, this message translates to: + /// **'Automatic (provider priority)'** + String get editMetadataAutoFillSourceAutomatic; + + /// Button label for finding an online metadata candidate + /// + /// In en, this message translates to: + /// **'Find metadata'** + String get editMetadataAutoFillFind; + + /// Title for the online metadata preview + /// + /// In en, this message translates to: + /// **'Data from {source}'** + String editMetadataAutoFillPreview(String source); + + /// Preview value when the selected metadata source provides cover artwork + /// + /// In en, this message translates to: + /// **'Cover artwork available'** + String get editMetadataAutoFillCoverAvailable; + + /// Button label for applying the previewed metadata + /// + /// In en, this message translates to: + /// **'Apply selected data'** + String get editMetadataAutoFillApply; + + /// Snackbar confirming fields applied from a selected metadata source + /// + /// In en, this message translates to: + /// **'Filled {count} {count, plural, =1{field} other{fields}} from {source}'** + String editMetadataAutoFillDoneFromSource(int count, String source); + /// Button label to fetch online metadata and fill selected fields /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index ab99c5ae..8da8c9bb 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3355,6 +3355,38 @@ class AppLocalizationsDe extends AppLocalizations { String get editMetadataAutoFillDesc => 'Wähle Felder aus, die automatisch aus Online-Metadaten ausgefüllt werden sollen'; + @override + String get editMetadataAutoFillSource => 'Metadata source'; + + @override + String get editMetadataAutoFillSourceAutomatic => + 'Automatic (provider priority)'; + + @override + String get editMetadataAutoFillFind => 'Find metadata'; + + @override + String editMetadataAutoFillPreview(String source) { + return 'Data from $source'; + } + + @override + String get editMetadataAutoFillCoverAvailable => 'Cover artwork available'; + + @override + String get editMetadataAutoFillApply => 'Apply selected data'; + + @override + String editMetadataAutoFillDoneFromSource(int count, String source) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from $source'; + } + @override String get editMetadataAutoFillFetch => 'Abrufen & Ausfüllen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 8fbcde86..774c331c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3313,7 +3313,39 @@ class AppLocalizationsEn extends AppLocalizations { @override String get editMetadataAutoFillDesc => - 'Select fields to fill automatically from online metadata'; + 'Choose a metadata extension, select fields, then review its data before applying'; + + @override + String get editMetadataAutoFillSource => 'Metadata source'; + + @override + String get editMetadataAutoFillSourceAutomatic => + 'Automatic (provider priority)'; + + @override + String get editMetadataAutoFillFind => 'Find metadata'; + + @override + String editMetadataAutoFillPreview(String source) { + return 'Data from $source'; + } + + @override + String get editMetadataAutoFillCoverAvailable => 'Cover artwork available'; + + @override + String get editMetadataAutoFillApply => 'Apply selected data'; + + @override + String editMetadataAutoFillDoneFromSource(int count, String source) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from $source'; + } @override String get editMetadataAutoFillFetch => 'Fetch & Fill'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index eebce9e4..4f6b92bf 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -3315,6 +3315,38 @@ class AppLocalizationsEs extends AppLocalizations { String get editMetadataAutoFillDesc => 'Select fields to fill automatically from online metadata'; + @override + String get editMetadataAutoFillSource => 'Metadata source'; + + @override + String get editMetadataAutoFillSourceAutomatic => + 'Automatic (provider priority)'; + + @override + String get editMetadataAutoFillFind => 'Find metadata'; + + @override + String editMetadataAutoFillPreview(String source) { + return 'Data from $source'; + } + + @override + String get editMetadataAutoFillCoverAvailable => 'Cover artwork available'; + + @override + String get editMetadataAutoFillApply => 'Apply selected data'; + + @override + String editMetadataAutoFillDoneFromSource(int count, String source) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from $source'; + } + @override String get editMetadataAutoFillFetch => 'Fetch & Fill'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 1737e603..cc305284 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -3404,6 +3404,38 @@ class AppLocalizationsFr extends AppLocalizations { String get editMetadataAutoFillDesc => 'Sélectionnez les champs à remplir automatiquement à partir des métadonnées en ligne'; + @override + String get editMetadataAutoFillSource => 'Metadata source'; + + @override + String get editMetadataAutoFillSourceAutomatic => + 'Automatic (provider priority)'; + + @override + String get editMetadataAutoFillFind => 'Find metadata'; + + @override + String editMetadataAutoFillPreview(String source) { + return 'Data from $source'; + } + + @override + String get editMetadataAutoFillCoverAvailable => 'Cover artwork available'; + + @override + String get editMetadataAutoFillApply => 'Apply selected data'; + + @override + String editMetadataAutoFillDoneFromSource(int count, String source) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from $source'; + } + @override String get editMetadataAutoFillFetch => 'Récupérer & remplir'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 590a49fd..dae5199e 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -3316,7 +3316,33 @@ class AppLocalizationsId extends AppLocalizations { @override String get editMetadataAutoFillDesc => - 'Select fields to fill automatically from online metadata'; + 'Pilih extension metadata dan field, lalu periksa datanya sebelum diterapkan'; + + @override + String get editMetadataAutoFillSource => 'Sumber metadata'; + + @override + String get editMetadataAutoFillSourceAutomatic => + 'Otomatis (prioritas provider)'; + + @override + String get editMetadataAutoFillFind => 'Cari metadata'; + + @override + String editMetadataAutoFillPreview(String source) { + return 'Data dari $source'; + } + + @override + String get editMetadataAutoFillCoverAvailable => 'Sampul tersedia'; + + @override + String get editMetadataAutoFillApply => 'Terapkan data terpilih'; + + @override + String editMetadataAutoFillDoneFromSource(int count, String source) { + return 'Mengisi $count field dari $source'; + } @override String get editMetadataAutoFillFetch => 'Fetch & Fill'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index fad6f5af..00c9a407 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -3304,6 +3304,38 @@ class AppLocalizationsJa extends AppLocalizations { String get editMetadataAutoFillDesc => 'Select fields to fill automatically from online metadata'; + @override + String get editMetadataAutoFillSource => 'Metadata source'; + + @override + String get editMetadataAutoFillSourceAutomatic => + 'Automatic (provider priority)'; + + @override + String get editMetadataAutoFillFind => 'Find metadata'; + + @override + String editMetadataAutoFillPreview(String source) { + return 'Data from $source'; + } + + @override + String get editMetadataAutoFillCoverAvailable => 'Cover artwork available'; + + @override + String get editMetadataAutoFillApply => 'Apply selected data'; + + @override + String editMetadataAutoFillDoneFromSource(int count, String source) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from $source'; + } + @override String get editMetadataAutoFillFetch => 'Fetch & Fill'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 96460318..e6b7301a 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -3218,6 +3218,38 @@ class AppLocalizationsKo extends AppLocalizations { @override String get editMetadataAutoFillDesc => '온라인 메타데이터에서 자동으로 채워질 필드를 선택하세요'; + @override + String get editMetadataAutoFillSource => 'Metadata source'; + + @override + String get editMetadataAutoFillSourceAutomatic => + 'Automatic (provider priority)'; + + @override + String get editMetadataAutoFillFind => 'Find metadata'; + + @override + String editMetadataAutoFillPreview(String source) { + return 'Data from $source'; + } + + @override + String get editMetadataAutoFillCoverAvailable => 'Cover artwork available'; + + @override + String get editMetadataAutoFillApply => 'Apply selected data'; + + @override + String editMetadataAutoFillDoneFromSource(int count, String source) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from $source'; + } + @override String get editMetadataAutoFillFetch => '가져오기 & 채우기'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 49c1e273..5fbfeeee 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -3315,6 +3315,38 @@ class AppLocalizationsPt extends AppLocalizations { String get editMetadataAutoFillDesc => 'Select fields to fill automatically from online metadata'; + @override + String get editMetadataAutoFillSource => 'Metadata source'; + + @override + String get editMetadataAutoFillSourceAutomatic => + 'Automatic (provider priority)'; + + @override + String get editMetadataAutoFillFind => 'Find metadata'; + + @override + String editMetadataAutoFillPreview(String source) { + return 'Data from $source'; + } + + @override + String get editMetadataAutoFillCoverAvailable => 'Cover artwork available'; + + @override + String get editMetadataAutoFillApply => 'Apply selected data'; + + @override + String editMetadataAutoFillDoneFromSource(int count, String source) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from $source'; + } + @override String get editMetadataAutoFillFetch => 'Fetch & Fill'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 4357810e..bcc5810f 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -3344,6 +3344,38 @@ class AppLocalizationsRu extends AppLocalizations { String get editMetadataAutoFillDesc => 'Select fields to fill automatically from online metadata'; + @override + String get editMetadataAutoFillSource => 'Metadata source'; + + @override + String get editMetadataAutoFillSourceAutomatic => + 'Automatic (provider priority)'; + + @override + String get editMetadataAutoFillFind => 'Find metadata'; + + @override + String editMetadataAutoFillPreview(String source) { + return 'Data from $source'; + } + + @override + String get editMetadataAutoFillCoverAvailable => 'Cover artwork available'; + + @override + String get editMetadataAutoFillApply => 'Apply selected data'; + + @override + String editMetadataAutoFillDoneFromSource(int count, String source) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from $source'; + } + @override String get editMetadataAutoFillFetch => 'Получить и заполнить'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 104f37de..9a45dafc 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -3345,6 +3345,38 @@ class AppLocalizationsTr extends AppLocalizations { String get editMetadataAutoFillDesc => 'Select fields to fill automatically from online metadata'; + @override + String get editMetadataAutoFillSource => 'Metadata source'; + + @override + String get editMetadataAutoFillSourceAutomatic => + 'Automatic (provider priority)'; + + @override + String get editMetadataAutoFillFind => 'Find metadata'; + + @override + String editMetadataAutoFillPreview(String source) { + return 'Data from $source'; + } + + @override + String get editMetadataAutoFillCoverAvailable => 'Cover artwork available'; + + @override + String get editMetadataAutoFillApply => 'Apply selected data'; + + @override + String editMetadataAutoFillDoneFromSource(int count, String source) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from $source'; + } + @override String get editMetadataAutoFillFetch => 'Fetch & Fill'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 8095b480..bd46e44c 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -3358,6 +3358,38 @@ class AppLocalizationsUk extends AppLocalizations { String get editMetadataAutoFillDesc => 'Виберіть поля для автоматичного заповнення з онлайн-метаданих'; + @override + String get editMetadataAutoFillSource => 'Metadata source'; + + @override + String get editMetadataAutoFillSourceAutomatic => + 'Automatic (provider priority)'; + + @override + String get editMetadataAutoFillFind => 'Find metadata'; + + @override + String editMetadataAutoFillPreview(String source) { + return 'Data from $source'; + } + + @override + String get editMetadataAutoFillCoverAvailable => 'Cover artwork available'; + + @override + String get editMetadataAutoFillApply => 'Apply selected data'; + + @override + String editMetadataAutoFillDoneFromSource(int count, String source) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'fields', + one: 'field', + ); + return 'Filled $count $_temp0 from $source'; + } + @override String get editMetadataAutoFillFetch => 'Отримання та заповнення'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 53ea1ba2..be91fbd0 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -4283,10 +4283,51 @@ "@editMetadataAutoFill": { "description": "Section title for selective online metadata auto-fill in the edit metadata sheet" }, - "editMetadataAutoFillDesc": "Select fields to fill automatically from online metadata", + "editMetadataAutoFillDesc": "Choose a metadata extension, select fields, then review its data before applying", "@editMetadataAutoFillDesc": { "description": "Description for the auto-fill section" }, + "editMetadataAutoFillSource": "Metadata source", + "@editMetadataAutoFillSource": { + "description": "Label for the metadata extension selector in online auto-fill" + }, + "editMetadataAutoFillSourceAutomatic": "Automatic (provider priority)", + "@editMetadataAutoFillSourceAutomatic": { + "description": "Automatic source option that follows configured metadata provider priority" + }, + "editMetadataAutoFillFind": "Find metadata", + "@editMetadataAutoFillFind": { + "description": "Button label for finding an online metadata candidate" + }, + "editMetadataAutoFillPreview": "Data from {source}", + "@editMetadataAutoFillPreview": { + "description": "Title for the online metadata preview", + "placeholders": { + "source": { + "type": "String" + } + } + }, + "editMetadataAutoFillCoverAvailable": "Cover artwork available", + "@editMetadataAutoFillCoverAvailable": { + "description": "Preview value when the selected metadata source provides cover artwork" + }, + "editMetadataAutoFillApply": "Apply selected data", + "@editMetadataAutoFillApply": { + "description": "Button label for applying the previewed metadata" + }, + "editMetadataAutoFillDoneFromSource": "Filled {count} {count, plural, =1{field} other{fields}} from {source}", + "@editMetadataAutoFillDoneFromSource": { + "description": "Snackbar confirming fields applied from a selected metadata source", + "placeholders": { + "count": { + "type": "int" + }, + "source": { + "type": "String" + } + } + }, "editMetadataAutoFillFetch": "Fetch & Fill", "@editMetadataAutoFillFetch": { "description": "Button label to fetch online metadata and fill selected fields" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 15b15291..bf56a3f8 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -2385,10 +2385,51 @@ } } }, - "editMetadataAutoFillDesc": "Select fields to fill automatically from online metadata", + "editMetadataAutoFillDesc": "Pilih extension metadata dan field, lalu periksa datanya sebelum diterapkan", "@editMetadataAutoFillDesc": { "description": "Description for the auto-fill section" }, + "editMetadataAutoFillSource": "Sumber metadata", + "@editMetadataAutoFillSource": { + "description": "Label for the metadata extension selector in online auto-fill" + }, + "editMetadataAutoFillSourceAutomatic": "Otomatis (prioritas provider)", + "@editMetadataAutoFillSourceAutomatic": { + "description": "Automatic source option that follows configured metadata provider priority" + }, + "editMetadataAutoFillFind": "Cari metadata", + "@editMetadataAutoFillFind": { + "description": "Button label for finding an online metadata candidate" + }, + "editMetadataAutoFillPreview": "Data dari {source}", + "@editMetadataAutoFillPreview": { + "description": "Title for the online metadata preview", + "placeholders": { + "source": { + "type": "String" + } + } + }, + "editMetadataAutoFillCoverAvailable": "Sampul tersedia", + "@editMetadataAutoFillCoverAvailable": { + "description": "Preview value when the selected metadata source provides cover artwork" + }, + "editMetadataAutoFillApply": "Terapkan data terpilih", + "@editMetadataAutoFillApply": { + "description": "Button label for applying the previewed metadata" + }, + "editMetadataAutoFillDoneFromSource": "Mengisi {count} field dari {source}", + "@editMetadataAutoFillDoneFromSource": { + "description": "Snackbar confirming fields applied from a selected metadata source", + "placeholders": { + "count": { + "type": "int" + }, + "source": { + "type": "String" + } + } + }, "@lyricsProviderExtensionDesc": { "description": "Generic description for extension-based lyrics providers" }, diff --git a/lib/screens/track_metadata_edit_sheet.dart b/lib/screens/track_metadata_edit_sheet.dart index 0862fa80..5b6de8de 100644 --- a/lib/screens/track_metadata_edit_sheet.dart +++ b/lib/screens/track_metadata_edit_sheet.dart @@ -7,6 +7,18 @@ class _ResolvedAutoFillTrack { const _ResolvedAutoFillTrack({required this.track, this.deezerId}); } +class _AutoFillPreview { + final Map values; + final String sourceName; + final String? coverUrl; + + const _AutoFillPreview({ + required this.values, + required this.sourceName, + this.coverUrl, + }); +} + class _EditMetadataSheet extends StatefulWidget { static const _onlineCoverSentinel = '__online_cover__'; final ColorScheme colorScheme; @@ -46,9 +58,50 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { String? _currentCoverPath; String? _currentCoverTempDir; bool _loadingCurrentCover = false; + String? _selectedMetadataProviderId; + _AutoFillPreview? _autoFillPreview; final Set _autoFillFields = {}; + List _orderedMetadataProviders(ExtensionState state) { + final providersById = { + for (final extension in state.extensions) + if (extension.enabled && extension.hasMetadataProvider) + extension.id: extension, + }; + final ordered = []; + for (final id in state.metadataProviderPriority) { + final extension = providersById.remove(id); + if (extension != null) ordered.add(extension); + } + final remaining = providersById.values.toList() + ..sort( + (a, b) => + a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()), + ); + return [...ordered, ...remaining]; + } + + String _metadataProviderName(String? providerId) { + final normalized = providerId?.trim() ?? ''; + if (normalized.isEmpty) { + return context.l10n.editMetadataAutoFillSourceAutomatic; + } + final extensionState = ProviderScope.containerOf( + context, + listen: false, + ).read(extensionProvider); + return extensionState.extensions + .where((extension) => extension.id == normalized) + .firstOrNull + ?.displayName ?? + normalized; + } + + void _invalidateAutoFillPreview() { + _autoFillPreview = null; + } + static const _fieldDefs = { 'title': 'title', 'artist': 'artist', @@ -330,12 +383,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { void _selectAllFields() { setState(() { + _invalidateAutoFillPreview(); _autoFillFields.addAll(_fieldDefs.keys); }); } void _selectEmptyFields() { setState(() { + _invalidateAutoFillPreview(); _autoFillFields.clear(); for (final key in _fieldDefs.keys) { if (key == 'cover') { @@ -353,7 +408,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } void _selectNoFields() { - setState(_autoFillFields.clear); + setState(() { + _invalidateAutoFillPreview(); + _autoFillFields.clear(); + }); } String _normalizeMetadataText(String value) { @@ -656,7 +714,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { return false; } - Future _fetchAndFill() async { + Future _fetchAutoFillPreview() async { if (_autoFillFields.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.editMetadataAutoFillNoneSelected)), @@ -664,7 +722,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { return; } - setState(() => _fetching = true); + setState(() { + _fetching = true; + _invalidateAutoFillPreview(); + }); final settingsNotifier = ProviderScope.containerOf( context, listen: false, @@ -675,12 +736,30 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { final artist = _artistCtrl.text.trim(); final album = _albumCtrl.text.trim(); final currentIsrc = _isrcCtrl.text.trim().toUpperCase(); + final configuredProviderId = _selectedMetadataProviderId?.trim(); + final extensionState = ProviderScope.containerOf( + context, + listen: false, + ).read(extensionProvider); + final selectedProviderId = + configuredProviderId != null && + extensionState.extensions.any( + (extension) => + extension.id == configuredProviderId && + extension.enabled && + extension.hasMetadataProvider, + ) + ? configuredProviderId + : null; + final usesAutomaticProvider = + selectedProviderId == null || selectedProviderId.isEmpty; final shouldFetchLyrics = _autoFillFields.contains('lyrics'); final needsTrackLookup = _autoFillFields.any((key) => key != 'lyrics'); Map? best; String? deezerId; + String? lyricsSourceName; - if (needsTrackLookup) { + if (needsTrackLookup && usesAutomaticProvider) { try { final resolved = await _resolveAutoFillTrackFromIdentifiers( currentIsrc, @@ -714,10 +793,16 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { if (needsTrackLookup && best == null) { final query = queryParts.join(' '); - final results = await PlatformBridge.searchTracksWithMetadataProviders( - query, - limit: 5, - ); + final results = usesAutomaticProvider + ? await PlatformBridge.searchTracksWithMetadataProviders( + query, + limit: 5, + ) + : await PlatformBridge.searchTracksWithMetadataProvider( + selectedProviderId, + query, + limit: 5, + ); if (!mounted) return; @@ -762,6 +847,32 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ); return; } + + if (!usesAutomaticProvider) { + final trackId = best['id']?.toString().trim() ?? ''; + if (trackId.isNotEmpty) { + try { + final details = await PlatformBridge.getProviderMetadata( + selectedProviderId, + 'track', + trackId, + ); + final mergedDetails = {...best}; + for (final entry in _unwrapTrackPayload(details).entries) { + final value = entry.value; + if (value != null && value.toString().trim().isNotEmpty) { + mergedDetails[entry.key] = value; + } + } + best = mergedDetails; + } catch (e) { + _log.w( + 'Detailed metadata lookup failed for ' + '$selectedProviderId/$trackId: $e', + ); + } + } + } } final selectedBest = best; @@ -810,7 +921,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ? currentIsrc : (_looksLikeIsrc(candidateIsrc) ? candidateIsrc : ''); - if (needsIsrc || needsExtended) { + if (usesAutomaticProvider && (needsIsrc || needsExtended)) { try { if (deezerId == null && deezerLookupIsrc.isNotEmpty) { final deezerResult = await PlatformBridge.searchDeezerByISRC( @@ -840,7 +951,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { if (!mounted) return; - if (needsIsrc && + if (usesAutomaticProvider && + needsIsrc && (enriched['isrc'] ?? '').trim().isEmpty && deezerId != null) { try { @@ -860,7 +972,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { if (!mounted) return; - if (needsExtended && deezerId != null) { + if (usesAutomaticProvider && needsExtended && deezerId != null) { try { final extended = await PlatformBridge.getDeezerExtendedMetadata( deezerId, @@ -898,6 +1010,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { durationMs: widget.durationMs, ); final lyricsText = lyricsResult['lyrics']?.toString().trim() ?? ''; + final lyricsSource = + lyricsResult['source']?.toString().trim() ?? ''; + if (lyricsSource.isNotEmpty) lyricsSourceName = lyricsSource; final instrumental = (lyricsResult['instrumental'] as bool? ?? false) || lyricsText == '[instrumental:true]'; @@ -912,72 +1027,116 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { if (!mounted) return; + final availableValues = {}; + for (final entry in enriched.entries) { + final value = entry.value.trim(); + if (value.isNotEmpty && value != '0' && value != 'null') { + availableValues[entry.key] = value; + } + } + final coverUrl = selectedBest == null + ? null + : (selectedBest['cover_url'] ?? selectedBest['images'] ?? '') + .toString() + .trim(); + final hasSelectedValue = _autoFillFields.any( + (key) => key == 'cover' + ? coverUrl != null && coverUrl.isNotEmpty + : availableValues.containsKey(key), + ); + if (!hasSelectedValue) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)), + ); + return; + } + + final resolvedSourceId = selectedProviderId?.isNotEmpty == true + ? selectedProviderId! + : (selectedBest?['provider_id']?.toString().trim() ?? ''); + final resolvedSourceName = + selectedBest == null && lyricsSourceName?.isNotEmpty == true + ? lyricsSourceName! + : _metadataProviderName(resolvedSourceId); + setState(() { + _autoFillPreview = _AutoFillPreview( + values: availableValues, + sourceName: resolvedSourceName, + coverUrl: coverUrl?.isNotEmpty == true ? coverUrl : null, + ); + }); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + ); + } + } finally { + if (mounted) setState(() => _fetching = false); + } + } + + Future _applyAutoFillPreview() async { + final preview = _autoFillPreview; + if (preview == null || _saving || _fetching) return; + + setState(() => _fetching = true); + try { var filledCount = 0; for (final key in _autoFillFields) { if (key == 'cover') continue; - final value = enriched[key]; - if (value != null && - value.isNotEmpty && - value != '0' && - value != 'null') { - final ctrl = _controllerForKey(key); - if (ctrl != null) { - ctrl.text = value; - filledCount++; - } + final value = preview.values[key]; + final ctrl = _controllerForKey(key); + if (value != null && ctrl != null) { + ctrl.text = value; + filledCount++; } } - if (_autoFillFields.contains('cover') && selectedBest != null) { - final coverUrl = - (selectedBest['cover_url'] ?? selectedBest['images'] ?? '') - .toString(); - if (coverUrl.isNotEmpty) { - try { - final tempDir = await Directory.systemTemp.createTemp( - 'autofill_cover_', - ); - final coverOutput = - '${tempDir.path}${Platform.pathSeparator}cover.jpg'; - await PlatformBridge.downloadCoverToFile( - coverUrl, - coverOutput, - maxQuality: false, - ); - final file = File(coverOutput); - if (await file.exists() && await file.length() > 0) { - await _cleanupSelectedCoverTemp(); - if (mounted) { - setState(() { - _selectedCoverPath = coverOutput; - _selectedCoverTempDir = tempDir.path; - _selectedCoverName = _EditMetadataSheet._onlineCoverSentinel; - }); - filledCount++; - } - } else { - try { - await tempDir.delete(recursive: true); - } catch (_) {} - } - } catch (_) { - // Cover download is best-effort - } - } - } - - if (mounted) { - setState(() {}); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - filledCount > 0 - ? context.l10n.editMetadataAutoFillDone(filledCount) - : context.l10n.editMetadataAutoFillNoResults, - ), - ), + if (_autoFillFields.contains('cover') && preview.coverUrl != null) { + final tempDir = await Directory.systemTemp.createTemp( + 'autofill_cover_', ); + final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg'; + try { + await PlatformBridge.downloadCoverToFile( + preview.coverUrl!, + coverOutput, + maxQuality: false, + ); + final file = File(coverOutput); + if (await file.exists() && await file.length() > 0) { + await _cleanupSelectedCoverTemp(); + if (mounted) { + _selectedCoverPath = coverOutput; + _selectedCoverTempDir = tempDir.path; + _selectedCoverName = _EditMetadataSheet._onlineCoverSentinel; + filledCount++; + } + } else { + await tempDir.delete(recursive: true); + } + } catch (_) { + try { + if (await tempDir.exists()) await tempDir.delete(recursive: true); + } catch (_) {} + } } + + if (!mounted) return; + setState(() {}); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + filledCount > 0 + ? context.l10n.editMetadataAutoFillDoneFromSource( + filledCount, + preview.sourceName, + ) + : context.l10n.editMetadataAutoFillNoResults, + ), + ), + ); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -1437,6 +1596,64 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), ), const SizedBox(height: 12), + Consumer( + builder: (context, ref, _) { + final extensionState = ref.watch(extensionProvider); + final providers = _orderedMetadataProviders(extensionState); + final selectedId = + providers.any( + (extension) => extension.id == _selectedMetadataProviderId, + ) + ? _selectedMetadataProviderId! + : ''; + return InputDecorator( + decoration: InputDecoration( + labelText: context.l10n.editMetadataAutoFillSource, + prefixIcon: const Icon(Icons.extension_outlined), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: selectedId, + isExpanded: true, + items: [ + DropdownMenuItem( + value: '', + child: Text( + context.l10n.editMetadataAutoFillSourceAutomatic, + ), + ), + ...providers.map( + (extension) => DropdownMenuItem( + value: extension.id, + child: Text( + extension.displayName, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + onChanged: (_fetching || _saving) + ? null + : (value) { + setState(() { + _selectedMetadataProviderId = + value == null || value.isEmpty ? null : value; + _invalidateAutoFillPreview(); + }); + }, + ), + ), + ); + }, + ), + const SizedBox(height: 12), Row( children: [ _quickSelectButton( @@ -1471,6 +1688,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ? null : (val) { setState(() { + _invalidateAutoFillPreview(); if (val) { _autoFillFields.add(key); } else { @@ -1494,7 +1712,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { child: FilledButton.icon( onPressed: (_fetching || _saving || _autoFillFields.isEmpty) ? null - : _fetchAndFill, + : _fetchAutoFillPreview, style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder( @@ -1514,10 +1732,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { label: Text( _fetching ? context.l10n.editMetadataAutoFillSearching - : context.l10n.editMetadataAutoFillFetch, + : context.l10n.editMetadataAutoFillFind, ), ), ), + if (_autoFillPreview case final preview?) ...[ + const SizedBox(height: 12), + _buildAutoFillPreview(cs, preview), + ], const SizedBox(height: 8), SizedBox( width: double.infinity, @@ -1547,6 +1769,82 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ); } + Widget _buildAutoFillPreview(ColorScheme cs, _AutoFillPreview preview) { + final previewFields = _autoFillFields + .where((key) { + if (key == 'cover') return preview.coverUrl != null; + return preview.values.containsKey(key); + }) + .toList(growable: false); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: cs.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.fact_check_outlined, size: 18, color: cs.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + context.l10n.editMetadataAutoFillPreview(preview.sourceName), + style: Theme.of(context).textTheme.titleSmall, + ), + ), + ], + ), + const SizedBox(height: 8), + ...previewFields.map((key) { + final value = key == 'cover' + ? context.l10n.editMetadataAutoFillCoverAvailable + : preview.values[key]!; + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 104, + child: Text( + _fieldLabel(key), + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: cs.onSurfaceVariant, + ), + ), + ), + Expanded( + child: Text( + value, + maxLines: key == 'lyrics' ? 3 : 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ); + }), + const SizedBox(height: 6), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: (_fetching || _saving) ? null : _applyAutoFillPreview, + icon: const Icon(Icons.check), + label: Text(context.l10n.editMetadataAutoFillApply), + ), + ), + ], + ), + ); + } + /// Fills genre and album artist from MusicBrainz (keyed by ISRC) as /// editable suggestions; nothing is saved until the user taps Save. Future _fetchFromMusicBrainz() async { @@ -1585,9 +1883,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } catch (_) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.editMetadataMusicBrainzNothing), - ), + SnackBar(content: Text(context.l10n.editMetadataMusicBrainzNothing)), ); } } finally { diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 98ab96aa..8564e7cb 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -16,6 +16,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/replaygain_service.dart'; diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 13d980a7..53f613a8 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1365,6 +1365,19 @@ class PlatformBridge { return _decodeMapListResult(result, 'searchTracksWithMetadataProviders'); } + static Future>> searchTracksWithMetadataProvider( + String extensionId, + String query, { + int limit = 20, + }) async { + _log.d('searchTracksWithMetadataProvider: $extensionId, query="$query"'); + final result = await _channel.invokeMethod( + 'searchTracksWithMetadataProvider', + {'extension_id': extensionId, 'query': query, 'limit': limit}, + ); + return _decodeMapListResult(result, 'searchTracksWithMetadataProvider'); + } + static Future>> findCollectionAcrossExtensions({ required String name, required String artists, diff --git a/test/platform_bridge_metadata_test.dart b/test/platform_bridge_metadata_test.dart index 84be721b..7dba5284 100644 --- a/test/platform_bridge_metadata_test.dart +++ b/test/platform_bridge_metadata_test.dart @@ -75,4 +75,33 @@ void main() { expect(result['audio_codec'], 'opus'); }, ); + + test('metadata search sends the explicitly selected extension', () async { + MethodCall? capturedCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(backendChannel, (call) async { + capturedCall = call; + return jsonEncode([ + { + 'id': 'track-1', + 'name': 'Song', + 'provider_id': 'selected-metadata', + }, + ]); + }); + + final results = await PlatformBridge.searchTracksWithMetadataProvider( + 'selected-metadata', + 'Song Artist', + limit: 5, + ); + + expect(capturedCall?.method, 'searchTracksWithMetadataProvider'); + expect(capturedCall?.arguments, { + 'extension_id': 'selected-metadata', + 'query': 'Song Artist', + 'limit': 5, + }); + expect(results.single['provider_id'], 'selected-metadata'); + }); }