feat(metadata): choose extension for online autofill

This commit is contained in:
zarzet
2026-07-28 15:18:10 +07:00
parent 209eb5f10e
commit fb538e9517
23 changed files with 1006 additions and 79 deletions
@@ -1853,6 +1853,26 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"searchTracksWithMetadataProvider" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val query = call.argument<String>("query") ?: ""
val limit = call.argument<Int>("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) {
+10
View File
@@ -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 == "" {
+34
View File
@@ -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")
+40
View File
@@ -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 {
+14
View File
@@ -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]
+43 -1
View File
@@ -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:
+32
View File
@@ -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';
+33 -1
View File
@@ -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';
+32
View File
@@ -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';
+32
View File
@@ -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';
+27 -1
View File
@@ -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';
+32
View File
@@ -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';
+32
View File
@@ -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 => '가져오기 & 채우기';
+32
View File
@@ -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';
+32
View File
@@ -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 => 'Получить и заполнить';
+32
View File
@@ -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';
+32
View File
@@ -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 => 'Отримання та заповнення';
+42 -1
View File
@@ -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"
+42 -1
View File
@@ -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"
},
+370 -74
View File
@@ -7,6 +7,18 @@ class _ResolvedAutoFillTrack {
const _ResolvedAutoFillTrack({required this.track, this.deezerId});
}
class _AutoFillPreview {
final Map<String, String> 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<String> _autoFillFields = {};
List<Extension> _orderedMetadataProviders(ExtensionState state) {
final providersById = <String, Extension>{
for (final extension in state.extensions)
if (extension.enabled && extension.hasMetadataProvider)
extension.id: extension,
};
final ordered = <Extension>[];
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 = <String, String>{
'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<void> _fetchAndFill() async {
Future<void> _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<String, dynamic>? 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 = <String, dynamic>{...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 = <String, String>{};
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<void> _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<String>(
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<void> _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 {
+1
View File
@@ -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';
+13
View File
@@ -1365,6 +1365,19 @@ class PlatformBridge {
return _decodeMapListResult(result, 'searchTracksWithMetadataProviders');
}
static Future<List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>> findCollectionAcrossExtensions({
required String name,
required String artists,
+29
View File
@@ -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');
});
}