feat: save cover art, save lyrics, re-enrich metadata with full SAF support + YouTube Cobalt provider with SpotubeDL fallback + metadata summary logging

This commit is contained in:
zarzet
2026-02-09 23:07:18 +07:00
parent 7ec5d28caf
commit df39d61ed4
31 changed files with 3119 additions and 316 deletions
+28
View File
@@ -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)
---
+1
View File
@@ -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)
@@ -1597,7 +1597,182 @@ class MainActivity: FlutterFragmentActivity() {
"readFileMetadata" -> {
val filePath = call.argument<String>("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<String>("file_path") ?: ""
val metadataJson = call.argument<String>("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<String>("temp_path") ?: ""
val safUri = call.argument<String>("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<String>("cover_url") ?: ""
val outputPath = call.argument<String>("output_path") ?: ""
val maxQuality = call.argument<Boolean>("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<String>("audio_path") ?: ""
val outputPath = call.argument<String>("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<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val spotifyId = call.argument<String>("spotify_id") ?: ""
val durationMs = call.argument<Long>("duration_ms") ?: 0L
val outputPath = call.argument<String>("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<String>("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<String>("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)
}
+68
View File
@@ -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
}
}
}
+537 -24
View File
@@ -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 {
+24
View File
@@ -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
}
}
+8 -7
View File
@@ -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)
}
}
+105 -52
View File
@@ -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 {
+8
View File
@@ -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
+96
View File
@@ -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
+56
View File
@@ -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';
}
}
+56
View File
@@ -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';
}
}
+56
View File
@@ -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`).
+56
View File
@@ -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';
}
}
+56
View File
@@ -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';
}
}
+59
View File
@@ -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';
}
}
+56
View File
@@ -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';
}
}
+56
View File
@@ -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';
}
}
+56
View File
@@ -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';
}
}
+56
View File
@@ -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`).
+56
View File
@@ -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';
}
}
+56
View File
@@ -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';
}
}
+56
View File
@@ -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`).
+49 -1
View File
@@ -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"}
}
}
}
+49 -1
View File
@@ -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"}
}
}
}
+102 -13
View File
@@ -1253,6 +1253,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
}
}
// 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<String, dynamic>) {
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<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
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');
+1 -1
View File
@@ -1549,7 +1549,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
duration: item.durationMs ~/ 1000,
trackNumber: 1,
discNumber: 1,
isrc: item.id,
isrc: null,
releaseDate: null,
coverUrl: item.coverUrl,
source: item.providerId ?? 'spotify-web',
@@ -22,7 +22,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
}
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
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<DownloadSettingsPage> {
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),
),
),
),
],
),
),
],
),
),
),
+882 -15
View File
@@ -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<TrackMetadataScreen> {
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<String, dynamic>? _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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
}
}
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<void> _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<String, dynamic> 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<String, dynamic> 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<void> _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<void> _reEnrichMetadata() async {
if (!_fileExists) return;
try {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackReEnrichSearching)),
);
final durationMs = (duration ?? 0) * 1000;
final request = <String, dynamic>{
'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<String, dynamic>?;
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<String, dynamic>?)
?.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 = <String>[];
@@ -1148,8 +1542,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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<TrackMetadataScreen> {
_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<TrackMetadataScreen> {
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<String, dynamic>? 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 = <String, String>{
'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<bool>(
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<TrackMetadataScreen> {
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<String, String> 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<void> _save() async {
setState(() => _saving = true);
final metadata = <String, String>{
'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 = <String, String>{};
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;
+9
View File
@@ -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;
}
+199 -129
View File
@@ -374,6 +374,55 @@ class PlatformBridge {
await _channel.invokeMethod('cleanupConnections');
}
static Future<Map<String, dynamic>> 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<String, dynamic>;
}
static Future<Map<String, dynamic>> 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<String, dynamic>;
}
static Future<Map<String, dynamic>> 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<String, dynamic>;
}
static Future<Map<String, dynamic>> reEnrichFile(Map<String, dynamic> request) async {
final requestJSON = jsonEncode(request);
final result = await _channel.invokeMethod('reEnrichFile', {
'request_json': requestJSON,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> 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<String, dynamic>;
}
static Future<Map<String, dynamic>> editFileMetadata(
String filePath,
Map<String, String> 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<String, dynamic>;
}
static Future<bool> 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<String, dynamic>;
return map['success'] == true;
}
static Future<void> startDownloadService({
String trackName = '',
String artistName = '',
@@ -953,68 +1023,68 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
});
}
/// Scan a folder for audio files and read their metadata
/// Returns a list of track metadata
static Future<List<Map<String, dynamic>>> 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<dynamic>;
return list.map((e) => e as Map<String, dynamic>).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<Map<String, dynamic>> scanLibraryFolderIncremental(
String folderPath,
Map<String, int> 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<String, dynamic>;
}
/// Scan a folder for audio files and read their metadata
/// Returns a list of track metadata
static Future<List<Map<String, dynamic>>> 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<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList();
}
static Future<List<Map<String, dynamic>>> 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<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList();
}
/// Incremental SAF tree scan - only scans new or modified files
/// Returns a map with 'files' (new/changed) and 'removedUris' (deleted files)
static Future<Map<String, dynamic>> scanSafTreeIncremental(
String treeUri,
Map<String, int> 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<String, dynamic>;
}
/// 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<Map<String, int>> getSafFileModTimes(List<String> uris) async {
final result = await _channel.invokeMethod('getSafFileModTimes', {
'uris': jsonEncode(uris),
});
final map = jsonDecode(result as String) as Map<String, dynamic>;
return map.map((key, value) => MapEntry(key, (value as num).toInt()));
}
/// Get current library scan progress
static Future<Map<String, dynamic>> 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<Map<String, dynamic>> scanLibraryFolderIncremental(
String folderPath,
Map<String, int> 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<String, dynamic>;
}
static Future<List<Map<String, dynamic>>> 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<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList();
}
/// Incremental SAF tree scan - only scans new or modified files
/// Returns a map with 'files' (new/changed) and 'removedUris' (deleted files)
static Future<Map<String, dynamic>> scanSafTreeIncremental(
String treeUri,
Map<String, int> 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<String, dynamic>;
}
/// 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<Map<String, int>> getSafFileModTimes(List<String> uris) async {
final result = await _channel.invokeMethod('getSafFileModTimes', {
'uris': jsonEncode(uris),
});
final map = jsonDecode(result as String) as Map<String, dynamic>;
return map.map((key, value) => MapEntry(key, (value as num).toInt()));
}
/// Get current library scan progress
static Future<Map<String, dynamic>> getLibraryScanProgress() async {
final result = await _channel.invokeMethod('getLibraryScanProgress');
return jsonDecode(result as String) as Map<String, dynamic>;
}
@@ -1113,71 +1183,71 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
return result as String;
}
static Future<void> 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<Map<String, dynamic>> 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<String, dynamic>;
if (response['success'] == true) {
_log.i('YouTube download success: ${response['file_path']}');
} else {
_log.w('YouTube download failed: ${response['error']}');
}
return response;
}
}
static Future<void> 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<Map<String, dynamic>> 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<String, dynamic>;
if (response['success'] == true) {
_log.i('YouTube download success: ${response['file_path']}');
} else {
_log.w('YouTube download failed: ${response['error']}');
}
return response;
}
}