From bec9e33c94774e34cd5b8c8ae62acdb35929453f Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 11 Aug 2026 16:40:06 +0700 Subject: [PATCH] feat(metadata): review batch tag enrichment #511 --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 4 +- go_backend/exports_reenrich.go | 239 +++++++++++++----- go_backend/exports_test.go | 81 ++++++ lib/l10n/app_localizations.dart | 78 ++++++ lib/l10n/app_localizations_de.dart | 45 ++++ lib/l10n/app_localizations_en.dart | 45 ++++ lib/l10n/app_localizations_es.dart | 45 ++++ lib/l10n/app_localizations_fr.dart | 45 ++++ lib/l10n/app_localizations_id.dart | 47 +++- lib/l10n/app_localizations_ja.dart | 45 ++++ lib/l10n/app_localizations_ko.dart | 45 ++++ lib/l10n/app_localizations_pt.dart | 45 ++++ lib/l10n/app_localizations_ru.dart | 45 ++++ lib/l10n/app_localizations_tr.dart | 45 ++++ lib/l10n/app_localizations_uk.dart | 45 ++++ lib/l10n/arb/app_en.arb | 56 ++++ lib/l10n/arb/app_id.arb | 21 +- lib/screens/local_album_screen.dart | 152 +++++++---- lib/screens/queue_tab.dart | 3 +- lib/screens/queue_tab_batch_actions.dart | 146 +++++++---- lib/services/batch_metadata_re_enrich.dart | 194 ++++++++++++++ lib/widgets/re_enrich_field_dialog.dart | 216 ++++++++-------- lib/widgets/re_enrich_review_sheet.dart | 193 ++++++++++++++ test/batch_metadata_re_enrich_test.dart | 83 ++++++ 24 files changed, 1698 insertions(+), 265 deletions(-) create mode 100644 lib/services/batch_metadata_re_enrich.dart create mode 100644 lib/widgets/re_enrich_review_sheet.dart create mode 100644 test/batch_metadata_re_enrich_test.dart diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 155230a7..8877ad1b 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1514,7 +1514,9 @@ class MainActivity: FlutterFragmentActivity() { val reqObj = JSONObject(requestJson) val filePath = reqObj.optString("file_path", "") - if (filePath.startsWith("content://")) { + // Preview only resolves online metadata; it does not need + // a full SAF document copy and never writes the source. + if (filePath.startsWith("content://") && !reqObj.optBoolean("preview_only", false)) { val uri = Uri.parse(filePath) val tempPath = copyUriToTemp(uri) ?: return@withContext """{"error":"Failed to copy SAF file to temp"}""" diff --git a/go_backend/exports_reenrich.go b/go_backend/exports_reenrich.go index c297cce6..a6bd1711 100644 --- a/go_backend/exports_reenrich.go +++ b/go_backend/exports_reenrich.go @@ -43,6 +43,10 @@ type reEnrichRequest struct { DurationMs int64 `json:"duration_ms"` SearchOnline bool `json:"search_online"` UpdateFields []string `json:"update_fields,omitempty"` + // PreviewOnly resolves the metadata candidate and returns the proposed + // values without downloading artwork, fetching lyrics, or touching the + // audio file. Batch callers use this to review changes before embedding. + PreviewOnly bool `json:"preview_only,omitempty"` // ReplaceReleaseMetadata lets a deliberate single-file re-enrich action // repair a stale album identity (for example, a playlist name stored as // ALBUM). Batch and older callers keep the conservative mismatch guard. @@ -63,6 +67,34 @@ func (r *reEnrichRequest) shouldUpdateField(field string) bool { return false } +// shouldUpdateTag accepts both the original field-group keys and granular tag +// keys. This keeps existing callers compatible while allowing batch actions +// such as "ISRC only" and "fill missing tags" to avoid changing neighboring +// values from the same group. +func (r *reEnrichRequest) shouldUpdateTag(group, tag string) bool { + if len(r.UpdateFields) == 0 { + return true + } + for _, field := range r.UpdateFields { + if field == group || field == tag { + return true + } + } + return false +} + +func (r *reEnrichRequest) shouldUpdateAnyTag(group string, tags ...string) bool { + if r.shouldUpdateField(group) { + return true + } + for _, tag := range tags { + if r.shouldUpdateTag(group, tag) { + return true + } + } + return false +} + // lyricsEmbedEnabled reports whether lyrics should be written into the audio // file's tags. It mirrors the download path semantics: 'embed' and 'both' embed, // 'external' does not. An empty mode keeps the legacy behavior (embed) so older @@ -118,45 +150,57 @@ func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) { req.SpotifyID = track.ID } - if req.shouldUpdateField("basic_tags") { + if req.shouldUpdateTag("basic_tags", "track_name") { if track.Name != "" { req.TrackName = track.Name } + } + if req.shouldUpdateTag("basic_tags", "artist_name") { if track.Artists != "" { req.ArtistName = track.Artists } - if sameRelease { - if track.AlbumName != "" { - req.AlbumName = track.AlbumName - } - if track.AlbumArtist != "" { - req.AlbumArtist = track.AlbumArtist - } + } + if sameRelease && req.shouldUpdateTag("basic_tags", "album_name") { + if track.AlbumName != "" { + req.AlbumName = track.AlbumName } } - if sameRelease && req.shouldUpdateField("track_info") { + if sameRelease && req.shouldUpdateTag("basic_tags", "album_artist") { + if track.AlbumArtist != "" { + req.AlbumArtist = track.AlbumArtist + } + } + if sameRelease && req.shouldUpdateTag("track_info", "track_number") { if track.TrackNumber > 0 { req.TrackNumber = track.TrackNumber } + } + if sameRelease && req.shouldUpdateTag("track_info", "total_tracks") { if track.TotalTracks > 0 { req.TotalTracks = track.TotalTracks } + } + if sameRelease && req.shouldUpdateTag("track_info", "disc_number") { if track.DiscNumber > 0 { req.DiscNumber = track.DiscNumber } + } + if sameRelease && req.shouldUpdateTag("track_info", "total_discs") { if track.TotalDiscs > 0 { req.TotalDiscs = track.TotalDiscs } } - if req.shouldUpdateField("release_info") { - if sameRelease && track.ReleaseDate != "" { + if sameRelease && req.shouldUpdateTag("release_info", "release_date") { + if track.ReleaseDate != "" { req.ReleaseDate = track.ReleaseDate } + } + if req.shouldUpdateTag("release_info", "isrc") { if track.ISRC != "" { req.ISRC = track.ISRC } } - if sameRelease && req.shouldUpdateField("cover") { + if sameRelease && req.shouldUpdateTag("cover", "cover") { if coverURL := track.ResolvedCoverURL(); coverURL != "" { req.CoverURL = coverURL } @@ -164,16 +208,22 @@ func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) { if track.DurationMS > 0 { req.DurationMs = int64(track.DurationMS) } - if req.shouldUpdateField("extra") { + if req.shouldUpdateTag("extra", "genre") { if track.Genre != "" { req.Genre = track.Genre } + } + if req.shouldUpdateTag("extra", "label") { if track.Label != "" { req.Label = track.Label } + } + if req.shouldUpdateTag("extra", "copyright") { if track.Copyright != "" { req.Copyright = track.Copyright } + } + if req.shouldUpdateTag("extra", "composer") { if track.Composer != "" { req.Composer = track.Composer } @@ -222,51 +272,67 @@ func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest { func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[string]string { metadata := map[string]string{} - if req.shouldUpdateField("basic_tags") { + if req.shouldUpdateTag("basic_tags", "track_name") { if req.TrackName != "" { metadata["TITLE"] = req.TrackName } + } + if req.shouldUpdateTag("basic_tags", "artist_name") { if req.ArtistName != "" { metadata["ARTIST"] = req.ArtistName } + } + if req.shouldUpdateTag("basic_tags", "album_name") { if req.AlbumName != "" { metadata["ALBUM"] = req.AlbumName } + } + if req.shouldUpdateTag("basic_tags", "album_artist") { if req.AlbumArtist != "" { metadata["ALBUMARTIST"] = req.AlbumArtist } } - if req.shouldUpdateField("release_info") { + if req.shouldUpdateTag("release_info", "release_date") { if req.ReleaseDate != "" { metadata["DATE"] = req.ReleaseDate } + } + if req.shouldUpdateTag("release_info", "isrc") { if req.ISRC != "" { metadata["ISRC"] = req.ISRC } } - if req.shouldUpdateField("extra") { + if req.shouldUpdateTag("extra", "genre") { if req.Genre != "" { metadata["GENRE"] = req.Genre } + } + if req.shouldUpdateTag("extra", "label") { if req.Label != "" { metadata["ORGANIZATION"] = req.Label } + } + if req.shouldUpdateTag("extra", "copyright") { if req.Copyright != "" { metadata["COPYRIGHT"] = req.Copyright } + } + if req.shouldUpdateTag("extra", "composer") { if req.Composer != "" { metadata["COMPOSER"] = req.Composer } } - if req.shouldUpdateField("track_info") { + if req.shouldUpdateTag("track_info", "track_number") || req.shouldUpdateTag("track_info", "total_tracks") { if req.TrackNumber > 0 { metadata["TRACKNUMBER"] = formatIndexValue(req.TrackNumber, req.TotalTracks) } + } + if req.shouldUpdateTag("track_info", "disc_number") || req.shouldUpdateTag("track_info", "total_discs") { if req.DiscNumber > 0 { metadata["DISCNUMBER"] = formatIndexValue(req.DiscNumber, req.TotalDiscs) } } - if req.shouldUpdateField("lyrics") { + if req.shouldUpdateTag("lyrics", "lyrics") { if lyricsLRC != "" && req.lyricsEmbedEnabled() { metadata["LYRICS"] = lyricsLRC metadata["UNSYNCEDLYRICS"] = lyricsLRC @@ -275,6 +341,59 @@ func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[str return metadata } +func buildReEnrichResultMetadata(req *reEnrichRequest) map[string]any { + enrichedMeta := map[string]any{ + "spotify_id": req.SpotifyID, + "duration_ms": req.DurationMs, + } + if req.shouldUpdateTag("basic_tags", "track_name") { + enrichedMeta["track_name"] = req.TrackName + } + if req.shouldUpdateTag("basic_tags", "artist_name") { + enrichedMeta["artist_name"] = req.ArtistName + } + if req.shouldUpdateTag("basic_tags", "album_name") { + enrichedMeta["album_name"] = req.AlbumName + } + if req.shouldUpdateTag("basic_tags", "album_artist") { + enrichedMeta["album_artist"] = req.AlbumArtist + } + if req.shouldUpdateTag("track_info", "track_number") { + enrichedMeta["track_number"] = req.TrackNumber + } + if req.shouldUpdateTag("track_info", "total_tracks") { + enrichedMeta["total_tracks"] = req.TotalTracks + } + if req.shouldUpdateTag("track_info", "disc_number") { + enrichedMeta["disc_number"] = req.DiscNumber + } + if req.shouldUpdateTag("track_info", "total_discs") { + enrichedMeta["total_discs"] = req.TotalDiscs + } + if req.shouldUpdateTag("release_info", "release_date") { + enrichedMeta["release_date"] = req.ReleaseDate + } + if req.shouldUpdateTag("release_info", "isrc") { + enrichedMeta["isrc"] = req.ISRC + } + if req.shouldUpdateTag("cover", "cover") { + enrichedMeta["cover_url"] = req.CoverURL + } + if req.shouldUpdateTag("extra", "genre") { + enrichedMeta["genre"] = req.Genre + } + if req.shouldUpdateTag("extra", "label") { + enrichedMeta["label"] = req.Label + } + if req.shouldUpdateTag("extra", "copyright") { + enrichedMeta["copyright"] = req.Copyright + } + if req.shouldUpdateTag("extra", "composer") { + enrichedMeta["composer"] = req.Composer + } + return enrichedMeta +} + func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *ExtTrackMetadata { if len(tracks) == 0 { return nil @@ -565,7 +684,7 @@ func ReEnrichFile(requestJSON string) (string, error) { GoLog("[ReEnrich] Skipping provider search: no usable title/artist/album query\n") } - if req.shouldUpdateField("basic_tags") && req.AlbumArtist == "" && req.ISRC != "" { + if req.shouldUpdateTag("basic_tags", "album_artist") && req.AlbumArtist == "" && req.ISRC != "" { albumArtist, err := fetchMusicBrainzAlbumArtistByISRC(req.ISRC, req.AlbumName) if err != nil { GoLog("[ReEnrich] Failed to get album artist from MusicBrainz: %v\n", err) @@ -577,7 +696,7 @@ func ReEnrichFile(requestJSON string) (string, error) { } // Try to enrich extra metadata from ISRC if not already set. - if found && req.ISRC != "" && req.shouldUpdateField("extra") && (req.Genre == "" || req.Label == "" || req.Copyright == "") { + if found && req.ISRC != "" && req.shouldUpdateAnyTag("extra", "genre", "label", "copyright") && (req.Genre == "" || req.Label == "" || req.Copyright == "") { enrichExtraMetadataByISRC("ReEnrich", req.ISRC, &req.Genre, &req.Label, &req.Copyright) } @@ -591,12 +710,23 @@ func ReEnrichFile(requestJSON string) (string, error) { GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n", req.TrackNumber, req.DiscNumber, req.ReleaseDate, req.ISRC, req.Genre, req.Label) + enrichedMeta := buildReEnrichResultMetadata(&req) + if req.PreviewOnly { + result := map[string]any{ + "method": "preview", + "success": true, + "enriched_metadata": enrichedMeta, + } + s, _ := marshalJSONString(result) + return s, nil + } + lower := strings.ToLower(req.FilePath) isFlac := strings.HasSuffix(lower, ".flac") var coverTempPath string var coverDataBytes []byte - if req.CoverURL != "" && req.shouldUpdateField("cover") { + if req.CoverURL != "" && req.shouldUpdateTag("cover", "cover") { coverData, err := downloadCoverToMemory(req.CoverURL, req.MaxQuality) if err != nil { GoLog("[ReEnrich] Failed to download cover: %v\n", err) @@ -646,7 +776,7 @@ func ReEnrichFile(requestJSON string) (string, error) { // Preserve existing lyrics when online enrichment does not return a replacement. var lyricsLRC string - if req.shouldUpdateField("lyrics") { + if req.shouldUpdateTag("lyrics", "lyrics") { existingLyrics, existingLyricsErr := ExtractLyrics(req.FilePath) if existingLyricsErr == nil && strings.TrimSpace(existingLyrics) != "" { lyricsLRC = existingLyrics @@ -654,7 +784,7 @@ func ReEnrichFile(requestJSON string) (string, error) { } } - if req.EmbedLyrics && req.shouldUpdateField("lyrics") { + if req.EmbedLyrics && req.shouldUpdateTag("lyrics", "lyrics") { client := NewLyricsClient() durationSec := float64(req.DurationMs) / 1000.0 lyrics, err := client.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, durationSec) @@ -668,39 +798,6 @@ func ReEnrichFile(requestJSON string) (string, error) { } } - // Build enrichedMeta map: only include fields from selected update groups - // so that the caller (Dart) does not overwrite non-selected metadata in its - // local library database with potentially stale cached values. - enrichedMeta := map[string]any{ - "spotify_id": req.SpotifyID, - "duration_ms": req.DurationMs, - } - if req.shouldUpdateField("basic_tags") { - enrichedMeta["track_name"] = req.TrackName - enrichedMeta["artist_name"] = req.ArtistName - enrichedMeta["album_name"] = req.AlbumName - enrichedMeta["album_artist"] = req.AlbumArtist - } - if req.shouldUpdateField("track_info") { - enrichedMeta["track_number"] = req.TrackNumber - enrichedMeta["total_tracks"] = req.TotalTracks - enrichedMeta["disc_number"] = req.DiscNumber - enrichedMeta["total_discs"] = req.TotalDiscs - } - if req.shouldUpdateField("release_info") { - enrichedMeta["release_date"] = req.ReleaseDate - enrichedMeta["isrc"] = req.ISRC - } - if req.shouldUpdateField("cover") { - enrichedMeta["cover_url"] = req.CoverURL - } - if req.shouldUpdateField("extra") { - enrichedMeta["genre"] = req.Genre - enrichedMeta["label"] = req.Label - enrichedMeta["copyright"] = req.Copyright - enrichedMeta["composer"] = req.Composer - } - if isFlac { // Only populate Metadata fields for selected update groups; empty/zero // values cause EmbedMetadata's setComment() to skip those tags, @@ -708,31 +805,47 @@ func ReEnrichFile(requestJSON string) (string, error) { metadata := Metadata{ ArtistTagMode: req.ArtistTagMode, } - if req.shouldUpdateField("basic_tags") { + if req.shouldUpdateTag("basic_tags", "track_name") { metadata.Title = req.TrackName + } + if req.shouldUpdateTag("basic_tags", "artist_name") { metadata.Artist = req.ArtistName + } + if req.shouldUpdateTag("basic_tags", "album_name") { metadata.Album = req.AlbumName + } + if req.shouldUpdateTag("basic_tags", "album_artist") { metadata.AlbumArtist = req.AlbumArtist } - if req.shouldUpdateField("track_info") { + if req.shouldUpdateTag("track_info", "track_number") || req.shouldUpdateTag("track_info", "total_tracks") { metadata.TrackNumber = req.TrackNumber metadata.TotalTracks = req.TotalTracks + } + if req.shouldUpdateTag("track_info", "disc_number") || req.shouldUpdateTag("track_info", "total_discs") { metadata.DiscNumber = req.DiscNumber metadata.TotalDiscs = req.TotalDiscs } - if req.shouldUpdateField("release_info") { + if req.shouldUpdateTag("release_info", "release_date") { metadata.Date = req.ReleaseDate + } + if req.shouldUpdateTag("release_info", "isrc") { metadata.ISRC = req.ISRC } - if req.shouldUpdateField("lyrics") { + if req.shouldUpdateTag("lyrics", "lyrics") { if req.lyricsEmbedEnabled() { metadata.Lyrics = lyricsLRC } } - if req.shouldUpdateField("extra") { + if req.shouldUpdateTag("extra", "genre") { metadata.Genre = req.Genre + } + if req.shouldUpdateTag("extra", "label") { metadata.Label = req.Label + } + if req.shouldUpdateTag("extra", "copyright") { metadata.Copyright = req.Copyright + } + if req.shouldUpdateTag("extra", "composer") { metadata.Composer = req.Composer } @@ -764,7 +877,7 @@ func ReEnrichFile(requestJSON string) (string, error) { "enriched_metadata": enrichedMeta, "lyrics": lyricsLRC, "write_external_lrc": req.EmbedLyrics && - req.shouldUpdateField("lyrics") && + req.shouldUpdateTag("lyrics", "lyrics") && req.lyricsSidecarEnabled() && strings.TrimSpace(lyricsLRC) != "", } @@ -783,7 +896,7 @@ func ReEnrichFile(requestJSON string) (string, error) { "enriched_metadata": enrichedMeta, "metadata": ffmpegMetadata, "write_external_lrc": req.EmbedLyrics && - req.shouldUpdateField("lyrics") && + req.shouldUpdateTag("lyrics", "lyrics") && req.lyricsSidecarEnabled() && strings.TrimSpace(lyricsLRC) != "", } diff --git a/go_backend/exports_test.go b/go_backend/exports_test.go index 47dfc320..860eba4e 100644 --- a/go_backend/exports_test.go +++ b/go_backend/exports_test.go @@ -2,6 +2,7 @@ package gobackend import ( "context" + "encoding/json" "fmt" "testing" ) @@ -682,3 +683,83 @@ func TestBuildReEnrichFFmpegMetadataFormatsTotalsAndComposer(t *testing.T) { t.Fatalf("COMPOSER = %q", metadata["COMPOSER"]) } } + +func TestReEnrichGranularISRCDoesNotChangeReleaseDate(t *testing.T) { + req := reEnrichRequest{ + ReleaseDate: "2020-01-02", + ISRC: "", + UpdateFields: []string{"isrc"}, + } + + applyReEnrichTrackMetadata(&req, ExtTrackMetadata{ + ReleaseDate: "2025-04-05", + ISRC: "USRC17607839", + }) + + if req.ISRC != "USRC17607839" { + t.Fatalf("isrc = %q", req.ISRC) + } + if req.ReleaseDate != "2020-01-02" { + t.Fatalf("release date = %q, want existing value", req.ReleaseDate) + } + metadata := buildReEnrichFFmpegMetadata(&req, "") + if metadata["ISRC"] != "USRC17607839" { + t.Fatalf("ISRC metadata = %q", metadata["ISRC"]) + } + if _, exists := metadata["DATE"]; exists { + t.Fatalf("granular ISRC update unexpectedly included DATE: %#v", metadata) + } +} + +func TestBuildReEnrichResultMetadataOnlyIncludesSelectedGranularTags(t *testing.T) { + req := reEnrichRequest{ + TrackName: "Existing title", + AlbumArtist: "Resolved album artist", + ISRC: "USRC17607839", + Genre: "Rock", + UpdateFields: []string{"album_artist", "isrc"}, + } + + metadata := buildReEnrichResultMetadata(&req) + if metadata["album_artist"] != "Resolved album artist" { + t.Fatalf("album_artist = %#v", metadata["album_artist"]) + } + if metadata["isrc"] != "USRC17607839" { + t.Fatalf("isrc = %#v", metadata["isrc"]) + } + for _, key := range []string{"track_name", "release_date", "genre"} { + if _, exists := metadata[key]; exists { + t.Fatalf("unexpected key %q in preview metadata: %#v", key, metadata) + } + } +} + +func TestReEnrichPreviewReturnsBeforeTouchingAudioFile(t *testing.T) { + request, err := json.Marshal(reEnrichRequest{ + FilePath: "content://library/nonexistent.flac", + TrackName: "Song", + ArtistName: "Artist", + ISRC: "USRC17607839", + UpdateFields: []string{"isrc"}, + PreviewOnly: true, + }) + if err != nil { + t.Fatal(err) + } + + raw, err := ReEnrichFile(string(request)) + if err != nil { + t.Fatalf("preview unexpectedly touched the nonexistent file: %v", err) + } + var result map[string]any + if err := json.Unmarshal([]byte(raw), &result); err != nil { + t.Fatal(err) + } + if result["method"] != "preview" || result["success"] != true { + t.Fatalf("result = %#v", result) + } + metadata, ok := result["enriched_metadata"].(map[string]any) + if !ok || metadata["isrc"] != "USRC17607839" { + t.Fatalf("preview metadata = %#v", result["enriched_metadata"]) + } +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d4758d57..9e9a6d55 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4270,6 +4270,84 @@ abstract class AppLocalizations { /// **'Select All'** String get trackReEnrichSelectAll; + /// Batch metadata mode that only writes ISRC + /// + /// In en, this message translates to: + /// **'ISRC only'** + String get trackReEnrichModeIsrc; + + /// Explanation for the ISRC-only batch metadata mode + /// + /// In en, this message translates to: + /// **'Find and add the recording identifier without changing other tags'** + String get trackReEnrichModeIsrcSubtitle; + + /// Batch metadata mode that fills only empty tags + /// + /// In en, this message translates to: + /// **'Fill missing tags'** + String get trackReEnrichModeMissing; + + /// Explanation for the fill-missing batch metadata mode + /// + /// In en, this message translates to: + /// **'Keep existing values and fill only fields that are empty'** + String get trackReEnrichModeMissingSubtitle; + + /// Batch metadata mode that replaces selected tag groups + /// + /// In en, this message translates to: + /// **'Update selected tags'** + String get trackReEnrichModeReplace; + + /// Explanation for the selected-tag batch metadata mode + /// + /// In en, this message translates to: + /// **'Choose which existing values may be replaced by online metadata'** + String get trackReEnrichModeReplaceSubtitle; + + /// Heading above batch re-enrich field checkboxes + /// + /// In en, this message translates to: + /// **'Tags to update'** + String get trackReEnrichFieldsTitle; + + /// Button that searches metadata and opens the batch change review + /// + /// In en, this message translates to: + /// **'Review changes'** + String get trackReEnrichReview; + + /// Title of the batch metadata review sheet + /// + /// In en, this message translates to: + /// **'Review metadata changes'** + String get trackReEnrichReviewTitle; + + /// Summary shown above proposed batch metadata changes + /// + /// In en, this message translates to: + /// **'{changeCount} proposed changes across {trackCount} tracks'** + String trackReEnrichReviewSubtitle(int changeCount, int trackCount); + + /// Message when batch metadata preview has no proposed changes + /// + /// In en, this message translates to: + /// **'No metadata changes were found for the selected tracks.'** + String get trackReEnrichNoChanges; + + /// Confirmation button in the batch metadata review sheet + /// + /// In en, this message translates to: + /// **'Apply changes'** + String get trackReEnrichApplyChanges; + + /// Proposed value when lyrics will be refreshed during re-enrich + /// + /// In en, this message translates to: + /// **'Refresh from online'** + String get trackReEnrichRefreshOnline; + /// Menu action - edit embedded metadata /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 4f5b240b..1b723cab 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2431,6 +2431,51 @@ class AppLocalizationsDe extends AppLocalizations { @override String get trackReEnrichSelectAll => 'Alles Auswählen'; + @override + String get trackReEnrichModeIsrc => 'ISRC only'; + + @override + String get trackReEnrichModeIsrcSubtitle => + 'Find and add the recording identifier without changing other tags'; + + @override + String get trackReEnrichModeMissing => 'Fill missing tags'; + + @override + String get trackReEnrichModeMissingSubtitle => + 'Keep existing values and fill only fields that are empty'; + + @override + String get trackReEnrichModeReplace => 'Update selected tags'; + + @override + String get trackReEnrichModeReplaceSubtitle => + 'Choose which existing values may be replaced by online metadata'; + + @override + String get trackReEnrichFieldsTitle => 'Tags to update'; + + @override + String get trackReEnrichReview => 'Review changes'; + + @override + String get trackReEnrichReviewTitle => 'Review metadata changes'; + + @override + String trackReEnrichReviewSubtitle(int changeCount, int trackCount) { + return '$changeCount proposed changes across $trackCount tracks'; + } + + @override + String get trackReEnrichNoChanges => + 'No metadata changes were found for the selected tracks.'; + + @override + String get trackReEnrichApplyChanges => 'Apply changes'; + + @override + String get trackReEnrichRefreshOnline => 'Refresh from online'; + @override String get trackEditMetadata => 'Metadaten bearbeiten'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 150cc862..6624c88a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2403,6 +2403,51 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trackReEnrichSelectAll => 'Select All'; + @override + String get trackReEnrichModeIsrc => 'ISRC only'; + + @override + String get trackReEnrichModeIsrcSubtitle => + 'Find and add the recording identifier without changing other tags'; + + @override + String get trackReEnrichModeMissing => 'Fill missing tags'; + + @override + String get trackReEnrichModeMissingSubtitle => + 'Keep existing values and fill only fields that are empty'; + + @override + String get trackReEnrichModeReplace => 'Update selected tags'; + + @override + String get trackReEnrichModeReplaceSubtitle => + 'Choose which existing values may be replaced by online metadata'; + + @override + String get trackReEnrichFieldsTitle => 'Tags to update'; + + @override + String get trackReEnrichReview => 'Review changes'; + + @override + String get trackReEnrichReviewTitle => 'Review metadata changes'; + + @override + String trackReEnrichReviewSubtitle(int changeCount, int trackCount) { + return '$changeCount proposed changes across $trackCount tracks'; + } + + @override + String get trackReEnrichNoChanges => + 'No metadata changes were found for the selected tracks.'; + + @override + String get trackReEnrichApplyChanges => 'Apply changes'; + + @override + String get trackReEnrichRefreshOnline => 'Refresh from online'; + @override String get trackEditMetadata => 'Edit Metadata'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 37905322..b15e013a 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2403,6 +2403,51 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trackReEnrichSelectAll => 'Select All'; + @override + String get trackReEnrichModeIsrc => 'ISRC only'; + + @override + String get trackReEnrichModeIsrcSubtitle => + 'Find and add the recording identifier without changing other tags'; + + @override + String get trackReEnrichModeMissing => 'Fill missing tags'; + + @override + String get trackReEnrichModeMissingSubtitle => + 'Keep existing values and fill only fields that are empty'; + + @override + String get trackReEnrichModeReplace => 'Update selected tags'; + + @override + String get trackReEnrichModeReplaceSubtitle => + 'Choose which existing values may be replaced by online metadata'; + + @override + String get trackReEnrichFieldsTitle => 'Tags to update'; + + @override + String get trackReEnrichReview => 'Review changes'; + + @override + String get trackReEnrichReviewTitle => 'Review metadata changes'; + + @override + String trackReEnrichReviewSubtitle(int changeCount, int trackCount) { + return '$changeCount proposed changes across $trackCount tracks'; + } + + @override + String get trackReEnrichNoChanges => + 'No metadata changes were found for the selected tracks.'; + + @override + String get trackReEnrichApplyChanges => 'Apply changes'; + + @override + String get trackReEnrichRefreshOnline => 'Refresh from online'; + @override String get trackEditMetadata => 'Edit Metadata'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index ee18ae93..89f02eb5 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2461,6 +2461,51 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trackReEnrichSelectAll => 'Tout sélectionner'; + @override + String get trackReEnrichModeIsrc => 'ISRC only'; + + @override + String get trackReEnrichModeIsrcSubtitle => + 'Find and add the recording identifier without changing other tags'; + + @override + String get trackReEnrichModeMissing => 'Fill missing tags'; + + @override + String get trackReEnrichModeMissingSubtitle => + 'Keep existing values and fill only fields that are empty'; + + @override + String get trackReEnrichModeReplace => 'Update selected tags'; + + @override + String get trackReEnrichModeReplaceSubtitle => + 'Choose which existing values may be replaced by online metadata'; + + @override + String get trackReEnrichFieldsTitle => 'Tags to update'; + + @override + String get trackReEnrichReview => 'Review changes'; + + @override + String get trackReEnrichReviewTitle => 'Review metadata changes'; + + @override + String trackReEnrichReviewSubtitle(int changeCount, int trackCount) { + return '$changeCount proposed changes across $trackCount tracks'; + } + + @override + String get trackReEnrichNoChanges => + 'No metadata changes were found for the selected tracks.'; + + @override + String get trackReEnrichApplyChanges => 'Apply changes'; + + @override + String get trackReEnrichRefreshOnline => 'Refresh from online'; + @override String get trackEditMetadata => 'Modifier les métadonnées'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index d4528889..82a42f06 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2385,7 +2385,7 @@ class AppLocalizationsId extends AppLocalizations { String get trackSaveLyricsProgress => 'Saving lyrics...'; @override - String get trackReEnrich => 'Re-enrich'; + String get trackReEnrich => 'Perkaya ulang'; @override String get trackReEnrichOnlineSubtitle => @@ -2412,6 +2412,51 @@ class AppLocalizationsId extends AppLocalizations { @override String get trackReEnrichSelectAll => 'Select All'; + @override + String get trackReEnrichModeIsrc => 'ISRC saja'; + + @override + String get trackReEnrichModeIsrcSubtitle => + 'Cari dan tambahkan pengenal rekaman tanpa mengubah tag lain'; + + @override + String get trackReEnrichModeMissing => 'Isi tag yang kosong'; + + @override + String get trackReEnrichModeMissingSubtitle => + 'Pertahankan nilai yang ada dan isi hanya kolom yang kosong'; + + @override + String get trackReEnrichModeReplace => 'Perbarui tag yang dipilih'; + + @override + String get trackReEnrichModeReplaceSubtitle => + 'Pilih nilai yang boleh diganti oleh metadata online'; + + @override + String get trackReEnrichFieldsTitle => 'Tag yang diperbarui'; + + @override + String get trackReEnrichReview => 'Tinjau perubahan'; + + @override + String get trackReEnrichReviewTitle => 'Tinjau perubahan metadata'; + + @override + String trackReEnrichReviewSubtitle(int changeCount, int trackCount) { + return '$changeCount perubahan diusulkan untuk $trackCount trek'; + } + + @override + String get trackReEnrichNoChanges => + 'Tidak ada perubahan metadata yang ditemukan untuk trek yang dipilih.'; + + @override + String get trackReEnrichApplyChanges => 'Terapkan perubahan'; + + @override + String get trackReEnrichRefreshOnline => 'Perbarui dari online'; + @override String get trackEditMetadata => 'Edit Metadata'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index f470be0a..f13f34ad 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2392,6 +2392,51 @@ class AppLocalizationsJa extends AppLocalizations { @override String get trackReEnrichSelectAll => 'Select All'; + @override + String get trackReEnrichModeIsrc => 'ISRC only'; + + @override + String get trackReEnrichModeIsrcSubtitle => + 'Find and add the recording identifier without changing other tags'; + + @override + String get trackReEnrichModeMissing => 'Fill missing tags'; + + @override + String get trackReEnrichModeMissingSubtitle => + 'Keep existing values and fill only fields that are empty'; + + @override + String get trackReEnrichModeReplace => 'Update selected tags'; + + @override + String get trackReEnrichModeReplaceSubtitle => + 'Choose which existing values may be replaced by online metadata'; + + @override + String get trackReEnrichFieldsTitle => 'Tags to update'; + + @override + String get trackReEnrichReview => 'Review changes'; + + @override + String get trackReEnrichReviewTitle => 'Review metadata changes'; + + @override + String trackReEnrichReviewSubtitle(int changeCount, int trackCount) { + return '$changeCount proposed changes across $trackCount tracks'; + } + + @override + String get trackReEnrichNoChanges => + 'No metadata changes were found for the selected tracks.'; + + @override + String get trackReEnrichApplyChanges => 'Apply changes'; + + @override + String get trackReEnrichRefreshOnline => 'Refresh from online'; + @override String get trackEditMetadata => 'メタデータを編集'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index e03fe49f..f72c5dd1 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2340,6 +2340,51 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trackReEnrichSelectAll => '모두 선택'; + @override + String get trackReEnrichModeIsrc => 'ISRC only'; + + @override + String get trackReEnrichModeIsrcSubtitle => + 'Find and add the recording identifier without changing other tags'; + + @override + String get trackReEnrichModeMissing => 'Fill missing tags'; + + @override + String get trackReEnrichModeMissingSubtitle => + 'Keep existing values and fill only fields that are empty'; + + @override + String get trackReEnrichModeReplace => 'Update selected tags'; + + @override + String get trackReEnrichModeReplaceSubtitle => + 'Choose which existing values may be replaced by online metadata'; + + @override + String get trackReEnrichFieldsTitle => 'Tags to update'; + + @override + String get trackReEnrichReview => 'Review changes'; + + @override + String get trackReEnrichReviewTitle => 'Review metadata changes'; + + @override + String trackReEnrichReviewSubtitle(int changeCount, int trackCount) { + return '$changeCount proposed changes across $trackCount tracks'; + } + + @override + String get trackReEnrichNoChanges => + 'No metadata changes were found for the selected tracks.'; + + @override + String get trackReEnrichApplyChanges => 'Apply changes'; + + @override + String get trackReEnrichRefreshOnline => 'Refresh from online'; + @override String get trackEditMetadata => '메타데이터 편집'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index c4595786..3c3c278d 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2403,6 +2403,51 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trackReEnrichSelectAll => 'Select All'; + @override + String get trackReEnrichModeIsrc => 'ISRC only'; + + @override + String get trackReEnrichModeIsrcSubtitle => + 'Find and add the recording identifier without changing other tags'; + + @override + String get trackReEnrichModeMissing => 'Fill missing tags'; + + @override + String get trackReEnrichModeMissingSubtitle => + 'Keep existing values and fill only fields that are empty'; + + @override + String get trackReEnrichModeReplace => 'Update selected tags'; + + @override + String get trackReEnrichModeReplaceSubtitle => + 'Choose which existing values may be replaced by online metadata'; + + @override + String get trackReEnrichFieldsTitle => 'Tags to update'; + + @override + String get trackReEnrichReview => 'Review changes'; + + @override + String get trackReEnrichReviewTitle => 'Review metadata changes'; + + @override + String trackReEnrichReviewSubtitle(int changeCount, int trackCount) { + return '$changeCount proposed changes across $trackCount tracks'; + } + + @override + String get trackReEnrichNoChanges => + 'No metadata changes were found for the selected tracks.'; + + @override + String get trackReEnrichApplyChanges => 'Apply changes'; + + @override + String get trackReEnrichRefreshOnline => 'Refresh from online'; + @override String get trackEditMetadata => 'Edit Metadata'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 630359bb..8af80d38 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2432,6 +2432,51 @@ class AppLocalizationsRu extends AppLocalizations { @override String get trackReEnrichSelectAll => 'Выбрать всё'; + @override + String get trackReEnrichModeIsrc => 'ISRC only'; + + @override + String get trackReEnrichModeIsrcSubtitle => + 'Find and add the recording identifier without changing other tags'; + + @override + String get trackReEnrichModeMissing => 'Fill missing tags'; + + @override + String get trackReEnrichModeMissingSubtitle => + 'Keep existing values and fill only fields that are empty'; + + @override + String get trackReEnrichModeReplace => 'Update selected tags'; + + @override + String get trackReEnrichModeReplaceSubtitle => + 'Choose which existing values may be replaced by online metadata'; + + @override + String get trackReEnrichFieldsTitle => 'Tags to update'; + + @override + String get trackReEnrichReview => 'Review changes'; + + @override + String get trackReEnrichReviewTitle => 'Review metadata changes'; + + @override + String trackReEnrichReviewSubtitle(int changeCount, int trackCount) { + return '$changeCount proposed changes across $trackCount tracks'; + } + + @override + String get trackReEnrichNoChanges => + 'No metadata changes were found for the selected tracks.'; + + @override + String get trackReEnrichApplyChanges => 'Apply changes'; + + @override + String get trackReEnrichRefreshOnline => 'Refresh from online'; + @override String get trackEditMetadata => 'Редактировать метаданные'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 084efce3..0fb42420 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2429,6 +2429,51 @@ class AppLocalizationsTr extends AppLocalizations { @override String get trackReEnrichSelectAll => 'Tümünü Seç'; + @override + String get trackReEnrichModeIsrc => 'ISRC only'; + + @override + String get trackReEnrichModeIsrcSubtitle => + 'Find and add the recording identifier without changing other tags'; + + @override + String get trackReEnrichModeMissing => 'Fill missing tags'; + + @override + String get trackReEnrichModeMissingSubtitle => + 'Keep existing values and fill only fields that are empty'; + + @override + String get trackReEnrichModeReplace => 'Update selected tags'; + + @override + String get trackReEnrichModeReplaceSubtitle => + 'Choose which existing values may be replaced by online metadata'; + + @override + String get trackReEnrichFieldsTitle => 'Tags to update'; + + @override + String get trackReEnrichReview => 'Review changes'; + + @override + String get trackReEnrichReviewTitle => 'Review metadata changes'; + + @override + String trackReEnrichReviewSubtitle(int changeCount, int trackCount) { + return '$changeCount proposed changes across $trackCount tracks'; + } + + @override + String get trackReEnrichNoChanges => + 'No metadata changes were found for the selected tracks.'; + + @override + String get trackReEnrichApplyChanges => 'Apply changes'; + + @override + String get trackReEnrichRefreshOnline => 'Refresh from online'; + @override String get trackEditMetadata => 'Edit Metadata'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index c5849055..3eb8775d 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -2436,6 +2436,51 @@ class AppLocalizationsUk extends AppLocalizations { @override String get trackReEnrichSelectAll => 'Вибрати все'; + @override + String get trackReEnrichModeIsrc => 'ISRC only'; + + @override + String get trackReEnrichModeIsrcSubtitle => + 'Find and add the recording identifier without changing other tags'; + + @override + String get trackReEnrichModeMissing => 'Fill missing tags'; + + @override + String get trackReEnrichModeMissingSubtitle => + 'Keep existing values and fill only fields that are empty'; + + @override + String get trackReEnrichModeReplace => 'Update selected tags'; + + @override + String get trackReEnrichModeReplaceSubtitle => + 'Choose which existing values may be replaced by online metadata'; + + @override + String get trackReEnrichFieldsTitle => 'Tags to update'; + + @override + String get trackReEnrichReview => 'Review changes'; + + @override + String get trackReEnrichReviewTitle => 'Review metadata changes'; + + @override + String trackReEnrichReviewSubtitle(int changeCount, int trackCount) { + return '$changeCount proposed changes across $trackCount tracks'; + } + + @override + String get trackReEnrichNoChanges => + 'No metadata changes were found for the selected tracks.'; + + @override + String get trackReEnrichApplyChanges => 'Apply changes'; + + @override + String get trackReEnrichRefreshOnline => 'Refresh from online'; + @override String get trackEditMetadata => 'Редагувати метадані'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 4dc77467..a6b36935 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -3161,6 +3161,62 @@ "@trackReEnrichSelectAll": { "description": "Select all fields checkbox in re-enrich" }, + "trackReEnrichModeIsrc": "ISRC only", + "@trackReEnrichModeIsrc": { + "description": "Batch metadata mode that only writes ISRC" + }, + "trackReEnrichModeIsrcSubtitle": "Find and add the recording identifier without changing other tags", + "@trackReEnrichModeIsrcSubtitle": { + "description": "Explanation for the ISRC-only batch metadata mode" + }, + "trackReEnrichModeMissing": "Fill missing tags", + "@trackReEnrichModeMissing": { + "description": "Batch metadata mode that fills only empty tags" + }, + "trackReEnrichModeMissingSubtitle": "Keep existing values and fill only fields that are empty", + "@trackReEnrichModeMissingSubtitle": { + "description": "Explanation for the fill-missing batch metadata mode" + }, + "trackReEnrichModeReplace": "Update selected tags", + "@trackReEnrichModeReplace": { + "description": "Batch metadata mode that replaces selected tag groups" + }, + "trackReEnrichModeReplaceSubtitle": "Choose which existing values may be replaced by online metadata", + "@trackReEnrichModeReplaceSubtitle": { + "description": "Explanation for the selected-tag batch metadata mode" + }, + "trackReEnrichFieldsTitle": "Tags to update", + "@trackReEnrichFieldsTitle": { + "description": "Heading above batch re-enrich field checkboxes" + }, + "trackReEnrichReview": "Review changes", + "@trackReEnrichReview": { + "description": "Button that searches metadata and opens the batch change review" + }, + "trackReEnrichReviewTitle": "Review metadata changes", + "@trackReEnrichReviewTitle": { + "description": "Title of the batch metadata review sheet" + }, + "trackReEnrichReviewSubtitle": "{changeCount} proposed changes across {trackCount} tracks", + "@trackReEnrichReviewSubtitle": { + "description": "Summary shown above proposed batch metadata changes", + "placeholders": { + "changeCount": {"type": "int"}, + "trackCount": {"type": "int"} + } + }, + "trackReEnrichNoChanges": "No metadata changes were found for the selected tracks.", + "@trackReEnrichNoChanges": { + "description": "Message when batch metadata preview has no proposed changes" + }, + "trackReEnrichApplyChanges": "Apply changes", + "@trackReEnrichApplyChanges": { + "description": "Confirmation button in the batch metadata review sheet" + }, + "trackReEnrichRefreshOnline": "Refresh from online", + "@trackReEnrichRefreshOnline": { + "description": "Proposed value when lyrics will be refreshed during re-enrich" + }, "trackEditMetadata": "Edit Metadata", "@trackEditMetadata": { "description": "Menu action - edit embedded metadata" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index fa1fcdd0..4752da11 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -717,7 +717,26 @@ } } }, - "trackReEnrich": "Re-enrich", + "trackReEnrich": "Perkaya ulang", + "trackReEnrichModeIsrc": "ISRC saja", + "trackReEnrichModeIsrcSubtitle": "Cari dan tambahkan pengenal rekaman tanpa mengubah tag lain", + "trackReEnrichModeMissing": "Isi tag yang kosong", + "trackReEnrichModeMissingSubtitle": "Pertahankan nilai yang ada dan isi hanya kolom yang kosong", + "trackReEnrichModeReplace": "Perbarui tag yang dipilih", + "trackReEnrichModeReplaceSubtitle": "Pilih nilai yang boleh diganti oleh metadata online", + "trackReEnrichFieldsTitle": "Tag yang diperbarui", + "trackReEnrichReview": "Tinjau perubahan", + "trackReEnrichReviewTitle": "Tinjau perubahan metadata", + "trackReEnrichReviewSubtitle": "{changeCount} perubahan diusulkan untuk {trackCount} trek", + "@trackReEnrichReviewSubtitle": { + "placeholders": { + "changeCount": {"type": "int"}, + "trackCount": {"type": "int"} + } + }, + "trackReEnrichNoChanges": "Tidak ada perubahan metadata yang ditemukan untuk trek yang dipilih.", + "trackReEnrichApplyChanges": "Terapkan perubahan", + "trackReEnrichRefreshOnline": "Perbarui dari online", "@trackReEnrich": { "description": "Menu action - re-embed metadata into audio file" }, diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 1db4e92f..63b3afa9 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -16,13 +16,14 @@ import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; -import 'package:spotiflac_android/utils/re_enrich_release_policy.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/batch_track_actions.dart'; +import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; import 'package:spotiflac_android/models/unified_library_item.dart'; import 'package:spotiflac_android/services/local_track_redownload_service.dart'; import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart'; +import 'package:spotiflac_android/widgets/re_enrich_review_sheet.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; @@ -433,39 +434,18 @@ class _LocalAlbumScreenState extends ConsumerState Future _reEnrichLocalTrack( LocalLibraryItem item, { - List? updateFields, + required List updateFields, + required Map resolvedMetadata, }) async { - final durationMs = (item.duration ?? 0) * 1000; final settings = ref.read(settingsProvider); final artistTagMode = settings.artistTagMode; await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend(); - final request = { - 'file_path': item.filePath, - 'cover_url': '', - 'max_quality': true, - 'embed_lyrics': settings.embedLyrics, - 'lyrics_mode': settings.lyricsMode, - 'artist_tag_mode': artistTagMode, - 'spotify_id': '', - 'track_name': item.trackName, - 'artist_name': item.artistName, - 'album_name': item.albumName, - 'album_artist': item.albumArtist ?? '', - 'track_number': item.trackNumber ?? 0, - 'disc_number': item.discNumber ?? 0, - 'release_date': item.releaseDate ?? '', - 'isrc': item.isrc ?? '', - 'genre': item.genre ?? '', - 'label': '', - 'copyright': '', - 'duration_ms': durationMs, - 'search_online': true, - 'replace_release_metadata': allowsReleaseIdentityReplacement( - ReEnrichOperationScope.batch, - ), - // ignore: use_null_aware_elements - if (updateFields != null) 'update_fields': updateFields, - }; + final request = buildBatchReEnrichRequest( + item: item, + settings: settings, + updateFields: updateFields, + resolvedMetadata: resolvedMetadata, + ); final result = await PlatformBridge.reEnrichFile(request); final method = result['method'] as String?; @@ -481,7 +461,7 @@ class _LocalAlbumScreenState extends ConsumerState return applyFfmpegReEnrichResult( item: item, result: result, - artistTagMode: ref.read(settingsProvider).artistTagMode, + artistTagMode: artistTagMode, ); } return false; @@ -653,12 +633,89 @@ class _LocalAlbumScreenState extends ConsumerState return; } - final updateFields = selection.isAll ? null : selection.fields; + await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend(); + if (!mounted) return; + final settings = ref.read(settingsProvider); + final previews = []; + var cancelled = false; + BatchProgressDialog.show( + context: context, + title: context.l10n.trackReEnrichSearching, + total: selected.length, + icon: Icons.manage_search, + onCancel: () { + cancelled = true; + BatchProgressDialog.dismiss(context); + }, + ); + + for (var i = 0; i < selected.length; i++) { + if (!mounted || cancelled) break; + final item = selected[i]; + BatchProgressDialog.update( + current: i + 1, + detail: '${item.trackName} - ${item.artistName}', + ); + final updateFields = selection.updateFieldsFor(item); + if (updateFields.isEmpty) continue; + try { + final result = await PlatformBridge.reEnrichFile( + buildBatchReEnrichRequest( + item: item, + settings: settings, + updateFields: updateFields, + previewOnly: true, + ), + ); + final rawMetadata = result['enriched_metadata']; + if (result['method'] != 'preview' || rawMetadata is! Map) continue; + final enrichedMetadata = rawMetadata.map( + (key, value) => MapEntry(key.toString(), value), + ); + final changes = buildReEnrichMetadataChanges( + item, + enrichedMetadata, + updateFields, + ); + if (changes.isEmpty) continue; + previews.add( + BatchReEnrichPreview( + item: item, + updateFields: updateFields, + enrichedMetadata: enrichedMetadata, + changes: changes, + ), + ); + } catch (_) {} + } + + if (!mounted) return; + if (!cancelled) BatchProgressDialog.dismiss(context); + if (cancelled) { + setState(() => isSelectionMode = true); + return; + } + + if (previews.isEmpty) { + setState(() => isSelectionMode = true); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackReEnrichNoChanges)), + ); + return; + } + + final confirmed = await showReEnrichReviewSheet( + context, + previews: previews, + ); + if (!confirmed || !mounted) { + if (mounted) setState(() => isSelectionMode = true); + return; + } var successCount = 0; - final total = selected.length; - - var cancelled = false; + final total = previews.length; + cancelled = false; BatchProgressDialog.show( context: context, title: context.l10n.trackReEnrichProgress, @@ -672,26 +729,24 @@ class _LocalAlbumScreenState extends ConsumerState for (var i = 0; i < total; i++) { if (!mounted || cancelled) break; - final item = selected[i]; - + final preview = previews[i]; BatchProgressDialog.update( current: i + 1, - detail: '${item.trackName} - ${item.artistName}', + detail: '${preview.item.trackName} - ${preview.item.artistName}', ); - try { - final ok = await _reEnrichLocalTrack(item, updateFields: updateFields); - if (ok) { - successCount++; - } + final ok = await _reEnrichLocalTrack( + preview.item, + updateFields: preview.updateFields, + resolvedMetadata: preview.enrichedMetadata, + ); + if (ok) successCount++; } catch (_) {} } - if (!mounted) { - return; - } + if (!mounted) return; + if (!cancelled) BatchProgressDialog.dismiss(context); - final settings = ref.read(settingsProvider); final localLibraryPath = settings.localLibraryPath.trim(); final iosBookmark = settings.localLibraryBookmark; try { @@ -716,9 +771,6 @@ class _LocalAlbumScreenState extends ConsumerState return; } - if (!cancelled) { - BatchProgressDialog.dismiss(context); - } ScaffoldMessenger.of(context).clearSnackBars(); final failedCount = total - successCount; final summary = failedCount <= 0 diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index f9a67fcf..15e2482a 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -16,7 +16,6 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/utils/audio_quality_badge_policy.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; -import 'package:spotiflac_android/utils/re_enrich_release_policy.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/utils/ffmpeg_reenrich.dart'; import 'package:spotiflac_android/utils/file_access.dart'; @@ -36,11 +35,13 @@ import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/local_track_redownload_service.dart'; import 'package:spotiflac_android/services/batch_track_actions.dart'; +import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/favorite_artists_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart'; +import 'package:spotiflac_android/widgets/re_enrich_review_sheet.dart'; import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; import 'package:cached_network_image/cached_network_image.dart'; diff --git a/lib/screens/queue_tab_batch_actions.dart b/lib/screens/queue_tab_batch_actions.dart index 67cfd63d..a1f3615a 100644 --- a/lib/screens/queue_tab_batch_actions.dart +++ b/lib/screens/queue_tab_batch_actions.dart @@ -3,39 +3,18 @@ part of 'queue_tab.dart'; extension _QueueTabBatchActions on _QueueTabState { Future _reEnrichQueueLocalTrack( LocalLibraryItem item, { - List? updateFields, + required List updateFields, + required Map resolvedMetadata, }) async { - final durationMs = (item.duration ?? 0) * 1000; final settings = ref.read(settingsProvider); final artistTagMode = settings.artistTagMode; await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend(); - final request = { - 'file_path': item.filePath, - 'cover_url': '', - 'max_quality': true, - 'embed_lyrics': settings.embedLyrics, - 'lyrics_mode': settings.lyricsMode, - 'artist_tag_mode': artistTagMode, - 'spotify_id': '', - 'track_name': item.trackName, - 'artist_name': item.artistName, - 'album_name': item.albumName, - 'album_artist': item.albumArtist ?? '', - 'track_number': item.trackNumber ?? 0, - 'disc_number': item.discNumber ?? 0, - 'release_date': item.releaseDate ?? '', - 'isrc': item.isrc ?? '', - 'genre': item.genre ?? '', - 'label': '', - 'copyright': '', - 'duration_ms': durationMs, - 'search_online': true, - 'replace_release_metadata': allowsReleaseIdentityReplacement( - ReEnrichOperationScope.batch, - ), - // ignore: use_null_aware_elements - if (updateFields != null) 'update_fields': updateFields, - }; + final request = buildBatchReEnrichRequest( + item: item, + settings: settings, + updateFields: updateFields, + resolvedMetadata: resolvedMetadata, + ); final result = await PlatformBridge.reEnrichFile(request); final method = result['method'] as String?; @@ -232,12 +211,89 @@ extension _QueueTabBatchActions on _QueueTabState { return; } - final updateFields = selection.isAll ? null : selection.fields; + await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend(); + if (!mounted) return; + final settings = ref.read(settingsProvider); + final previews = []; + var cancelled = false; + BatchProgressDialog.show( + context: context, + title: context.l10n.trackReEnrichSearching, + total: selectedLocalItems.length, + icon: Icons.manage_search, + onCancel: () { + cancelled = true; + BatchProgressDialog.dismiss(context); + }, + ); + + for (var i = 0; i < selectedLocalItems.length; i++) { + if (!mounted || cancelled) break; + final item = selectedLocalItems[i]; + BatchProgressDialog.update( + current: i + 1, + detail: '${item.trackName} - ${item.artistName}', + ); + final updateFields = selection.updateFieldsFor(item); + if (updateFields.isEmpty) continue; + try { + final result = await PlatformBridge.reEnrichFile( + buildBatchReEnrichRequest( + item: item, + settings: settings, + updateFields: updateFields, + previewOnly: true, + ), + ); + final rawMetadata = result['enriched_metadata']; + if (result['method'] != 'preview' || rawMetadata is! Map) continue; + final enrichedMetadata = rawMetadata.map( + (key, value) => MapEntry(key.toString(), value), + ); + final changes = buildReEnrichMetadataChanges( + item, + enrichedMetadata, + updateFields, + ); + if (changes.isEmpty) continue; + previews.add( + BatchReEnrichPreview( + item: item, + updateFields: updateFields, + enrichedMetadata: enrichedMetadata, + changes: changes, + ), + ); + } catch (_) {} + } + + if (!mounted) return; + if (!cancelled) BatchProgressDialog.dismiss(context); + if (cancelled) { + _setState(() => _isSelectionMode = true); + return; + } + + if (previews.isEmpty) { + _setState(() => _isSelectionMode = true); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackReEnrichNoChanges)), + ); + return; + } + + final confirmed = await showReEnrichReviewSheet( + context, + previews: previews, + ); + if (!confirmed || !mounted) { + if (mounted) _setState(() => _isSelectionMode = true); + return; + } var successCount = 0; - final total = selectedLocalItems.length; - - var cancelled = false; + final total = previews.length; + cancelled = false; BatchProgressDialog.show( context: context, title: context.l10n.trackReEnrichProgress, @@ -251,29 +307,24 @@ extension _QueueTabBatchActions on _QueueTabState { for (var i = 0; i < total; i++) { if (!mounted || cancelled) break; - final item = selectedLocalItems[i]; - + final preview = previews[i]; BatchProgressDialog.update( current: i + 1, - detail: '${item.trackName} - ${item.artistName}', + detail: '${preview.item.trackName} - ${preview.item.artistName}', ); - try { final ok = await _reEnrichQueueLocalTrack( - item, - updateFields: updateFields, + preview.item, + updateFields: preview.updateFields, + resolvedMetadata: preview.enrichedMetadata, ); - if (ok) { - successCount++; - } + if (ok) successCount++; } catch (_) {} } - if (!mounted) { - return; - } + if (!mounted) return; + if (!cancelled) BatchProgressDialog.dismiss(context); - final settings = ref.read(settingsProvider); final localLibraryPath = settings.localLibraryPath.trim(); final iosBookmark = settings.localLibraryBookmark; try { @@ -298,9 +349,6 @@ extension _QueueTabBatchActions on _QueueTabState { return; } - if (!cancelled) { - BatchProgressDialog.dismiss(context); - } ScaffoldMessenger.of(context).clearSnackBars(); final failedCount = total - successCount; final summary = failedCount <= 0 diff --git a/lib/services/batch_metadata_re_enrich.dart b/lib/services/batch_metadata_re_enrich.dart new file mode 100644 index 00000000..cff4b7af --- /dev/null +++ b/lib/services/batch_metadata_re_enrich.dart @@ -0,0 +1,194 @@ +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/services/library_database.dart'; + +/// Field group keys understood by the Go re-enrich backend. +class ReEnrichFields { + static const String cover = 'cover'; + static const String lyrics = 'lyrics'; + static const String basicTags = 'basic_tags'; + static const String trackInfo = 'track_info'; + static const String releaseInfo = 'release_info'; + static const String extra = 'extra'; + + static const List all = [ + cover, + lyrics, + basicTags, + trackInfo, + releaseInfo, + extra, + ]; +} + +enum ReEnrichBatchMode { isrcOnly, missingOnly, selectedFields } + +class ReEnrichFieldSelection { + final ReEnrichBatchMode mode; + final List fields; + + const ReEnrichFieldSelection({required this.mode, this.fields = const []}); + + List updateFieldsFor(LocalLibraryItem item) { + switch (mode) { + case ReEnrichBatchMode.isrcOnly: + return const ['isrc']; + case ReEnrichBatchMode.selectedFields: + return fields; + case ReEnrichBatchMode.missingOnly: + return missingReEnrichFields(item); + } + } +} + +bool _missingText(String? value) { + final normalized = value?.trim().toLowerCase() ?? ''; + return normalized.isEmpty || + normalized == 'unknown' || + normalized == 'unknown title' || + normalized == 'unknown artist' || + normalized == 'unknown album'; +} + +/// Returns granular tag keys, preventing a missing value from causing its +/// already-populated neighbors in the same backend group to be overwritten. +List missingReEnrichFields(LocalLibraryItem item) { + final fields = []; + if (_missingText(item.trackName)) fields.add('track_name'); + if (_missingText(item.artistName)) fields.add('artist_name'); + if (_missingText(item.albumName)) fields.add('album_name'); + if (_missingText(item.albumArtist)) fields.add('album_artist'); + if ((item.trackNumber ?? 0) <= 0) fields.add('track_number'); + if ((item.totalTracks ?? 0) <= 0) fields.add('total_tracks'); + if ((item.discNumber ?? 0) <= 0) fields.add('disc_number'); + if ((item.totalDiscs ?? 0) <= 0) fields.add('total_discs'); + if (_missingText(item.releaseDate)) fields.add('release_date'); + if (_missingText(item.isrc)) fields.add('isrc'); + if (_missingText(item.genre)) fields.add('genre'); + if (_missingText(item.composer)) fields.add('composer'); + if (_missingText(item.label)) fields.add('label'); + if (_missingText(item.copyright)) fields.add('copyright'); + if (_missingText(item.coverPath)) fields.add('cover'); + return fields; +} + +Map buildBatchReEnrichRequest({ + required LocalLibraryItem item, + required AppSettings settings, + required List updateFields, + bool previewOnly = false, + Map? resolvedMetadata, +}) { + final request = { + 'file_path': item.filePath, + 'cover_url': '', + 'max_quality': true, + 'embed_lyrics': settings.embedLyrics, + 'lyrics_mode': settings.lyricsMode, + 'artist_tag_mode': settings.artistTagMode, + 'spotify_id': '', + 'track_name': item.trackName, + 'artist_name': item.artistName, + 'album_name': item.albumName, + 'album_artist': item.albumArtist ?? '', + 'track_number': item.trackNumber ?? 0, + 'total_tracks': item.totalTracks ?? 0, + 'disc_number': item.discNumber ?? 0, + 'total_discs': item.totalDiscs ?? 0, + 'release_date': item.releaseDate ?? '', + 'isrc': item.isrc ?? '', + 'genre': item.genre ?? '', + 'composer': item.composer ?? '', + 'label': item.label ?? '', + 'copyright': item.copyright ?? '', + 'duration_ms': (item.duration ?? 0) * 1000, + 'search_online': resolvedMetadata == null, + 'replace_release_metadata': false, + 'update_fields': updateFields, + if (previewOnly) 'preview_only': true, + }; + if (resolvedMetadata != null) { + request.addAll(resolvedMetadata); + } + return request; +} + +class ReEnrichMetadataChange { + final String field; + final String oldValue; + final String newValue; + + const ReEnrichMetadataChange({ + required this.field, + required this.oldValue, + required this.newValue, + }); +} + +class BatchReEnrichPreview { + final LocalLibraryItem item; + final List updateFields; + final Map enrichedMetadata; + final List changes; + + const BatchReEnrichPreview({ + required this.item, + required this.updateFields, + required this.enrichedMetadata, + required this.changes, + }); +} + +String _displayValue(Object? value) { + if (value == null) return ''; + if (value is num && value == 0) return ''; + return value.toString().trim(); +} + +List buildReEnrichMetadataChanges( + LocalLibraryItem item, + Map enrichedMetadata, + List updateFields, +) { + final current = { + 'track_name': item.trackName, + 'artist_name': item.artistName, + 'album_name': item.albumName, + 'album_artist': item.albumArtist, + 'track_number': item.trackNumber, + 'total_tracks': item.totalTracks, + 'disc_number': item.discNumber, + 'total_discs': item.totalDiscs, + 'release_date': item.releaseDate, + 'isrc': item.isrc, + 'genre': item.genre, + 'composer': item.composer, + 'label': item.label, + 'copyright': item.copyright, + 'cover_url': item.coverPath, + }; + final changes = []; + for (final field in current.keys) { + if (!enrichedMetadata.containsKey(field)) continue; + final oldValue = _displayValue(current[field]); + final newValue = _displayValue(enrichedMetadata[field]); + if (newValue.isEmpty || oldValue == newValue) continue; + changes.add( + ReEnrichMetadataChange( + field: field, + oldValue: oldValue, + newValue: newValue, + ), + ); + } + if (updateFields.contains(ReEnrichFields.lyrics) || + updateFields.contains('lyrics')) { + changes.add( + const ReEnrichMetadataChange( + field: 'lyrics', + oldValue: '', + newValue: '__refresh_online__', + ), + ); + } + return changes; +} diff --git a/lib/widgets/re_enrich_field_dialog.dart b/lib/widgets/re_enrich_field_dialog.dart index 989cefca..924a3820 100644 --- a/lib/widgets/re_enrich_field_dialog.dart +++ b/lib/widgets/re_enrich_field_dialog.dart @@ -1,43 +1,19 @@ import 'package:flutter/material.dart'; import '../l10n/app_localizations.dart'; - -/// Field group keys matching the Go backend `update_fields` values. -class ReEnrichFields { - static const String cover = 'cover'; - static const String lyrics = 'lyrics'; - static const String basicTags = 'basic_tags'; - static const String trackInfo = 'track_info'; - static const String releaseInfo = 'release_info'; - static const String extra = 'extra'; - - static const List all = [ - cover, - lyrics, - basicTags, - trackInfo, - releaseInfo, - extra, - ]; -} - -/// Result returned by the re-enrich field selection sheet. -class ReEnrichFieldSelection { - final List fields; - const ReEnrichFieldSelection(this.fields); - - /// True when every available field is selected (or update_fields can be omitted). - bool get isAll => fields.length == ReEnrichFields.all.length; -} +import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; Future showReEnrichFieldDialog( BuildContext context, { required int selectedCount, }) { - return showModalBottomSheet( + return showAppBottomSheet( context: context, useRootNavigator: true, - showDragHandle: true, - isScrollControlled: true, + title: AppLocalizations.of(context).trackReEnrich, + subtitle: AppLocalizations.of(context).trackReEnrichOnlineSubtitle, + maxHeightFactor: 0.9, builder: (ctx) => _ReEnrichFieldSheet(selectedCount: selectedCount), ); } @@ -52,6 +28,7 @@ class _ReEnrichFieldSheet extends StatefulWidget { class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> { final Set _selected = Set.from(ReEnrichFields.all); + ReEnrichBatchMode _mode = ReEnrichBatchMode.missingOnly; bool get _allSelected => _selected.length == ReEnrichFields.all.length; @@ -117,80 +94,121 @@ class _ReEnrichFieldSheetState extends State<_ReEnrichFieldSheet> { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 4), - child: Text( - l10n.trackReEnrich, - style: textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, + top: false, + child: SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), + child: Text( + l10n.downloadedAlbumSelectedCount(widget.selectedCount), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 4), - child: Text( - l10n.trackReEnrichOnlineSubtitle, - style: textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, + SettingsGroup( + children: [ + ListTile( + leading: const Icon(Icons.fingerprint), + title: Text(l10n.trackReEnrichModeIsrc), + subtitle: Text(l10n.trackReEnrichModeIsrcSubtitle), + trailing: _mode == ReEnrichBatchMode.isrcOnly + ? Icon(Icons.check, color: colorScheme.primary) + : null, + onTap: () => + setState(() => _mode = ReEnrichBatchMode.isrcOnly), + ), + const Divider(height: 1, indent: 56), + ListTile( + leading: const Icon(Icons.playlist_add_check), + title: Text(l10n.trackReEnrichModeMissing), + subtitle: Text(l10n.trackReEnrichModeMissingSubtitle), + trailing: _mode == ReEnrichBatchMode.missingOnly + ? Icon(Icons.check, color: colorScheme.primary) + : null, + onTap: () => + setState(() => _mode = ReEnrichBatchMode.missingOnly), + ), + const Divider(height: 1, indent: 56), + ListTile( + leading: const Icon(Icons.tune), + title: Text(l10n.trackReEnrichModeReplace), + subtitle: Text(l10n.trackReEnrichModeReplaceSubtitle), + trailing: _mode == ReEnrichBatchMode.selectedFields + ? Icon(Icons.check, color: colorScheme.primary) + : null, + onTap: () => + setState(() => _mode = ReEnrichBatchMode.selectedFields), + ), + ], + ), + if (_mode == ReEnrichBatchMode.selectedFields) ...[ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + l10n.trackReEnrichFieldsTitle, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + SettingsGroup( + children: [ + CheckboxListTile( + title: Text( + l10n.trackReEnrichSelectAll, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + value: _allSelected, + tristate: true, + onChanged: _toggleAll, + controlAffinity: ListTileControlAffinity.leading, + ), + for (final field in ReEnrichFields.all) ...[ + const Divider(height: 1, indent: 56), + CheckboxListTile( + secondary: Icon(_iconFor(field), size: 20), + title: Text(_labelFor(field, l10n)), + value: _selected.contains(field), + onChanged: (value) => _toggle(field, value), + controlAffinity: ListTileControlAffinity.leading, + ), + ], + ], + ), + ], + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: + _mode == ReEnrichBatchMode.selectedFields && + _selected.isEmpty + ? null + : () => Navigator.pop( + context, + ReEnrichFieldSelection( + mode: _mode, + fields: _selected.toList(), + ), + ), + icon: const Icon(Icons.preview_outlined, size: 18), + label: Text(l10n.trackReEnrichReview), + ), ), ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), - child: Text( - l10n.downloadedAlbumSelectedCount(widget.selectedCount), - style: textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - const Divider(height: 1), - CheckboxListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16), - title: Text( - l10n.trackReEnrichSelectAll, - style: const TextStyle(fontWeight: FontWeight.w600), - ), - value: _allSelected, - tristate: true, - onChanged: _toggleAll, - controlAffinity: ListTileControlAffinity.leading, - ), - const Divider(height: 1, indent: 16, endIndent: 16), - for (final field in ReEnrichFields.all) - CheckboxListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16), - secondary: Icon(_iconFor(field), size: 20), - title: Text(_labelFor(field, l10n)), - value: _selected.contains(field), - onChanged: (v) => _toggle(field, v), - controlAffinity: ListTileControlAffinity.leading, - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: _selected.isEmpty - ? null - : () => Navigator.pop( - context, - ReEnrichFieldSelection(_selected.toList()), - ), - icon: const Icon(Icons.auto_fix_high, size: 18), - label: Text(l10n.trackReEnrich), - ), - ), - ), - ], + ], + ), ), ); } diff --git a/lib/widgets/re_enrich_review_sheet.dart b/lib/widgets/re_enrich_review_sheet.dart new file mode 100644 index 00000000..f9e72c42 --- /dev/null +++ b/lib/widgets/re_enrich_review_sheet.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +Future showReEnrichReviewSheet( + BuildContext context, { + required List previews, +}) async { + final changeCount = previews.fold( + 0, + (total, preview) => total + preview.changes.length, + ); + if (changeCount == 0) return false; + + final confirmed = await showAppBottomSheet( + context: context, + useRootNavigator: true, + title: context.l10n.trackReEnrichReviewTitle, + subtitle: context.l10n.trackReEnrichReviewSubtitle( + changeCount, + previews.length, + ), + maxHeightFactor: 0.9, + builder: (sheetContext) => _ReEnrichReviewContent(previews: previews), + ); + return confirmed == true; +} + +class _ReEnrichReviewContent extends StatelessWidget { + final List previews; + + const _ReEnrichReviewContent({required this.previews}); + + String _fieldLabel(BuildContext context, String field) { + switch (field) { + case 'track_name': + return context.l10n.editMetadataFieldTitle; + case 'artist_name': + return context.l10n.editMetadataFieldArtist; + case 'album_name': + return context.l10n.editMetadataFieldAlbum; + case 'album_artist': + return context.l10n.editMetadataFieldAlbumArtist; + case 'track_number': + return context.l10n.editMetadataFieldTrackNum; + case 'total_tracks': + return context.l10n.editMetadataFieldTrackTotal; + case 'disc_number': + return context.l10n.editMetadataFieldDiscNum; + case 'total_discs': + return context.l10n.editMetadataFieldDiscTotal; + case 'release_date': + return context.l10n.editMetadataFieldDate; + case 'isrc': + return context.l10n.editMetadataFieldIsrc; + case 'genre': + return context.l10n.editMetadataFieldGenre; + case 'composer': + return context.l10n.editMetadataFieldComposer; + case 'label': + return context.l10n.editMetadataFieldLabel; + case 'copyright': + return context.l10n.editMetadataFieldCopyright; + case 'cover_url': + return context.l10n.editMetadataFieldCover; + case 'lyrics': + return context.l10n.trackReEnrichFieldLyrics; + default: + return field; + } + } + + String _oldValue(BuildContext context, ReEnrichMetadataChange change) { + if (change.field == 'cover_url' && change.oldValue.isNotEmpty) { + return context.l10n.trackCoverCurrent; + } + return change.oldValue.isEmpty ? '—' : change.oldValue; + } + + String _newValue(BuildContext context, ReEnrichMetadataChange change) { + if (change.field == 'cover_url') { + return context.l10n.editMetadataAutoFillCoverAvailable; + } + if (change.newValue == '__refresh_online__') { + return context.l10n.trackReEnrichRefreshOnline; + } + return change.newValue; + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final visible = previews + .where((preview) => preview.changes.isNotEmpty) + .toList(growable: false); + + return SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: ListView.builder( + shrinkWrap: true, + padding: const EdgeInsets.only(bottom: 8), + itemCount: visible.length, + itemBuilder: (context, index) { + final preview = visible[index]; + return SettingsGroup( + children: [ + ListTile( + leading: const Icon(Icons.audio_file_outlined), + title: Text( + preview.item.trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + preview.item.artistName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + for (final change in preview.changes) ...[ + const Divider(height: 1, indent: 56), + Semantics( + label: + '${_fieldLabel(context, change.field)}: ${_oldValue(context, change)}, ${_newValue(context, change)}', + child: Padding( + padding: const EdgeInsets.fromLTRB(56, 10, 16, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _fieldLabel(context, change.field), + style: Theme.of(context).textTheme.labelMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 3), + Text.rich( + TextSpan( + children: [ + TextSpan( + text: _oldValue(context, change), + style: TextStyle( + color: colorScheme.onSurfaceVariant, + ), + ), + TextSpan( + text: ' → ', + style: TextStyle( + color: colorScheme.primary, + ), + ), + TextSpan( + text: _newValue(context, change), + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ], + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () => Navigator.pop(context, true), + icon: const Icon(Icons.save_outlined), + label: Text(context.l10n.trackReEnrichApplyChanges), + ), + ), + ), + ], + ), + ); + } +} diff --git a/test/batch_metadata_re_enrich_test.dart b/test/batch_metadata_re_enrich_test.dart new file mode 100644 index 00000000..e0a91b4c --- /dev/null +++ b/test/batch_metadata_re_enrich_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/services/batch_metadata_re_enrich.dart'; +import 'package:spotiflac_android/services/library_database.dart'; + +LocalLibraryItem _item({ + String? albumArtist, + String? isrc, + String? genre, + String? coverPath = '/music/cover.jpg', +}) { + return LocalLibraryItem( + id: 'track-1', + trackName: 'Song', + artistName: 'Artist', + albumName: 'Album', + albumArtist: albumArtist, + filePath: '/music/song.flac', + coverPath: coverPath, + scannedAt: DateTime(2026), + isrc: isrc, + trackNumber: 1, + totalTracks: 10, + discNumber: 1, + totalDiscs: 1, + duration: 180, + releaseDate: '2026-01-02', + genre: genre, + ); +} + +void main() { + test( + 'missing mode returns granular keys and preserves populated neighbors', + () { + final fields = missingReEnrichFields(_item()); + + expect(fields, containsAll(['album_artist', 'isrc', 'genre'])); + expect(fields, isNot(contains('basic_tags'))); + expect(fields, isNot(contains('track_name'))); + expect(fields, isNot(contains('release_date'))); + expect(fields, isNot(contains('cover'))); + }, + ); + + test('ISRC-only mode never selects the full release-info group', () { + final fields = const ReEnrichFieldSelection( + mode: ReEnrichBatchMode.isrcOnly, + ).updateFieldsFor(_item()); + + expect(fields, const ['isrc']); + }); + + test('resolved preview metadata is reused without another online search', () { + final request = buildBatchReEnrichRequest( + item: _item(), + settings: const AppSettings(), + updateFields: const ['isrc'], + resolvedMetadata: const { + 'isrc': 'USRC17607839', + 'spotify_id': 'resolved-id', + }, + ); + + expect(request['search_online'], isFalse); + expect(request['update_fields'], const ['isrc']); + expect(request['isrc'], 'USRC17607839'); + expect(request['spotify_id'], 'resolved-id'); + }); + + test('review only includes values that would actually change', () { + final changes = buildReEnrichMetadataChanges( + _item(isrc: 'OLD'), + const {'track_name': 'Song', 'artist_name': 'Artist', 'isrc': 'NEW'}, + const ['isrc'], + ); + + expect(changes, hasLength(1)); + expect(changes.single.field, 'isrc'); + expect(changes.single.oldValue, 'OLD'); + expect(changes.single.newValue, 'NEW'); + }); +}