From df39d61ed432e069d8baf007253ec257afc68087 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 9 Feb 2026 23:07:18 +0700 Subject: [PATCH] feat: save cover art, save lyrics, re-enrich metadata with full SAF support + YouTube Cobalt provider with SpotubeDL fallback + metadata summary logging --- CHANGELOG.md | 28 + README.md | 1 + .../kotlin/com/zarz/spotiflac/MainActivity.kt | 194 +++- go_backend/audio_metadata.go | 68 ++ go_backend/exports.go | 561 ++++++++++- go_backend/metadata.go | 24 + go_backend/songlink.go | 15 +- go_backend/youtube.go | 157 ++- ios/Runner/AppDelegate.swift | 8 + lib/l10n/app_localizations.dart | 96 ++ lib/l10n/app_localizations_de.dart | 56 ++ lib/l10n/app_localizations_en.dart | 56 ++ lib/l10n/app_localizations_es.dart | 56 ++ lib/l10n/app_localizations_fr.dart | 56 ++ lib/l10n/app_localizations_hi.dart | 56 ++ lib/l10n/app_localizations_id.dart | 59 ++ lib/l10n/app_localizations_ja.dart | 56 ++ lib/l10n/app_localizations_ko.dart | 56 ++ lib/l10n/app_localizations_nl.dart | 56 ++ lib/l10n/app_localizations_pt.dart | 56 ++ lib/l10n/app_localizations_ru.dart | 56 ++ lib/l10n/app_localizations_tr.dart | 56 ++ lib/l10n/app_localizations_zh.dart | 56 ++ lib/l10n/arb/app_en.arb | 50 +- lib/l10n/arb/app_id.arb | 50 +- lib/providers/download_queue_provider.dart | 115 ++- lib/screens/home_tab.dart | 2 +- .../settings/download_settings_page.dart | 101 +- lib/screens/track_metadata_screen.dart | 897 +++++++++++++++++- lib/services/ffmpeg_service.dart | 9 + lib/services/platform_bridge.dart | 328 ++++--- 31 files changed, 3119 insertions(+), 316 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b1d8e84..cdb01dd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,21 @@ - Full metadata embedding: cover art, title, artist, album, track/disc number, year, ISRC - Lyrics fetching from lrclib.net with embed and external .lrc support - Works as fallback when Tidal/Qobuz/Amazon downloads fail +- **Edit Metadata**: Edit embedded metadata directly from the Track Metadata screen (FLAC, MP3, Opus) + - Editable fields: Title, Artist, Album, Album Artist, Date, Track#, Disc#, Genre, ISRC + - Advanced fields: Label, Copyright, Composer, Comment + - FLAC: native Go writer, MP3/Opus: FFmpeg-based writer + - UI refreshes in-place after save without needing to re-open the screen + - iOS and Android support ### Added +- Save Cover Art: download high-quality album art as standalone .jpg from track metadata screen +- Save Lyrics (.lrc): fetch and save lyrics as standalone .lrc file without downloading the song +- Re-enrich Metadata: re-embed metadata, cover art, and lyrics into existing audio files without re-downloading (FLAC native, MP3/Opus via FFmpeg) +- Re-enrich now supports local library items: searches Spotify/Deezer by track name + artist to fetch complete metadata from the internet, then embeds cover art, lyrics, genre, label, and all tags into the file - YouTube download provider using Cobalt API with SongLink/Odesli integration for Spotify/Deezer ID → YouTube URL conversion +- SpotubeDL as fallback Cobalt proxy when primary API fails - YouTube video ID detection for YT Music extension compatibility - Parallel cover art and lyrics fetching during YouTube download - Queue progress now shows "X.X MB" instead of "0%" for streaming downloads where total size is unknown (Cobalt tunnel mode) @@ -22,6 +33,23 @@ - Removed Tidal HIGH (lossy AAC) quality option - use YouTube provider for lossy downloads instead - Simplified download service picker by removing dead lossy format code +- Removed Amazon from download settings UI (now only used as automatic fallback) +- Cleaned up dead disabled-chip code in download service selector + +### Fixed + +- Fixed `error.api.youtube.login` by using YouTube Music URLs instead of regular YouTube URLs for Cobalt requests +- Fixed SongLink to prioritize `youtubeMusic` platform URL over `youtube` for Cobalt compatibility +- Fixed YouTube metadata not being overwritten by setting `DisableMetadata: true` in Cobalt requests +- Fixed ISRC validation in metadata enrichment flow - invalid ISRCs no longer trigger failed Deezer lookups +- Fixed YouTube metadata enrichment to work like other providers (SongLink Deezer ID extraction, proper metadata embedding) +- Go metadata parsers now read Composer, Comment, Label, Copyright from FLAC, MP3 (ID3v2.2/v2.3/v2.4), and Opus/OGG files +- Added proper COMM frame parser for ID3v2 (handles language code + description prefix correctly) +- Fixed Re-enrich Metadata failing on SAF storage files (`content://` URIs) - Kotlin now copies SAF file to temp, Go processes temp file, then writes back for FLAC or returns temp path for FFmpeg (MP3/Opus) +- Fixed Save Cover Art and Save Lyrics crashing on SAF-stored download history items - now saves to temp then writes to SAF tree via `createSafFileFromPath` +- Fixed `_getFileDirectory()` crash when called with `content://` URI by adding SAF guard +- Fixed `readAudioMetadata` Kotlin handler not handling SAF URIs - now copies to temp for reading +- Added metadata summary log in Re-enrich flow showing all fields before embedding (title, artist, album, track#, disc#, date, ISRC, genre, label) --- diff --git a/README.md b/README.md index 54b9381b..9aeb2131 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ The software is provided "as is", without warranty of any kind. The author assum - **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev) - **Amazon**: [AfkarXYZ](https://github.com/afkarxyz) - **Lyrics**: [LRCLib](https://lrclib.net) +- **YouTube Audio**: [Cobalt](https://cobalt.tools) via [qwkuns.me](https://qwkuns.me), [SpotubeDL](https://spotubedl.com) - **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 88f54edf..30bfdd77 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1597,7 +1597,182 @@ class MainActivity: FlutterFragmentActivity() { "readFileMetadata" -> { val filePath = call.argument("file_path") ?: "" val response = withContext(Dispatchers.IO) { - Gobackend.readFileMetadata(filePath) + try { + if (filePath.startsWith("content://")) { + val uri = Uri.parse(filePath) + val tempPath = copyUriToTemp(uri) + ?: return@withContext """{"error":"Failed to copy SAF file to temp"}""" + try { + Gobackend.readFileMetadata(tempPath) + } finally { + try { File(tempPath).delete() } catch (_: Exception) {} + } + } else { + Gobackend.readFileMetadata(filePath) + } + } catch (e: Exception) { + android.util.Log.e("SpotiFLAC", "readFileMetadata failed: ${e.message}", e) + """{"error":"${e.message?.replace("\"", "'")}"}""" + } + } + result.success(response) + } + "editFileMetadata" -> { + val filePath = call.argument("file_path") ?: "" + val metadataJson = call.argument("metadata_json") ?: "{}" + val response = withContext(Dispatchers.IO) { + try { + if (filePath.startsWith("content://")) { + val uri = Uri.parse(filePath) + val tempPath = copyUriToTemp(uri) + ?: return@withContext """{"error":"Failed to copy SAF file to temp"}""" + try { + val raw = Gobackend.editFileMetadata(tempPath, metadataJson) + val obj = JSONObject(raw) + val method = obj.optString("method", "") + if (method == "ffmpeg") { + // MP3/Opus: Dart needs to FFmpeg the temp file, then call writeTempToSaf + obj.put("temp_path", tempPath) + obj.put("saf_uri", filePath) + return@withContext obj.toString() + // Note: temp file NOT deleted here - Dart will clean up after FFmpeg + writeTempToSaf + } + // FLAC: Go wrote directly to temp, copy back now + if (!writeUriFromPath(uri, tempPath)) { + return@withContext """{"error":"Failed to write metadata back to SAF file"}""" + } + raw + } catch (e: Exception) { + try { File(tempPath).delete() } catch (_: Exception) {} + throw e + } + } else { + Gobackend.editFileMetadata(filePath, metadataJson) + } + } catch (e: Exception) { + android.util.Log.e("SpotiFLAC", "editFileMetadata failed: ${e.message}", e) + """{"error":"${e.message?.replace("\"", "'")}"}""" + } + } + result.success(response) + } + "writeTempToSaf" -> { + val tempPath = call.argument("temp_path") ?: "" + val safUri = call.argument("saf_uri") ?: "" + val response = withContext(Dispatchers.IO) { + try { + val uri = Uri.parse(safUri) + if (writeUriFromPath(uri, tempPath)) { + """{"success":true}""" + } else { + """{"success":false,"error":"Failed to write back to SAF"}""" + } + } finally { + try { File(tempPath).delete() } catch (_: Exception) {} + } + } + result.success(response) + } + "downloadCoverToFile" -> { + val coverUrl = call.argument("cover_url") ?: "" + val outputPath = call.argument("output_path") ?: "" + val maxQuality = call.argument("max_quality") ?: true + val response = withContext(Dispatchers.IO) { + try { + Gobackend.downloadCoverToFile(coverUrl, outputPath, maxQuality) + """{"success":true}""" + } catch (e: Exception) { + """{"success":false,"error":"${e.message?.replace("\"", "'")}"}""" + } + } + result.success(response) + } + "extractCoverToFile" -> { + val audioPath = call.argument("audio_path") ?: "" + val outputPath = call.argument("output_path") ?: "" + val response = withContext(Dispatchers.IO) { + try { + if (audioPath.startsWith("content://")) { + val uri = Uri.parse(audioPath) + val tempPath = copyUriToTemp(uri) + ?: return@withContext """{"success":false,"error":"Failed to copy SAF file to temp"}""" + try { + Gobackend.extractCoverToFile(tempPath, outputPath) + """{"success":true}""" + } finally { + try { File(tempPath).delete() } catch (_: Exception) {} + } + } else { + Gobackend.extractCoverToFile(audioPath, outputPath) + """{"success":true}""" + } + } catch (e: Exception) { + """{"success":false,"error":"${e.message?.replace("\"", "'")}"}""" + } + } + result.success(response) + } + "fetchAndSaveLyrics" -> { + val trackName = call.argument("track_name") ?: "" + val artistName = call.argument("artist_name") ?: "" + val spotifyId = call.argument("spotify_id") ?: "" + val durationMs = call.argument("duration_ms") ?: 0L + val outputPath = call.argument("output_path") ?: "" + val response = withContext(Dispatchers.IO) { + try { + Gobackend.fetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath) + """{"success":true}""" + } catch (e: Exception) { + """{"success":false,"error":"${e.message?.replace("\"", "'")}"}""" + } + } + result.success(response) + } + "reEnrichFile" -> { + val requestJson = call.argument("request_json") ?: "{}" + val response = withContext(Dispatchers.IO) { + try { + val reqObj = JSONObject(requestJson) + val filePath = reqObj.optString("file_path", "") + + if (filePath.startsWith("content://")) { + val uri = Uri.parse(filePath) + val tempPath = copyUriToTemp(uri) + ?: return@withContext """{"error":"Failed to copy SAF file to temp"}""" + try { + // Replace file_path with temp path for Go + reqObj.put("file_path", tempPath) + val raw = Gobackend.reEnrichFile(reqObj.toString()) + val obj = JSONObject(raw) + + if (obj.has("error")) { + return@withContext raw + } + + val method = obj.optString("method", "") + if (method == "ffmpeg") { + // MP3/Opus: Dart handles FFmpeg on temp file, then writes back + obj.put("temp_path", tempPath) + obj.put("saf_uri", filePath) + return@withContext obj.toString() + // temp file NOT deleted - Dart cleans up after FFmpeg + writeTempToSaf + } + + // FLAC: Go wrote directly to temp, copy back now + if (!writeUriFromPath(uri, tempPath)) { + return@withContext """{"error":"Failed to write enriched metadata back to SAF file"}""" + } + raw + } catch (e: Exception) { + try { File(tempPath).delete() } catch (_: Exception) {} + throw e + } + } else { + Gobackend.reEnrichFile(requestJson) + } + } catch (e: Exception) { + """{"error":"${e.message?.replace("\"", "'")}"}""" + } } result.success(response) } @@ -2258,7 +2433,22 @@ class MainActivity: FlutterFragmentActivity() { "readAudioMetadata" -> { val filePath = call.argument("file_path") ?: "" val response = withContext(Dispatchers.IO) { - Gobackend.readAudioMetadataJSON(filePath) + try { + if (filePath.startsWith("content://")) { + val uri = Uri.parse(filePath) + val tempPath = copyUriToTemp(uri) + ?: return@withContext """{"error":"Failed to copy SAF file to temp"}""" + try { + Gobackend.readAudioMetadataJSON(tempPath) + } finally { + try { File(tempPath).delete() } catch (_: Exception) {} + } + } else { + Gobackend.readAudioMetadataJSON(filePath) + } + } catch (e: Exception) { + """{"error":"${e.message?.replace("\"", "'")}"}""" + } } result.success(response) } diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index c84efbef..73279971 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -23,6 +23,10 @@ type AudioMetadata struct { TrackNumber int DiscNumber int ISRC string + Label string + Copyright string + Composer string + Comment string } // MP3Quality represents MP3 specific quality info @@ -171,6 +175,12 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) { metadata.TrackNumber = parseTrackNumber(value) case "TPA": metadata.DiscNumber = parseTrackNumber(value) + case "TCM": + metadata.Composer = value + case "TPB": + metadata.Label = value + case "TCR": + metadata.Copyright = value } pos += 6 + frameSize @@ -277,6 +287,16 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn metadata.DiscNumber = parseTrackNumber(value) case "TSRC": metadata.ISRC = value + case "TCOM": + metadata.Composer = value + case "TPUB": + metadata.Label = value + case "TCOP": + metadata.Copyright = value + case "COMM": + if v := extractCommentFrame(frameData); v != "" { + metadata.Comment = v + } } pos += 10 + frameSize @@ -339,6 +359,46 @@ func extractTextFrame(data []byte) string { } } +// extractCommentFrame parses an ID3v2 COMM frame. +// Format: encoding(1) + language(3) + description(null-terminated) + text +func extractCommentFrame(data []byte) string { + if len(data) < 5 { + return "" + } + encoding := data[0] + // skip 3-byte language code + rest := data[4:] + + // find null terminator separating description from text + var text []byte + switch encoding { + case 1, 2: // UTF-16 variants use double-null terminator + for i := 0; i+1 < len(rest); i += 2 { + if rest[i] == 0 && rest[i+1] == 0 { + text = rest[i+2:] + break + } + } + default: // ISO-8859-1 or UTF-8 + idx := bytes.IndexByte(rest, 0) + if idx >= 0 && idx+1 < len(rest) { + text = rest[idx+1:] + } else { + text = rest + } + } + + if len(text) == 0 { + return "" + } + + // re-prepend encoding byte so extractTextFrame can decode properly + framed := make([]byte, 1+len(text)) + framed[0] = encoding + copy(framed[1:], text) + return extractTextFrame(framed) +} + func decodeUTF16(data []byte) string { if len(data) < 2 { return "" @@ -779,6 +839,14 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) { metadata.DiscNumber = parseTrackNumber(value) case "ISRC": metadata.ISRC = value + case "COMPOSER": + metadata.Composer = value + case "COMMENT", "DESCRIPTION": + metadata.Comment = value + case "ORGANIZATION", "LABEL", "PUBLISHER": + metadata.Label = value + case "COPYRIGHT": + metadata.Copyright = value } } } diff --git a/go_backend/exports.go b/go_backend/exports.go index 9c9102e6..a30e1628 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "os" "strings" "time" @@ -556,34 +557,106 @@ func CleanupConnections() { } func ReadFileMetadata(filePath string) (string, error) { - metadata, err := ReadMetadata(filePath) - if err != nil { - return "", fmt.Errorf("failed to read metadata: %w", err) - } - - quality, qualityErr := GetAudioQuality(filePath) - - duration := 0 - if qualityErr == nil && quality.SampleRate > 0 && quality.TotalSamples > 0 { - duration = int(quality.TotalSamples / int64(quality.SampleRate)) - } + lower := strings.ToLower(filePath) + isFlac := strings.HasSuffix(lower, ".flac") + isMp3 := strings.HasSuffix(lower, ".mp3") + isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") result := map[string]interface{}{ - "title": metadata.Title, - "artist": metadata.Artist, - "album": metadata.Album, - "album_artist": metadata.AlbumArtist, - "date": metadata.Date, - "track_number": metadata.TrackNumber, - "disc_number": metadata.DiscNumber, - "isrc": metadata.ISRC, - "lyrics": metadata.Lyrics, - "duration": duration, + "title": "", + "artist": "", + "album": "", + "album_artist": "", + "date": "", + "track_number": 0, + "disc_number": 0, + "isrc": "", + "lyrics": "", + "genre": "", + "label": "", + "copyright": "", + "composer": "", + "comment": "", + "duration": 0, } - if qualityErr == nil { - result["bit_depth"] = quality.BitDepth - result["sample_rate"] = quality.SampleRate + if isFlac { + metadata, err := ReadMetadata(filePath) + if err != nil { + return "", fmt.Errorf("failed to read metadata: %w", err) + } + result["title"] = metadata.Title + result["artist"] = metadata.Artist + result["album"] = metadata.Album + result["album_artist"] = metadata.AlbumArtist + result["date"] = metadata.Date + result["track_number"] = metadata.TrackNumber + result["disc_number"] = metadata.DiscNumber + result["isrc"] = metadata.ISRC + result["lyrics"] = metadata.Lyrics + result["genre"] = metadata.Genre + result["label"] = metadata.Label + result["copyright"] = metadata.Copyright + result["composer"] = metadata.Composer + result["comment"] = metadata.Comment + + quality, qualityErr := GetAudioQuality(filePath) + if qualityErr == nil { + result["bit_depth"] = quality.BitDepth + result["sample_rate"] = quality.SampleRate + if quality.SampleRate > 0 && quality.TotalSamples > 0 { + result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate)) + } + } + } else if isMp3 { + meta, err := ReadID3Tags(filePath) + if err == nil && meta != nil { + result["title"] = meta.Title + result["artist"] = meta.Artist + result["album"] = meta.Album + result["album_artist"] = meta.AlbumArtist + result["date"] = meta.Date + if meta.Date == "" { + result["date"] = meta.Year + } + result["track_number"] = meta.TrackNumber + result["disc_number"] = meta.DiscNumber + result["isrc"] = meta.ISRC + result["genre"] = meta.Genre + result["composer"] = meta.Composer + result["comment"] = meta.Comment + } + quality, qualityErr := GetMP3Quality(filePath) + if qualityErr == nil { + result["bit_depth"] = quality.BitDepth + result["sample_rate"] = quality.SampleRate + result["duration"] = quality.Duration + } + } else if isOgg { + meta, err := ReadOggVorbisComments(filePath) + if err == nil && meta != nil { + result["title"] = meta.Title + result["artist"] = meta.Artist + result["album"] = meta.Album + result["album_artist"] = meta.AlbumArtist + result["date"] = meta.Date + if meta.Date == "" { + result["date"] = meta.Year + } + result["track_number"] = meta.TrackNumber + result["disc_number"] = meta.DiscNumber + result["isrc"] = meta.ISRC + result["genre"] = meta.Genre + result["composer"] = meta.Composer + result["comment"] = meta.Comment + } + quality, qualityErr := GetOggQuality(filePath) + if qualityErr == nil { + result["sample_rate"] = quality.SampleRate + result["duration"] = quality.Duration + } + } else { + return "", fmt.Errorf("unsupported file format: %s", filePath) } jsonBytes, err := json.Marshal(result) @@ -594,6 +667,66 @@ func ReadFileMetadata(filePath string) (string, error) { return string(jsonBytes), nil } +// EditFileMetadata writes metadata to an audio file. +// For FLAC files, uses native Go FLAC library. +// For MP3/Opus, returns the metadata map so Dart can use FFmpeg. +func EditFileMetadata(filePath, metadataJSON string) (string, error) { + var fields map[string]string + if err := json.Unmarshal([]byte(metadataJSON), &fields); err != nil { + return "", fmt.Errorf("invalid metadata JSON: %w", err) + } + + lower := strings.ToLower(filePath) + isFlac := strings.HasSuffix(lower, ".flac") + + if isFlac { + trackNum := 0 + discNum := 0 + if v, ok := fields["track_number"]; ok && v != "" { + fmt.Sscanf(v, "%d", &trackNum) + } + if v, ok := fields["disc_number"]; ok && v != "" { + fmt.Sscanf(v, "%d", &discNum) + } + + meta := Metadata{ + Title: fields["title"], + Artist: fields["artist"], + Album: fields["album"], + AlbumArtist: fields["album_artist"], + Date: fields["date"], + TrackNumber: trackNum, + DiscNumber: discNum, + ISRC: fields["isrc"], + Genre: fields["genre"], + Label: fields["label"], + Copyright: fields["copyright"], + Composer: fields["composer"], + Comment: fields["comment"], + } + + if err := EmbedMetadata(filePath, meta, ""); err != nil { + return "", fmt.Errorf("failed to write FLAC metadata: %w", err) + } + + resp := map[string]any{ + "success": true, + "method": "native", + } + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil + } + + // MP3/Opus: return metadata for Dart-side FFmpeg embedding + resp := map[string]any{ + "success": true, + "method": "ffmpeg", + "fields": fields, + } + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil +} + func SetDownloadDirectory(path string) error { return setDownloadDir(path) } @@ -1149,6 +1282,386 @@ func ExtractYouTubeVideoIDExport(urlStr string) (string, error) { return ExtractYouTubeVideoID(urlStr) } +// ==================== COVER & LYRICS SAVE ==================== + +// DownloadCoverToFile downloads cover art from URL and saves to outputPath. +// If maxQuality is true, upgrades to highest available resolution. +func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error { + if coverURL == "" { + return fmt.Errorf("no cover URL provided") + } + + data, err := downloadCoverToMemory(coverURL, maxQuality) + if err != nil { + return fmt.Errorf("failed to download cover: %w", err) + } + + if err := os.WriteFile(outputPath, data, 0644); err != nil { + return fmt.Errorf("failed to write cover file: %w", err) + } + + GoLog("[Cover] Saved cover art to: %s (%d KB)\n", outputPath, len(data)/1024) + return nil +} + +// ExtractCoverToFile extracts embedded cover art from audio file and saves to outputPath. +func ExtractCoverToFile(audioPath string, outputPath string) error { + lower := strings.ToLower(audioPath) + + var coverData []byte + var err error + + if strings.HasSuffix(lower, ".flac") { + coverData, err = ExtractCoverArt(audioPath) + } else if strings.HasSuffix(lower, ".mp3") { + coverData, _, err = extractMP3CoverArt(audioPath) + } else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") { + coverData, _, err = extractOggCoverArt(audioPath) + } else { + return fmt.Errorf("unsupported audio format for cover extraction") + } + + if err != nil { + return fmt.Errorf("failed to extract cover: %w", err) + } + + if err := os.WriteFile(outputPath, coverData, 0644); err != nil { + return fmt.Errorf("failed to write cover file: %w", err) + } + + GoLog("[Cover] Extracted cover art to: %s (%d KB)\n", outputPath, len(coverData)/1024) + return nil +} + +// FetchAndSaveLyrics fetches lyrics from lrclib and saves as .lrc file. +func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int64, outputPath string) error { + client := NewLyricsClient() + durationSec := float64(durationMs) / 1000.0 + + lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec) + if err != nil { + return fmt.Errorf("lyrics not found: %w", err) + } + + if lyrics.Instrumental { + return fmt.Errorf("track is instrumental, no lyrics available") + } + + lrcContent := convertToLRCWithMetadata(lyrics, trackName, artistName) + if lrcContent == "" { + return fmt.Errorf("failed to generate LRC content") + } + + if err := os.WriteFile(outputPath, []byte(lrcContent), 0644); err != nil { + return fmt.Errorf("failed to write LRC file: %w", err) + } + + GoLog("[Lyrics] Saved LRC to: %s (%d lines)\n", outputPath, len(lyrics.Lines)) + return nil +} + +// ReEnrichFile re-embeds metadata, cover art, and lyrics into an existing audio file. +// When search_online is true, searches Spotify/Deezer by track name + artist to fetch +// complete metadata from the internet before embedding. +func ReEnrichFile(requestJSON string) (string, error) { + var req struct { + FilePath string `json:"file_path"` + CoverURL string `json:"cover_url"` + MaxQuality bool `json:"max_quality"` + EmbedLyrics bool `json:"embed_lyrics"` + SpotifyID string `json:"spotify_id"` + TrackName string `json:"track_name"` + ArtistName string `json:"artist_name"` + AlbumName string `json:"album_name"` + AlbumArtist string `json:"album_artist"` + TrackNumber int `json:"track_number"` + DiscNumber int `json:"disc_number"` + ReleaseDate string `json:"release_date"` + ISRC string `json:"isrc"` + Genre string `json:"genre"` + Label string `json:"label"` + Copyright string `json:"copyright"` + DurationMs int64 `json:"duration_ms"` + SearchOnline bool `json:"search_online"` + } + + if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { + return "", fmt.Errorf("failed to parse request: %w", err) + } + + if req.FilePath == "" { + return "", fmt.Errorf("file_path is required") + } + + GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath) + + // When search_online is true, search for metadata from internet + // Priority: 1) Deezer (reliable, no credentials) 2) Extension providers (spotify-web etc) 3) Spotify built-in API (last resort, deprecated) + if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" { + GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName) + searchQuery := req.TrackName + " " + req.ArtistName + found := false + + // 1) Try Deezer first (reliable, no credentials needed) + GoLog("[ReEnrich] Trying Deezer search...\n") + deezerClient := GetDeezerClient() + { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + deezerResults, err := deezerClient.SearchAll(ctx, searchQuery, 5, 0, "track") + cancel() + if err == nil && len(deezerResults.Tracks) > 0 { + track := deezerResults.Tracks[0] + GoLog("[ReEnrich] Deezer match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName) + req.SpotifyID = "deezer:" + track.SpotifyID + req.AlbumName = track.AlbumName + req.AlbumArtist = track.AlbumArtist + req.TrackNumber = track.TrackNumber + req.DiscNumber = track.DiscNumber + req.ReleaseDate = track.ReleaseDate + req.ISRC = track.ISRC + if track.Images != "" { + req.CoverURL = track.Images + } + req.DurationMs = int64(track.DurationMS) + found = true + } else if err != nil { + GoLog("[ReEnrich] Deezer search failed: %v\n", err) + } + } + + // 2) Try extension metadata providers (spotify-web etc) if Deezer failed + if !found { + GoLog("[ReEnrich] Trying extension metadata providers...\n") + manager := GetExtensionManager() + extTracks, extErr := manager.SearchTracksWithExtensions(searchQuery, 5) + if extErr == nil && len(extTracks) > 0 { + track := extTracks[0] + GoLog("[ReEnrich] Extension match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName) + if track.SpotifyID != "" { + req.SpotifyID = track.SpotifyID + } else if track.DeezerID != "" { + req.SpotifyID = "deezer:" + track.DeezerID + } else { + req.SpotifyID = track.ID + } + req.AlbumName = track.AlbumName + req.AlbumArtist = track.AlbumArtist + req.TrackNumber = track.TrackNumber + req.DiscNumber = track.DiscNumber + req.ReleaseDate = track.ReleaseDate + req.ISRC = track.ISRC + coverURL := track.ResolvedCoverURL() + if coverURL != "" { + req.CoverURL = coverURL + } + req.DurationMs = int64(track.DurationMS) + if track.Genre != "" { + req.Genre = track.Genre + } + if track.Label != "" { + req.Label = track.Label + } + if track.Copyright != "" { + req.Copyright = track.Copyright + } + found = true + } else if extErr != nil { + GoLog("[ReEnrich] Extension search failed: %v\n", extErr) + } + } + + // 3) Try Spotify built-in API as last resort (will be deprecated) + if !found { + GoLog("[ReEnrich] Trying Spotify API (fallback)...\n") + spotifyClient, spotifyErr := NewSpotifyMetadataClient() + if spotifyErr == nil { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + results, err := spotifyClient.SearchTracks(ctx, searchQuery, 5) + cancel() + if err == nil && len(results.Tracks) > 0 { + track := results.Tracks[0] + GoLog("[ReEnrich] Spotify match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName) + req.SpotifyID = track.SpotifyID + req.AlbumName = track.AlbumName + req.AlbumArtist = track.AlbumArtist + req.TrackNumber = track.TrackNumber + req.DiscNumber = track.DiscNumber + req.ReleaseDate = track.ReleaseDate + req.ISRC = track.ISRC + if track.Images != "" { + req.CoverURL = track.Images + } + req.DurationMs = int64(track.DurationMS) + found = true + } else if err != nil { + GoLog("[ReEnrich] Spotify search failed: %v\n", err) + } + } else { + GoLog("[ReEnrich] Spotify client unavailable: %v\n", spotifyErr) + } + } + + // Try to get extended metadata (genre, label) from Deezer if not already set + if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC) + cancel() + if err == nil && extMeta != nil { + if req.Genre == "" && extMeta.Genre != "" { + req.Genre = extMeta.Genre + } + if req.Label == "" && extMeta.Label != "" { + req.Label = extMeta.Label + } + GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s\n", req.Genre, req.Label) + } + } + + if !found { + GoLog("[ReEnrich] No online match found, using existing metadata\n") + } + } + + // Log metadata summary before embedding + GoLog("[ReEnrich] Metadata to embed: title=%s, artist=%s, album=%s, albumArtist=%s\n", + req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist) + 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) + + // Download cover art to temp file + var coverTempPath string + if req.CoverURL != "" { + coverData, err := downloadCoverToMemory(req.CoverURL, req.MaxQuality) + if err != nil { + GoLog("[ReEnrich] Failed to download cover: %v\n", err) + } else { + tmpFile, err := os.CreateTemp("", "reenrich_cover_*.jpg") + if err == nil { + coverTempPath = tmpFile.Name() + tmpFile.Write(coverData) + tmpFile.Close() + GoLog("[ReEnrich] Cover downloaded: %d KB\n", len(coverData)/1024) + } + } + } + // Only cleanup cover temp for FLAC (native embed). + // For MP3/Opus, Dart needs the file for FFmpeg — Dart handles cleanup. + cleanupCover := true + + defer func() { + if cleanupCover && coverTempPath != "" { + os.Remove(coverTempPath) + } + }() + + // Fetch lyrics + var lyricsLRC string + if req.EmbedLyrics { + client := NewLyricsClient() + durationSec := float64(req.DurationMs) / 1000.0 + lyrics, err := client.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, durationSec) + if err != nil { + GoLog("[ReEnrich] Lyrics not found: %v\n", err) + } else if !lyrics.Instrumental { + lyricsLRC = convertToLRCWithMetadata(lyrics, req.TrackName, req.ArtistName) + GoLog("[ReEnrich] Lyrics fetched: %d lines\n", len(lyrics.Lines)) + } else { + GoLog("[ReEnrich] Track is instrumental\n") + } + } + + lower := strings.ToLower(req.FilePath) + isFlac := strings.HasSuffix(lower, ".flac") + + // Build enriched metadata response for Dart (includes online search results) + enrichedMeta := map[string]interface{}{ + "track_name": req.TrackName, + "artist_name": req.ArtistName, + "album_name": req.AlbumName, + "album_artist": req.AlbumArtist, + "release_date": req.ReleaseDate, + "track_number": req.TrackNumber, + "disc_number": req.DiscNumber, + "isrc": req.ISRC, + "genre": req.Genre, + "label": req.Label, + "copyright": req.Copyright, + "cover_url": req.CoverURL, + "spotify_id": req.SpotifyID, + "duration_ms": req.DurationMs, + } + + if isFlac { + // Native Go FLAC metadata embedding + metadata := Metadata{ + Title: req.TrackName, + Artist: req.ArtistName, + Album: req.AlbumName, + AlbumArtist: req.AlbumArtist, + Date: req.ReleaseDate, + TrackNumber: req.TrackNumber, + DiscNumber: req.DiscNumber, + ISRC: req.ISRC, + Genre: req.Genre, + Label: req.Label, + Copyright: req.Copyright, + Lyrics: lyricsLRC, + } + + if err := EmbedMetadata(req.FilePath, metadata, coverTempPath); err != nil { + return "", fmt.Errorf("failed to embed metadata: %w", err) + } + + GoLog("[ReEnrich] FLAC metadata embedded successfully\n") + + result := map[string]interface{}{ + "method": "native", + "success": true, + "enriched_metadata": enrichedMeta, + } + jsonBytes, _ := json.Marshal(result) + return string(jsonBytes), nil + } + + // MP3/Opus: return metadata map for Dart to use FFmpeg + // Don't cleanup cover temp — Dart needs it for FFmpeg embed + cleanupCover = false + result := map[string]interface{}{ + "method": "ffmpeg", + "cover_path": coverTempPath, + "lyrics": lyricsLRC, + "enriched_metadata": enrichedMeta, + "metadata": map[string]string{ + "TITLE": req.TrackName, + "ARTIST": req.ArtistName, + "ALBUM": req.AlbumName, + "ALBUMARTIST": req.AlbumArtist, + "DATE": req.ReleaseDate, + "ISRC": req.ISRC, + "GENRE": req.Genre, + }, + } + if req.TrackNumber > 0 { + result["metadata"].(map[string]string)["TRACKNUMBER"] = fmt.Sprintf("%d", req.TrackNumber) + } + if req.DiscNumber > 0 { + result["metadata"].(map[string]string)["DISCNUMBER"] = fmt.Sprintf("%d", req.DiscNumber) + } + if req.Label != "" { + result["metadata"].(map[string]string)["ORGANIZATION"] = req.Label + } + if req.Copyright != "" { + result["metadata"].(map[string]string)["COPYRIGHT"] = req.Copyright + } + if lyricsLRC != "" { + result["metadata"].(map[string]string)["LYRICS"] = lyricsLRC + result["metadata"].(map[string]string)["UNSYNCEDLYRICS"] = lyricsLRC + } + + jsonBytes, _ := json.Marshal(result) + return string(jsonBytes), nil +} + // ==================== EXTENSION SYSTEM ==================== func InitExtensionSystem(extensionsDir, dataDir string) error { diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 6db9d7ab..7f8c9723 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -29,6 +29,8 @@ type Metadata struct { Genre string Label string Copyright string + Composer string + Comment string } func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { @@ -98,6 +100,14 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { setComment(cmt, "COPYRIGHT", metadata.Copyright) } + if metadata.Composer != "" { + setComment(cmt, "COMPOSER", metadata.Composer) + } + + if metadata.Comment != "" { + setComment(cmt, "COMMENT", metadata.Comment) + } + cmtBlock := cmt.Marshal() if cmtIdx >= 0 { f.Meta[cmtIdx] = &cmtBlock @@ -206,6 +216,14 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData [] setComment(cmt, "COPYRIGHT", metadata.Copyright) } + if metadata.Composer != "" { + setComment(cmt, "COMPOSER", metadata.Composer) + } + + if metadata.Comment != "" { + setComment(cmt, "COMMENT", metadata.Comment) + } + cmtBlock := cmt.Marshal() if cmtIdx >= 0 { f.Meta[cmtIdx] = &cmtBlock @@ -292,6 +310,12 @@ func ReadMetadata(filePath string) (*Metadata, error) { metadata.Date = getComment(cmt, "YEAR") } + metadata.Genre = getComment(cmt, "GENRE") + metadata.Label = getComment(cmt, "ORGANIZATION") + metadata.Copyright = getComment(cmt, "COPYRIGHT") + metadata.Composer = getComment(cmt, "COMPOSER") + metadata.Comment = getComment(cmt, "COMMENT") + break } } diff --git a/go_backend/songlink.go b/go_backend/songlink.go index 1af317b2..ed9346bd 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -122,18 +122,19 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL) } - if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" { + // Prefer youtubeMusic URLs — they bypass Cobalt login requirements + if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" { availability.YouTube = true - availability.YouTubeURL = youtubeLink.URL - availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL) + availability.YouTubeURL = ytMusicLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL) } - // Also check youtubeMusic as fallback + // Fallback to regular youtube if youtubeMusic not available if !availability.YouTube { - if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" { + if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" { availability.YouTube = true - availability.YouTubeURL = ytMusicLink.URL - availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL) + availability.YouTubeURL = youtubeLink.URL + availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL) } } diff --git a/go_backend/youtube.go b/go_backend/youtube.go index 46b50934..a71f79af 100644 --- a/go_backend/youtube.go +++ b/go_backend/youtube.go @@ -1,5 +1,4 @@ -// Package gobackend provides YouTube download functionality via Cobalt API -// YouTube is a lossy-only provider (not part of lossless fallback chain) +// Package gobackend - YouTube download via Cobalt API (lossy-only provider) package gobackend import ( @@ -70,36 +69,28 @@ type YouTubeDownloadResult struct { CoverData []byte } -// NewYouTubeDownloader creates or returns the singleton YouTube downloader +func NewYouTubeDownloader() *YouTubeDownloader { youtubeDownloaderOnce.Do(func() { globalYouTubeDownloader = &YouTubeDownloader{ client: NewHTTPClientWithTimeout(120 * time.Second), - apiURL: "https://api.qwkuns.me", // Cobalt-based API + apiURL: "https://api.qwkuns.me", } }) return globalYouTubeDownloader } -// SearchYouTube searches for a track on YouTube and returns the best matching video URL +// SearchYouTube returns a YouTube Music search URL for the given track func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) { - // Build search query query := fmt.Sprintf("%s %s", artistName, trackName) - - // Use YouTube's search to find the video - // We'll use a simple approach: construct a YouTube Music search URL pattern - // The actual video ID will be resolved by Cobalt searchQuery := url.QueryEscape(query) GoLog("[YouTube] Search query: %s\n", query) - // For now, we'll need to use YouTube Music's /watch endpoint with search - // A better approach is to use YouTube Data API, but Cobalt can handle music.youtube.com youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery) return youtubeMusicURL, nil } -// GetDownloadURL gets the direct download URL from Cobalt API func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) { y.mu.Lock() defer y.mu.Unlock() @@ -119,13 +110,40 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua audioBitrate = "320" } + // Try primary Cobalt API first (music.youtube.com URL bypasses login) + cobaltURL := toYouTubeMusicURL(youtubeURL) + GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n", + cobaltURL, audioFormat, audioBitrate) + + resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate) + if err == nil { + return resp, nil + } + GoLog("[YouTube] Primary Cobalt failed: %v, trying SpotubeDL fallback...\n", err) + + // Fallback: SpotubeDL proxy (has its own Cobalt auth) + videoID, extractErr := ExtractYouTubeVideoID(youtubeURL) + if extractErr != nil { + return nil, fmt.Errorf("primary cobalt failed: %w (and could not extract video ID for fallback)", err) + } + + resp, fallbackErr := y.requestSpotubeDL(videoID, audioFormat, audioBitrate) + if fallbackErr != nil { + return nil, fmt.Errorf("all download methods failed: primary: %v, fallback: %v", err, fallbackErr) + } + + return resp, nil +} + +// requestCobaltDirect sends a download request to the primary Cobalt API. +func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) { reqBody := CobaltRequest{ - URL: youtubeURL, + URL: videoURL, AudioFormat: audioFormat, AudioBitrate: audioBitrate, DownloadMode: "audio", FilenameStyle: "basic", - DisableMetadata: false, + DisableMetadata: true, } jsonData, err := json.Marshal(reqBody) @@ -133,9 +151,6 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua return nil, fmt.Errorf("failed to marshal request: %w", err) } - GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n", - youtubeURL, audioFormat, audioBitrate) - req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData))) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) @@ -179,11 +194,58 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua } GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status) - return &cobaltResp, nil } -// DownloadFile downloads the audio file from the given URL +// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances). +func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) { + apiURL := fmt.Sprintf("https://spotubedl.com/api/download/%s?engine=v1&format=%s&quality=%s", + videoID, audioFormat, audioBitrate) + + GoLog("[YouTube] Requesting from SpotubeDL: %s\n", apiURL) + + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + resp, err := DoRequestWithUserAgent(y.client, req) + if err != nil { + return nil, fmt.Errorf("spotubedl request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + GoLog("[YouTube] SpotubeDL response status: %d\n", resp.StatusCode) + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("spotubedl returned status %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + URL string `json:"url"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse spotubedl response: %w", err) + } + + if result.URL == "" { + return nil, fmt.Errorf("no download URL from spotubedl") + } + + GoLog("[YouTube] Got download URL from SpotubeDL\n") + return &CobaltResponse{ + Status: "tunnel", + URL: result.URL, + }, nil +} + func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error { ctx := context.Background() @@ -265,19 +327,16 @@ func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputF return nil } -// BuildYouTubeSearchURL constructs a YouTube Music search URL for a track func BuildYouTubeSearchURL(trackName, artistName string) string { query := fmt.Sprintf("%s %s official audio", artistName, trackName) return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query)) } -// BuildYouTubeWatchURL constructs a YouTube watch URL from a video ID func BuildYouTubeWatchURL(videoID string) string { - return fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID) + return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID) } -// isYouTubeVideoID checks if a string looks like a YouTube video ID -// YouTube video IDs are exactly 11 characters, containing alphanumeric, - and _ +// isYouTubeVideoID checks if s is an 11-char YouTube video ID func isYouTubeVideoID(s string) bool { if len(s) != 11 { return false @@ -290,7 +349,6 @@ func isYouTubeVideoID(s string) bool { return true } -// IsYouTubeURL checks if the given URL is a YouTube URL func IsYouTubeURL(urlStr string) bool { lower := strings.ToLower(urlStr) return strings.Contains(lower, "youtube.com") || @@ -298,9 +356,17 @@ func IsYouTubeURL(urlStr string) bool { strings.Contains(lower, "music.youtube.com") } -// ExtractYouTubeVideoID extracts the video ID from a YouTube URL +// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format. +// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt. +func toYouTubeMusicURL(rawURL string) string { + videoID, err := ExtractYouTubeVideoID(rawURL) + if err != nil { + return rawURL + } + return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID) +} + func ExtractYouTubeVideoID(urlStr string) (string, error) { - // Handle youtu.be short URLs if strings.Contains(urlStr, "youtu.be/") { parts := strings.Split(urlStr, "youtu.be/") if len(parts) >= 2 { @@ -310,18 +376,17 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) { } } - // Handle youtube.com URLs parsed, err := url.Parse(urlStr) if err != nil { return "", fmt.Errorf("invalid URL: %w", err) } - // Check for /watch?v= format + // /watch?v= if v := parsed.Query().Get("v"); v != "" { return v, nil } - // Check for /embed/ format + // /embed/ if strings.Contains(parsed.Path, "/embed/") { parts := strings.Split(parsed.Path, "/embed/") if len(parts) >= 2 { @@ -329,7 +394,7 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) { } } - // Check for /v/ format + // /v/ if strings.Contains(parsed.Path, "/v/") { parts := strings.Split(parsed.Path, "/v/") if len(parts) >= 2 { @@ -340,11 +405,9 @@ func ExtractYouTubeVideoID(urlStr string) (string, error) { return "", fmt.Errorf("could not extract video ID from URL") } -// downloadFromYouTube handles the complete download flow from YouTube func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { downloader := NewYouTubeDownloader() - // Determine quality from request var quality YouTubeQuality switch strings.ToLower(req.Quality) { case "opus_256", "opus256", "opus": @@ -355,19 +418,17 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { quality = YouTubeQualityMP3320 // Default to MP3 320kbps } - // Try to get YouTube URL - // Priority: Direct YouTube Video ID -> Spotify ID -> Deezer ID -> ISRC + // URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC var youtubeURL string var lookupErr error - // Method 0: Check if SpotifyID is actually a YouTube video ID (from YT Music extension) - // YouTube video IDs are 11 characters, alphanumeric with _ and - + // SpotifyID might actually be a YouTube video ID (from YT Music extension) if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) { youtubeURL = BuildYouTubeWatchURL(req.SpotifyID) GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL) } - // Method 1: Try Spotify ID via SongLink (if it looks like a real Spotify ID) + // Try Spotify ID via SongLink if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) { GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID) songlink := NewSongLinkClient() @@ -379,7 +440,7 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { } } - // Method 2: Try Deezer ID if Spotify lookup failed or Spotify ID not available + // Try Deezer ID via SongLink if youtubeURL == "" && req.DeezerID != "" { GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID) songlink := NewSongLinkClient() @@ -391,11 +452,10 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { } } - // Method 3: Try ISRC if both Spotify and Deezer failed + // Try ISRC via SongLink if youtubeURL == "" && req.ISRC != "" { GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC) songlink := NewSongLinkClient() - // First get Spotify ID from ISRC, then get YouTube URL availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC) if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" { youtubeURL = availability.YouTubeURL @@ -405,21 +465,18 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { } } - // Fallback: if we couldn't get URL from SongLink, return error - // (Cobalt doesn't support search URLs, only direct video URLs) + // Cobalt requires direct video URLs, not search URLs if youtubeURL == "" { return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName) } GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL) - // Get download URL from Cobalt cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality) if err != nil { return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) } - // Determine file extension based on quality var ext string var format string var bitrate int @@ -434,8 +491,7 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { bitrate = 320 } - // Build filename - filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ + filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{ "title": req.TrackName, "artist": req.ArtistName, "album": req.AlbumName, @@ -445,7 +501,6 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { }) filename = sanitizeFilename(filename) + ext - // Determine output path var outputPath string isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" if isSafOutput { @@ -459,7 +514,7 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { GoLog("[YouTube] Downloading to: %s\n", outputPath) - // Start parallel fetch for cover art and lyrics while downloading + // Parallel fetch cover art + lyrics var parallelResult *ParallelDownloadResult if req.EmbedLyrics || req.CoverURL != "" { GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n") @@ -474,12 +529,10 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { ) } - // Download the file if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil { return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err) } - // Extract lyrics LRC if available lyricsLRC := "" var coverData []byte if parallelResult != nil { diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c37e69dd..d67aa7c1 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -217,6 +217,14 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + case "editFileMetadata": + let args = call.arguments as! [String: Any] + let filePath = args["file_path"] as! String + let metadataJson = args["metadata_json"] as? String ?? "{}" + let response = GobackendEditFileMetadata(filePath, metadataJson, &error) + if let error = error { throw error } + return response + case "searchDeezerAll": let args = call.arguments as! [String: Any] let query = args["query"] as! String diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2918949f..abe371bc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5037,6 +5037,102 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Refresh stats'** String get cacheRefreshStats; + + /// Menu action - save album cover art as file + /// + /// In en, this message translates to: + /// **'Save Cover Art'** + String get trackSaveCoverArt; + + /// Subtitle for save cover art action + /// + /// In en, this message translates to: + /// **'Save album art as .jpg file'** + String get trackSaveCoverArtSubtitle; + + /// Menu action - save lyrics as .lrc file + /// + /// In en, this message translates to: + /// **'Save Lyrics (.lrc)'** + String get trackSaveLyrics; + + /// Subtitle for save lyrics action + /// + /// In en, this message translates to: + /// **'Fetch and save lyrics as .lrc file'** + String get trackSaveLyricsSubtitle; + + /// Menu action - re-embed metadata into audio file + /// + /// In en, this message translates to: + /// **'Re-enrich Metadata'** + String get trackReEnrich; + + /// Subtitle for re-enrich metadata action + /// + /// In en, this message translates to: + /// **'Re-embed metadata without re-downloading'** + String get trackReEnrichSubtitle; + + /// Subtitle for re-enrich metadata action for local items + /// + /// In en, this message translates to: + /// **'Search metadata online and embed into file'** + String get trackReEnrichOnlineSubtitle; + + /// Menu action - edit embedded metadata + /// + /// In en, this message translates to: + /// **'Edit Metadata'** + String get trackEditMetadata; + + /// Snackbar after cover art saved + /// + /// In en, this message translates to: + /// **'Cover art saved to {fileName}'** + String trackCoverSaved(String fileName); + + /// Snackbar when no cover art URL or embedded cover + /// + /// In en, this message translates to: + /// **'No cover art source available'** + String get trackCoverNoSource; + + /// Snackbar after lyrics saved + /// + /// In en, this message translates to: + /// **'Lyrics saved to {fileName}'** + String trackLyricsSaved(String fileName); + + /// Snackbar while re-enriching metadata + /// + /// In en, this message translates to: + /// **'Re-enriching metadata...'** + String get trackReEnrichProgress; + + /// Snackbar while searching metadata from internet for local items + /// + /// In en, this message translates to: + /// **'Searching metadata online...'** + String get trackReEnrichSearching; + + /// Snackbar after successful re-enrichment + /// + /// In en, this message translates to: + /// **'Metadata re-enriched successfully'** + String get trackReEnrichSuccess; + + /// Snackbar when FFmpeg embed fails for MP3/Opus + /// + /// In en, this message translates to: + /// **'FFmpeg metadata embed failed'** + String get trackReEnrichFfmpegFailed; + + /// Snackbar when save operation fails + /// + /// In en, this message translates to: + /// **'Failed: {error}'** + String trackSaveFailed(String error); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index a7930a2e..ffeb6845 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2848,4 +2848,60 @@ class AppLocalizationsDe extends AppLocalizations { @override String get cacheRefreshStats => 'Refresh stats'; + + @override + String get trackSaveCoverArt => 'Save Cover Art'; + + @override + String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file'; + + @override + String get trackSaveLyrics => 'Save Lyrics (.lrc)'; + + @override + String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + + @override + String get trackReEnrich => 'Re-enrich Metadata'; + + @override + String get trackReEnrichSubtitle => + 'Re-embed metadata without re-downloading'; + + @override + String get trackReEnrichOnlineSubtitle => + 'Search metadata online and embed into file'; + + @override + String get trackEditMetadata => 'Edit Metadata'; + + @override + String trackCoverSaved(String fileName) { + return 'Cover art saved to $fileName'; + } + + @override + String get trackCoverNoSource => 'No cover art source available'; + + @override + String trackLyricsSaved(String fileName) { + return 'Lyrics saved to $fileName'; + } + + @override + String get trackReEnrichProgress => 'Re-enriching metadata...'; + + @override + String get trackReEnrichSearching => 'Searching metadata online...'; + + @override + String get trackReEnrichSuccess => 'Metadata re-enriched successfully'; + + @override + String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + + @override + String trackSaveFailed(String error) { + return 'Failed: $error'; + } } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 41bddfee..25a79292 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2833,4 +2833,60 @@ class AppLocalizationsEn extends AppLocalizations { @override String get cacheRefreshStats => 'Refresh stats'; + + @override + String get trackSaveCoverArt => 'Save Cover Art'; + + @override + String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file'; + + @override + String get trackSaveLyrics => 'Save Lyrics (.lrc)'; + + @override + String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + + @override + String get trackReEnrich => 'Re-enrich Metadata'; + + @override + String get trackReEnrichSubtitle => + 'Re-embed metadata without re-downloading'; + + @override + String get trackReEnrichOnlineSubtitle => + 'Search metadata online and embed into file'; + + @override + String get trackEditMetadata => 'Edit Metadata'; + + @override + String trackCoverSaved(String fileName) { + return 'Cover art saved to $fileName'; + } + + @override + String get trackCoverNoSource => 'No cover art source available'; + + @override + String trackLyricsSaved(String fileName) { + return 'Lyrics saved to $fileName'; + } + + @override + String get trackReEnrichProgress => 'Re-enriching metadata...'; + + @override + String get trackReEnrichSearching => 'Searching metadata online...'; + + @override + String get trackReEnrichSuccess => 'Metadata re-enriched successfully'; + + @override + String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + + @override + String trackSaveFailed(String error) { + return 'Failed: $error'; + } } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 0e6ea103..2ee6b9a5 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2833,6 +2833,62 @@ class AppLocalizationsEs extends AppLocalizations { @override String get cacheRefreshStats => 'Refresh stats'; + + @override + String get trackSaveCoverArt => 'Save Cover Art'; + + @override + String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file'; + + @override + String get trackSaveLyrics => 'Save Lyrics (.lrc)'; + + @override + String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + + @override + String get trackReEnrich => 'Re-enrich Metadata'; + + @override + String get trackReEnrichSubtitle => + 'Re-embed metadata without re-downloading'; + + @override + String get trackReEnrichOnlineSubtitle => + 'Search metadata online and embed into file'; + + @override + String get trackEditMetadata => 'Edit Metadata'; + + @override + String trackCoverSaved(String fileName) { + return 'Cover art saved to $fileName'; + } + + @override + String get trackCoverNoSource => 'No cover art source available'; + + @override + String trackLyricsSaved(String fileName) { + return 'Lyrics saved to $fileName'; + } + + @override + String get trackReEnrichProgress => 'Re-enriching metadata...'; + + @override + String get trackReEnrichSearching => 'Searching metadata online...'; + + @override + String get trackReEnrichSuccess => 'Metadata re-enriched successfully'; + + @override + String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + + @override + String trackSaveFailed(String error) { + return 'Failed: $error'; + } } /// The translations for Spanish Castilian, as used in Spain (`es_ES`). diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 66ef7d1e..d2b7fe4d 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2833,4 +2833,60 @@ class AppLocalizationsFr extends AppLocalizations { @override String get cacheRefreshStats => 'Refresh stats'; + + @override + String get trackSaveCoverArt => 'Save Cover Art'; + + @override + String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file'; + + @override + String get trackSaveLyrics => 'Save Lyrics (.lrc)'; + + @override + String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + + @override + String get trackReEnrich => 'Re-enrich Metadata'; + + @override + String get trackReEnrichSubtitle => + 'Re-embed metadata without re-downloading'; + + @override + String get trackReEnrichOnlineSubtitle => + 'Search metadata online and embed into file'; + + @override + String get trackEditMetadata => 'Edit Metadata'; + + @override + String trackCoverSaved(String fileName) { + return 'Cover art saved to $fileName'; + } + + @override + String get trackCoverNoSource => 'No cover art source available'; + + @override + String trackLyricsSaved(String fileName) { + return 'Lyrics saved to $fileName'; + } + + @override + String get trackReEnrichProgress => 'Re-enriching metadata...'; + + @override + String get trackReEnrichSearching => 'Searching metadata online...'; + + @override + String get trackReEnrichSuccess => 'Metadata re-enriched successfully'; + + @override + String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + + @override + String trackSaveFailed(String error) { + return 'Failed: $error'; + } } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index c51b2f92..5e635a9f 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2833,4 +2833,60 @@ class AppLocalizationsHi extends AppLocalizations { @override String get cacheRefreshStats => 'Refresh stats'; + + @override + String get trackSaveCoverArt => 'Save Cover Art'; + + @override + String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file'; + + @override + String get trackSaveLyrics => 'Save Lyrics (.lrc)'; + + @override + String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + + @override + String get trackReEnrich => 'Re-enrich Metadata'; + + @override + String get trackReEnrichSubtitle => + 'Re-embed metadata without re-downloading'; + + @override + String get trackReEnrichOnlineSubtitle => + 'Search metadata online and embed into file'; + + @override + String get trackEditMetadata => 'Edit Metadata'; + + @override + String trackCoverSaved(String fileName) { + return 'Cover art saved to $fileName'; + } + + @override + String get trackCoverNoSource => 'No cover art source available'; + + @override + String trackLyricsSaved(String fileName) { + return 'Lyrics saved to $fileName'; + } + + @override + String get trackReEnrichProgress => 'Re-enriching metadata...'; + + @override + String get trackReEnrichSearching => 'Searching metadata online...'; + + @override + String get trackReEnrichSuccess => 'Metadata re-enriched successfully'; + + @override + String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + + @override + String trackSaveFailed(String error) { + return 'Failed: $error'; + } } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 22525c08..6885b02b 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2849,4 +2849,63 @@ class AppLocalizationsId extends AppLocalizations { @override String get cacheRefreshStats => 'Segarkan statistik'; + + @override + String get trackSaveCoverArt => 'Simpan Cover Art'; + + @override + String get trackSaveCoverArtSubtitle => + 'Simpan cover album sebagai file .jpg'; + + @override + String get trackSaveLyrics => 'Simpan Lirik (.lrc)'; + + @override + String get trackSaveLyricsSubtitle => + 'Ambil dan simpan lirik sebagai file .lrc'; + + @override + String get trackReEnrich => 'Perkaya Ulang Metadata'; + + @override + String get trackReEnrichSubtitle => + 'Tanamkan ulang metadata tanpa mengunduh ulang'; + + @override + String get trackReEnrichOnlineSubtitle => + 'Cari metadata dari internet dan tanamkan ke file'; + + @override + String get trackEditMetadata => 'Edit Metadata'; + + @override + String trackCoverSaved(String fileName) { + return 'Cover art disimpan ke $fileName'; + } + + @override + String get trackCoverNoSource => 'Tidak ada sumber cover art'; + + @override + String trackLyricsSaved(String fileName) { + return 'Lirik disimpan ke $fileName'; + } + + @override + String get trackReEnrichProgress => 'Memperkaya ulang metadata...'; + + @override + String get trackReEnrichSearching => 'Mencari metadata dari internet...'; + + @override + String get trackReEnrichSuccess => 'Metadata berhasil diperkaya ulang'; + + @override + String get trackReEnrichFfmpegFailed => + 'Gagal menanamkan metadata via FFmpeg'; + + @override + String trackSaveFailed(String error) { + return 'Gagal: $error'; + } } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index d5270e9e..8a46307c 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2819,4 +2819,60 @@ class AppLocalizationsJa extends AppLocalizations { @override String get cacheRefreshStats => 'Refresh stats'; + + @override + String get trackSaveCoverArt => 'Save Cover Art'; + + @override + String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file'; + + @override + String get trackSaveLyrics => 'Save Lyrics (.lrc)'; + + @override + String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + + @override + String get trackReEnrich => 'Re-enrich Metadata'; + + @override + String get trackReEnrichSubtitle => + 'Re-embed metadata without re-downloading'; + + @override + String get trackReEnrichOnlineSubtitle => + 'Search metadata online and embed into file'; + + @override + String get trackEditMetadata => 'Edit Metadata'; + + @override + String trackCoverSaved(String fileName) { + return 'Cover art saved to $fileName'; + } + + @override + String get trackCoverNoSource => 'No cover art source available'; + + @override + String trackLyricsSaved(String fileName) { + return 'Lyrics saved to $fileName'; + } + + @override + String get trackReEnrichProgress => 'Re-enriching metadata...'; + + @override + String get trackReEnrichSearching => 'Searching metadata online...'; + + @override + String get trackReEnrichSuccess => 'Metadata re-enriched successfully'; + + @override + String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + + @override + String trackSaveFailed(String error) { + return 'Failed: $error'; + } } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 167e028c..63c65cba 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2833,4 +2833,60 @@ class AppLocalizationsKo extends AppLocalizations { @override String get cacheRefreshStats => 'Refresh stats'; + + @override + String get trackSaveCoverArt => 'Save Cover Art'; + + @override + String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file'; + + @override + String get trackSaveLyrics => 'Save Lyrics (.lrc)'; + + @override + String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + + @override + String get trackReEnrich => 'Re-enrich Metadata'; + + @override + String get trackReEnrichSubtitle => + 'Re-embed metadata without re-downloading'; + + @override + String get trackReEnrichOnlineSubtitle => + 'Search metadata online and embed into file'; + + @override + String get trackEditMetadata => 'Edit Metadata'; + + @override + String trackCoverSaved(String fileName) { + return 'Cover art saved to $fileName'; + } + + @override + String get trackCoverNoSource => 'No cover art source available'; + + @override + String trackLyricsSaved(String fileName) { + return 'Lyrics saved to $fileName'; + } + + @override + String get trackReEnrichProgress => 'Re-enriching metadata...'; + + @override + String get trackReEnrichSearching => 'Searching metadata online...'; + + @override + String get trackReEnrichSuccess => 'Metadata re-enriched successfully'; + + @override + String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + + @override + String trackSaveFailed(String error) { + return 'Failed: $error'; + } } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 5842cd24..e144e991 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2833,4 +2833,60 @@ class AppLocalizationsNl extends AppLocalizations { @override String get cacheRefreshStats => 'Refresh stats'; + + @override + String get trackSaveCoverArt => 'Save Cover Art'; + + @override + String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file'; + + @override + String get trackSaveLyrics => 'Save Lyrics (.lrc)'; + + @override + String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + + @override + String get trackReEnrich => 'Re-enrich Metadata'; + + @override + String get trackReEnrichSubtitle => + 'Re-embed metadata without re-downloading'; + + @override + String get trackReEnrichOnlineSubtitle => + 'Search metadata online and embed into file'; + + @override + String get trackEditMetadata => 'Edit Metadata'; + + @override + String trackCoverSaved(String fileName) { + return 'Cover art saved to $fileName'; + } + + @override + String get trackCoverNoSource => 'No cover art source available'; + + @override + String trackLyricsSaved(String fileName) { + return 'Lyrics saved to $fileName'; + } + + @override + String get trackReEnrichProgress => 'Re-enriching metadata...'; + + @override + String get trackReEnrichSearching => 'Searching metadata online...'; + + @override + String get trackReEnrichSuccess => 'Metadata re-enriched successfully'; + + @override + String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + + @override + String trackSaveFailed(String error) { + return 'Failed: $error'; + } } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index a1b3b668..6ffcf11c 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2833,6 +2833,62 @@ class AppLocalizationsPt extends AppLocalizations { @override String get cacheRefreshStats => 'Refresh stats'; + + @override + String get trackSaveCoverArt => 'Save Cover Art'; + + @override + String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file'; + + @override + String get trackSaveLyrics => 'Save Lyrics (.lrc)'; + + @override + String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + + @override + String get trackReEnrich => 'Re-enrich Metadata'; + + @override + String get trackReEnrichSubtitle => + 'Re-embed metadata without re-downloading'; + + @override + String get trackReEnrichOnlineSubtitle => + 'Search metadata online and embed into file'; + + @override + String get trackEditMetadata => 'Edit Metadata'; + + @override + String trackCoverSaved(String fileName) { + return 'Cover art saved to $fileName'; + } + + @override + String get trackCoverNoSource => 'No cover art source available'; + + @override + String trackLyricsSaved(String fileName) { + return 'Lyrics saved to $fileName'; + } + + @override + String get trackReEnrichProgress => 'Re-enriching metadata...'; + + @override + String get trackReEnrichSearching => 'Searching metadata online...'; + + @override + String get trackReEnrichSuccess => 'Metadata re-enriched successfully'; + + @override + String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + + @override + String trackSaveFailed(String error) { + return 'Failed: $error'; + } } /// The translations for Portuguese, as used in Portugal (`pt_PT`). diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index f795098c..14844d15 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2879,4 +2879,60 @@ class AppLocalizationsRu extends AppLocalizations { @override String get cacheRefreshStats => 'Refresh stats'; + + @override + String get trackSaveCoverArt => 'Save Cover Art'; + + @override + String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file'; + + @override + String get trackSaveLyrics => 'Save Lyrics (.lrc)'; + + @override + String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + + @override + String get trackReEnrich => 'Re-enrich Metadata'; + + @override + String get trackReEnrichSubtitle => + 'Re-embed metadata without re-downloading'; + + @override + String get trackReEnrichOnlineSubtitle => + 'Search metadata online and embed into file'; + + @override + String get trackEditMetadata => 'Edit Metadata'; + + @override + String trackCoverSaved(String fileName) { + return 'Cover art saved to $fileName'; + } + + @override + String get trackCoverNoSource => 'No cover art source available'; + + @override + String trackLyricsSaved(String fileName) { + return 'Lyrics saved to $fileName'; + } + + @override + String get trackReEnrichProgress => 'Re-enriching metadata...'; + + @override + String get trackReEnrichSearching => 'Searching metadata online...'; + + @override + String get trackReEnrichSuccess => 'Metadata re-enriched successfully'; + + @override + String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + + @override + String trackSaveFailed(String error) { + return 'Failed: $error'; + } } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 0ad6a988..79eca336 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2848,4 +2848,60 @@ class AppLocalizationsTr extends AppLocalizations { @override String get cacheRefreshStats => 'Refresh stats'; + + @override + String get trackSaveCoverArt => 'Save Cover Art'; + + @override + String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file'; + + @override + String get trackSaveLyrics => 'Save Lyrics (.lrc)'; + + @override + String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + + @override + String get trackReEnrich => 'Re-enrich Metadata'; + + @override + String get trackReEnrichSubtitle => + 'Re-embed metadata without re-downloading'; + + @override + String get trackReEnrichOnlineSubtitle => + 'Search metadata online and embed into file'; + + @override + String get trackEditMetadata => 'Edit Metadata'; + + @override + String trackCoverSaved(String fileName) { + return 'Cover art saved to $fileName'; + } + + @override + String get trackCoverNoSource => 'No cover art source available'; + + @override + String trackLyricsSaved(String fileName) { + return 'Lyrics saved to $fileName'; + } + + @override + String get trackReEnrichProgress => 'Re-enriching metadata...'; + + @override + String get trackReEnrichSearching => 'Searching metadata online...'; + + @override + String get trackReEnrichSuccess => 'Metadata re-enriched successfully'; + + @override + String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + + @override + String trackSaveFailed(String error) { + return 'Failed: $error'; + } } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index e36052f6..938b5f94 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2833,6 +2833,62 @@ class AppLocalizationsZh extends AppLocalizations { @override String get cacheRefreshStats => 'Refresh stats'; + + @override + String get trackSaveCoverArt => 'Save Cover Art'; + + @override + String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file'; + + @override + String get trackSaveLyrics => 'Save Lyrics (.lrc)'; + + @override + String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file'; + + @override + String get trackReEnrich => 'Re-enrich Metadata'; + + @override + String get trackReEnrichSubtitle => + 'Re-embed metadata without re-downloading'; + + @override + String get trackReEnrichOnlineSubtitle => + 'Search metadata online and embed into file'; + + @override + String get trackEditMetadata => 'Edit Metadata'; + + @override + String trackCoverSaved(String fileName) { + return 'Cover art saved to $fileName'; + } + + @override + String get trackCoverNoSource => 'No cover art source available'; + + @override + String trackLyricsSaved(String fileName) { + return 'Lyrics saved to $fileName'; + } + + @override + String get trackReEnrichProgress => 'Re-enriching metadata...'; + + @override + String get trackReEnrichSearching => 'Searching metadata online...'; + + @override + String get trackReEnrichSuccess => 'Metadata re-enriched successfully'; + + @override + String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed'; + + @override + String trackSaveFailed(String error) { + return 'Failed: $error'; + } } /// The translations for Chinese, as used in China (`zh_CN`). diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 39c59f67..8187756d 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -2134,5 +2134,53 @@ } }, "cacheRefreshStats": "Refresh stats", - "@cacheRefreshStats": {"description": "Button label to refresh cache statistics"} + "@cacheRefreshStats": {"description": "Button label to refresh cache statistics"}, + + "trackSaveCoverArt": "Save Cover Art", + "@trackSaveCoverArt": {"description": "Menu action - save album cover art as file"}, + "trackSaveCoverArtSubtitle": "Save album art as .jpg file", + "@trackSaveCoverArtSubtitle": {"description": "Subtitle for save cover art action"}, + "trackSaveLyrics": "Save Lyrics (.lrc)", + "@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"}, + "trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file", + "@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"}, + "trackReEnrich": "Re-enrich Metadata", + "@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"}, + "trackReEnrichSubtitle": "Re-embed metadata without re-downloading", + "@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"}, + "trackReEnrichOnlineSubtitle": "Search metadata online and embed into file", + "@trackReEnrichOnlineSubtitle": {"description": "Subtitle for re-enrich metadata action for local items"}, + "trackEditMetadata": "Edit Metadata", + "@trackEditMetadata": {"description": "Menu action - edit embedded metadata"}, + "trackCoverSaved": "Cover art saved to {fileName}", + "@trackCoverSaved": { + "description": "Snackbar after cover art saved", + "placeholders": { + "fileName": {"type": "String"} + } + }, + "trackCoverNoSource": "No cover art source available", + "@trackCoverNoSource": {"description": "Snackbar when no cover art URL or embedded cover"}, + "trackLyricsSaved": "Lyrics saved to {fileName}", + "@trackLyricsSaved": { + "description": "Snackbar after lyrics saved", + "placeholders": { + "fileName": {"type": "String"} + } + }, + "trackReEnrichProgress": "Re-enriching metadata...", + "@trackReEnrichProgress": {"description": "Snackbar while re-enriching metadata"}, + "trackReEnrichSearching": "Searching metadata online...", + "@trackReEnrichSearching": {"description": "Snackbar while searching metadata from internet for local items"}, + "trackReEnrichSuccess": "Metadata re-enriched successfully", + "@trackReEnrichSuccess": {"description": "Snackbar after successful re-enrichment"}, + "trackReEnrichFfmpegFailed": "FFmpeg metadata embed failed", + "@trackReEnrichFfmpegFailed": {"description": "Snackbar when FFmpeg embed fails for MP3/Opus"}, + "trackSaveFailed": "Failed: {error}", + "@trackSaveFailed": { + "description": "Snackbar when save operation fails", + "placeholders": { + "error": {"type": "String"} + } + } } diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index d607e6fe..84ee93e9 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -3154,5 +3154,53 @@ } }, "cacheRefreshStats": "Segarkan statistik", - "@cacheRefreshStats": {"description": "Button label to refresh cache statistics"} + "@cacheRefreshStats": {"description": "Button label to refresh cache statistics"}, + + "trackSaveCoverArt": "Simpan Cover Art", + "@trackSaveCoverArt": {"description": "Menu action - save album cover art as file"}, + "trackSaveCoverArtSubtitle": "Simpan cover album sebagai file .jpg", + "@trackSaveCoverArtSubtitle": {"description": "Subtitle for save cover art action"}, + "trackSaveLyrics": "Simpan Lirik (.lrc)", + "@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"}, + "trackSaveLyricsSubtitle": "Ambil dan simpan lirik sebagai file .lrc", + "@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"}, + "trackReEnrich": "Perkaya Ulang Metadata", + "@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"}, + "trackReEnrichSubtitle": "Tanamkan ulang metadata tanpa mengunduh ulang", + "@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"}, + "trackReEnrichOnlineSubtitle": "Cari metadata dari internet dan tanamkan ke file", + "@trackReEnrichOnlineSubtitle": {"description": "Subtitle for re-enrich metadata action for local items"}, + "trackEditMetadata": "Edit Metadata", + "@trackEditMetadata": {"description": "Menu action - edit embedded metadata"}, + "trackCoverSaved": "Cover art disimpan ke {fileName}", + "@trackCoverSaved": { + "description": "Snackbar after cover art saved", + "placeholders": { + "fileName": {"type": "String"} + } + }, + "trackCoverNoSource": "Tidak ada sumber cover art", + "@trackCoverNoSource": {"description": "Snackbar when no cover art URL or embedded cover"}, + "trackLyricsSaved": "Lirik disimpan ke {fileName}", + "@trackLyricsSaved": { + "description": "Snackbar after lyrics saved", + "placeholders": { + "fileName": {"type": "String"} + } + }, + "trackReEnrichProgress": "Memperkaya ulang metadata...", + "@trackReEnrichProgress": {"description": "Snackbar while re-enriching metadata"}, + "trackReEnrichSearching": "Mencari metadata dari internet...", + "@trackReEnrichSearching": {"description": "Snackbar while searching metadata from internet for local items"}, + "trackReEnrichSuccess": "Metadata berhasil diperkaya ulang", + "@trackReEnrichSuccess": {"description": "Snackbar after successful re-enrichment"}, + "trackReEnrichFfmpegFailed": "Gagal menanamkan metadata via FFmpeg", + "@trackReEnrichFfmpegFailed": {"description": "Snackbar when FFmpeg embed fails for MP3/Opus"}, + "trackSaveFailed": "Gagal: {error}", + "@trackSaveFailed": { + "description": "Snackbar when save operation fails", + "placeholders": { + "error": {"type": "String"} + } + } } diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 27d863e9..6e7fdf75 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1253,6 +1253,12 @@ class DownloadQueueNotifier extends Notifier { return match?.group(1); } + static final _isrcRegex = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$'); + + bool _isValidISRC(String value) { + return _isrcRegex.hasMatch(value.toUpperCase()); + } + void updateSettings(AppSettings settings) { final concurrentDownloads = settings.concurrentDownloads.clamp(1, 5); state = state.copyWith( @@ -2607,7 +2613,8 @@ class DownloadQueueNotifier extends Notifier { if (deezerTrackId == null && trackToDownload.isrc != null && - trackToDownload.isrc!.isNotEmpty) { + trackToDownload.isrc!.isNotEmpty && + _isValidISRC(trackToDownload.isrc!)) { try { _log.d('No Deezer ID, searching by ISRC: ${trackToDownload.isrc}'); final deezerResult = await PlatformBridge.searchDeezerByISRC( @@ -2623,6 +2630,75 @@ class DownloadQueueNotifier extends Notifier { } } + // Fallback: Use SongLink to convert Spotify ID to Deezer ID + if (deezerTrackId == null && + trackToDownload.id.isNotEmpty && + !trackToDownload.id.startsWith('deezer:') && + !trackToDownload.id.startsWith('extension:')) { + try { + // Extract clean Spotify ID (remove spotify: prefix if present) + String spotifyId = trackToDownload.id; + if (spotifyId.startsWith('spotify:track:')) { + spotifyId = spotifyId.split(':').last; + } + _log.d('No Deezer ID, converting from Spotify via SongLink: $spotifyId'); + final deezerData = await PlatformBridge.convertSpotifyToDeezer('track', spotifyId); + // Response is TrackResponse: {"track": {"spotify_id": "deezer:XXXXX", ...}} + final trackData = deezerData['track']; + if (trackData is Map) { + final rawId = trackData['spotify_id'] as String?; + if (rawId != null && rawId.startsWith('deezer:')) { + deezerTrackId = rawId.split(':')[1]; + _log.d('Found Deezer track ID via SongLink: $deezerTrackId'); + } else if (deezerData['id'] != null) { + deezerTrackId = deezerData['id'].toString(); + _log.d('Found Deezer track ID via SongLink (legacy): $deezerTrackId'); + } + + // Enrich track metadata from Deezer response (release_date, isrc, etc.) + final deezerReleaseDate = _normalizeOptionalString(trackData['release_date'] as String?); + final deezerIsrc = _normalizeOptionalString(trackData['isrc'] as String?); + final deezerTrackNum = trackData['track_number'] as int?; + final deezerDiscNum = trackData['disc_number'] as int?; + + final needsEnrich = + (trackToDownload.releaseDate == null && deezerReleaseDate != null) || + (trackToDownload.isrc == null && deezerIsrc != null) || + (!_isValidISRC(trackToDownload.isrc ?? '') && deezerIsrc != null) || + (trackToDownload.trackNumber == null && deezerTrackNum != null) || + (trackToDownload.discNumber == null && deezerDiscNum != null); + + if (needsEnrich) { + trackToDownload = Track( + id: trackToDownload.id, + name: trackToDownload.name, + artistName: trackToDownload.artistName, + albumName: trackToDownload.albumName, + albumArtist: trackToDownload.albumArtist, + coverUrl: trackToDownload.coverUrl, + duration: trackToDownload.duration, + isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc)) + ? deezerIsrc + : trackToDownload.isrc, + trackNumber: trackToDownload.trackNumber ?? deezerTrackNum, + discNumber: trackToDownload.discNumber ?? deezerDiscNum, + releaseDate: trackToDownload.releaseDate ?? deezerReleaseDate, + deezerId: deezerTrackId, + availability: trackToDownload.availability, + albumType: trackToDownload.albumType, + source: trackToDownload.source, + ); + _log.d('Enriched track from Deezer - date: ${trackToDownload.releaseDate}, ISRC: ${trackToDownload.isrc}, track: ${trackToDownload.trackNumber}, disc: ${trackToDownload.discNumber}'); + } + } else if (deezerData['id'] != null) { + deezerTrackId = deezerData['id'].toString(); + _log.d('Found Deezer track ID via SongLink (flat): $deezerTrackId'); + } + } catch (e) { + _log.w('Failed to convert Spotify to Deezer via SongLink: $e'); + } + } + if (deezerTrackId != null && deezerTrackId.isNotEmpty) { try { final extendedMetadata = @@ -3305,6 +3381,15 @@ class DownloadQueueNotifier extends Notifier { progress: 0.95, ); + final finalTrack = _buildTrackForMetadataEmbedding( + trackToDownload, + result, + normalizedAlbumArtist, + ); + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; + final isContentUriPath = isContentUri(filePath); if (isContentUriPath && effectiveSafMode) { // SAF mode: copy to temp, embed, write back @@ -3314,16 +3399,18 @@ class DownloadQueueNotifier extends Notifier { if (isMp3File) { await _embedMetadataToMp3( tempPath, - trackToDownload, - genre: genre, - label: label, + finalTrack, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, ); } else { await _embedMetadataToOpus( tempPath, - trackToDownload, - genre: genre, - label: label, + finalTrack, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, ); } // Write back to SAF @@ -3360,16 +3447,18 @@ class DownloadQueueNotifier extends Notifier { if (isMp3File) { await _embedMetadataToMp3( filePath, - trackToDownload, - genre: genre, - label: label, + finalTrack, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, ); } else { await _embedMetadataToOpus( filePath, - trackToDownload, - genre: genre, - label: label, + finalTrack, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, ); } _log.d('YouTube metadata embedding completed'); diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index b3cd0dd0..35207f2e 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -1549,7 +1549,7 @@ class _HomeTabState extends ConsumerState duration: item.durationMs ~/ 1000, trackNumber: 1, discNumber: 1, - isrc: item.id, + isrc: null, releaseDate: null, coverUrl: item.coverUrl, source: item.providerId ?? 'spotify-web', diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 00b48ed1..ca18ed54 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -22,7 +22,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget { } class _DownloadSettingsPageState extends ConsumerState { - static const _builtInServices = ['tidal', 'qobuz', 'amazon']; + static const _builtInServices = ['tidal', 'qobuz']; int _androidSdkVersion = 0; bool _hasAllFilesAccess = false; @@ -248,7 +248,7 @@ class _DownloadSettingsPageState extends ConsumerState { const SizedBox(width: 8), Expanded( child: Text( - 'Select Tidal, Qobuz, or Amazon above to configure quality', + 'Select Tidal or Qobuz above to configure quality', style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: colorScheme.onSurfaceVariant, @@ -1354,7 +1354,6 @@ class _ServiceSelector extends ConsumerWidget { final isExtensionService = ![ 'tidal', 'qobuz', - 'amazon', ].contains(currentService); final isCurrentExtensionEnabled = isExtensionService ? extensionProviders.any((e) => e.id == currentService) @@ -1381,15 +1380,6 @@ class _ServiceSelector extends ConsumerWidget { isSelected: effectiveService == 'qobuz', onTap: () => onChanged('qobuz'), ), - const SizedBox(width: 8), - _ServiceChip( - icon: Icons.shopping_bag, - label: 'Amazon', - isSelected: effectiveService == 'amazon', - isDisabled: true, - disabledReason: 'Coming soon', - onTap: () {}, - ), ], ), if (extensionProviders.isNotEmpty) ...[ @@ -1425,15 +1415,11 @@ class _ServiceChip extends StatelessWidget { final String label; final bool isSelected; final VoidCallback onTap; - final bool isDisabled; - final String? disabledReason; const _ServiceChip({ required this.icon, required this.label, required this.isSelected, required this.onTap, - this.isDisabled = false, - this.disabledReason, }); @override @@ -1448,66 +1434,39 @@ class _ServiceChip extends StatelessWidget { ) : colorScheme.surfaceContainerHigh; - final disabledColor = isDark - ? Color.alphaBlend( - Colors.white.withValues(alpha: 0.02), - colorScheme.surface, - ) - : colorScheme.surfaceContainerLow; - return Expanded( - child: Tooltip( - message: isDisabled && disabledReason != null ? disabledReason! : '', - child: Material( - color: isDisabled - ? disabledColor - : isSelected - ? colorScheme.primaryContainer - : unselectedColor, + child: Material( + color: isSelected + ? colorScheme.primaryContainer + : unselectedColor, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, borderRadius: BorderRadius.circular(12), - child: InkWell( - onTap: isDisabled ? null : onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 14), - child: Column( - children: [ - Icon( - icon, - color: isDisabled - ? colorScheme.onSurface.withValues(alpha: 0.38) - : isSelected + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Column( + children: [ + Icon( + icon, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, ), - const SizedBox(height: 6), - Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: isSelected && !isDisabled - ? FontWeight.w600 - : FontWeight.normal, - color: isDisabled - ? colorScheme.onSurface.withValues(alpha: 0.38) - : isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - ), - ), - if (isDisabled && disabledReason != null) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - disabledReason!, - style: TextStyle( - fontSize: 9, - color: colorScheme.onSurface.withValues(alpha: 0.38), - ), - ), - ), - ], - ), + ), + ], ), ), ), diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 2adf2216..8a695600 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -11,6 +11,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:share_plus/share_plus.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; class TrackMetadataScreen extends ConsumerStatefulWidget { @@ -35,6 +36,7 @@ class _TrackMetadataScreenState extends ConsumerState { bool _lyricsEmbedded = false; // Track if lyrics are embedded in file bool _isEmbedding = false; // Track embed operation in progress bool _isInstrumental = false; // Track if detected as instrumental + Map? _editedMetadata; // Overrides after metadata edit final ScrollController _scrollController = ScrollController(); static final RegExp _lrcTimestampPattern = RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]'); @@ -117,17 +119,35 @@ class _TrackMetadataScreenState extends ConsumerState { LocalLibraryItem? get _localLibraryItem => widget.localItem; String get _itemId => _isLocalItem ? _localLibraryItem!.id : _downloadItem!.id; - String get trackName => _isLocalItem ? _localLibraryItem!.trackName : _downloadItem!.trackName; - String get artistName => _isLocalItem ? _localLibraryItem!.artistName : _downloadItem!.artistName; - String get albumName => _isLocalItem ? _localLibraryItem!.albumName : _downloadItem!.albumName; - String? get albumArtist => _normalizeOptionalString(_isLocalItem ? _localLibraryItem!.albumArtist : _downloadItem!.albumArtist); - int? get trackNumber => _isLocalItem ? _localLibraryItem!.trackNumber : _downloadItem!.trackNumber; - int? get discNumber => _isLocalItem ? _localLibraryItem!.discNumber : _downloadItem!.discNumber; - String? get releaseDate => _isLocalItem ? _localLibraryItem!.releaseDate : _downloadItem!.releaseDate; - String? get isrc => _isLocalItem ? _localLibraryItem!.isrc : _downloadItem!.isrc; - String? get genre => _isLocalItem ? _localLibraryItem!.genre : _downloadItem!.genre; - String? get label => _isLocalItem ? null : _downloadItem!.label; - String? get copyright => _isLocalItem ? null : _downloadItem!.copyright; + String get trackName => _editedMetadata?['title']?.toString() ?? (_isLocalItem ? _localLibraryItem!.trackName : _downloadItem!.trackName); + String get artistName => _editedMetadata?['artist']?.toString() ?? (_isLocalItem ? _localLibraryItem!.artistName : _downloadItem!.artistName); + String get albumName => _editedMetadata?['album']?.toString() ?? (_isLocalItem ? _localLibraryItem!.albumName : _downloadItem!.albumName); + String? get albumArtist { + final edited = _editedMetadata?['album_artist']?.toString(); + if (edited != null && edited.isNotEmpty) return edited; + return _normalizeOptionalString(_isLocalItem ? _localLibraryItem!.albumArtist : _downloadItem!.albumArtist); + } + int? get trackNumber { + final edited = _editedMetadata?['track_number']; + if (edited != null) { + final v = int.tryParse(edited.toString()); + if (v != null && v > 0) return v; + } + return _isLocalItem ? _localLibraryItem!.trackNumber : _downloadItem!.trackNumber; + } + int? get discNumber { + final edited = _editedMetadata?['disc_number']; + if (edited != null) { + final v = int.tryParse(edited.toString()); + if (v != null && v > 0) return v; + } + return _isLocalItem ? _localLibraryItem!.discNumber : _downloadItem!.discNumber; + } + String? get releaseDate => _editedMetadata?['date']?.toString() ?? (_isLocalItem ? _localLibraryItem!.releaseDate : _downloadItem!.releaseDate); + String? get isrc => _editedMetadata?['isrc']?.toString() ?? (_isLocalItem ? _localLibraryItem!.isrc : _downloadItem!.isrc); + String? get genre => _editedMetadata?['genre']?.toString() ?? (_isLocalItem ? _localLibraryItem!.genre : _downloadItem!.genre); + String? get label => _editedMetadata?['label']?.toString() ?? (_isLocalItem ? null : _downloadItem!.label); + String? get copyright => _editedMetadata?['copyright']?.toString() ?? (_isLocalItem ? null : _downloadItem!.copyright); int? get duration => _isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration; int? get bitDepth => _isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth; int? get sampleRate => _isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate; @@ -1083,6 +1103,380 @@ class _TrackMetadataScreenState extends ConsumerState { } } + String _buildSaveBaseName() { + final artist = artistName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'); + final track = trackName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_'); + return '$artist - $track'; + } + + String _getFileDirectory() { + if (isContentUri(cleanFilePath)) { + // SAF URIs don't have a filesystem parent directory + return ''; + } + final file = File(cleanFilePath); + return file.parent.path; + } + + bool get _isSafFile => isContentUri(cleanFilePath); + + Future _saveCoverArt() async { + try { + final baseName = _buildSaveBaseName(); + + if (_isSafFile) { + // SAF file: save to temp, then copy to SAF tree + final tempDir = await Directory.systemTemp.createTemp('cover_'); + final tempOutput = '${tempDir.path}${Platform.pathSeparator}$baseName.jpg'; + + Map result; + if (_coverUrl != null && _coverUrl!.isNotEmpty) { + result = await PlatformBridge.downloadCoverToFile( + _coverUrl!, + tempOutput, + maxQuality: true, + ); + } else if (_fileExists) { + result = await PlatformBridge.extractCoverToFile( + cleanFilePath, + tempOutput, + ); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackCoverNoSource)), + ); + } + return; + } + + if (result['error'] != null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveFailed(result['error'].toString()))), + ); + } + try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {} + return; + } + + // Write temp file to SAF tree + final treeUri = _downloadItem?.downloadTreeUri; + final relativeDir = _downloadItem?.safRelativeDir ?? ''; + if (treeUri != null && treeUri.isNotEmpty) { + final safUri = await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: '$baseName.jpg', + mimeType: 'image/jpeg', + srcPath: tempOutput, + ); + try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {} + if (mounted) { + if (safUri != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackCoverSaved(baseName))), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveFailed('Failed to write to storage'))), + ); + } + } + } else { + // No SAF tree info, keep in temp + try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {} + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveFailed('No storage access'))), + ); + } + } + return; + } + + // Regular file path + final dir = _getFileDirectory(); + final outputPath = '$dir${Platform.pathSeparator}$baseName.jpg'; + + Map result; + if (_coverUrl != null && _coverUrl!.isNotEmpty) { + result = await PlatformBridge.downloadCoverToFile( + _coverUrl!, + outputPath, + maxQuality: true, + ); + } else if (_fileExists) { + result = await PlatformBridge.extractCoverToFile( + cleanFilePath, + outputPath, + ); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackCoverNoSource)), + ); + } + return; + } + + if (mounted) { + if (result['error'] != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveFailed(result['error'].toString()))), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackCoverSaved(baseName))), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))), + ); + } + } + } + + Future _saveLyrics() async { + try { + final baseName = _buildSaveBaseName(); + final durationMs = (duration ?? 0) * 1000; + + if (_isSafFile) { + // SAF file: save to temp, then copy to SAF tree + final tempDir = await Directory.systemTemp.createTemp('lyrics_'); + final tempOutput = '${tempDir.path}${Platform.pathSeparator}$baseName.lrc'; + + final result = await PlatformBridge.fetchAndSaveLyrics( + trackName: trackName, + artistName: artistName, + spotifyId: _spotifyId ?? '', + durationMs: durationMs, + outputPath: tempOutput, + ); + + if (result['error'] != null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveFailed(result['error'].toString()))), + ); + } + try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {} + return; + } + + // Write temp file to SAF tree + final treeUri = _downloadItem?.downloadTreeUri; + final relativeDir = _downloadItem?.safRelativeDir ?? ''; + if (treeUri != null && treeUri.isNotEmpty) { + final safUri = await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: '$baseName.lrc', + mimeType: 'text/plain', + srcPath: tempOutput, + ); + try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {} + if (mounted) { + if (safUri != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackLyricsSaved(baseName))), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveFailed('Failed to write to storage'))), + ); + } + } + } else { + try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {} + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveFailed('No storage access'))), + ); + } + } + return; + } + + // Regular file path + final dir = _getFileDirectory(); + final outputPath = '$dir${Platform.pathSeparator}$baseName.lrc'; + + final result = await PlatformBridge.fetchAndSaveLyrics( + trackName: trackName, + artistName: artistName, + spotifyId: _spotifyId ?? '', + durationMs: durationMs, + outputPath: outputPath, + ); + + if (mounted) { + if (result['error'] != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveFailed(result['error'].toString()))), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackLyricsSaved(baseName))), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))), + ); + } + } + } + + Future _reEnrichMetadata() async { + if (!_fileExists) return; + + try { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackReEnrichSearching)), + ); + + final durationMs = (duration ?? 0) * 1000; + final request = { + 'file_path': cleanFilePath, + 'cover_url': _coverUrl ?? '', + 'max_quality': true, + 'embed_lyrics': true, + 'spotify_id': _spotifyId ?? '', + 'track_name': trackName, + 'artist_name': artistName, + 'album_name': albumName, + 'album_artist': albumArtist ?? artistName, + 'track_number': trackNumber ?? 0, + 'disc_number': discNumber ?? 0, + 'release_date': releaseDate ?? '', + 'isrc': isrc ?? '', + 'genre': genre ?? '', + 'label': label ?? '', + 'copyright': copyright ?? '', + 'duration_ms': durationMs, + 'search_online': true, + }; + + final result = await PlatformBridge.reEnrichFile(request); + final method = result['method'] as String?; + + // Update local UI state with enriched metadata from online search + final enriched = result['enriched_metadata'] as Map?; + if (enriched != null && mounted) { + setState(() { + _editedMetadata = { + 'title': enriched['track_name'] ?? trackName, + 'artist': enriched['artist_name'] ?? artistName, + 'album': enriched['album_name'] ?? albumName, + 'album_artist': enriched['album_artist'] ?? albumArtist, + 'date': enriched['release_date'] ?? releaseDate, + 'track_number': enriched['track_number'] ?? trackNumber, + 'disc_number': enriched['disc_number'] ?? discNumber, + 'isrc': enriched['isrc'] ?? isrc, + 'genre': enriched['genre'] ?? genre, + 'label': enriched['label'] ?? label, + 'copyright': enriched['copyright'] ?? copyright, + }; + }); + } + + if (method == 'native') { + // FLAC - handled natively by Go (SAF write-back handled in Kotlin) + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackReEnrichSuccess)), + ); + } + } else if (method == 'ffmpeg') { + // MP3/Opus - need FFmpeg from Dart side + // For SAF files, Kotlin returns temp_path + saf_uri + final tempPath = result['temp_path'] as String?; + final safUri = result['saf_uri'] as String?; + final ffmpegTarget = tempPath ?? cleanFilePath; + + final coverPath = result['cover_path'] as String?; + final metadata = (result['metadata'] as Map?) + ?.map((k, v) => MapEntry(k, v.toString())); + final lower = cleanFilePath.toLowerCase(); + + String? ffmpegResult; + if (lower.endsWith('.mp3')) { + ffmpegResult = await FFmpegService.embedMetadataToMp3( + mp3Path: ffmpegTarget, + coverPath: coverPath, + metadata: metadata, + ); + } else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) { + ffmpegResult = await FFmpegService.embedMetadataToOpus( + opusPath: ffmpegTarget, + coverPath: coverPath, + metadata: metadata, + ); + } + + // For SAF files, copy processed temp file back + if (ffmpegResult != null && tempPath != null && safUri != null) { + final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri); + if (!ok && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveFailed('Failed to write back to storage'))), + ); + // Cleanup temp files + if (coverPath != null && coverPath.isNotEmpty) { + try { await File(coverPath).delete(); } catch (_) {} + } + if (tempPath.isNotEmpty) { + try { await File(tempPath).delete(); } catch (_) {} + } + return; + } + } + + // Cleanup temp files + if (tempPath != null && tempPath.isNotEmpty) { + try { await File(tempPath).delete(); } catch (_) {} + } + + if (mounted) { + if (ffmpegResult != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackReEnrichSuccess)), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackReEnrichFfmpegFailed)), + ); + } + } + + // Cleanup temp cover from Go backend + if (coverPath != null && coverPath.isNotEmpty) { + try { await File(coverPath).delete(); } catch (_) {} + } + } else { + if (mounted) { + final error = result['error']?.toString() ?? 'Unknown error'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveFailed(error))), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))), + ); + } + } + } + String _cleanLrcForDisplay(String lrc) { final lines = lrc.split('\n'); final cleanLines = []; @@ -1148,8 +1542,13 @@ class _TrackMetadataScreenState extends ConsumerState { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), + isScrollControlled: true, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, + ), builder: (context) => SafeArea( - child: Column( + child: SingleChildScrollView( + child: Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 8), @@ -1170,6 +1569,46 @@ class _TrackMetadataScreenState extends ConsumerState { _copyToClipboard(context, cleanFilePath); }, ), + if (_fileExists) + ListTile( + leading: const Icon(Icons.edit_outlined), + title: Text(context.l10n.trackEditMetadata), + onTap: () { + Navigator.pop(context); + _showEditMetadataSheet(context, ref, colorScheme); + }, + ), + if (!_isLocalItem && (_coverUrl != null || _fileExists)) + ListTile( + leading: const Icon(Icons.image_outlined), + title: Text(context.l10n.trackSaveCoverArt), + subtitle: Text(context.l10n.trackSaveCoverArtSubtitle), + onTap: () { + Navigator.pop(context); + _saveCoverArt(); + }, + ), + if (!_isLocalItem) + ListTile( + leading: const Icon(Icons.lyrics_outlined), + title: Text(context.l10n.trackSaveLyrics), + subtitle: Text(context.l10n.trackSaveLyricsSubtitle), + onTap: () { + Navigator.pop(context); + _saveLyrics(); + }, + ), + if (_fileExists) + ListTile( + leading: const Icon(Icons.travel_explore), + title: Text(context.l10n.trackReEnrich), + subtitle: Text(context.l10n.trackReEnrichOnlineSubtitle), + onTap: () { + Navigator.pop(context); + _reEnrichMetadata(); + }, + ), + const Divider(height: 1), ListTile( leading: const Icon(Icons.share), title: Text(context.l10n.trackMetadataShare), @@ -1189,10 +1628,75 @@ class _TrackMetadataScreenState extends ConsumerState { const SizedBox(height: 16), ], ), + ), ), ); } + void _showEditMetadataSheet(BuildContext context, WidgetRef ref, ColorScheme colorScheme) async { + // Read current metadata from file, fall back to item data on failure + Map? fileMetadata; + try { + final result = await PlatformBridge.readFileMetadata(cleanFilePath); + if (result['error'] == null) { + fileMetadata = result; + } + } catch (e) { + debugPrint('readFileMetadata failed, using item data: $e'); + } + + // Build initial values map — prefer file metadata, fall back to item data + String val(String key, String? fallback) { + final v = fileMetadata?[key]?.toString(); + return (v != null && v.isNotEmpty) ? v : (fallback ?? ''); + } + + final initialValues = { + 'title': val('title', trackName), + 'artist': val('artist', artistName), + 'album': val('album', albumName), + 'album_artist': val('album_artist', albumArtist), + 'date': val('date', releaseDate), + 'track_number': (fileMetadata?['track_number'] ?? trackNumber ?? '').toString(), + 'disc_number': (fileMetadata?['disc_number'] ?? discNumber ?? '').toString(), + 'genre': val('genre', genre), + 'isrc': val('isrc', isrc), + 'label': val('label', label), + 'copyright': val('copyright', copyright), + 'composer': fileMetadata?['composer']?.toString() ?? '', + 'comment': fileMetadata?['comment']?.toString() ?? '', + }; + + if (!context.mounted) return; + + final saved = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (sheetContext) => _EditMetadataSheet( + colorScheme: colorScheme, + initialValues: initialValues, + filePath: cleanFilePath, + ), + ); + + if (saved == true && mounted) { + ScaffoldMessenger.of(this.context).showSnackBar( + const SnackBar(content: Text('Metadata saved successfully')), + ); + // Re-read metadata from file to refresh the display + try { + final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath); + setState(() => _editedMetadata = refreshed); + } catch (_) { + setState(() {}); + } + } + } + void _confirmDelete(BuildContext context, WidgetRef ref, ColorScheme colorScheme) { showDialog( context: context, @@ -1324,17 +1828,380 @@ class _TrackMetadataScreenState extends ConsumerState { Color _getServiceColor(String service, ColorScheme colorScheme) { switch (service.toLowerCase()) { case 'tidal': - return const Color(0xFF0077B5); // Tidal blue (darker, more readable) + return const Color(0xFF0077B5); case 'qobuz': - return const Color(0xFF0052CC); // Qobuz blue + return const Color(0xFF0052CC); case 'amazon': - return const Color(0xFFFF9900); // Amazon orange + return const Color(0xFFFF9900); default: return colorScheme.primary; } } } +class _EditMetadataSheet extends StatefulWidget { + final ColorScheme colorScheme; + final Map initialValues; + final String filePath; + + const _EditMetadataSheet({ + required this.colorScheme, + required this.initialValues, + required this.filePath, + }); + + @override + State<_EditMetadataSheet> createState() => _EditMetadataSheetState(); +} + +class _EditMetadataSheetState extends State<_EditMetadataSheet> { + bool _saving = false; + bool _showAdvanced = false; + + late final TextEditingController _titleCtrl; + late final TextEditingController _artistCtrl; + late final TextEditingController _albumCtrl; + late final TextEditingController _albumArtistCtrl; + late final TextEditingController _dateCtrl; + late final TextEditingController _trackNumCtrl; + late final TextEditingController _discNumCtrl; + late final TextEditingController _genreCtrl; + late final TextEditingController _isrcCtrl; + late final TextEditingController _labelCtrl; + late final TextEditingController _copyrightCtrl; + late final TextEditingController _composerCtrl; + late final TextEditingController _commentCtrl; + + @override + void initState() { + super.initState(); + final v = widget.initialValues; + _titleCtrl = TextEditingController(text: v['title'] ?? ''); + _artistCtrl = TextEditingController(text: v['artist'] ?? ''); + _albumCtrl = TextEditingController(text: v['album'] ?? ''); + _albumArtistCtrl = TextEditingController(text: v['album_artist'] ?? ''); + _dateCtrl = TextEditingController(text: v['date'] ?? ''); + _trackNumCtrl = TextEditingController(text: v['track_number'] ?? ''); + _discNumCtrl = TextEditingController(text: v['disc_number'] ?? ''); + _genreCtrl = TextEditingController(text: v['genre'] ?? ''); + _isrcCtrl = TextEditingController(text: v['isrc'] ?? ''); + _labelCtrl = TextEditingController(text: v['label'] ?? ''); + _copyrightCtrl = TextEditingController(text: v['copyright'] ?? ''); + _composerCtrl = TextEditingController(text: v['composer'] ?? ''); + _commentCtrl = TextEditingController(text: v['comment'] ?? ''); + } + + @override + void dispose() { + _titleCtrl.dispose(); + _artistCtrl.dispose(); + _albumCtrl.dispose(); + _albumArtistCtrl.dispose(); + _dateCtrl.dispose(); + _trackNumCtrl.dispose(); + _discNumCtrl.dispose(); + _genreCtrl.dispose(); + _isrcCtrl.dispose(); + _labelCtrl.dispose(); + _copyrightCtrl.dispose(); + _composerCtrl.dispose(); + _commentCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + setState(() => _saving = true); + + final metadata = { + 'title': _titleCtrl.text, + 'artist': _artistCtrl.text, + 'album': _albumCtrl.text, + 'album_artist': _albumArtistCtrl.text, + 'date': _dateCtrl.text, + 'track_number': _trackNumCtrl.text, + 'disc_number': _discNumCtrl.text, + 'genre': _genreCtrl.text, + 'isrc': _isrcCtrl.text, + 'label': _labelCtrl.text, + 'copyright': _copyrightCtrl.text, + 'composer': _composerCtrl.text, + 'comment': _commentCtrl.text, + }; + + try { + final result = await PlatformBridge.editFileMetadata( + widget.filePath, + metadata, + ); + + if (result['error'] != null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${result['error']}')), + ); + } + setState(() => _saving = false); + return; + } + + final method = result['method'] as String?; + + if (method == 'ffmpeg') { + // MP3/Opus: use FFmpeg to write metadata + // For SAF files, Kotlin returns temp_path + saf_uri + final tempPath = result['temp_path'] as String?; + final safUri = result['saf_uri'] as String?; + final ffmpegTarget = tempPath ?? widget.filePath; + + final lower = widget.filePath.toLowerCase(); + final isMp3 = lower.endsWith('.mp3'); + final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg'); + + final vorbisMap = {}; + if (metadata['title']?.isNotEmpty == true) vorbisMap['TITLE'] = metadata['title']!; + if (metadata['artist']?.isNotEmpty == true) vorbisMap['ARTIST'] = metadata['artist']!; + if (metadata['album']?.isNotEmpty == true) vorbisMap['ALBUM'] = metadata['album']!; + if (metadata['album_artist']?.isNotEmpty == true) vorbisMap['ALBUMARTIST'] = metadata['album_artist']!; + if (metadata['date']?.isNotEmpty == true) vorbisMap['DATE'] = metadata['date']!; + if (metadata['track_number']?.isNotEmpty == true && metadata['track_number'] != '0') { + vorbisMap['TRACKNUMBER'] = metadata['track_number']!; + } + if (metadata['disc_number']?.isNotEmpty == true && metadata['disc_number'] != '0') { + vorbisMap['DISCNUMBER'] = metadata['disc_number']!; + } + if (metadata['genre']?.isNotEmpty == true) vorbisMap['GENRE'] = metadata['genre']!; + if (metadata['isrc']?.isNotEmpty == true) vorbisMap['ISRC'] = metadata['isrc']!; + if (metadata['label']?.isNotEmpty == true) vorbisMap['ORGANIZATION'] = metadata['label']!; + if (metadata['copyright']?.isNotEmpty == true) vorbisMap['COPYRIGHT'] = metadata['copyright']!; + if (metadata['composer']?.isNotEmpty == true) vorbisMap['COMPOSER'] = metadata['composer']!; + if (metadata['comment']?.isNotEmpty == true) vorbisMap['COMMENT'] = metadata['comment']!; + + // Extract existing cover art before re-embedding metadata + String? existingCoverPath; + try { + final tempDir = await Directory.systemTemp.createTemp('cover_'); + final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg'; + final coverResult = await PlatformBridge.extractCoverToFile(ffmpegTarget, coverOutput); + if (coverResult['error'] == null) { + existingCoverPath = coverOutput; + } + } catch (_) { + // No cover to preserve, continue without + } + + String? ffmpegResult; + if (isMp3) { + ffmpegResult = await FFmpegService.embedMetadataToMp3( + mp3Path: ffmpegTarget, + coverPath: existingCoverPath, + metadata: vorbisMap, + ); + } else if (isOpus) { + ffmpegResult = await FFmpegService.embedMetadataToOpus( + opusPath: ffmpegTarget, + coverPath: existingCoverPath, + metadata: vorbisMap, + ); + } + + // Cleanup temp cover + if (existingCoverPath != null) { + try { await File(existingCoverPath).delete(); } catch (_) {} + } + + if (ffmpegResult == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to save metadata via FFmpeg')), + ); + } + setState(() => _saving = false); + return; + } + + // For SAF files, copy the processed temp file back + if (tempPath != null && safUri != null) { + final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri); + if (!ok && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to write metadata back to storage')), + ); + setState(() => _saving = false); + return; + } + } + } + + if (mounted) { + Navigator.pop(context, true); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to save metadata: $e')), + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final cs = widget.colorScheme; + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: DraggableScrollableSheet( + initialChildSize: 0.85, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) => Column( + children: [ + // Handle bar + Padding( + padding: const EdgeInsets.only(top: 12, bottom: 8), + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Title row + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + Expanded( + child: Text( + 'Edit Metadata', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + if (_saving) + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + FilledButton( + onPressed: _save, + child: const Text('Save'), + ), + ], + ), + ), + const SizedBox(height: 12), + // Fields + Expanded( + child: ListView( + controller: scrollController, + padding: const EdgeInsets.symmetric(horizontal: 24), + children: [ + _field('Title', _titleCtrl), + _field('Artist', _artistCtrl), + _field('Album', _albumCtrl), + _field('Album Artist', _albumArtistCtrl), + _field('Date', _dateCtrl, hint: 'YYYY-MM-DD or YYYY'), + Row( + children: [ + Expanded(child: _field('Track #', _trackNumCtrl, keyboard: TextInputType.number)), + const SizedBox(width: 12), + Expanded(child: _field('Disc #', _discNumCtrl, keyboard: TextInputType.number)), + ], + ), + _field('Genre', _genreCtrl), + _field('ISRC', _isrcCtrl), + // Advanced fields toggle + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: InkWell( + onTap: () => setState(() => _showAdvanced = !_showAdvanced), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Icon( + _showAdvanced ? Icons.expand_less : Icons.expand_more, + size: 20, + color: cs.onSurfaceVariant, + ), + const SizedBox(width: 8), + Text( + 'Advanced', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: cs.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + if (_showAdvanced) ...[ + _field('Label', _labelCtrl), + _field('Copyright', _copyrightCtrl), + _field('Composer', _composerCtrl), + _field('Comment', _commentCtrl, maxLines: 3), + ], + const SizedBox(height: 24), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _field( + String label, + TextEditingController controller, { + String? hint, + TextInputType? keyboard, + int maxLines = 1, + }) { + final cs = widget.colorScheme; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TextField( + controller: controller, + keyboardType: keyboard, + maxLines: maxLines, + decoration: InputDecoration( + labelText: label, + hintText: hint, + filled: true, + fillColor: cs.surfaceContainerHighest.withValues(alpha: 0.5), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: cs.primary, width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + ), + ); + } +} + class _MetadataItem { final String label; final String value; diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index cfe7d70b..b7194bb1 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -365,6 +365,7 @@ class FFmpegService { } cmdBuffer.write('-map 0:a '); + cmdBuffer.write('-map_metadata -1 '); if (coverPath != null) { cmdBuffer.write('-map 1:0 '); @@ -441,6 +442,8 @@ class FFmpegService { final StringBuffer cmdBuffer = StringBuffer(); cmdBuffer.write('-i "$opusPath" '); cmdBuffer.write('-map 0:a '); + cmdBuffer.write('-map_metadata -1 '); + cmdBuffer.write('-map_metadata:s:a -1 '); cmdBuffer.write('-c:a copy '); if (metadata != null) { @@ -654,6 +657,12 @@ class FFmpegService { case 'UNSYNCEDLYRICS': id3Map['lyrics'] = value; break; + case 'COMPOSER': + id3Map['composer'] = value; + break; + case 'COMMENT': + id3Map['comment'] = value; + break; default: id3Map[key.toLowerCase()] = value; } diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 1aeacfe4..7bcae04a 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -374,6 +374,55 @@ class PlatformBridge { await _channel.invokeMethod('cleanupConnections'); } + static Future> downloadCoverToFile( + String coverUrl, + String outputPath, { + bool maxQuality = true, + }) async { + final result = await _channel.invokeMethod('downloadCoverToFile', { + 'cover_url': coverUrl, + 'output_path': outputPath, + 'max_quality': maxQuality, + }); + return jsonDecode(result as String) as Map; + } + + static Future> extractCoverToFile( + String audioPath, + String outputPath, + ) async { + final result = await _channel.invokeMethod('extractCoverToFile', { + 'audio_path': audioPath, + 'output_path': outputPath, + }); + return jsonDecode(result as String) as Map; + } + + static Future> fetchAndSaveLyrics({ + required String trackName, + required String artistName, + required String spotifyId, + required int durationMs, + required String outputPath, + }) async { + final result = await _channel.invokeMethod('fetchAndSaveLyrics', { + 'track_name': trackName, + 'artist_name': artistName, + 'spotify_id': spotifyId, + 'duration_ms': durationMs, + 'output_path': outputPath, + }); + return jsonDecode(result as String) as Map; + } + + static Future> reEnrichFile(Map request) async { + final requestJSON = jsonEncode(request); + final result = await _channel.invokeMethod('reEnrichFile', { + 'request_json': requestJSON, + }); + return jsonDecode(result as String) as Map; + } + static Future> readFileMetadata(String filePath) async { final result = await _channel.invokeMethod('readFileMetadata', { 'file_path': filePath, @@ -381,6 +430,27 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Future> editFileMetadata( + String filePath, + Map metadata, + ) async { + final metadataJSON = jsonEncode(metadata); + final result = await _channel.invokeMethod('editFileMetadata', { + 'file_path': filePath, + 'metadata_json': metadataJSON, + }); + return jsonDecode(result as String) as Map; + } + + static Future writeTempToSaf(String tempPath, String safUri) async { + final result = await _channel.invokeMethod('writeTempToSaf', { + 'temp_path': tempPath, + 'saf_uri': safUri, + }); + final map = jsonDecode(result as String) as Map; + return map['success'] == true; + } + static Future startDownloadService({ String trackName = '', String artistName = '', @@ -953,68 +1023,68 @@ static Future> downloadWithExtensions({ }); } -/// Scan a folder for audio files and read their metadata - /// Returns a list of track metadata - static Future>> scanLibraryFolder(String folderPath) async { - _log.i('scanLibraryFolder: $folderPath'); - final result = await _channel.invokeMethod('scanLibraryFolder', { - 'folder_path': folderPath, - }); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); - } - - /// Perform an incremental scan of the library folder - /// Only scans files that are new or have changed since last scan - /// [existingFiles] is a map of filePath -> modTime (unix millis) - /// Returns IncrementalScanResult with scanned items, deleted paths, and skip count - static Future> scanLibraryFolderIncremental( - String folderPath, - Map existingFiles, - ) async { - _log.i('scanLibraryFolderIncremental: $folderPath (${existingFiles.length} existing files)'); - final result = await _channel.invokeMethod('scanLibraryFolderIncremental', { - 'folder_path': folderPath, - 'existing_files': jsonEncode(existingFiles), - }); - return jsonDecode(result as String) as Map; - } +/// Scan a folder for audio files and read their metadata + /// Returns a list of track metadata + static Future>> scanLibraryFolder(String folderPath) async { + _log.i('scanLibraryFolder: $folderPath'); + final result = await _channel.invokeMethod('scanLibraryFolder', { + 'folder_path': folderPath, + }); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } - static Future>> scanSafTree(String treeUri) async { - _log.i('scanSafTree: $treeUri'); - final result = await _channel.invokeMethod('scanSafTree', { - 'tree_uri': treeUri, - }); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); - } - - /// Incremental SAF tree scan - only scans new or modified files - /// Returns a map with 'files' (new/changed) and 'removedUris' (deleted files) - static Future> scanSafTreeIncremental( - String treeUri, - Map existingFiles, - ) async { - _log.i('scanSafTreeIncremental: $treeUri (${existingFiles.length} existing files)'); - final result = await _channel.invokeMethod('scanSafTreeIncremental', { - 'tree_uri': treeUri, - 'existing_files': jsonEncode(existingFiles), - }); - return jsonDecode(result as String) as Map; - } - - /// Get last-modified timestamps for a list of SAF file URIs. - /// Returns map uri -> modTime (unix millis), only for files that still exist. - static Future> getSafFileModTimes(List uris) async { - final result = await _channel.invokeMethod('getSafFileModTimes', { - 'uris': jsonEncode(uris), - }); - final map = jsonDecode(result as String) as Map; - return map.map((key, value) => MapEntry(key, (value as num).toInt())); - } - - /// Get current library scan progress - static Future> getLibraryScanProgress() async { + /// Perform an incremental scan of the library folder + /// Only scans files that are new or have changed since last scan + /// [existingFiles] is a map of filePath -> modTime (unix millis) + /// Returns IncrementalScanResult with scanned items, deleted paths, and skip count + static Future> scanLibraryFolderIncremental( + String folderPath, + Map existingFiles, + ) async { + _log.i('scanLibraryFolderIncremental: $folderPath (${existingFiles.length} existing files)'); + final result = await _channel.invokeMethod('scanLibraryFolderIncremental', { + 'folder_path': folderPath, + 'existing_files': jsonEncode(existingFiles), + }); + return jsonDecode(result as String) as Map; + } + + static Future>> scanSafTree(String treeUri) async { + _log.i('scanSafTree: $treeUri'); + final result = await _channel.invokeMethod('scanSafTree', { + 'tree_uri': treeUri, + }); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + /// Incremental SAF tree scan - only scans new or modified files + /// Returns a map with 'files' (new/changed) and 'removedUris' (deleted files) + static Future> scanSafTreeIncremental( + String treeUri, + Map existingFiles, + ) async { + _log.i('scanSafTreeIncremental: $treeUri (${existingFiles.length} existing files)'); + final result = await _channel.invokeMethod('scanSafTreeIncremental', { + 'tree_uri': treeUri, + 'existing_files': jsonEncode(existingFiles), + }); + return jsonDecode(result as String) as Map; + } + + /// Get last-modified timestamps for a list of SAF file URIs. + /// Returns map uri -> modTime (unix millis), only for files that still exist. + static Future> getSafFileModTimes(List uris) async { + final result = await _channel.invokeMethod('getSafFileModTimes', { + 'uris': jsonEncode(uris), + }); + final map = jsonDecode(result as String) as Map; + return map.map((key, value) => MapEntry(key, (value as num).toInt())); + } + + /// Get current library scan progress + static Future> getLibraryScanProgress() async { final result = await _channel.invokeMethod('getLibraryScanProgress'); return jsonDecode(result as String) as Map; } @@ -1113,71 +1183,71 @@ static Future> downloadWithExtensions({ return result as String; } - static Future clearStoreCache() async { - _log.d('clearStoreCache'); - await _channel.invokeMethod('clearStoreCache'); - } - - // ==================== YOUTUBE / COBALT ==================== - - /// Download a track from YouTube using the Cobalt API. - /// YouTube is a lossy-only provider (Opus 256kbps or MP3 320kbps). - /// It does NOT participate in the lossless fallback chain. - static Future> downloadFromYouTube({ - required String trackName, - required String artistName, - required String albumName, - String? albumArtist, - String? coverUrl, - required String outputDir, - required String filenameFormat, - String quality = 'opus_256', - int trackNumber = 1, - int discNumber = 1, - String? releaseDate, - String? itemId, - int durationMs = 0, - String? isrc, - String? spotifyId, - String? deezerId, - String storageMode = 'app', - String safTreeUri = '', - String safRelativeDir = '', - String safFileName = '', - String safOutputExt = '', - }) async { - _log.i('downloadFromYouTube: "$trackName" by $artistName (quality: $quality)'); - final request = jsonEncode({ - 'track_name': trackName, - 'artist_name': artistName, - 'album_name': albumName, - 'album_artist': albumArtist ?? artistName, - 'cover_url': coverUrl, - 'output_dir': outputDir, - 'filename_format': filenameFormat, - 'quality': quality, - 'track_number': trackNumber, - 'disc_number': discNumber, - 'release_date': releaseDate ?? '', - 'item_id': itemId ?? '', - 'duration_ms': durationMs, - 'isrc': isrc ?? '', - 'spotify_id': spotifyId ?? '', - 'deezer_id': deezerId ?? '', - 'storage_mode': storageMode, - 'saf_tree_uri': safTreeUri, - 'saf_relative_dir': safRelativeDir, - 'saf_file_name': safFileName, - 'saf_output_ext': safOutputExt, - }); - - final result = await _channel.invokeMethod('downloadFromYouTube', request); - final response = jsonDecode(result as String) as Map; - if (response['success'] == true) { - _log.i('YouTube download success: ${response['file_path']}'); - } else { - _log.w('YouTube download failed: ${response['error']}'); - } - return response; - } -} + static Future clearStoreCache() async { + _log.d('clearStoreCache'); + await _channel.invokeMethod('clearStoreCache'); + } + + // ==================== YOUTUBE / COBALT ==================== + + /// Download a track from YouTube using the Cobalt API. + /// YouTube is a lossy-only provider (Opus 256kbps or MP3 320kbps). + /// It does NOT participate in the lossless fallback chain. + static Future> downloadFromYouTube({ + required String trackName, + required String artistName, + required String albumName, + String? albumArtist, + String? coverUrl, + required String outputDir, + required String filenameFormat, + String quality = 'opus_256', + int trackNumber = 1, + int discNumber = 1, + String? releaseDate, + String? itemId, + int durationMs = 0, + String? isrc, + String? spotifyId, + String? deezerId, + String storageMode = 'app', + String safTreeUri = '', + String safRelativeDir = '', + String safFileName = '', + String safOutputExt = '', + }) async { + _log.i('downloadFromYouTube: "$trackName" by $artistName (quality: $quality)'); + final request = jsonEncode({ + 'track_name': trackName, + 'artist_name': artistName, + 'album_name': albumName, + 'album_artist': albumArtist ?? artistName, + 'cover_url': coverUrl, + 'output_dir': outputDir, + 'filename_format': filenameFormat, + 'quality': quality, + 'track_number': trackNumber, + 'disc_number': discNumber, + 'release_date': releaseDate ?? '', + 'item_id': itemId ?? '', + 'duration_ms': durationMs, + 'isrc': isrc ?? '', + 'spotify_id': spotifyId ?? '', + 'deezer_id': deezerId ?? '', + 'storage_mode': storageMode, + 'saf_tree_uri': safTreeUri, + 'saf_relative_dir': safRelativeDir, + 'saf_file_name': safFileName, + 'saf_output_ext': safOutputExt, + }); + + final result = await _channel.invokeMethod('downloadFromYouTube', request); + final response = jsonDecode(result as String) as Map; + if (response['success'] == true) { + _log.i('YouTube download success: ${response['file_path']}'); + } else { + _log.w('YouTube download failed: ${response['error']}'); + } + return response; + } +}