From 0119db094dce35ad037b233fb522c96ff0ba6895 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 19 Jan 2026 21:11:57 +0700 Subject: [PATCH] feat: add extended metadata (genre, label, copyright) support - Add genre, label, copyright fields to ExtTrackMetadata and DownloadResponse - Add utils.randomUserAgent() for extensions to get random User-Agent strings - Fix VM race condition panic by adding mutex locks to all provider methods - Fix Tidal release date fallback when req.ReleaseDate is empty - Display genre, label, copyright in track metadata screen - Store extended metadata in download history for persistence - Add trackGenre, trackLabel, trackCopyright localization strings --- .github/FUNDING.yml | 1 + CHANGELOG.md | 74 ++++++++++++++++++ go_backend/exports.go | 4 + go_backend/extension_manager.go | 1 + go_backend/extension_providers.go | 82 ++++++++++++++++++++ go_backend/extension_runtime.go | 1 + go_backend/extension_runtime_utils.go | 5 ++ go_backend/httputil.go | 15 ++-- go_backend/tidal.go | 9 ++- lib/l10n/app_localizations.dart | 24 ++++++ lib/l10n/app_localizations_de.dart | 14 ++++ lib/l10n/app_localizations_en.dart | 14 ++++ lib/l10n/app_localizations_es.dart | 14 ++++ lib/l10n/app_localizations_fr.dart | 14 ++++ lib/l10n/app_localizations_hi.dart | 14 ++++ lib/l10n/app_localizations_id.dart | 14 ++++ lib/l10n/app_localizations_ja.dart | 14 ++++ lib/l10n/app_localizations_ko.dart | 14 ++++ lib/l10n/app_localizations_nl.dart | 14 ++++ lib/l10n/app_localizations_pt.dart | 14 ++++ lib/l10n/app_localizations_ru.dart | 14 ++++ lib/l10n/app_localizations_zh.dart | 14 ++++ lib/l10n/arb/app_en.arb | 13 ++++ lib/providers/download_queue_provider.dart | 57 +++++++++++++- lib/screens/home_tab.dart | 87 ++++++++++++++-------- lib/screens/track_metadata_screen.dart | 9 +++ 26 files changed, 509 insertions(+), 41 deletions(-) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..326c8513 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +ko_fi: zarzet diff --git a/CHANGELOG.md b/CHANGELOG.md index bd8f606f..af3d6a6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,80 @@ - LRC files include metadata headers (title, artist, by:SpotiFLAC-Mobile) - Works with all download services (Tidal, Qobuz, Amazon) +- **CSV Import Quality Selection**: Choose audio quality when importing CSV playlists + - Quality picker now appears before adding CSV tracks to download queue + - Select between FLAC qualities (Lossless, Hi-Res, Hi-Res Max) or MP3 + - Respects "Ask quality before download" setting - uses default quality if disabled + +- **Extended Metadata from Deezer Enrichment**: Track downloads now include label, copyright, and genre metadata from Deezer + - New fields in `ExtTrackMetadata`: `label`, `copyright`, `genre` + - Metadata fetched during `enrichTrack()` via Deezer album API + - Embedded as FLAC Vorbis comments: `GENRE`, `ORGANIZATION` (label), `COPYRIGHT` + - Works for both extension downloads and built-in provider downloads (Tidal, Qobuz, Amazon) + +- **Track Metadata Screen Extended Info**: Genre, label, and copyright now displayed in track metadata screen + - Added `genre`, `label`, `copyright` fields to `DownloadHistoryItem` model + - Metadata is stored in download history and persists across app restarts + - New localization strings: `trackGenre`, `trackLabel`, `trackCopyright` + +- **`utils.randomUserAgent()` for Extensions**: New utility function for extensions to get random browser User-Agent strings + - Returns modern Chrome User-Agent format: `Chrome/{120-145}.0.{6000-7499}.{100-299}` with `Windows NT 10.0` + - Useful for extensions that need to rotate User-Agents to avoid detection + +### Fixed + +- **Portuguese Language Bug**: Fixed locale parsing for languages with country codes (e.g., pt_PT, es_ES) + - App now correctly loads Portuguese and Spanish translations + - Updated Portuguese label to "Português (Brasil)" + +- **VM Race Condition Panic**: Fixed `panic during execution: runtime error: index out of range [-2]` crash when switching search providers + - Root cause: Goja VM was being accessed concurrently by multiple goroutines without synchronization + - Added `VMMu sync.Mutex` to `LoadedExtension` struct + - Added mutex lock/unlock to ALL `ExtensionProviderWrapper` methods: + - `SearchTracks`, `GetTrack`, `GetAlbum`, `GetArtist` + - `EnrichTrack`, `CheckAvailability`, `GetDownloadURL`, `Download` + - `CustomSearch`, `HandleURL`, `MatchTrack`, `PostProcess` + - Prevents race conditions when rapidly switching between extension search providers + +- **Tidal Release Date Fallback**: Fixed missing release date in FLAC metadata when downloading from Tidal + - Now uses Tidal API's release date when `req.ReleaseDate` is empty + - Ensures release date is always embedded in downloaded files + +- **Extended Metadata for M4A→FLAC Conversion**: Fixed genre, label, and copyright not being embedded when converting Amazon M4A to FLAC + - Flutter now extracts extended metadata from Go backend response + - Passes `genre`, `label`, `copyright` parameters to `_embedMetadataAndCover()` + - Tags correctly embedded during FFmpeg conversion + +### Extensions + +- **spotify-web Extension**: Updated to v1.7.0 + - Added `getMetadataFromDeezer()` function to fetch extended metadata: + - ISRC from track + - Label from album + - Copyright (generated as "YEAR LABEL") + - Genre from album genres + - Release date + - `enrichTrack()` now returns all extended metadata to Go backend + - Replaced all hardcoded User-Agent strings with `utils.randomUserAgent()` + +### Technical + +- **Go Backend Changes**: + - `go_backend/extension_providers.go`: Added `Label`, `Copyright`, `Genre` fields to `ExtTrackMetadata`; added mutex locks to all provider methods + - `go_backend/extension_manager.go`: Added `VMMu sync.Mutex` to `LoadedExtension` struct + - `go_backend/extension_runtime.go`: Added `utils.randomUserAgent` function + - `go_backend/extension_runtime_utils.go`: Added `randomUserAgent()` implementation + - `go_backend/httputil.go`: Updated `getRandomUserAgent()` to use modern Chrome versions + - `go_backend/tidal.go`: Added release date fallback logic + - `go_backend/exports.go`: Added `Genre`, `Label`, `Copyright` fields to `DownloadResponse` + +- **Flutter Changes**: + - `lib/providers/download_queue_provider.dart`: Updated `_embedMetadataAndCover()` to accept and embed genre, label, copyright; added `genre`, `label`, `copyright` fields to `DownloadHistoryItem` + - `lib/screens/track_metadata_screen.dart`: Display genre, label, copyright in metadata grid + - `lib/l10n/arb/app_en.arb`: Added `trackGenre`, `trackLabel`, `trackCopyright` localization strings + +--- + ## [3.1.2] - 2026-01-19 ### Added diff --git a/go_backend/exports.go b/go_backend/exports.go index 1468660f..d5268f56 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -186,6 +186,10 @@ type DownloadResponse struct { DiscNumber int `json:"disc_number,omitempty"` ISRC string `json:"isrc,omitempty"` CoverURL string `json:"cover_url,omitempty"` + // Extended metadata for FLAC tagging (passed to Flutter for M4A->FLAC conversion) + Genre string `json:"genre,omitempty"` // Music genre(s) + Label string `json:"label,omitempty"` // Record label + Copyright string `json:"copyright,omitempty"` // Copyright info // If true, skip metadata enrichment from Deezer/Spotify (extension already provides metadata) SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` } diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index a601c1a4..9ab396aa 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -51,6 +51,7 @@ type LoadedExtension struct { ID string `json:"id"` Manifest *ExtensionManifest `json:"manifest"` VM *goja.Runtime `json:"-"` + VMMu sync.Mutex `json:"-"` // Mutex to prevent concurrent VM access Enabled bool `json:"enabled"` Error string `json:"error,omitempty"` DataDir string `json:"data_dir"` diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 456bee20..83c63629 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -38,6 +38,10 @@ type ExtTrackMetadata struct { DeezerID string `json:"deezer_id,omitempty"` SpotifyID string `json:"spotify_id,omitempty"` ExternalLinks map[string]string `json:"external_links,omitempty"` // service -> URL mapping + // Extended metadata from enrichment (can come from Deezer, Spotify, etc.) + Label string `json:"label,omitempty"` // Record label + Copyright string `json:"copyright,omitempty"` // Copyright information + Genre string `json:"genre,omitempty"` // Music genre(s) } // ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields @@ -144,6 +148,10 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + // Call extension's searchTracks function script := fmt.Sprintf(` (function() { @@ -206,6 +214,10 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + script := fmt.Sprintf(` (function() { if (typeof extension !== 'undefined' && typeof extension.getTrack === 'function') { @@ -252,6 +264,10 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + script := fmt.Sprintf(` (function() { if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') { @@ -301,6 +317,10 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + script := fmt.Sprintf(` (function() { if (typeof extension !== 'undefined' && typeof extension.getArtist === 'function') { @@ -349,6 +369,10 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra return track, nil // Extension disabled, return as-is } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + // Convert track to JSON for passing to JS trackJSON, err := json.Marshal(track) if err != nil { @@ -415,6 +439,10 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + script := fmt.Sprintf(` (function() { if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') { @@ -460,6 +488,10 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + script := fmt.Sprintf(` (function() { if (typeof extension !== 'undefined' && typeof extension.getDownloadUrl === 'function') { @@ -508,6 +540,10 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + // Set up progress callback in VM p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value { if len(call.Arguments) > 0 { @@ -758,6 +794,23 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro if enrichedTrack.Artists != "" { req.ArtistName = enrichedTrack.Artists } + // Copy extended metadata from enrichment (label, copyright, genre, release_date) + if enrichedTrack.Label != "" && req.Label == "" { + GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label) + req.Label = enrichedTrack.Label + } + if enrichedTrack.Copyright != "" && req.Copyright == "" { + GoLog("[DownloadWithExtensionFallback] Copyright from enrichment: %s\n", enrichedTrack.Copyright) + req.Copyright = enrichedTrack.Copyright + } + if enrichedTrack.Genre != "" && req.Genre == "" { + GoLog("[DownloadWithExtensionFallback] Genre from enrichment: %s\n", enrichedTrack.Genre) + req.Genre = enrichedTrack.Genre + } + if enrichedTrack.ReleaseDate != "" && req.ReleaseDate == "" { + GoLog("[DownloadWithExtensionFallback] ReleaseDate from enrichment: %s\n", enrichedTrack.ReleaseDate) + req.ReleaseDate = enrichedTrack.ReleaseDate + } } } } @@ -891,6 +944,19 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro result, err := tryBuiltInProvider(providerID, req) if err == nil && result.Success { result.Service = providerID + // Copy enriched metadata to response for Flutter (needed for M4A->FLAC conversion) + if req.Label != "" { + result.Label = req.Label + } + if req.Copyright != "" { + result.Copyright = req.Copyright + } + if req.Genre != "" { + result.Genre = req.Genre + } + if req.ReleaseDate != "" && result.ReleaseDate == "" { + result.ReleaseDate = req.ReleaseDate + } return result, nil } if err != nil { @@ -1138,6 +1204,10 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + // Convert options to JSON optionsJSON, _ := json.Marshal(options) @@ -1209,6 +1279,10 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + script := fmt.Sprintf(` (function() { if (typeof extension !== 'undefined' && typeof extension.handleUrl === 'function') { @@ -1290,6 +1364,10 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{} return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + sourceJSON, _ := json.Marshal(sourceTrack) candidatesJSON, _ := json.Marshal(candidates) @@ -1353,6 +1431,10 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + // Lock VM to prevent concurrent access + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + metadataJSON, _ := json.Marshal(metadata) script := fmt.Sprintf(` diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index a41b4ef6..33de3653 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -299,6 +299,7 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) { utilsObj.Set("encrypt", r.cryptoEncrypt) utilsObj.Set("decrypt", r.cryptoDecrypt) utilsObj.Set("generateKey", r.cryptoGenerateKey) + utilsObj.Set("randomUserAgent", r.randomUserAgent) vm.Set("utils", utilsObj) // Log object (already set in extension_manager.go, but we can enhance it) diff --git a/go_backend/extension_runtime_utils.go b/go_backend/extension_runtime_utils.go index cd3819c1..abed98b4 100644 --- a/go_backend/extension_runtime_utils.go +++ b/go_backend/extension_runtime_utils.go @@ -268,6 +268,11 @@ func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value }) } +// randomUserAgent returns a random Chrome User-Agent string +func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value { + return r.vm.ToValue(getRandomUserAgent()) +} + // ==================== Logging Functions ==================== func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value { diff --git a/go_backend/httputil.go b/go_backend/httputil.go index 0700cfde..3a9e2b80 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -18,17 +18,16 @@ import ( // HTTP utility functions for consistent request handling across all downloaders // getRandomUserAgent generates a random Windows Chrome User-Agent string -// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility +// Uses modern Chrome format with build and patch numbers +// Windows 11 still reports as "Windows NT 10.0" for compatibility func getRandomUserAgent() string { - winMajor := rand.Intn(2) + 10 - - chromeVersion := rand.Intn(25) + 100 - chromeBuild := rand.Intn(1500) + 3000 - chromePatch := rand.Intn(65) + 60 + // Chrome version 120-145 (modern range) + chromeVersion := rand.Intn(26) + 120 + chromeBuild := rand.Intn(1500) + 6000 + chromePatch := rand.Intn(200) + 100 return fmt.Sprintf( - "Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36", - winMajor, + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36", chromeVersion, chromeBuild, chromePatch, diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 3b89015f..aeeef441 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1706,12 +1706,19 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } // Embed metadata using parallel-fetched cover data + // Use release date from Tidal API if not provided in request + releaseDate := req.ReleaseDate + if releaseDate == "" && track.Album.ReleaseDate != "" { + releaseDate = track.Album.ReleaseDate + GoLog("[Tidal] Using release date from Tidal API: %s\n", releaseDate) + } + metadata := Metadata{ Title: req.TrackName, Artist: req.ArtistName, Album: req.AlbumName, AlbumArtist: req.AlbumArtist, - Date: req.ReleaseDate, + Date: releaseDate, TrackNumber: track.TrackNumber, // Use actual track number from Tidal TotalTracks: req.TotalTracks, DiscNumber: track.VolumeNumber, // Use actual disc number from Tidal diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index da89b490..4cdaa83d 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1688,6 +1688,12 @@ abstract class AppLocalizations { /// **'Found {count} tracks in CSV. Add them to download queue?'** String dialogImportPlaylistMessage(int count); + /// Label shown in quality picker for CSV import + /// + /// In en, this message translates to: + /// **'{count} tracks from CSV'** + String csvImportTracks(int count); + /// Snackbar - track added to download queue /// /// In en, this message translates to: @@ -2870,6 +2876,24 @@ abstract class AppLocalizations { /// **'Release date'** String get trackReleaseDate; + /// Metadata label - music genre + /// + /// In en, this message translates to: + /// **'Genre'** + String get trackGenre; + + /// Metadata label - record label + /// + /// In en, this message translates to: + /// **'Label'** + String get trackLabel; + + /// Metadata label - copyright information + /// + /// In en, this message translates to: + /// **'Copyright'** + String get trackCopyright; + /// Metadata label - download date /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index f9c1b25b..2dd62b0d 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -910,6 +910,11 @@ class AppLocalizationsDe extends AppLocalizations { return 'Found $count tracks in CSV. Add them to download queue?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Added \"$trackName\" to queue'; @@ -1584,6 +1589,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get trackReleaseDate => 'Release date'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Downloaded'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 93573f3e..de382bda 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -897,6 +897,11 @@ class AppLocalizationsEn extends AppLocalizations { return 'Found $count tracks in CSV. Add them to download queue?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Added \"$trackName\" to queue'; @@ -1571,6 +1576,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trackReleaseDate => 'Release date'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Downloaded'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 5a44e2d9..0ff7baad 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -897,6 +897,11 @@ class AppLocalizationsEs extends AppLocalizations { return 'Found $count tracks in CSV. Add them to download queue?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Added \"$trackName\" to queue'; @@ -1571,6 +1576,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trackReleaseDate => 'Release date'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Downloaded'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index d94be538..92bf6148 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -897,6 +897,11 @@ class AppLocalizationsFr extends AppLocalizations { return 'Found $count tracks in CSV. Add them to download queue?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Added \"$trackName\" to queue'; @@ -1571,6 +1576,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trackReleaseDate => 'Release date'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Downloaded'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 83569552..f95470da 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -897,6 +897,11 @@ class AppLocalizationsHi extends AppLocalizations { return 'Found $count tracks in CSV. Add them to download queue?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Added \"$trackName\" to queue'; @@ -1571,6 +1576,15 @@ class AppLocalizationsHi extends AppLocalizations { @override String get trackReleaseDate => 'Release date'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Downloaded'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 46ecfbf9..258ebad7 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -903,6 +903,11 @@ class AppLocalizationsId extends AppLocalizations { return 'Ditemukan $count lagu di CSV. Tambahkan ke antrian unduhan?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Menambahkan \"$trackName\" ke antrian'; @@ -1581,6 +1586,15 @@ class AppLocalizationsId extends AppLocalizations { @override String get trackReleaseDate => 'Tanggal rilis'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Diunduh'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 3f301bd2..9dd91796 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -897,6 +897,11 @@ class AppLocalizationsJa extends AppLocalizations { return 'Found $count tracks in CSV. Add them to download queue?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Added \"$trackName\" to queue'; @@ -1571,6 +1576,15 @@ class AppLocalizationsJa extends AppLocalizations { @override String get trackReleaseDate => 'Release date'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Downloaded'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index aa747ec2..4b3487af 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -897,6 +897,11 @@ class AppLocalizationsKo extends AppLocalizations { return 'Found $count tracks in CSV. Add them to download queue?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Added \"$trackName\" to queue'; @@ -1571,6 +1576,15 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trackReleaseDate => 'Release date'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Downloaded'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 6d4d14da..67086594 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -897,6 +897,11 @@ class AppLocalizationsNl extends AppLocalizations { return 'Found $count tracks in CSV. Add them to download queue?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Added \"$trackName\" to queue'; @@ -1571,6 +1576,15 @@ class AppLocalizationsNl extends AppLocalizations { @override String get trackReleaseDate => 'Release date'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Downloaded'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index a700c861..42c9e1c6 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -897,6 +897,11 @@ class AppLocalizationsPt extends AppLocalizations { return 'Found $count tracks in CSV. Add them to download queue?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Added \"$trackName\" to queue'; @@ -1571,6 +1576,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trackReleaseDate => 'Release date'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Downloaded'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 1158112b..8bd4c674 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -919,6 +919,11 @@ class AppLocalizationsRu extends AppLocalizations { return 'Найдено $count треков в CSV. Добавить их в очередь загрузки?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return '\"$trackName\" добавлен в очередь'; @@ -1603,6 +1608,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get trackReleaseDate => 'Дата выхода'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Скачано'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 68f13857..30835ee2 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -897,6 +897,11 @@ class AppLocalizationsZh extends AppLocalizations { return 'Found $count tracks in CSV. Add them to download queue?'; } + @override + String csvImportTracks(int count) { + return '$count tracks from CSV'; + } + @override String snackbarAddedToQueue(String trackName) { return 'Added \"$trackName\" to queue'; @@ -1571,6 +1576,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get trackReleaseDate => 'Release date'; + @override + String get trackGenre => 'Genre'; + + @override + String get trackLabel => 'Label'; + + @override + String get trackCopyright => 'Copyright'; + @override String get trackDownloaded => 'Downloaded'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 8603a0b1..fce509d7 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -619,6 +619,13 @@ "dialogImportPlaylistTitle": "Import Playlist", "@dialogImportPlaylistTitle": {"description": "Dialog title - import CSV playlist"}, "dialogImportPlaylistMessage": "Found {count} tracks in CSV. Add them to download queue?", + "csvImportTracks": "{count} tracks from CSV", + "@csvImportTracks": { + "description": "Label shown in quality picker for CSV import", + "placeholders": { + "count": {"type": "int"} + } + }, "@dialogImportPlaylistMessage": { "description": "Dialog message - import playlist confirmation", "placeholders": { @@ -1153,6 +1160,12 @@ "@trackAudioQuality": {"description": "Metadata label - audio quality"}, "trackReleaseDate": "Release date", "@trackReleaseDate": {"description": "Metadata label - release date"}, + "trackGenre": "Genre", + "@trackGenre": {"description": "Metadata label - music genre"}, + "trackLabel": "Label", + "@trackLabel": {"description": "Metadata label - record label"}, + "trackCopyright": "Copyright", + "@trackCopyright": {"description": "Metadata label - copyright information"}, "trackDownloaded": "Downloaded", "@trackDownloaded": {"description": "Metadata label - download date"}, "trackCopyLyrics": "Copy lyrics", diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 03f35234..f3ce7c8a 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -45,6 +45,9 @@ class DownloadHistoryItem { final String? quality; final int? bitDepth; final int? sampleRate; + final String? genre; + final String? label; + final String? copyright; const DownloadHistoryItem({ required this.id, @@ -65,6 +68,9 @@ class DownloadHistoryItem { this.quality, this.bitDepth, this.sampleRate, + this.genre, + this.label, + this.copyright, }); Map toJson() => { @@ -86,6 +92,9 @@ class DownloadHistoryItem { 'quality': quality, 'bitDepth': bitDepth, 'sampleRate': sampleRate, + 'genre': genre, + 'label': label, + 'copyright': copyright, }; factory DownloadHistoryItem.fromJson(Map json) => @@ -108,6 +117,9 @@ class DownloadHistoryItem { quality: json['quality'] as String?, bitDepth: json['bitDepth'] as int?, sampleRate: json['sampleRate'] as int?, + genre: json['genre'] as String?, + label: json['label'] as String?, + copyright: json['copyright'] as String?, ); } @@ -1016,7 +1028,13 @@ class DownloadQueueNotifier extends Notifier { } /// Embed metadata and cover to a FLAC file after M4A conversion - Future _embedMetadataAndCover(String flacPath, Track track) async { + Future _embedMetadataAndCover( + String flacPath, + Track track, { + String? genre, + String? label, + String? copyright, + }) async { final settings = ref.read(settingsProvider); String? coverPath; @@ -1083,6 +1101,20 @@ class DownloadQueueNotifier extends Notifier { metadata['ISRC'] = track.isrc!; } + // Extended metadata from enrichment (genre, label, copyright) + if (genre != null && genre.isNotEmpty) { + metadata['GENRE'] = genre; + _log.d('Adding GENRE: $genre'); + } + if (label != null && label.isNotEmpty) { + metadata['ORGANIZATION'] = label; + _log.d('Adding ORGANIZATION (label): $label'); + } + if (copyright != null && copyright.isNotEmpty) { + metadata['COPYRIGHT'] = copyright; + _log.d('Adding COPYRIGHT: $copyright'); + } + _log.d('Metadata map content: $metadata'); try { @@ -1809,7 +1841,22 @@ class DownloadQueueNotifier extends Notifier { ); } - await _embedMetadataAndCover(flacPath, finalTrack); + // Get extended metadata from backend response + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; + + if (backendGenre != null || backendLabel != null || backendCopyright != null) { + _log.d('Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright'); + } + + await _embedMetadataAndCover( + flacPath, + finalTrack, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + ); _log.d('Metadata and cover embedded successfully'); } catch (e) { _log.w('Warning: Failed to embed metadata/cover: $e'); @@ -1920,6 +1967,9 @@ class DownloadQueueNotifier extends Notifier { final backendBitDepth = result['actual_bit_depth'] as int?; final backendSampleRate = result['actual_sample_rate'] as int?; final backendISRC = result['isrc'] as String?; + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; _log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}'); @@ -1970,6 +2020,9 @@ class DownloadQueueNotifier extends Notifier { quality: actualQuality, bitDepth: historyBitDepth, sampleRate: historySampleRate, + genre: backendGenre, + label: backendLabel, + copyright: backendCopyright, ), ); diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 90179966..df6926fc 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -355,37 +355,64 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient // ignore: use_build_context_synchronously final l10n = context.l10n; - final confirmed = await showDialog( - context: this.context, - builder: (dialogCtx) => AlertDialog( - title: Text(l10n.dialogImportPlaylistTitle), - content: Text(l10n.dialogImportPlaylistMessage(tracks.length)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogCtx, false), - child: Text(l10n.dialogCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(dialogCtx, true), - child: Text(l10n.dialogImport), - ), - ], - ), - ); - - if (confirmed == true) { - ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); - if (mounted) { - ScaffoldMessenger.of(this.context).showSnackBar( - SnackBar( - content: Text(l10n.snackbarAddedTracksToQueue(tracks.length)), - action: SnackBarAction( - label: l10n.snackbarViewQueue, - onPressed: () { - }, + // Show quality picker if enabled in settings + if (settings.askQualityBeforeDownload) { + DownloadServicePicker.show( + this.context, + trackName: l10n.csvImportTracks(tracks.length), + artistName: l10n.dialogImportPlaylistTitle, + onSelect: (quality, service) { + ref.read(downloadQueueProvider.notifier).addMultipleToQueue( + tracks, + service, + qualityOverride: quality, + ); + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text(l10n.snackbarAddedTracksToQueue(tracks.length)), + action: SnackBarAction( + label: l10n.snackbarViewQueue, + onPressed: () {}, + ), + ), + ); + } + }, + ); + } else { + // Use default settings without quality picker + final confirmed = await showDialog( + context: this.context, + builder: (dialogCtx) => AlertDialog( + title: Text(l10n.dialogImportPlaylistTitle), + content: Text(l10n.dialogImportPlaylistMessage(tracks.length)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogCtx, false), + child: Text(l10n.dialogCancel), ), - ), - ); + FilledButton( + onPressed: () => Navigator.pop(dialogCtx, true), + child: Text(l10n.dialogImport), + ), + ], + ), + ); + + if (confirmed == true) { + ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); + if (mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text(l10n.snackbarAddedTracksToQueue(tracks.length)), + action: SnackBarAction( + label: l10n.snackbarViewQueue, + onPressed: () {}, + ), + ), + ); + } } } } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 603c1939..d360f196 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -119,6 +119,9 @@ class _TrackMetadataScreenState extends ConsumerState { int? get discNumber => item.discNumber; String? get releaseDate => item.releaseDate; String? get isrc => item.isrc; + String? get genre => item.genre; + String? get label => item.label; + String? get copyright => item.copyright; String get cleanFilePath { final path = item.filePath; @@ -519,6 +522,12 @@ class _TrackMetadataScreenState extends ConsumerState { _MetadataItem(context.l10n.trackAudioQuality, audioQualityStr), if (releaseDate != null && releaseDate!.isNotEmpty) _MetadataItem(context.l10n.trackReleaseDate, releaseDate!), + if (genre != null && genre!.isNotEmpty) + _MetadataItem(context.l10n.trackGenre, genre!), + if (label != null && label!.isNotEmpty) + _MetadataItem(context.l10n.trackLabel, label!), + if (copyright != null && copyright!.isNotEmpty) + _MetadataItem(context.l10n.trackCopyright, copyright!), if (isrc != null && isrc!.isNotEmpty) _MetadataItem('ISRC', isrc!), ];