diff --git a/CHANGELOG.md b/CHANGELOG.md index 438dac32..0569a894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ### Added +- **Genre & Label Metadata**: Downloaded tracks now include genre and record label information + - Fetches genre and label from Deezer album API for each track + - Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files + - Works automatically when Deezer track ID is available (via ISRC matching) + - Supports all download services (Tidal, Qobuz, Amazon) + - **MP3 Quality Option**: Optional MP3 download format with FLAC-to-MP3 conversion - New "Enable MP3 Option" toggle in Settings > Download > Audio Quality - When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index fb5ec321..625bfd66 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -284,6 +284,13 @@ class MainActivity: FlutterActivity() { } result.success(response) } + "getDeezerExtendedMetadata" -> { + val trackId = call.argument("track_id") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.getDeezerExtendedMetadata(trackId) + } + result.success(response) + } "convertSpotifyToDeezer" -> { val resourceType = call.argument("resource_type") ?: "" val spotifyId = call.argument("spotify_id") ?: "" @@ -438,6 +445,14 @@ class MainActivity: FlutterActivity() { } result.success(null) } + "invokeExtensionAction" -> { + val extensionId = call.argument("extension_id") ?: "" + val actionName = call.argument("action") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.invokeExtensionActionJSON(extensionId, actionName) + } + result.success(response) + } "searchTracksWithExtensions" -> { val query = call.argument("query") ?: "" val limit = call.argument("limit") ?: 20 diff --git a/go_backend/amazon.go b/go_backend/amazon.go index b6bb4c40..f5e6dc69 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -564,6 +564,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { TotalTracks: req.TotalTracks, DiscNumber: actualDiscNum, ISRC: req.ISRC, + Genre: req.Genre, // From Deezer album metadata + Label: req.Label, // From Deezer album metadata + Copyright: req.Copyright, // From Deezer album metadata } // Use cover data from parallel fetch diff --git a/go_backend/deezer.go b/go_backend/deezer.go index a3fcedd5..03ef7fdc 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -132,16 +132,25 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata { } } +type deezerGenre struct { + ID int `json:"id"` + Name string `json:"name"` +} + type deezerAlbumFull struct { - ID int64 `json:"id"` - Title string `json:"title"` - Cover string `json:"cover"` - CoverMedium string `json:"cover_medium"` - CoverBig string `json:"cover_big"` - CoverXL string `json:"cover_xl"` - ReleaseDate string `json:"release_date"` - NbTracks int `json:"nb_tracks"` - RecordType string `json:"record_type"` // album, single, ep, compile + ID int64 `json:"id"` + Title string `json:"title"` + Cover string `json:"cover"` + CoverMedium string `json:"cover_medium"` + CoverBig string `json:"cover_big"` + CoverXL string `json:"cover_xl"` + ReleaseDate string `json:"release_date"` + NbTracks int `json:"nb_tracks"` + RecordType string `json:"record_type"` // album, single, ep, compile + Label string `json:"label"` // Record label name + Genres struct { + Data []deezerGenre `json:"data"` + } `json:"genres"` Artist deezerArtist `json:"artist"` Contributors []deezerArtist `json:"contributors"` Tracks struct { @@ -310,12 +319,23 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp artistName = strings.Join(names, ", ") } + // Extract genres as comma-separated string + var genres []string + for _, g := range album.Genres.Data { + if g.Name != "" { + genres = append(genres, g.Name) + } + } + genreStr := strings.Join(genres, ", ") + info := AlbumInfoMetadata{ TotalTracks: album.NbTracks, Name: album.Title, ReleaseDate: album.ReleaseDate, Artists: artistName, Images: albumImage, + Genre: genreStr, // From Deezer album + Label: album.Label, // From Deezer album } // Fetch ISRCs in parallel @@ -677,6 +697,84 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string { return album.Cover } +// AlbumExtendedMetadata contains genre and label information from an album +type AlbumExtendedMetadata struct { + Genre string // Comma-separated list of genres + Label string // Record label name +} + +// GetAlbumExtendedMetadata fetches genre and label from a Deezer album +// Uses the album ID from a track to fetch extended metadata +func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) { + if albumID == "" { + return nil, fmt.Errorf("empty album ID") + } + + // Check cache first + cacheKey := fmt.Sprintf("album_meta:%s", albumID) + c.cacheMu.RLock() + if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() { + c.cacheMu.RUnlock() + return entry.data.(*AlbumExtendedMetadata), nil + } + c.cacheMu.RUnlock() + + albumURL := fmt.Sprintf(deezerAlbumURL, albumID) + + var album deezerAlbumFull + if err := c.getJSON(ctx, albumURL, &album); err != nil { + return nil, fmt.Errorf("failed to fetch album: %w", err) + } + + // Extract genres as comma-separated string + var genres []string + for _, g := range album.Genres.Data { + if g.Name != "" { + genres = append(genres, g.Name) + } + } + + result := &AlbumExtendedMetadata{ + Genre: strings.Join(genres, ", "), + Label: album.Label, + } + + // Cache the result + c.cacheMu.Lock() + c.searchCache[cacheKey] = &cacheEntry{ + data: result, + expiresAt: time.Now().Add(deezerCacheTTL), + } + c.cacheMu.Unlock() + + GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label) + + return result, nil +} + +// GetTrackAlbumID fetches the album ID for a Deezer track +func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) { + trackURL := fmt.Sprintf(deezerTrackURL, trackID) + + var track deezerTrack + if err := c.getJSON(ctx, trackURL, &track); err != nil { + return "", err + } + + return fmt.Sprintf("%d", track.Album.ID), nil +} + +// GetExtendedMetadataByTrackID fetches genre and label using a Deezer track ID +// This is a convenience function that first gets the album ID, then fetches album metadata +func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) { + albumID, err := c.GetTrackAlbumID(ctx, trackID) + if err != nil { + return nil, fmt.Errorf("failed to get album ID: %w", err) + } + + return c.GetAlbumExtendedMetadata(ctx, albumID) +} + func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { diff --git a/go_backend/exports.go b/go_backend/exports.go index 3206b494..6d65ef0c 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -153,6 +153,10 @@ type DownloadRequest struct { ItemID string `json:"item_id"` // Unique ID for progress tracking DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification) Source string `json:"source"` // Extension ID that provided this track (prioritize this extension) + // Extended metadata from Deezer for FLAC tagging + Genre string `json:"genre,omitempty"` // Music genre(s), comma-separated + Label string `json:"label,omitempty"` // Record label name + Copyright string `json:"copyright,omitempty"` // Copyright information // Enriched IDs from Odesli/song.link - used to skip search and directly fetch TidalID string `json:"tidal_id,omitempty"` QobuzID string `json:"qobuz_id,omitempty"` @@ -837,6 +841,37 @@ func ParseDeezerURLExport(url string) (string, error) { return string(jsonBytes), nil } +// GetDeezerExtendedMetadata fetches genre and label from Deezer album +// trackID: Deezer track ID (will look up album ID from track) +// Returns JSON with genre, label fields +func GetDeezerExtendedMetadata(trackID string) (string, error) { + if trackID == "" { + return "", fmt.Errorf("empty track ID") + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + client := GetDeezerClient() + metadata, err := client.GetExtendedMetadataByTrackID(ctx, trackID) + if err != nil { + GoLog("[Deezer] Failed to get extended metadata: %v\n", err) + return "", err + } + + result := map[string]string{ + "genre": metadata.Genre, + "label": metadata.Label, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + // SearchDeezerByISRC searches for a track by ISRC on Deezer func SearchDeezerByISRC(isrc string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -1290,6 +1325,23 @@ func CleanupExtensions() { manager.UnloadAllExtensions() } +// InvokeExtensionActionJSON invokes a custom action on an extension (e.g., button click handler) +// actionName is the JS function name to call (e.g., "startLogin", "authenticate", etc.) +func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) { + manager := GetExtensionManager() + result, err := manager.InvokeAction(extensionID, actionName) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + // ==================== EXTENSION AUTH API ==================== // GetExtensionPendingAuthJSON returns pending auth request for an extension diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 25f09dac..fd390704 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -24,6 +24,9 @@ type Metadata struct { ISRC string Description string Lyrics string + Genre string // Music genre (e.g., "Rock", "Pop", "Electronic") + Label string // Record label (ORGANIZATION tag in Vorbis) + Copyright string // Copyright information } // EmbedMetadata embeds metadata into a FLAC file @@ -82,6 +85,18 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics) } + if metadata.Genre != "" { + setComment(cmt, "GENRE", metadata.Genre) + } + + if metadata.Label != "" { + setComment(cmt, "ORGANIZATION", metadata.Label) + } + + if metadata.Copyright != "" { + setComment(cmt, "COPYRIGHT", metadata.Copyright) + } + cmtBlock := cmt.Marshal() if cmtIdx >= 0 { f.Meta[cmtIdx] = &cmtBlock @@ -180,6 +195,18 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData [] setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics) } + if metadata.Genre != "" { + setComment(cmt, "GENRE", metadata.Genre) + } + + if metadata.Label != "" { + setComment(cmt, "ORGANIZATION", metadata.Label) + } + + if metadata.Copyright != "" { + setComment(cmt, "COPYRIGHT", metadata.Copyright) + } + cmtBlock := cmt.Marshal() if cmtIdx >= 0 { f.Meta[cmtIdx] = &cmtBlock diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 2719b909..5165c127 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -1120,6 +1120,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { TotalTracks: req.TotalTracks, DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result ISRC: track.ISRC, + Genre: req.Genre, // From Deezer album metadata + Label: req.Label, // From Deezer album metadata + Copyright: req.Copyright, // From Deezer album metadata } var coverData []byte diff --git a/go_backend/spotify.go b/go_backend/spotify.go index b88d275f..22f4cf99 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -182,6 +182,9 @@ type AlbumInfoMetadata struct { ReleaseDate string `json:"release_date"` Artists string `json:"artists"` Images string `json:"images"` + Genre string `json:"genre,omitempty"` // Music genre(s), comma-separated + Label string `json:"label,omitempty"` // Record label name + Copyright string `json:"copyright,omitempty"` // Copyright information } // AlbumResponsePayload is the response for album requests diff --git a/go_backend/tidal.go b/go_backend/tidal.go index d537a954..45a92ca2 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1716,6 +1716,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { TotalTracks: req.TotalTracks, DiscNumber: track.VolumeNumber, // Use actual disc number from Tidal ISRC: track.ISRC, // Use actual ISRC from Tidal + Genre: req.Genre, // From Deezer album metadata + Label: req.Label, // From Deezer album metadata + Copyright: req.Copyright, // From Deezer album metadata } var coverData []byte diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 789a950d..c6a373d7 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -227,6 +227,13 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + case "getDeezerExtendedMetadata": + let args = call.arguments as! [String: Any] + let trackId = args["track_id"] as! String + let response = GobackendGetDeezerExtendedMetadata(trackId, &error) + if let error = error { throw error } + return response + case "convertSpotifyToDeezer": let args = call.arguments as! [String: Any] let resourceType = args["resource_type"] as! String @@ -375,6 +382,14 @@ import Gobackend // Import Go framework if let error = error { throw error } return nil + case "invokeExtensionAction": + let args = call.arguments as! [String: Any] + let extensionId = args["extension_id"] as! String + let actionName = args["action"] as! String + let response = GobackendInvokeExtensionActionJSON(extensionId, actionName, &error) + if let error = error { throw error } + return response + case "searchTracksWithExtensions": let args = call.arguments as! [String: Any] let query = args["query"] as! String diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index c846dbd0..3a3dc082 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1568,6 +1568,35 @@ class DownloadQueueNotifier extends Notifier { final quality = item.qualityOverride ?? state.audioQuality; + // Fetch extended metadata (genre, label) from Deezer if available + String? genre; + String? label; + + // Try to get Deezer track ID from various sources + String? deezerTrackId = trackToDownload.deezerId; + if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) { + deezerTrackId = trackToDownload.id.split(':')[1]; + } + if (deezerTrackId == null && trackToDownload.availability?.deezerId != null) { + deezerTrackId = trackToDownload.availability!.deezerId; + } + + if (deezerTrackId != null && deezerTrackId.isNotEmpty) { + try { + final extendedMetadata = await PlatformBridge.getDeezerExtendedMetadata(deezerTrackId); + if (extendedMetadata != null) { + genre = extendedMetadata['genre']; + label = extendedMetadata['label']; + if (genre != null && genre.isNotEmpty) { + _log.d('Extended metadata - Genre: $genre, Label: $label'); + } + } + } catch (e) { + _log.w('Failed to fetch extended metadata from Deezer: $e'); + // Continue without extended metadata + } + } + Map result; final extensionState = ref.read(extensionProvider); @@ -1622,6 +1651,8 @@ class DownloadQueueNotifier extends Notifier { itemId: item.id, // Pass item ID for progress tracking durationMs: trackToDownload.duration, // Duration in ms for verification + genre: genre, + label: label, ); } else { result = await PlatformBridge.downloadTrack( diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 3df93bfc..44ef3939 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -129,6 +129,10 @@ class PlatformBridge { String preferredService = 'tidal', String? itemId, int durationMs = 0, + // Extended metadata for FLAC tagging + String? genre, + String? label, + String? copyright, }) async { _log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)'); final request = jsonEncode({ @@ -151,6 +155,10 @@ class PlatformBridge { 'release_date': releaseDate ?? '', 'item_id': itemId ?? '', 'duration_ms': durationMs, + // Extended metadata + 'genre': genre ?? '', + 'label': label ?? '', + 'copyright': copyright ?? '', }); final result = await _channel.invokeMethod('downloadWithFallback', request); @@ -411,6 +419,25 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + /// Get extended metadata (genre, label) from Deezer using track ID + /// Returns {"genre": "...", "label": "..."} or null if not found + static Future?> getDeezerExtendedMetadata(String trackId) async { + try { + final result = await _channel.invokeMethod('getDeezerExtendedMetadata', { + 'track_id': trackId, + }); + if (result == null) return null; + final data = jsonDecode(result as String) as Map; + return { + 'genre': data['genre'] as String? ?? '', + 'label': data['label'] as String? ?? '', + }; + } catch (e) { + _log.w('Failed to get Deezer extended metadata for $trackId: $e'); + return null; + } + } + /// Convert Spotify track to Deezer and get metadata (for rate limit fallback) static Future> convertSpotifyToDeezer(String resourceType, String spotifyId) async { final result = await _channel.invokeMethod('convertSpotifyToDeezer', { @@ -583,6 +610,20 @@ class PlatformBridge { }); } + /// Invoke an action on an extension (e.g., button click handler like "startLogin") + /// Returns the result from the JS function + static Future> invokeExtensionAction(String extensionId, String actionName) async { + _log.d('invokeExtensionAction: $extensionId.$actionName'); + final result = await _channel.invokeMethod('invokeExtensionAction', { + 'extension_id': extensionId, + 'action': actionName, + }); + if (result == null || (result as String).isEmpty) { + return {'success': true}; + } + return jsonDecode(result) as Map; + } + /// Search tracks using extension providers static Future>> searchTracksWithExtensions(String query, {int limit = 20}) async { _log.d('searchTracksWithExtensions: "$query"');