feat(metadata): review batch tag enrichment #511

This commit is contained in:
zarzet
2026-08-11 16:40:06 +07:00
parent 28538395c2
commit bec9e33c94
24 changed files with 1698 additions and 265 deletions
@@ -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"}"""
+176 -63
View File
@@ -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) != "",
}
+81
View File
@@ -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"])
}
}
+78
View File
@@ -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:
+45
View File
@@ -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';
+45
View File
@@ -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';
+45
View File
@@ -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';
+45
View File
@@ -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';
+46 -1
View File
@@ -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';
+45
View File
@@ -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 => 'メタデータを編集';
+45
View File
@@ -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 => '메타데이터 편집';
+45
View File
@@ -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';
+45
View File
@@ -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 => 'Редактировать метаданные';
+45
View File
@@ -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';
+45
View File
@@ -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 => 'Редагувати метадані';
+56
View File
@@ -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"
+20 -1
View File
@@ -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"
},
+102 -50
View File
@@ -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<LocalAlbumScreen>
Future<bool> _reEnrichLocalTrack(
LocalLibraryItem item, {
List<String>? updateFields,
required List<String> updateFields,
required Map<String, dynamic> 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 = <String, dynamic>{
'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<LocalAlbumScreen>
return applyFfmpegReEnrichResult(
item: item,
result: result,
artistTagMode: ref.read(settingsProvider).artistTagMode,
artistTagMode: artistTagMode,
);
}
return false;
@@ -653,12 +633,89 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
return;
}
final updateFields = selection.isAll ? null : selection.fields;
await ref.read(settingsProvider.notifier).syncLyricsSettingsToBackend();
if (!mounted) return;
final settings = ref.read(settingsProvider);
final previews = <BatchReEnrichPreview>[];
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<LocalAlbumScreen>
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<LocalAlbumScreen>
return;
}
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars();
final failedCount = total - successCount;
final summary = failedCount <= 0
+2 -1
View File
@@ -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';
+97 -49
View File
@@ -3,39 +3,18 @@ part of 'queue_tab.dart';
extension _QueueTabBatchActions on _QueueTabState {
Future<bool> _reEnrichQueueLocalTrack(
LocalLibraryItem item, {
List<String>? updateFields,
required List<String> updateFields,
required Map<String, dynamic> 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 = <String, dynamic>{
'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 = <BatchReEnrichPreview>[];
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
+194
View File
@@ -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<String> all = [
cover,
lyrics,
basicTags,
trackInfo,
releaseInfo,
extra,
];
}
enum ReEnrichBatchMode { isrcOnly, missingOnly, selectedFields }
class ReEnrichFieldSelection {
final ReEnrichBatchMode mode;
final List<String> fields;
const ReEnrichFieldSelection({required this.mode, this.fields = const []});
List<String> 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<String> missingReEnrichFields(LocalLibraryItem item) {
final fields = <String>[];
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<String, dynamic> buildBatchReEnrichRequest({
required LocalLibraryItem item,
required AppSettings settings,
required List<String> updateFields,
bool previewOnly = false,
Map<String, dynamic>? resolvedMetadata,
}) {
final request = <String, dynamic>{
'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<String> updateFields;
final Map<String, dynamic> enrichedMetadata;
final List<ReEnrichMetadataChange> 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<ReEnrichMetadataChange> buildReEnrichMetadataChanges(
LocalLibraryItem item,
Map<String, dynamic> enrichedMetadata,
List<String> updateFields,
) {
final current = <String, Object?>{
'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 = <ReEnrichMetadataChange>[];
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;
}
+117 -99
View File
@@ -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<String> all = [
cover,
lyrics,
basicTags,
trackInfo,
releaseInfo,
extra,
];
}
/// Result returned by the re-enrich field selection sheet.
class ReEnrichFieldSelection {
final List<String> 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<ReEnrichFieldSelection?> showReEnrichFieldDialog(
BuildContext context, {
required int selectedCount,
}) {
return showModalBottomSheet<ReEnrichFieldSelection>(
return showAppBottomSheet<ReEnrichFieldSelection>(
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<String> _selected = Set<String>.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),
),
),
),
],
],
),
),
);
}
+193
View File
@@ -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<bool> showReEnrichReviewSheet(
BuildContext context, {
required List<BatchReEnrichPreview> previews,
}) async {
final changeCount = previews.fold<int>(
0,
(total, preview) => total + preview.changes.length,
);
if (changeCount == 0) return false;
final confirmed = await showAppBottomSheet<bool>(
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<BatchReEnrichPreview> 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),
),
),
),
],
),
);
}
}
+83
View File
@@ -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(<String>['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');
});
}