mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-27 13:22:49 +02:00
feat(metadata): review batch tag enrichment #511
This commit is contained in:
@@ -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
@@ -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) != "",
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'メタデータを編集';
|
||||
|
||||
|
||||
@@ -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 => '메타데이터 편집';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Редактировать метаданные';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 => 'Редагувати метадані';
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user