diff --git a/go_backend/deezer.go b/go_backend/deezer.go index 568e61c4..a389f3c8 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -256,6 +256,7 @@ type deezerAlbumFull struct { NbTracks int `json:"nb_tracks"` RecordType string `json:"record_type"` Label string `json:"label"` + Copyright string `json:"copyright"` Genres struct { Data []deezerGenre `json:"data"` } `json:"genres"` @@ -1084,8 +1085,9 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string { } type AlbumExtendedMetadata struct { - Genre string - Label string + Genre string + Label string + Copyright string } func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) { @@ -1116,8 +1118,9 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str } result := &AlbumExtendedMetadata{ - Genre: strings.Join(genres, ", "), - Label: album.Label, + Genre: strings.Join(genres, ", "), + Label: album.Label, + Copyright: album.Copyright, } c.cacheMu.Lock() @@ -1129,7 +1132,7 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str c.maybeCleanupCachesLocked(now) c.cacheMu.Unlock() - GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label) + GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s, Copyright: %s\n", result.Genre, result.Label, result.Copyright) return result, nil } diff --git a/go_backend/exports.go b/go_backend/exports.go index b8178d77..1c5f84ed 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -283,7 +283,7 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) { return } - if req.ISRC == "" || (req.Genre != "" && req.Label != "") { + if req.ISRC == "" || (req.Genre != "" && req.Label != "" && req.Copyright != "") { return } @@ -305,8 +305,11 @@ func enrichRequestExtendedMetadata(req *DownloadRequest) { if req.Label == "" && extMeta.Label != "" { req.Label = extMeta.Label } - if req.Genre != "" || req.Label != "" { - GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s\n", req.Genre, req.Label) + if req.Copyright == "" && extMeta.Copyright != "" { + req.Copyright = extMeta.Copyright + } + if req.Genre != "" || req.Label != "" || req.Copyright != "" { + GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright) } } @@ -1335,10 +1338,7 @@ func GetDeezerExtendedMetadata(trackID string) (string, error) { return "", err } - result := map[string]string{ - "genre": metadata.Genre, - "label": metadata.Label, - } + result := buildDeezerExtendedMetadataResult(metadata) jsonBytes, err := json.Marshal(result) if err != nil { @@ -1358,7 +1358,8 @@ func SearchDeezerByISRC(isrc string) (string, error) { return "", err } - jsonBytes, err := json.Marshal(track) + result := buildDeezerISRCSearchResult(track) + jsonBytes, err := json.Marshal(result) if err != nil { return "", err } @@ -1366,6 +1367,55 @@ func SearchDeezerByISRC(isrc string) (string, error) { return string(jsonBytes), nil } +func buildDeezerExtendedMetadataResult(metadata *AlbumExtendedMetadata) map[string]string { + if metadata == nil { + return map[string]string{ + "genre": "", + "label": "", + "copyright": "", + } + } + + return map[string]string{ + "genre": metadata.Genre, + "label": metadata.Label, + "copyright": metadata.Copyright, + } +} + +func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]interface{} { + if track == nil { + return map[string]interface{}{} + } + + result := map[string]interface{}{ + "spotify_id": track.SpotifyID, + "artists": track.Artists, + "name": track.Name, + "album_name": track.AlbumName, + "album_artist": track.AlbumArtist, + "duration_ms": track.DurationMS, + "images": track.Images, + "release_date": track.ReleaseDate, + "track_number": track.TrackNumber, + "total_tracks": track.TotalTracks, + "disc_number": track.DiscNumber, + "external_urls": track.ExternalURL, + "isrc": track.ISRC, + "album_id": track.AlbumID, + "artist_id": track.ArtistID, + "album_type": track.AlbumType, + } + + if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" { + result["id"] = deezerID + result["track_id"] = deezerID + result["success"] = true + } + + return result +} + func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -1824,8 +1874,8 @@ func ReEnrichFile(requestJSON string) (string, error) { GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr) } - // Try to get extended metadata (genre, label) from Deezer if not already set - if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") { + // Try to get extended metadata from Deezer if not already set + if found && req.ISRC != "" && (req.Genre == "" || req.Label == "" || req.Copyright == "") { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC) cancel() @@ -1836,7 +1886,10 @@ func ReEnrichFile(requestJSON string) (string, error) { if req.Label == "" && extMeta.Label != "" { req.Label = extMeta.Label } - GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s\n", req.Genre, req.Label) + if req.Copyright == "" && extMeta.Copyright != "" { + req.Copyright = extMeta.Copyright + } + GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright) } } diff --git a/go_backend/exports_deezer_metadata_test.go b/go_backend/exports_deezer_metadata_test.go new file mode 100644 index 00000000..763965fc --- /dev/null +++ b/go_backend/exports_deezer_metadata_test.go @@ -0,0 +1,59 @@ +package gobackend + +import "testing" + +func TestBuildDeezerExtendedMetadataResultHandlesNil(t *testing.T) { + result := buildDeezerExtendedMetadataResult(nil) + + if result["genre"] != "" { + t.Fatalf("expected empty genre, got %q", result["genre"]) + } + if result["label"] != "" { + t.Fatalf("expected empty label, got %q", result["label"]) + } + if result["copyright"] != "" { + t.Fatalf("expected empty copyright, got %q", result["copyright"]) + } +} + +func TestBuildDeezerExtendedMetadataResultIncludesCopyright(t *testing.T) { + result := buildDeezerExtendedMetadataResult(&AlbumExtendedMetadata{ + Genre: "Rock", + Label: "EMI", + Copyright: "(C) Queen", + }) + + if result["genre"] != "Rock" { + t.Fatalf("unexpected genre: %q", result["genre"]) + } + if result["label"] != "EMI" { + t.Fatalf("unexpected label: %q", result["label"]) + } + if result["copyright"] != "(C) Queen" { + t.Fatalf("unexpected copyright: %q", result["copyright"]) + } +} + +func TestBuildDeezerISRCSearchResultAddsCompatibilityIDs(t *testing.T) { + result := buildDeezerISRCSearchResult(&TrackMetadata{ + SpotifyID: "deezer:3135556", + Name: "Love Of My Life", + Artists: "Queen", + AlbumName: "A Night at the Opera", + ISRC: "GBUM71029604", + ReleaseDate: "1975-11-21", + }) + + if result["spotify_id"] != "deezer:3135556" { + t.Fatalf("unexpected spotify_id: %v", result["spotify_id"]) + } + if result["id"] != "3135556" { + t.Fatalf("unexpected id: %v", result["id"]) + } + if result["track_id"] != "3135556" { + t.Fatalf("unexpected track_id: %v", result["track_id"]) + } + if result["success"] != true { + t.Fatalf("expected success=true, got %v", result["success"]) + } +} diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 1866543f..b9f79bc4 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -1065,8 +1065,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr) } - // Try Deezer extended metadata for genre/label if we have ISRC - if req.ISRC != "" && (req.Genre == "" || req.Label == "") { + // Try Deezer extended metadata if we have ISRC + if req.ISRC != "" && + (req.Genre == "" || req.Label == "" || req.Copyright == "") { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) extMeta, err := GetDeezerClient().GetExtendedMetadataByISRC(ctx, req.ISRC) cancel() @@ -1077,7 +1078,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro if req.Label == "" && extMeta.Label != "" { req.Label = extMeta.Label } - GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s\n", req.Genre, req.Label) + if req.Copyright == "" && extMeta.Copyright != "" { + req.Copyright = extMeta.Copyright + } + GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright) } } } @@ -1249,7 +1253,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID) if isBuiltInProvider(providerIDNormalized) { - if (req.Genre == "" || req.Label == "") && req.ISRC != "" { + if (req.Genre == "" || req.Label == "" || req.Copyright == "") && + req.ISRC != "" { GoLog("[DownloadWithExtensionFallback] Enriching extended metadata from Deezer for ISRC: %s\n", req.ISRC) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) deezerClient := GetDeezerClient() @@ -1264,6 +1269,10 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro req.Label = extMeta.Label GoLog("[DownloadWithExtensionFallback] Label from Deezer: %s\n", req.Label) } + if req.Copyright == "" && extMeta.Copyright != "" { + req.Copyright = extMeta.Copyright + GoLog("[DownloadWithExtensionFallback] Copyright from Deezer: %s\n", req.Copyright) + } } else if err != nil { GoLog("[DownloadWithExtensionFallback] Failed to get extended metadata from Deezer: %v\n", err) } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 5dde4282..85e803b6 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -307,11 +307,13 @@ class _TrackMetadataScreenState extends ConsumerState { ); // Fill in album name from file tags if stored value is empty - final needsAlbum = resolvedAlbum != null && + final needsAlbum = + resolvedAlbum != null && resolvedAlbum.isNotEmpty && (albumName.isEmpty); // Fill in duration from file if stored value is missing/zero - final needsDuration = resolvedDuration != null && + final needsDuration = + resolvedDuration != null && resolvedDuration > 0 && (duration == null || duration == 0); @@ -585,9 +587,9 @@ class _TrackMetadataScreenState extends ConsumerState { } void _showCueVirtualTrackSnackBar(BuildContext context) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(_cueVirtualTrackGuidance(context))), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(_cueVirtualTrackGuidance(context)))); } void _hideCurrentSnackBar() { @@ -606,17 +608,14 @@ class _TrackMetadataScreenState extends ConsumerState { } void _showSnackBarMessage(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); } void _showLongSnackBarMessage(String message) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - duration: const Duration(seconds: 60), - ), + SnackBar(content: Text(message), duration: const Duration(seconds: 60)), ); } @@ -1144,9 +1143,7 @@ class _TrackMetadataScreenState extends ConsumerState { _copyToClipboard(context, webUrl); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - context.l10n.snackbarUrlCopied(serviceName), - ), + content: Text(context.l10n.snackbarUrlCopied(serviceName)), ), ); } @@ -1879,16 +1876,18 @@ class _TrackMetadataScreenState extends ConsumerState { } else { setState(() => _isEmbedding = false); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(error ?? context.l10n.snackbarFailedToEmbedLyrics)), + SnackBar( + content: Text(error ?? context.l10n.snackbarFailedToEmbedLyrics), + ), ); } } } catch (e) { if (mounted) { setState(() => _isEmbedding = false); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString())))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + ); } } finally { if (coverPath != null) { @@ -2568,8 +2567,11 @@ class _TrackMetadataScreenState extends ConsumerState { onTap: () { _closeOptionsMenuAndRun( sheetContext, - () => - _showEditMetadataSheet(screenContext, ref, colorScheme), + () => _showEditMetadataSheet( + screenContext, + ref, + colorScheme, + ), ); }, ), @@ -3008,31 +3010,29 @@ class _TrackMetadataScreenState extends ConsumerState { const SizedBox(height: 16), Text( sheetContext.l10n.cueSplitTitle, - style: Theme.of(sheetContext).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of(sheetContext).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 12), Text( sheetContext.l10n.cueSplitAlbum(album), - style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(sheetContext).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), ), const SizedBox(height: 4), Text( sheetContext.l10n.cueSplitArtist(artist), - style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(sheetContext).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), ), const SizedBox(height: 4), Text( sheetContext.l10n.cueSplitTrackCount(tracks.length), - style: Theme.of(sheetContext).textTheme.bodyMedium?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.w600, - ), + style: Theme.of(sheetContext).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), ), const SizedBox(height: 16), // Track list preview (scrollable, max 200px) @@ -3259,8 +3259,12 @@ class _TrackMetadataScreenState extends ConsumerState { // Determine output directory final String outputDir; - final treeUri = !_isLocalItem ? (_downloadItem?.downloadTreeUri ?? '') : ''; - final relativeDir = !_isLocalItem ? (_downloadItem?.safRelativeDir ?? '') : ''; + final treeUri = !_isLocalItem + ? (_downloadItem?.downloadTreeUri ?? '') + : ''; + final relativeDir = !_isLocalItem + ? (_downloadItem?.safRelativeDir ?? '') + : ''; final writeBackToSaf = isSafSource && treeUri.isNotEmpty; if (writeBackToSaf) { final tempDir = await getTemporaryDirectory(); @@ -3326,9 +3330,7 @@ class _TrackMetadataScreenState extends ConsumerState { // Read existing metadata first final metadata = await PlatformBridge.readFileMetadata(path); if (metadata['error'] == null) { - final fields = { - 'cover_path': coverPath, - }; + final fields = {'cover_path': coverPath}; // Preserve existing fields for (final entry in metadata.entries) { if (entry.key == 'error' || entry.value == null) continue; @@ -3706,6 +3708,7 @@ class _TrackMetadataScreenState extends ConsumerState { colorScheme: colorScheme, initialValues: initialValues, filePath: cleanFilePath, + sourceTrackId: _spotifyId, ), ); @@ -3789,10 +3792,7 @@ class _TrackMetadataScreenState extends ConsumerState { ); } - void _closeOptionsMenuAndRun( - BuildContext sheetContext, - VoidCallback action, - ) { + void _closeOptionsMenuAndRun(BuildContext sheetContext, VoidCallback action) { Navigator.pop(sheetContext); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; @@ -3919,15 +3919,24 @@ class _TrackMetadataScreenState extends ConsumerState { } } +class _ResolvedAutoFillTrack { + final Map track; + final String? deezerId; + + const _ResolvedAutoFillTrack({required this.track, this.deezerId}); +} + class _EditMetadataSheet extends StatefulWidget { final ColorScheme colorScheme; final Map initialValues; final String filePath; + final String? sourceTrackId; const _EditMetadataSheet({ required this.colorScheme, required this.initialValues, required this.filePath, + this.sourceTrackId, }); @override @@ -3935,6 +3944,12 @@ class _EditMetadataSheet extends StatefulWidget { } class _EditMetadataSheetState extends State<_EditMetadataSheet> { + static final RegExp _metadataCollapsePattern = RegExp(r'[^a-z0-9]+'); + static final RegExp _metadataWhitespacePattern = RegExp(r'\s+'); + static final RegExp _spotifyTrackIdPattern = RegExp(r'^[A-Za-z0-9]{22}$'); + static final RegExp _deezerTrackIdPattern = RegExp(r'^\d+$'); + static final RegExp _isrcPattern = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{7}$'); + bool _saving = false; bool _showAdvanced = false; bool _showAutoFill = false; @@ -4152,9 +4167,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { }); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString())))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + ); } } @@ -4243,6 +4258,251 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { }); } + String _normalizeMetadataText(String value) { + final collapsed = value + .toLowerCase() + .replaceAll(_metadataCollapsePattern, ' ') + .trim(); + return collapsed.replaceAll(_metadataWhitespacePattern, ' '); + } + + bool _looksLikeIsrc(String value) { + return _isrcPattern.hasMatch(value.trim().toUpperCase()); + } + + String? _extractRawSpotifyTrackIdFromValue(Object? value) { + final raw = value?.toString().trim() ?? ''; + if (raw.isEmpty) return null; + + if (_spotifyTrackIdPattern.hasMatch(raw)) { + return raw; + } + + if (raw.startsWith('spotify:')) { + final parts = raw.split(':'); + final last = parts.isNotEmpty ? parts.last.trim() : ''; + if (_spotifyTrackIdPattern.hasMatch(last)) { + return last; + } + return null; + } + + final uri = Uri.tryParse(raw); + if (uri != null && + uri.host.contains('spotify.com') && + uri.pathSegments.length >= 2 && + uri.pathSegments.first == 'track') { + final candidate = uri.pathSegments[1].trim(); + if (_spotifyTrackIdPattern.hasMatch(candidate)) { + return candidate; + } + } + + return null; + } + + String? _extractRawDeezerTrackIdFromValue(Object? value) { + final raw = value?.toString().trim() ?? ''; + if (raw.isEmpty) return null; + + if (_deezerTrackIdPattern.hasMatch(raw)) { + return raw; + } + + if (raw.startsWith('deezer:')) { + final parts = raw.split(':'); + final last = parts.isNotEmpty ? parts.last.trim() : ''; + if (_deezerTrackIdPattern.hasMatch(last)) { + return last; + } + } + + final uri = Uri.tryParse(raw); + if (uri != null && uri.host.contains('deezer.com')) { + final trackIndex = uri.pathSegments.indexOf('track'); + if (trackIndex >= 0 && trackIndex + 1 < uri.pathSegments.length) { + final candidate = uri.pathSegments[trackIndex + 1].trim(); + if (_deezerTrackIdPattern.hasMatch(candidate)) { + return candidate; + } + } + } + + return null; + } + + String? _extractRawSpotifyTrackId(Map track) { + for (final candidate in [track['spotify_id'], track['id']]) { + final spotifyId = _extractRawSpotifyTrackIdFromValue(candidate); + if (spotifyId != null) return spotifyId; + } + + final externalLinks = track['external_links']; + if (externalLinks is Map) { + final spotifyId = _extractRawSpotifyTrackIdFromValue( + externalLinks['spotify'], + ); + if (spotifyId != null) return spotifyId; + } + + return null; + } + + String? _extractRawDeezerTrackId(Map track) { + for (final candidate in [ + track['deezer_id'], + track['spotify_id'], + track['id'], + ]) { + final deezerId = _extractRawDeezerTrackIdFromValue(candidate); + if (deezerId != null) return deezerId; + } + + final externalLinks = track['external_links']; + if (externalLinks is Map) { + final deezerId = _extractRawDeezerTrackIdFromValue( + externalLinks['deezer'], + ); + if (deezerId != null) return deezerId; + } + + return null; + } + + Map _unwrapTrackPayload(Map payload) { + final track = payload['track']; + if (track is Map) { + return track; + } + return payload; + } + + void _mergeOnlineTrackData( + Map enriched, + Map track, + ) { + void put(String key, Object? value) { + final text = value?.toString().trim() ?? ''; + if (text.isNotEmpty && text != 'null') { + enriched[key] = text; + } + } + + put('title', track['name'] ?? track['title']); + put('artist', track['artists'] ?? track['artist']); + put('album', track['album_name'] ?? track['album']); + put('album_artist', track['album_artist']); + put('date', track['release_date']); + put('track_number', track['track_number']); + put('disc_number', track['disc_number']); + put('isrc', track['isrc']); + put('genre', track['genre']); + put('label', track['label']); + put('copyright', track['copyright']); + } + + Future<_ResolvedAutoFillTrack?> _resolveAutoFillTrackFromIdentifiers( + String currentIsrc, + ) async { + if (_looksLikeIsrc(currentIsrc)) { + final deezerTrack = await PlatformBridge.searchDeezerByISRC(currentIsrc); + return _ResolvedAutoFillTrack( + track: _unwrapTrackPayload(deezerTrack), + deezerId: _extractRawDeezerTrackId(deezerTrack), + ); + } + + final sourceTrackId = widget.sourceTrackId?.trim() ?? ''; + if (sourceTrackId.isEmpty) { + return null; + } + + final deezerId = _extractRawDeezerTrackIdFromValue(sourceTrackId); + if (deezerId != null) { + final deezerTrack = await PlatformBridge.getDeezerMetadata( + 'track', + deezerId, + ); + return _ResolvedAutoFillTrack( + track: _unwrapTrackPayload(deezerTrack), + deezerId: deezerId, + ); + } + + final spotifyId = _extractRawSpotifyTrackIdFromValue(sourceTrackId); + if (spotifyId != null) { + final deezerTrack = await PlatformBridge.convertSpotifyToDeezer( + 'track', + spotifyId, + ); + final track = _unwrapTrackPayload(deezerTrack); + return _ResolvedAutoFillTrack( + track: track, + deezerId: + _extractRawDeezerTrackId(track) ?? + _extractRawDeezerTrackId(deezerTrack), + ); + } + + return null; + } + + int _metadataMatchScore( + Map track, { + required String currentTitle, + required String currentArtist, + required String currentAlbum, + required String currentIsrc, + }) { + var score = 0; + + final candidateIsrc = (track['isrc']?.toString() ?? '') + .trim() + .toUpperCase(); + if (currentIsrc.isNotEmpty && candidateIsrc == currentIsrc) { + score += 10000; + } + + final candidateTitle = _normalizeMetadataText( + (track['name'] ?? track['title'] ?? '').toString(), + ); + final candidateArtist = _normalizeMetadataText( + (track['artists'] ?? track['artist'] ?? '').toString(), + ); + final candidateAlbum = _normalizeMetadataText( + (track['album_name'] ?? track['album'] ?? '').toString(), + ); + + if (currentTitle.isNotEmpty && candidateTitle.isNotEmpty) { + if (candidateTitle == currentTitle) { + score += 400; + } else if (candidateTitle.contains(currentTitle) || + currentTitle.contains(candidateTitle)) { + score += 180; + } + } + + if (currentArtist.isNotEmpty && candidateArtist.isNotEmpty) { + if (candidateArtist == currentArtist) { + score += 320; + } else if (candidateArtist.contains(currentArtist) || + currentArtist.contains(candidateArtist)) { + score += 140; + } + } + + if (currentAlbum.isNotEmpty && candidateAlbum.isNotEmpty) { + if (candidateAlbum == currentAlbum) { + score += 120; + } else if (candidateAlbum.contains(currentAlbum) || + currentAlbum.contains(candidateAlbum)) { + score += 50; + } + } + + return score; + } + Future _fetchAndFill() async { if (_autoFillFields.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( @@ -4254,117 +4514,137 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { setState(() => _fetching = true); try { - // Build search query from current field values final title = _titleCtrl.text.trim(); final artist = _artistCtrl.text.trim(); final album = _albumCtrl.text.trim(); + final currentIsrc = _isrcCtrl.text.trim().toUpperCase(); + Map? best; + String? deezerId; + + try { + final resolved = await _resolveAutoFillTrackFromIdentifiers( + currentIsrc, + ); + if (resolved != null) { + best = resolved.track; + deezerId = resolved.deezerId; + } + } catch (e) { + _log.w('Identifier-first autofill lookup failed: $e'); + } + final queryParts = []; if (title.isNotEmpty) queryParts.add(title); if (artist.isNotEmpty) queryParts.add(artist); - if (album.isNotEmpty) queryParts.add(album); + if (queryParts.isEmpty && album.isNotEmpty) queryParts.add(album); - if (queryParts.isEmpty) { + if (best == null && queryParts.isEmpty) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.editMetadataAutoFillNoResults), - ), + SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)), ); } return; } - final query = queryParts.join(' '); - final results = await PlatformBridge.searchTracksWithMetadataProviders( - query, - limit: 5, - ); + final normalizedTitle = _normalizeMetadataText(title); + final normalizedArtist = _normalizeMetadataText(artist); + final normalizedAlbum = _normalizeMetadataText(album); - if (!mounted) return; - - if (results.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)), + if (best == null) { + final query = queryParts.join(' '); + final results = await PlatformBridge.searchTracksWithMetadataProviders( + query, + limit: 5, ); - return; - } - // Pick best match: prefer ISRC match, then first result - final currentIsrc = _isrcCtrl.text.trim().toUpperCase(); - Map? best; - if (currentIsrc.isNotEmpty) { - for (final r in results) { - final candidateIsrc = - (r['isrc']?.toString() ?? '').trim().toUpperCase(); - if (candidateIsrc == currentIsrc) { - best = r; - break; + if (!mounted) return; + + if (results.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)), + ); + return; + } + + // Pick best match using current metadata, not only provider order. + best = results.first; + var bestScore = -1; + for (final result in results) { + final score = _metadataMatchScore( + result, + currentTitle: normalizedTitle, + currentArtist: normalizedArtist, + currentAlbum: normalizedAlbum, + currentIsrc: currentIsrc, + ); + if (score > bestScore) { + bestScore = score; + best = result; } } } - best ??= results.first; + + final selectedBest = best; + if (selectedBest == null) { + throw StateError('No metadata match resolved for auto-fill'); + } // Extract basic metadata from search result final enriched = { - 'title': (best['name'] ?? '').toString(), - 'artist': (best['artists'] ?? best['artist'] ?? '').toString(), - 'album': (best['album_name'] ?? best['album'] ?? '').toString(), - 'album_artist': (best['album_artist'] ?? '').toString(), - 'date': (best['release_date'] ?? '').toString(), - 'track_number': (best['track_number'] ?? '').toString(), - 'disc_number': (best['disc_number'] ?? '').toString(), - 'isrc': (best['isrc'] ?? '').toString(), + 'title': (selectedBest['name'] ?? '').toString(), + 'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '') + .toString(), + 'album': (selectedBest['album_name'] ?? selectedBest['album'] ?? '') + .toString(), + 'album_artist': (selectedBest['album_artist'] ?? '').toString(), + 'date': (selectedBest['release_date'] ?? '').toString(), + 'track_number': (selectedBest['track_number'] ?? '').toString(), + 'disc_number': (selectedBest['disc_number'] ?? '').toString(), + 'isrc': (selectedBest['isrc'] ?? '').toString(), }; + _mergeOnlineTrackData(enriched, selectedBest); - final needsIsrc = _autoFillFields.contains('isrc') && - enriched['isrc']!.isEmpty; - final needsExtended = _autoFillFields.contains('genre') || + final needsIsrc = + _autoFillFields.contains('isrc') && enriched['isrc']!.isEmpty; + final needsExtended = + _autoFillFields.contains('genre') || _autoFillFields.contains('label') || _autoFillFields.contains('copyright'); - final trackId = - (best['spotify_id'] ?? best['id'] ?? '').toString(); - final source = - (best['source'] ?? best['provider_id'] ?? '').toString(); - final isDeezerSource = source.toLowerCase().contains('deezer'); + final rawSpotifyId = _extractRawSpotifyTrackId(selectedBest); - // Resolve Deezer track ID for extended metadata + ISRC - String? deezerId; + deezerId ??= _extractRawDeezerTrackId(selectedBest); + final candidateIsrc = enriched['isrc']!.trim().toUpperCase(); + final deezerLookupIsrc = _looksLikeIsrc(currentIsrc) + ? currentIsrc + : (_looksLikeIsrc(candidateIsrc) ? candidateIsrc : ''); - if ((needsIsrc || needsExtended) && trackId.isNotEmpty) { + if (needsIsrc || needsExtended) { try { - if (isDeezerSource) { - // Source is Deezer — trackId is already a Deezer ID - deezerId = trackId; - } else { - // Source is Spotify/extension — convert to Deezer via SongLink + if (deezerId == null && deezerLookupIsrc.isNotEmpty) { + final deezerResult = await PlatformBridge.searchDeezerByISRC( + deezerLookupIsrc, + ); + deezerId = _extractRawDeezerTrackId(deezerResult); + _mergeOnlineTrackData(enriched, deezerResult); + } + + if (deezerId == null && rawSpotifyId != null) { + // Spotify IDs can be mapped through SongLink to a Deezer track. final deezerData = await PlatformBridge.convertSpotifyToDeezer( 'track', - trackId, + rawSpotifyId, ); final trackData = deezerData['track']; if (trackData is Map) { - final rawId = trackData['spotify_id'] as String?; - if (rawId != null && rawId.startsWith('deezer:')) { - deezerId = rawId.split(':')[1]; - } - // Also grab ISRC and release_date from the conversion response - final convIsrc = (trackData['isrc'] ?? '').toString().trim(); - if (convIsrc.isNotEmpty && enriched['isrc']!.isEmpty) { - enriched['isrc'] = convIsrc; - } - final convDate = - (trackData['release_date'] ?? '').toString().trim(); - if (convDate.isNotEmpty && enriched['date']!.isEmpty) { - enriched['date'] = convDate; - } + deezerId = _extractRawDeezerTrackId(trackData); + _mergeOnlineTrackData(enriched, trackData); } - // Fallback: legacy ID format - deezerId ??= (deezerData['id'] ?? '').toString(); - if (deezerId.isEmpty) deezerId = null; + deezerId ??= _extractRawDeezerTrackId(deezerData); } } catch (_) { - // SongLink conversion is best-effort + // Deezer resolution is best-effort } } @@ -4377,7 +4657,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { 'track', deezerId, ); - final deezerIsrc = (deezerMeta['isrc'] ?? '').toString().trim(); + final trackData = _unwrapTrackPayload(deezerMeta); + _mergeOnlineTrackData(enriched, trackData); + final deezerIsrc = (trackData['isrc'] ?? '').toString().trim(); if (deezerIsrc.isNotEmpty) { enriched['isrc'] = deezerIsrc; } @@ -4402,29 +4684,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } } - // Fallback: if still no Deezer ID but we have ISRC, try ISRC lookup - if (needsExtended && - deezerId == null && - enriched['isrc']!.isNotEmpty) { - try { - final deezerResult = await PlatformBridge.searchDeezerByISRC( - enriched['isrc']!, - ); - final fallbackId = - (deezerResult['id'] ?? deezerResult['track_id'] ?? '') - .toString(); - if (fallbackId.isNotEmpty) { - final extended = - await PlatformBridge.getDeezerExtendedMetadata(fallbackId); - if (extended != null) { - enriched['genre'] = extended['genre'] ?? ''; - enriched['label'] = extended['label'] ?? ''; - enriched['copyright'] = extended['copyright'] ?? ''; - } - } - } catch (_) {} - } - if (!mounted) return; // Apply selected fields to controllers @@ -4432,7 +4691,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { for (final key in _autoFillFields) { if (key == 'cover') continue; // Handle cover separately below final value = enriched[key]; - if (value != null && value.isNotEmpty && value != '0' && value != 'null') { + if (value != null && + value.isNotEmpty && + value != '0' && + value != 'null') { final ctrl = _controllerForKey(key); if (ctrl != null) { ctrl.text = value; @@ -4444,7 +4706,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { // Handle cover art download if (_autoFillFields.contains('cover')) { final coverUrl = - (best['cover_url'] ?? best['images'] ?? '').toString(); + (selectedBest['cover_url'] ?? selectedBest['images'] ?? '') + .toString(); if (coverUrl.isNotEmpty) { try { final tempDir = await Directory.systemTemp.createTemp( @@ -4494,9 +4757,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.snackbarError(e.toString())), - ), + SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), ); } } finally { @@ -4722,9 +4983,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } } catch (e) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString())))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + ); } } finally { if (mounted) setState(() => _saving = false); @@ -4882,11 +5143,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), child: Row( children: [ - Icon( - Icons.travel_explore, - size: 20, - color: cs.primary, - ), + Icon(Icons.travel_explore, size: 20, color: cs.primary), const SizedBox(width: 8), Expanded( child: Text( @@ -4911,9 +5168,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { padding: const EdgeInsets.symmetric(horizontal: 12), child: Text( context.l10n.editMetadataAutoFillDesc, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: cs.onSurfaceVariant, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant), ), ), const SizedBox(height: 8), @@ -4976,18 +5233,13 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { const SizedBox(height: 10), // Fetch button Padding( - padding: const EdgeInsets.only( - left: 12, - right: 12, - bottom: 12, - ), + padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12), child: SizedBox( width: double.infinity, child: FilledButton.icon( - onPressed: - (_fetching || _saving || _autoFillFields.isEmpty) - ? null - : _fetchAndFill, + onPressed: (_fetching || _saving || _autoFillFields.isEmpty) + ? null + : _fetchAndFill, icon: _fetching ? const SizedBox( width: 16, @@ -5029,9 +5281,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), child: Text( label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: cs.primary, - ), + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: cs.primary), ), ), );