feat: improve auto-fill track resolution in Edit Metadata sheet

- Identifier-first resolution (ISRC/Deezer/Spotify) before falling back to text search
- Score-based match selection via _metadataMatchScore instead of provider order
- Pass sourceTrackId from TrackMetadataScreen into _EditMetadataSheet
- Refactor buildDeezerExtendedMetadataResult and buildDeezerISRCSearchResult as testable helpers
- Add unit tests for buildDeezerExtendedMetadataResult and buildDeezerISRCSearchResult
- Propagate copyright through Deezer enrichment chain (exports, extension_providers)
This commit is contained in:
zarzet
2026-03-15 21:11:36 +07:00
parent f36096e0ac
commit 35f2f119db
5 changed files with 566 additions and 190 deletions
+8 -5
View File
@@ -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
}
+64 -11
View File
@@ -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)
}
}
@@ -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"])
}
}
+13 -4
View File
@@ -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)
}
+422 -170
View File
@@ -307,11 +307,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
// 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<TrackMetadataScreen> {
}
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<TrackMetadataScreen> {
}
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<TrackMetadataScreen> {
_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<TrackMetadataScreen> {
} 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<TrackMetadataScreen> {
onTap: () {
_closeOptionsMenuAndRun(
sheetContext,
() =>
_showEditMetadataSheet(screenContext, ref, colorScheme),
() => _showEditMetadataSheet(
screenContext,
ref,
colorScheme,
),
);
},
),
@@ -3008,31 +3010,29 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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<TrackMetadataScreen> {
// 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<TrackMetadataScreen> {
// Read existing metadata first
final metadata = await PlatformBridge.readFileMetadata(path);
if (metadata['error'] == null) {
final fields = <String, String>{
'cover_path': coverPath,
};
final fields = <String, String>{'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<TrackMetadataScreen> {
colorScheme: colorScheme,
initialValues: initialValues,
filePath: cleanFilePath,
sourceTrackId: _spotifyId,
),
);
@@ -3789,10 +3792,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
}
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<TrackMetadataScreen> {
}
}
class _ResolvedAutoFillTrack {
final Map<String, dynamic> track;
final String? deezerId;
const _ResolvedAutoFillTrack({required this.track, this.deezerId});
}
class _EditMetadataSheet extends StatefulWidget {
final ColorScheme colorScheme;
final Map<String, String> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> _unwrapTrackPayload(Map<String, dynamic> payload) {
final track = payload['track'];
if (track is Map<String, dynamic>) {
return track;
}
return payload;
}
void _mergeOnlineTrackData(
Map<String, String> enriched,
Map<String, dynamic> 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<String, dynamic> 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<void> _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<String, dynamic>? 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 = <String>[];
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<String, dynamic>? 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 = <String, String>{
'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<String, dynamic>) {
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),
),
),
);