mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 23:38:50 +02:00
marshalJSONString collapses 62 identical marshal-and-return blocks in the gomobile exports; jsError/jsSuccess collapse 135 goja extension-response maps; the two MusicBrainz fetchers share one request+retry helper; the extension-HTTP default User-Agent block and fileExists get single homes. No exported signature changes.
765 lines
23 KiB
Go
765 lines
23 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
|
return GetDeezerClient().GetExtendedMetadataByISRC(ctx, isrc)
|
|
}
|
|
|
|
var fetchMusicBrainzGenreByISRC = FetchMusicBrainzGenreByISRC
|
|
|
|
var fetchMusicBrainzAlbumArtistByISRC = FetchMusicBrainzAlbumArtistByISRC
|
|
|
|
type reEnrichRequest struct {
|
|
FilePath string `json:"file_path"`
|
|
CoverURL string `json:"cover_url"`
|
|
MaxQuality bool `json:"max_quality"`
|
|
EmbedLyrics bool `json:"embed_lyrics"`
|
|
LyricsMode string `json:"lyrics_mode,omitempty"`
|
|
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
|
|
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"`
|
|
TotalTracks int `json:"total_tracks,omitempty"`
|
|
TotalDiscs int `json:"total_discs,omitempty"`
|
|
ReleaseDate string `json:"release_date"`
|
|
ISRC string `json:"isrc"`
|
|
Genre string `json:"genre"`
|
|
Label string `json:"label"`
|
|
Copyright string `json:"copyright"`
|
|
Composer string `json:"composer"`
|
|
DurationMs int64 `json:"duration_ms"`
|
|
SearchOnline bool `json:"search_online"`
|
|
UpdateFields []string `json:"update_fields,omitempty"`
|
|
}
|
|
|
|
// shouldUpdateField returns true if the given field group should be updated.
|
|
// When UpdateFields is empty/nil, all fields are updated (backward compatible).
|
|
func (r *reEnrichRequest) shouldUpdateField(field string) bool {
|
|
if len(r.UpdateFields) == 0 {
|
|
return true
|
|
}
|
|
for _, f := range r.UpdateFields {
|
|
if f == field {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// lyricsEmbedEnabled reports whether lyrics should be written into the audio
|
|
// file's tags. It mirrors the download path semantics: 'embed' and 'both' embed,
|
|
// 'external' does not. An empty mode keeps the legacy behavior (embed) so older
|
|
// callers that do not send lyrics_mode are unaffected.
|
|
func (r *reEnrichRequest) lyricsEmbedEnabled() bool {
|
|
return strings.ToLower(strings.TrimSpace(r.LyricsMode)) != "external"
|
|
}
|
|
|
|
// lyricsSidecarEnabled reports whether a .lrc sidecar file should be written
|
|
// next to the audio file. Only 'external' and 'both' request a sidecar.
|
|
func (r *reEnrichRequest) lyricsSidecarEnabled() bool {
|
|
mode := strings.ToLower(strings.TrimSpace(r.LyricsMode))
|
|
return mode == "external" || mode == "both"
|
|
}
|
|
|
|
func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
|
|
if req == nil {
|
|
return
|
|
}
|
|
|
|
if track.SpotifyID != "" {
|
|
req.SpotifyID = track.SpotifyID
|
|
} else if track.DeezerID != "" {
|
|
req.SpotifyID = "deezer:" + track.DeezerID
|
|
} else if track.QobuzID != "" {
|
|
req.SpotifyID = "qobuz:" + track.QobuzID
|
|
} else if track.TidalID != "" {
|
|
req.SpotifyID = "tidal:" + track.TidalID
|
|
} else if track.ID != "" {
|
|
req.SpotifyID = track.ID
|
|
}
|
|
|
|
if req.shouldUpdateField("basic_tags") {
|
|
if track.Name != "" {
|
|
req.TrackName = track.Name
|
|
}
|
|
if track.Artists != "" {
|
|
req.ArtistName = track.Artists
|
|
}
|
|
if track.AlbumName != "" {
|
|
req.AlbumName = track.AlbumName
|
|
}
|
|
if track.AlbumArtist != "" {
|
|
req.AlbumArtist = track.AlbumArtist
|
|
}
|
|
}
|
|
if req.shouldUpdateField("track_info") {
|
|
if track.TrackNumber > 0 {
|
|
req.TrackNumber = track.TrackNumber
|
|
}
|
|
if track.TotalTracks > 0 {
|
|
req.TotalTracks = track.TotalTracks
|
|
}
|
|
if track.DiscNumber > 0 {
|
|
req.DiscNumber = track.DiscNumber
|
|
}
|
|
if track.TotalDiscs > 0 {
|
|
req.TotalDiscs = track.TotalDiscs
|
|
}
|
|
}
|
|
if req.shouldUpdateField("release_info") {
|
|
if track.ReleaseDate != "" {
|
|
req.ReleaseDate = track.ReleaseDate
|
|
}
|
|
if track.ISRC != "" {
|
|
req.ISRC = track.ISRC
|
|
}
|
|
}
|
|
if req.shouldUpdateField("cover") {
|
|
if coverURL := track.ResolvedCoverURL(); coverURL != "" {
|
|
req.CoverURL = coverURL
|
|
}
|
|
}
|
|
if track.DurationMS > 0 {
|
|
req.DurationMs = int64(track.DurationMS)
|
|
}
|
|
if req.shouldUpdateField("extra") {
|
|
if track.Genre != "" {
|
|
req.Genre = track.Genre
|
|
}
|
|
if track.Label != "" {
|
|
req.Label = track.Label
|
|
}
|
|
if track.Copyright != "" {
|
|
req.Copyright = track.Copyright
|
|
}
|
|
if track.Composer != "" {
|
|
req.Composer = track.Composer
|
|
}
|
|
}
|
|
}
|
|
|
|
func isPlaceholderReEnrichValue(value string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "", "unknown", "unknown artist", "unknown title", "unknown album":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func buildReEnrichSearchQuery(req reEnrichRequest) string {
|
|
parts := make([]string, 0, 2)
|
|
if !isPlaceholderReEnrichValue(req.TrackName) {
|
|
parts = append(parts, strings.TrimSpace(req.TrackName))
|
|
}
|
|
if !isPlaceholderReEnrichValue(req.ArtistName) {
|
|
parts = append(parts, strings.TrimSpace(req.ArtistName))
|
|
}
|
|
if len(parts) == 0 && !isPlaceholderReEnrichValue(req.AlbumName) {
|
|
parts = append(parts, strings.TrimSpace(req.AlbumName))
|
|
}
|
|
return strings.TrimSpace(strings.Join(parts, " "))
|
|
}
|
|
|
|
func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest {
|
|
return DownloadRequest{
|
|
TrackName: req.TrackName,
|
|
ArtistName: req.ArtistName,
|
|
AlbumName: req.AlbumName,
|
|
ReleaseDate: req.ReleaseDate,
|
|
ISRC: req.ISRC,
|
|
DurationMS: int(req.DurationMs),
|
|
ArtistTagMode: req.ArtistTagMode,
|
|
TrackNumber: req.TrackNumber,
|
|
TotalTracks: req.TotalTracks,
|
|
DiscNumber: req.DiscNumber,
|
|
TotalDiscs: req.TotalDiscs,
|
|
Composer: req.Composer,
|
|
}
|
|
}
|
|
|
|
func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[string]string {
|
|
metadata := map[string]string{}
|
|
if req.shouldUpdateField("basic_tags") {
|
|
if req.TrackName != "" {
|
|
metadata["TITLE"] = req.TrackName
|
|
}
|
|
if req.ArtistName != "" {
|
|
metadata["ARTIST"] = req.ArtistName
|
|
}
|
|
if req.AlbumName != "" {
|
|
metadata["ALBUM"] = req.AlbumName
|
|
}
|
|
if req.AlbumArtist != "" {
|
|
metadata["ALBUMARTIST"] = req.AlbumArtist
|
|
}
|
|
}
|
|
if req.shouldUpdateField("release_info") {
|
|
if req.ReleaseDate != "" {
|
|
metadata["DATE"] = req.ReleaseDate
|
|
}
|
|
if req.ISRC != "" {
|
|
metadata["ISRC"] = req.ISRC
|
|
}
|
|
}
|
|
if req.shouldUpdateField("extra") {
|
|
if req.Genre != "" {
|
|
metadata["GENRE"] = req.Genre
|
|
}
|
|
if req.Label != "" {
|
|
metadata["ORGANIZATION"] = req.Label
|
|
}
|
|
if req.Copyright != "" {
|
|
metadata["COPYRIGHT"] = req.Copyright
|
|
}
|
|
if req.Composer != "" {
|
|
metadata["COMPOSER"] = req.Composer
|
|
}
|
|
}
|
|
if req.shouldUpdateField("track_info") {
|
|
if req.TrackNumber > 0 {
|
|
metadata["TRACKNUMBER"] = formatIndexValue(req.TrackNumber, req.TotalTracks)
|
|
}
|
|
if req.DiscNumber > 0 {
|
|
metadata["DISCNUMBER"] = formatIndexValue(req.DiscNumber, req.TotalDiscs)
|
|
}
|
|
}
|
|
if req.shouldUpdateField("lyrics") {
|
|
if lyricsLRC != "" && req.lyricsEmbedEnabled() {
|
|
metadata["LYRICS"] = lyricsLRC
|
|
metadata["UNSYNCEDLYRICS"] = lyricsLRC
|
|
}
|
|
}
|
|
return metadata
|
|
}
|
|
|
|
func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *ExtTrackMetadata {
|
|
if len(tracks) == 0 {
|
|
return nil
|
|
}
|
|
|
|
downloadReq := reEnrichDownloadRequest(req)
|
|
currentISRC := strings.TrimSpace(req.ISRC)
|
|
currentAlbum := strings.TrimSpace(req.AlbumName)
|
|
effectiveTrackName := req.TrackName
|
|
if isPlaceholderReEnrichValue(effectiveTrackName) {
|
|
effectiveTrackName = ""
|
|
}
|
|
effectiveArtistName := req.ArtistName
|
|
if isPlaceholderReEnrichValue(effectiveArtistName) {
|
|
effectiveArtistName = ""
|
|
}
|
|
var best *ExtTrackMetadata
|
|
bestScore := -1 << 30
|
|
|
|
for i := range tracks {
|
|
track := &tracks[i]
|
|
score := 0
|
|
exactISRCMatch := currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC))
|
|
titleMatches := effectiveTrackName != "" && track.Name != "" && titlesMatch(effectiveTrackName, track.Name)
|
|
artistMatches := effectiveArtistName != "" && track.Artists != "" && artistsMatch(effectiveArtistName, track.Artists)
|
|
albumMatches := currentAlbum != "" && track.AlbumName != "" && titlesMatch(currentAlbum, track.AlbumName)
|
|
|
|
resolved := resolvedTrackInfo{
|
|
Title: track.Name,
|
|
ArtistName: track.Artists,
|
|
ISRC: track.ISRC,
|
|
Duration: track.DurationMS / 1000,
|
|
}
|
|
verified := trackMatchesRequest(downloadReq, resolved, "ReEnrich")
|
|
|
|
if !exactISRCMatch {
|
|
if effectiveTrackName != "" && !titleMatches {
|
|
continue
|
|
}
|
|
if effectiveArtistName != "" && !artistMatches {
|
|
continue
|
|
}
|
|
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum != "" && !albumMatches {
|
|
continue
|
|
}
|
|
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum == "" && !verified {
|
|
continue
|
|
}
|
|
}
|
|
|
|
if verified {
|
|
score += 2000
|
|
}
|
|
|
|
if exactISRCMatch {
|
|
score += 10000
|
|
}
|
|
if titleMatches {
|
|
score += 400
|
|
}
|
|
if artistMatches {
|
|
score += 320
|
|
}
|
|
if currentAlbum != "" && track.AlbumName != "" {
|
|
switch {
|
|
case albumMatches:
|
|
score += 120
|
|
case strings.Contains(strings.ToLower(track.AlbumName), strings.ToLower(currentAlbum)),
|
|
strings.Contains(strings.ToLower(currentAlbum), strings.ToLower(track.AlbumName)):
|
|
score += 50
|
|
}
|
|
}
|
|
|
|
if req.DurationMs > 0 && track.DurationMS > 0 {
|
|
diff := int(req.DurationMs/1000) - (track.DurationMS / 1000)
|
|
if diff < 0 {
|
|
diff = -diff
|
|
}
|
|
if diff <= 10 {
|
|
score += 80
|
|
}
|
|
}
|
|
|
|
if track.ReleaseDate != "" {
|
|
score += 70
|
|
}
|
|
if track.TrackNumber > 0 {
|
|
score += 20
|
|
}
|
|
if track.DiscNumber > 0 {
|
|
score += 10
|
|
}
|
|
if track.ISRC != "" {
|
|
score += 40
|
|
}
|
|
|
|
if best == nil || score > bestScore {
|
|
best = track
|
|
bestScore = score
|
|
}
|
|
}
|
|
|
|
return best
|
|
}
|
|
|
|
func extTrackFromTrackMetadata(track *TrackMetadata, providerID string) *ExtTrackMetadata {
|
|
if track == nil {
|
|
return nil
|
|
}
|
|
|
|
deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:"))
|
|
return &ExtTrackMetadata{
|
|
ID: track.SpotifyID,
|
|
Name: track.Name,
|
|
Artists: track.Artists,
|
|
AlbumName: track.AlbumName,
|
|
AlbumArtist: track.AlbumArtist,
|
|
DurationMS: track.DurationMS,
|
|
CoverURL: track.Images,
|
|
Images: track.Images,
|
|
ReleaseDate: track.ReleaseDate,
|
|
TrackNumber: track.TrackNumber,
|
|
TotalTracks: track.TotalTracks,
|
|
DiscNumber: track.DiscNumber,
|
|
TotalDiscs: track.TotalDiscs,
|
|
ISRC: track.ISRC,
|
|
ProviderID: providerID,
|
|
DeezerID: deezerID,
|
|
SpotifyID: track.SpotifyID,
|
|
Composer: track.Composer,
|
|
Explicit: track.Explicit,
|
|
}
|
|
}
|
|
|
|
func normalizeReEnrichSpotifyTrackID(raw string) string {
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
if extracted := extractSpotifyIDFromURL(trimmed); extracted != "" {
|
|
return extracted
|
|
}
|
|
if len(trimmed) == 22 && !strings.Contains(trimmed, ":") && !strings.Contains(trimmed, "/") {
|
|
return trimmed
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func resolveReEnrichTrackFromIdentifiers(req reEnrichRequest) (*ExtTrackMetadata, error) {
|
|
deezerClient := GetDeezerClient()
|
|
downloadReq := reEnrichDownloadRequest(req)
|
|
|
|
if isrc := strings.TrimSpace(req.ISRC); isrc != "" {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
track, err := deezerClient.SearchByISRC(ctx, isrc)
|
|
cancel()
|
|
if err == nil && track != nil {
|
|
resolved := resolvedTrackInfo{
|
|
Title: track.Name,
|
|
ArtistName: track.Artists,
|
|
ISRC: track.ISRC,
|
|
Duration: track.DurationMS / 1000,
|
|
}
|
|
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
|
|
return extTrackFromTrackMetadata(track, "deezer"), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
sourceTrackID := strings.TrimSpace(req.SpotifyID)
|
|
if sourceTrackID == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
deezerID := strings.TrimSpace(strings.TrimPrefix(sourceTrackID, "deezer:"))
|
|
if deezerID == sourceTrackID {
|
|
deezerID = extractDeezerIDFromURL(sourceTrackID)
|
|
}
|
|
if deezerID == "" {
|
|
spotifyID := normalizeReEnrichSpotifyTrackID(sourceTrackID)
|
|
if spotifyID != "" {
|
|
resolvedDeezerID, err := NewSongLinkClient().GetDeezerIDFromSpotify(spotifyID)
|
|
if err == nil {
|
|
deezerID = strings.TrimSpace(resolvedDeezerID)
|
|
}
|
|
}
|
|
}
|
|
if deezerID == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
|
|
if err != nil || trackResp == nil {
|
|
return nil, err
|
|
}
|
|
|
|
track := &trackResp.Track
|
|
resolved := resolvedTrackInfo{
|
|
Title: track.Name,
|
|
ArtistName: track.Artists,
|
|
ISRC: track.ISRC,
|
|
Duration: track.DurationMS / 1000,
|
|
}
|
|
if !trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
|
|
return nil, nil
|
|
}
|
|
|
|
return extTrackFromTrackMetadata(track, "deezer"), nil
|
|
}
|
|
|
|
func preferredReleaseMetadata(
|
|
req DownloadRequest,
|
|
album string,
|
|
releaseDate string,
|
|
trackNumber int,
|
|
discNumber int,
|
|
) (string, string, int, int) {
|
|
preferredAlbum := strings.TrimSpace(req.AlbumName)
|
|
if preferredAlbum == "" {
|
|
preferredAlbum = album
|
|
}
|
|
|
|
preferredReleaseDate := strings.TrimSpace(req.ReleaseDate)
|
|
if preferredReleaseDate == "" {
|
|
preferredReleaseDate = releaseDate
|
|
}
|
|
|
|
preferredTrackNumber := req.TrackNumber
|
|
if preferredTrackNumber == 0 {
|
|
preferredTrackNumber = trackNumber
|
|
}
|
|
|
|
preferredDiscNumber := req.DiscNumber
|
|
if preferredDiscNumber == 0 {
|
|
preferredDiscNumber = discNumber
|
|
}
|
|
|
|
return preferredAlbum, preferredReleaseDate, preferredTrackNumber, preferredDiscNumber
|
|
}
|
|
|
|
// 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 reEnrichRequest
|
|
|
|
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)
|
|
|
|
if req.SearchOnline {
|
|
found := false
|
|
|
|
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
|
|
manager := getExtensionManager()
|
|
if identifierTrack, err := resolveReEnrichTrackFromIdentifiers(req); err == nil && identifierTrack != nil {
|
|
GoLog("[ReEnrich] Identifier-first metadata match (%s): %s - %s (album: %s, date: %s)\n",
|
|
identifierTrack.ProviderID, identifierTrack.Name, identifierTrack.Artists, identifierTrack.AlbumName, identifierTrack.ReleaseDate)
|
|
applyReEnrichTrackMetadata(&req, *identifierTrack)
|
|
found = true
|
|
}
|
|
|
|
searchQuery := buildReEnrichSearchQuery(req)
|
|
if searchQuery != "" {
|
|
GoLog("[ReEnrich] Searching online metadata for query: %s\n", searchQuery)
|
|
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
|
|
if searchErr == nil && len(tracks) > 0 {
|
|
track := selectBestReEnrichTrack(req, tracks)
|
|
if track != nil {
|
|
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n",
|
|
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate)
|
|
applyReEnrichTrackMetadata(&req, *track)
|
|
found = true
|
|
}
|
|
} else if searchErr != nil {
|
|
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
|
|
}
|
|
} else {
|
|
GoLog("[ReEnrich] Skipping provider search: no usable title/artist/album query\n")
|
|
}
|
|
|
|
if req.shouldUpdateField("basic_tags") && req.AlbumArtist == "" && req.ISRC != "" {
|
|
albumArtist, err := fetchMusicBrainzAlbumArtistByISRC(req.ISRC, req.AlbumName)
|
|
if err != nil {
|
|
GoLog("[ReEnrich] Failed to get album artist from MusicBrainz: %v\n", err)
|
|
} else if strings.TrimSpace(albumArtist) != "" {
|
|
req.AlbumArtist = strings.TrimSpace(albumArtist)
|
|
GoLog("[ReEnrich] Album artist fallback from MusicBrainz: %s\n", req.AlbumArtist)
|
|
found = true
|
|
}
|
|
}
|
|
|
|
// Try to enrich extra metadata from ISRC if not already set.
|
|
if found && req.ISRC != "" && req.shouldUpdateField("extra") && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
|
enrichExtraMetadataByISRC("ReEnrich", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
|
|
}
|
|
|
|
if !found {
|
|
GoLog("[ReEnrich] No online match found, using existing metadata\n")
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
lower := strings.ToLower(req.FilePath)
|
|
isFlac := strings.HasSuffix(lower, ".flac")
|
|
|
|
var coverTempPath string
|
|
var coverDataBytes []byte
|
|
if req.CoverURL != "" && req.shouldUpdateField("cover") {
|
|
coverData, err := downloadCoverToMemory(req.CoverURL, req.MaxQuality)
|
|
if err != nil {
|
|
GoLog("[ReEnrich] Failed to download cover: %v\n", err)
|
|
} else {
|
|
coverDataBytes = coverData
|
|
GoLog("[ReEnrich] Cover downloaded: %d KB\n", len(coverData)/1024)
|
|
// MP3/Opus requires a real image file path for Dart FFmpeg.
|
|
// FLAC uses in-memory embed and does not require temp files.
|
|
if !isFlac {
|
|
tmpFile, err := os.CreateTemp("", "reenrich_cover_*.jpg")
|
|
if err != nil {
|
|
fallbackDir := filepath.Dir(req.FilePath)
|
|
if fallbackDir == "" || fallbackDir == "." {
|
|
GoLog("[ReEnrich] Failed to create cover temp file: %v\n", err)
|
|
} else {
|
|
tmpFile, err = os.CreateTemp(fallbackDir, "reenrich_cover_*.jpg")
|
|
if err != nil {
|
|
GoLog("[ReEnrich] Failed to create cover temp file (fallback dir %s): %v\n", fallbackDir, err)
|
|
}
|
|
}
|
|
}
|
|
if err == nil && tmpFile != nil {
|
|
coverTempPath = tmpFile.Name()
|
|
if _, writeErr := tmpFile.Write(coverData); writeErr != nil {
|
|
GoLog("[ReEnrich] Failed writing cover temp file: %v\n", writeErr)
|
|
tmpFile.Close()
|
|
os.Remove(coverTempPath)
|
|
coverTempPath = ""
|
|
} else if closeErr := tmpFile.Close(); closeErr != nil {
|
|
GoLog("[ReEnrich] Failed closing cover temp file: %v\n", closeErr)
|
|
os.Remove(coverTempPath)
|
|
coverTempPath = ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// 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)
|
|
}
|
|
}()
|
|
|
|
// Preserve existing lyrics when online enrichment does not return a replacement.
|
|
var lyricsLRC string
|
|
if req.shouldUpdateField("lyrics") {
|
|
existingLyrics, existingLyricsErr := ExtractLyrics(req.FilePath)
|
|
if existingLyricsErr == nil && strings.TrimSpace(existingLyrics) != "" {
|
|
lyricsLRC = existingLyrics
|
|
GoLog("[ReEnrich] Preserving existing embedded/sidecar lyrics\n")
|
|
}
|
|
}
|
|
|
|
if req.EmbedLyrics && req.shouldUpdateField("lyrics") {
|
|
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")
|
|
}
|
|
}
|
|
|
|
// Build enrichedMeta map: only include fields from selected update groups
|
|
// so that the caller (Dart) does not overwrite non-selected metadata in its
|
|
// local library database with potentially stale cached values.
|
|
enrichedMeta := map[string]any{
|
|
"spotify_id": req.SpotifyID,
|
|
"duration_ms": req.DurationMs,
|
|
}
|
|
if req.shouldUpdateField("basic_tags") {
|
|
enrichedMeta["track_name"] = req.TrackName
|
|
enrichedMeta["artist_name"] = req.ArtistName
|
|
enrichedMeta["album_name"] = req.AlbumName
|
|
enrichedMeta["album_artist"] = req.AlbumArtist
|
|
}
|
|
if req.shouldUpdateField("track_info") {
|
|
enrichedMeta["track_number"] = req.TrackNumber
|
|
enrichedMeta["total_tracks"] = req.TotalTracks
|
|
enrichedMeta["disc_number"] = req.DiscNumber
|
|
enrichedMeta["total_discs"] = req.TotalDiscs
|
|
}
|
|
if req.shouldUpdateField("release_info") {
|
|
enrichedMeta["release_date"] = req.ReleaseDate
|
|
enrichedMeta["isrc"] = req.ISRC
|
|
}
|
|
if req.shouldUpdateField("cover") {
|
|
enrichedMeta["cover_url"] = req.CoverURL
|
|
}
|
|
if req.shouldUpdateField("extra") {
|
|
enrichedMeta["genre"] = req.Genre
|
|
enrichedMeta["label"] = req.Label
|
|
enrichedMeta["copyright"] = req.Copyright
|
|
enrichedMeta["composer"] = req.Composer
|
|
}
|
|
|
|
if isFlac {
|
|
// Only populate Metadata fields for selected update groups; empty/zero
|
|
// values cause EmbedMetadata's setComment() to skip those tags,
|
|
// preserving whatever is already in the file.
|
|
metadata := Metadata{
|
|
ArtistTagMode: req.ArtistTagMode,
|
|
}
|
|
if req.shouldUpdateField("basic_tags") {
|
|
metadata.Title = req.TrackName
|
|
metadata.Artist = req.ArtistName
|
|
metadata.Album = req.AlbumName
|
|
metadata.AlbumArtist = req.AlbumArtist
|
|
}
|
|
if req.shouldUpdateField("track_info") {
|
|
metadata.TrackNumber = req.TrackNumber
|
|
metadata.TotalTracks = req.TotalTracks
|
|
metadata.DiscNumber = req.DiscNumber
|
|
metadata.TotalDiscs = req.TotalDiscs
|
|
}
|
|
if req.shouldUpdateField("release_info") {
|
|
metadata.Date = req.ReleaseDate
|
|
metadata.ISRC = req.ISRC
|
|
}
|
|
if req.shouldUpdateField("lyrics") {
|
|
if req.lyricsEmbedEnabled() {
|
|
metadata.Lyrics = lyricsLRC
|
|
}
|
|
}
|
|
if req.shouldUpdateField("extra") {
|
|
metadata.Genre = req.Genre
|
|
metadata.Label = req.Label
|
|
metadata.Copyright = req.Copyright
|
|
metadata.Composer = req.Composer
|
|
}
|
|
|
|
if len(coverDataBytes) > 0 {
|
|
if err := EmbedMetadataWithCoverData(req.FilePath, metadata, coverDataBytes); err != nil {
|
|
return "", fmt.Errorf("failed to embed metadata with cover: %w", err)
|
|
}
|
|
} else {
|
|
if err := EmbedMetadata(req.FilePath, metadata, ""); err != nil {
|
|
return "", fmt.Errorf("failed to embed metadata: %w", err)
|
|
}
|
|
}
|
|
if len(coverDataBytes) > 0 {
|
|
embeddedCover, err := ExtractCoverArt(req.FilePath)
|
|
if err != nil || len(embeddedCover) == 0 {
|
|
if err != nil {
|
|
return "", fmt.Errorf("metadata embedded but cover verification failed: %w", err)
|
|
}
|
|
return "", fmt.Errorf("metadata embedded but cover verification failed: empty embedded cover")
|
|
}
|
|
GoLog("[ReEnrich] Cover verified after embed (%d bytes)\n", len(embeddedCover))
|
|
}
|
|
|
|
GoLog("[ReEnrich] FLAC metadata embedded successfully\n")
|
|
|
|
result := map[string]any{
|
|
"method": "native",
|
|
"success": true,
|
|
"enriched_metadata": enrichedMeta,
|
|
"lyrics": lyricsLRC,
|
|
"write_external_lrc": req.EmbedLyrics &&
|
|
req.shouldUpdateField("lyrics") &&
|
|
req.lyricsSidecarEnabled() &&
|
|
strings.TrimSpace(lyricsLRC) != "",
|
|
}
|
|
s, _ := marshalJSONString(result)
|
|
return s, nil
|
|
}
|
|
|
|
// Don't cleanup cover temp — Dart needs it for FFmpeg embed
|
|
cleanupCover = false
|
|
ffmpegMetadata := buildReEnrichFFmpegMetadata(&req, lyricsLRC)
|
|
|
|
result := map[string]any{
|
|
"method": "ffmpeg",
|
|
"cover_path": coverTempPath,
|
|
"lyrics": lyricsLRC,
|
|
"enriched_metadata": enrichedMeta,
|
|
"metadata": ffmpegMetadata,
|
|
"write_external_lrc": req.EmbedLyrics &&
|
|
req.shouldUpdateField("lyrics") &&
|
|
req.lyricsSidecarEnabled() &&
|
|
strings.TrimSpace(lyricsLRC) != "",
|
|
}
|
|
|
|
s, _ := marshalJSONString(result)
|
|
return s, nil
|
|
}
|