feat: add genre and label metadata to FLAC downloads

- Fetch genre and label from Deezer album API before download
- Add GENRE, ORGANIZATION (label), and COPYRIGHT tags to FLAC files
- Update Go Metadata struct with new fields
- Add GetDeezerExtendedMetadata export function for Flutter
- Register platform channel handlers for Android and iOS
- Pass genre/label through download flow to all services (Tidal/Qobuz/Amazon)
This commit is contained in:
zarzet
2026-01-19 00:48:11 +07:00
parent 42d15db4ca
commit e7077781e6
12 changed files with 306 additions and 9 deletions
+6
View File
@@ -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
@@ -284,6 +284,13 @@ class MainActivity: FlutterActivity() {
}
result.success(response)
}
"getDeezerExtendedMetadata" -> {
val trackId = call.argument<String>("track_id") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getDeezerExtendedMetadata(trackId)
}
result.success(response)
}
"convertSpotifyToDeezer" -> {
val resourceType = call.argument<String>("resource_type") ?: ""
val spotifyId = call.argument<String>("spotify_id") ?: ""
@@ -438,6 +445,14 @@ class MainActivity: FlutterActivity() {
}
result.success(null)
}
"invokeExtensionAction" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val actionName = call.argument<String>("action") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.invokeExtensionActionJSON(extensionId, actionName)
}
result.success(response)
}
"searchTracksWithExtensions" -> {
val query = call.argument<String>("query") ?: ""
val limit = call.argument<Int>("limit") ?: 20
+3
View File
@@ -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
+107 -9
View File
@@ -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 {
+52
View File
@@ -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
+27
View File
@@ -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
+3
View File
@@ -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
+3
View File
@@ -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
+3
View File
@@ -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
+15
View File
@@ -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
@@ -1568,6 +1568,35 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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<String, dynamic> result;
final extensionState = ref.read(extensionProvider);
@@ -1622,6 +1651,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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(
+41
View File
@@ -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<String, dynamic>;
}
/// Get extended metadata (genre, label) from Deezer using track ID
/// Returns {"genre": "...", "label": "..."} or null if not found
static Future<Map<String, String>?> 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<String, dynamic>;
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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<String, dynamic>;
}
/// Search tracks using extension providers
static Future<List<Map<String, dynamic>>> searchTracksWithExtensions(String query, {int limit = 20}) async {
_log.d('searchTracksWithExtensions: "$query"');