From d909067c49a4d33d2b0a0602713e8c2ba76a8b51 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 9 Jul 2026 19:27:11 +0700 Subject: [PATCH] refactor(backend): split exports.go into themed files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure mechanical move within package gobackend — no signature or behavior change, so the gomobile binding surface is identical. exports.go (4,141 lines) now holds only availability/dir/duplicate/filename exports; the rest moved to exports_{musicbrainz,download,reenrich,metadata,lyrics, deezer,extensions,store,library}.go along existing responsibility boundaries. --- go_backend/exports.go | 4062 ----------------------------- go_backend/exports_deezer.go | 307 +++ go_backend/exports_download.go | 491 ++++ go_backend/exports_extensions.go | 1233 +++++++++ go_backend/exports_library.go | 37 + go_backend/exports_lyrics.go | 221 ++ go_backend/exports_metadata.go | 679 +++++ go_backend/exports_musicbrainz.go | 235 ++ go_backend/exports_reenrich.go | 764 ++++++ go_backend/exports_store.go | 164 ++ 10 files changed, 4131 insertions(+), 4062 deletions(-) create mode 100644 go_backend/exports_deezer.go create mode 100644 go_backend/exports_download.go create mode 100644 go_backend/exports_extensions.go create mode 100644 go_backend/exports_library.go create mode 100644 go_backend/exports_lyrics.go create mode 100644 go_backend/exports_metadata.go create mode 100644 go_backend/exports_musicbrainz.go create mode 100644 go_backend/exports_reenrich.go create mode 100644 go_backend/exports_store.go diff --git a/go_backend/exports.go b/go_backend/exports.go index bffb0251..4759e736 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1,20 +1,8 @@ package gobackend import ( - "context" "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "os" - "path/filepath" "strings" - "time" - - "github.com/dop251/goja" - "golang.org/x/text/cases" - "golang.org/x/text/language" ) func CheckAvailability(spotifyID, isrc string) (string, error) { @@ -37,1725 +25,6 @@ func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) { SetNetworkCompatibilityOptions(allowHTTP, insecureTLS) } -const musicBrainzAPIBase = "https://musicbrainz.org/ws/2" - -type musicBrainzTag struct { - Count int `json:"count"` - Name string `json:"name"` -} - -type musicBrainzRecordingResponse struct { - Recordings []struct { - Tags []musicBrainzTag `json:"tags"` - } `json:"recordings"` -} - -type musicBrainzArtistCredit struct { - Name string `json:"name"` - JoinPhrase string `json:"joinphrase"` -} - -type musicBrainzRelease struct { - Title string `json:"title"` - ArtistCredit []musicBrainzArtistCredit `json:"artist-credit"` -} - -type musicBrainzAlbumArtistResponse struct { - Recordings []struct { - Releases []musicBrainzRelease `json:"releases"` - } `json:"recordings"` -} - -func formatMusicBrainzGenre(tags []musicBrainzTag) string { - if len(tags) == 0 { - return "" - } - - caser := cases.Title(language.English) - seen := make(map[string]struct{}, len(tags)) - maxCount := -1 - bestTag := "" - - for _, tag := range tags { - name := strings.TrimSpace(tag.Name) - if name == "" { - continue - } - - key := strings.ToLower(name) - if _, exists := seen[key]; exists { - continue - } - seen[key] = struct{}{} - - formatted := caser.String(name) - if tag.Count > maxCount { - maxCount = tag.Count - bestTag = formatted - } - } - - return bestTag -} - -func formatMusicBrainzArtistCredit(credits []musicBrainzArtistCredit) string { - var builder strings.Builder - for _, credit := range credits { - name := strings.TrimSpace(credit.Name) - if name == "" { - continue - } - builder.WriteString(name) - builder.WriteString(credit.JoinPhrase) - } - return strings.TrimSpace(builder.String()) -} - -func selectMusicBrainzAlbumArtist(releases []musicBrainzRelease, albumName string) string { - if len(releases) == 0 { - return "" - } - - normalizedAlbum := strings.ToLower(strings.TrimSpace(albumName)) - if normalizedAlbum != "" { - for _, release := range releases { - if strings.ToLower(strings.TrimSpace(release.Title)) != normalizedAlbum { - continue - } - if albumArtist := formatMusicBrainzArtistCredit(release.ArtistCredit); albumArtist != "" { - return albumArtist - } - } - } - - for _, release := range releases { - if albumArtist := formatMusicBrainzArtistCredit(release.ArtistCredit); albumArtist != "" { - return albumArtist - } - } - - return "" -} - -func FetchMusicBrainzAlbumArtistByISRC(isrc string, albumName string) (string, error) { - normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc)) - if normalizedISRC == "" { - return "", fmt.Errorf("no ISRC provided") - } - - client := NewMetadataHTTPClient(10 * time.Second) - query := fmt.Sprintf("isrc:%s", normalizedISRC) - reqURL := fmt.Sprintf( - "%s/recording?query=%s&fmt=json&inc=releases+artist-credits", - musicBrainzAPIBase, - url.QueryEscape(query), - ) - - req, err := http.NewRequest(http.MethodGet, reqURL, nil) - if err != nil { - return "", err - } - req.Header.Set("User-Agent", getRandomUserAgent()) - - var resp *http.Response - var lastErr error - for attempt := 0; attempt < 3; attempt++ { - resp, lastErr = client.Do(req) - if lastErr == nil && resp.StatusCode == http.StatusOK { - break - } - if resp != nil { - resp.Body.Close() - } - if attempt < 2 { - time.Sleep(2 * time.Second) - } - } - - if lastErr != nil { - return "", lastErr - } - if resp == nil { - return "", fmt.Errorf("MusicBrainz request failed without response") - } - if resp.StatusCode != http.StatusOK { - resp.Body.Close() - return "", fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode) - } - defer resp.Body.Close() - - var payload musicBrainzAlbumArtistResponse - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return "", err - } - for _, recording := range payload.Recordings { - if albumArtist := selectMusicBrainzAlbumArtist(recording.Releases, albumName); albumArtist != "" { - return albumArtist, nil - } - } - - return "", fmt.Errorf("no MusicBrainz album artist found for ISRC: %s", normalizedISRC) -} - -func FetchMusicBrainzGenreByISRC(isrc string) (string, error) { - normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc)) - if normalizedISRC == "" { - return "", fmt.Errorf("no ISRC provided") - } - - client := NewMetadataHTTPClient(10 * time.Second) - query := fmt.Sprintf("isrc:%s", normalizedISRC) - reqURL := fmt.Sprintf( - "%s/recording?query=%s&fmt=json&inc=tags", - musicBrainzAPIBase, - url.QueryEscape(query), - ) - - req, err := http.NewRequest(http.MethodGet, reqURL, nil) - if err != nil { - return "", err - } - req.Header.Set("User-Agent", getRandomUserAgent()) - - var resp *http.Response - var lastErr error - for attempt := 0; attempt < 3; attempt++ { - resp, lastErr = client.Do(req) - if lastErr == nil && resp.StatusCode == http.StatusOK { - break - } - if resp != nil { - resp.Body.Close() - } - if attempt < 2 { - time.Sleep(2 * time.Second) - } - } - - if lastErr != nil { - return "", lastErr - } - if resp == nil { - return "", fmt.Errorf("MusicBrainz request failed without response") - } - if resp.StatusCode != http.StatusOK { - resp.Body.Close() - return "", fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode) - } - defer resp.Body.Close() - - var payload musicBrainzRecordingResponse - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return "", err - } - if len(payload.Recordings) == 0 { - return "", fmt.Errorf("no recordings found for ISRC: %s", normalizedISRC) - } - - genre := formatMusicBrainzGenre(payload.Recordings[0].Tags) - if genre == "" { - return "", fmt.Errorf("no MusicBrainz genre tags found for ISRC: %s", normalizedISRC) - } - return genre, nil -} - -type DownloadRequest struct { - ContractVersion int `json:"contract_version,omitempty"` - ISRC string `json:"isrc"` - Service string `json:"service"` - 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"` - CoverURL string `json:"cover_url"` - OutputDir string `json:"output_dir"` - OutputPath string `json:"output_path,omitempty"` - OutputFD int `json:"output_fd,omitempty"` - OutputExt string `json:"output_ext,omitempty"` - FilenameFormat string `json:"filename_format"` - Quality string `json:"quality"` - EmbedMetadata bool `json:"embed_metadata"` - ArtistTagMode string `json:"artist_tag_mode,omitempty"` - EmbedLyrics bool `json:"embed_lyrics"` - EmbedMaxQualityCover bool `json:"embed_max_quality_cover"` - EmbedReplayGain bool `json:"embed_replaygain,omitempty"` - PostProcessingEnabled bool `json:"post_processing_enabled,omitempty"` - TidalHighFormat string `json:"tidal_high_format,omitempty"` - TrackNumber int `json:"track_number"` - PlaylistPosition int `json:"playlist_position,omitempty"` - DiscNumber int `json:"disc_number"` - TotalTracks int `json:"total_tracks"` - TotalDiscs int `json:"total_discs,omitempty"` - ReleaseDate string `json:"release_date"` - ItemID string `json:"item_id"` - DurationMS int `json:"duration_ms"` - Source string `json:"source"` - Genre string `json:"genre,omitempty"` - Label string `json:"label,omitempty"` - Copyright string `json:"copyright,omitempty"` - Composer string `json:"composer,omitempty"` - TidalID string `json:"tidal_id,omitempty"` - QobuzID string `json:"qobuz_id,omitempty"` - DeezerID string `json:"deezer_id,omitempty"` - LyricsMode string `json:"lyrics_mode,omitempty"` - UseExtensions bool `json:"use_extensions,omitempty"` - UseFallback bool `json:"use_fallback,omitempty"` - RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"` - SongLinkRegion string `json:"songlink_region,omitempty"` -} - -type DownloadResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - FilePath string `json:"file_path,omitempty"` - Error string `json:"error,omitempty"` - ErrorType string `json:"error_type,omitempty"` - RetryAfterSeconds int `json:"retry_after_seconds,omitempty"` - AlreadyExists bool `json:"already_exists,omitempty"` - ActualBitDepth int `json:"actual_bit_depth,omitempty"` - ActualSampleRate int `json:"actual_sample_rate,omitempty"` - AudioCodec string `json:"audio_codec,omitempty"` - ActualExtension string `json:"actual_extension,omitempty"` - ActualContainer string `json:"actual_container,omitempty"` - RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"` - Service string `json:"service,omitempty"` - Title string `json:"title,omitempty"` - Artist string `json:"artist,omitempty"` - Album string `json:"album,omitempty"` - AlbumArtist string `json:"album_artist,omitempty"` - ReleaseDate string `json:"release_date,omitempty"` - TrackNumber int `json:"track_number,omitempty"` - DiscNumber int `json:"disc_number,omitempty"` - TotalTracks int `json:"total_tracks,omitempty"` - TotalDiscs int `json:"total_discs,omitempty"` - ISRC string `json:"isrc,omitempty"` - CoverURL string `json:"cover_url,omitempty"` - Genre string `json:"genre,omitempty"` - Label string `json:"label,omitempty"` - Copyright string `json:"copyright,omitempty"` - Composer string `json:"composer,omitempty"` - SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` - LyricsLRC string `json:"lyrics_lrc,omitempty"` - DecryptionKey string `json:"decryption_key,omitempty"` - Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"` -} - -type DownloadResult struct { - FilePath string - BitDepth int - SampleRate int - AudioCodec string - Title string - Artist string - Album string - ReleaseDate string - TrackNumber int - TotalTracks int - DiscNumber int - TotalDiscs int - ISRC string - CoverURL string - Genre string - Label string - Copyright string - Composer string - LyricsLRC string - DecryptionKey string - Decryption *DownloadDecryptionInfo - ActualExtension string - ActualContainer string - RequiresContainerConversion bool -} - -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 -} - -func buildDownloadSuccessResponse( - req DownloadRequest, - result DownloadResult, - service string, - message string, - filePath string, - alreadyExists bool, -) DownloadResponse { - title := result.Title - if title == "" { - title = req.TrackName - } - - artist := result.Artist - if artist == "" { - artist = req.ArtistName - } - - // Preserve requested release metadata when available so mixed-provider - // fallback downloads from the same source album do not get split into - // different albums just because Tidal/Qobuz report variant titles/dates. - album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata( - req, - result.Album, - result.ReleaseDate, - result.TrackNumber, - result.DiscNumber, - ) - - isrc := result.ISRC - if isrc == "" { - isrc = req.ISRC - } - - genre := result.Genre - if genre == "" { - genre = req.Genre - } - - label := result.Label - if label == "" { - label = req.Label - } - - copyright := result.Copyright - if copyright == "" { - copyright = req.Copyright - } - - composer := result.Composer - if composer == "" { - composer = req.Composer - } - - coverURL := strings.TrimSpace(result.CoverURL) - if coverURL == "" { - coverURL = strings.TrimSpace(req.CoverURL) - } - - return DownloadResponse{ - Success: true, - Message: message, - FilePath: filePath, - AlreadyExists: alreadyExists, - ActualBitDepth: result.BitDepth, - ActualSampleRate: result.SampleRate, - AudioCodec: result.AudioCodec, - ActualExtension: result.ActualExtension, - ActualContainer: result.ActualContainer, - RequiresContainerConversion: result.RequiresContainerConversion, - Service: service, - Title: title, - Artist: artist, - Album: album, - AlbumArtist: req.AlbumArtist, - ReleaseDate: releaseDate, - TrackNumber: trackNumber, - TotalTracks: req.TotalTracks, - DiscNumber: discNumber, - TotalDiscs: req.TotalDiscs, - ISRC: isrc, - CoverURL: coverURL, - Genre: genre, - Label: label, - Copyright: copyright, - Composer: composer, - LyricsLRC: result.LyricsLRC, - DecryptionKey: result.DecryptionKey, - Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey), - } -} - -func shouldSkipQualityProbe(filePath string) bool { - path := strings.TrimSpace(filePath) - if path == "" { - return true - } - if strings.HasPrefix(path, "/proc/self/fd/") { - return true - } - // Content URI and other non-filesystem schemes cannot be read directly by os.Open. - if strings.Contains(path, "://") { - return true - } - return false -} - -func enrichResultQualityFromFile(result *DownloadResult) { - if result == nil { - return - } - - path := strings.TrimSpace(result.FilePath) - if shouldSkipQualityProbe(path) { - if strings.HasPrefix(path, "/proc/self/fd/") { - LogDebug("Download", "Skipping quality probe for ephemeral SAF FD output: %s", path) - } - return - } - - quality, qErr := GetAudioQuality(path) - if qErr == nil { - result.BitDepth = quality.BitDepth - result.SampleRate = quality.SampleRate - result.AudioCodec = quality.Codec - if quality.Codec != "" { - GoLog("[Download] Actual quality from file: %s %d-bit/%dHz\n", quality.Codec, quality.BitDepth, quality.SampleRate) - } else { - GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) - } - return - } - - LogDebug("Download", "Post-download quality probe unavailable for %s: %v", path, qErr) -} - -func applyExtendedMetadataFields( - genre *string, - label *string, - copyright *string, - extMeta *AlbumExtendedMetadata, -) { - if extMeta == nil { - return - } - - if genre != nil && *genre == "" && extMeta.Genre != "" { - *genre = extMeta.Genre - } - if label != nil && *label == "" && extMeta.Label != "" { - *label = extMeta.Label - } - if copyright != nil && *copyright == "" && extMeta.Copyright != "" { - *copyright = extMeta.Copyright - } -} - -func enrichExtraMetadataByISRC( - logPrefix string, - isrc string, - genre *string, - label *string, - copyright *string, -) { - normalizedISRC := strings.TrimSpace(isrc) - if normalizedISRC == "" { - return - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - extMeta, err := fetchDeezerExtendedMetadataByISRC(ctx, normalizedISRC) - if err != nil { - GoLog("[%s] Failed to get extended metadata from Deezer: %v\n", logPrefix, err) - } - applyExtendedMetadataFields(genre, label, copyright, extMeta) - - if genre != nil && *genre == "" { - musicBrainzGenre, err := fetchMusicBrainzGenreByISRC(normalizedISRC) - if err != nil { - GoLog("[%s] Failed to get genre from MusicBrainz: %v\n", logPrefix, err) - } else if musicBrainzGenre != "" { - *genre = musicBrainzGenre - GoLog("[%s] Genre fallback from MusicBrainz: %s\n", logPrefix, *genre) - } - } - - currentGenre := "" - currentLabel := "" - currentCopyright := "" - if genre != nil { - currentGenre = *genre - } - if label != nil { - currentLabel = *label - } - if copyright != nil { - currentCopyright = *copyright - } - if currentGenre != "" || currentLabel != "" || currentCopyright != "" { - GoLog("[%s] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", logPrefix, currentGenre, currentLabel, currentCopyright) - } -} - -func enrichRequestExtendedMetadata(req *DownloadRequest) { - if req == nil { - return - } - - if req.ISRC == "" { - return - } - - if strings.TrimSpace(req.AlbumArtist) == "" { - albumArtist, err := fetchMusicBrainzAlbumArtistByISRC(req.ISRC, req.AlbumName) - if err != nil { - GoLog("[DownloadWithFallback] Failed to get album artist from MusicBrainz: %v\n", err) - } else if strings.TrimSpace(albumArtist) != "" { - req.AlbumArtist = strings.TrimSpace(albumArtist) - GoLog("[DownloadWithFallback] Album artist fallback from MusicBrainz: %s\n", req.AlbumArtist) - } - } - - if req.Genre == "" || req.Label == "" || req.Copyright == "" { - enrichExtraMetadataByISRC( - "DownloadWithFallback", - req.ISRC, - &req.Genre, - &req.Label, - &req.Copyright, - ) - } -} - -func applySongLinkRegionFromRequest(req *DownloadRequest) { - if req == nil { - return - } - SetSongLinkRegion(req.SongLinkRegion) -} - -func DownloadTrack(requestJSON string) (string, error) { - return errorResponse("Built-in download providers have been retired. Use downloadByStrategy with extension providers.") -} - -// DownloadByStrategy routes all download requests through extension providers. -func DownloadByStrategy(requestJSON string) (string, error) { - var req DownloadRequest - if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { - return errorResponse("Invalid request: " + err.Error()) - } - normalizedBytes, err := json.Marshal(req) - if err != nil { - return errorResponse("Invalid request: " + err.Error()) - } - normalizedJSON := string(normalizedBytes) - - if req.UseExtensions { - resp, err := DownloadWithExtensionsJSON(normalizedJSON) - if err != nil { - return errorResponse(err.Error()) - } - return resp, nil - } - - return errorResponse("Extension providers are disabled; built-in download providers have been retired") -} - -func DownloadWithFallback(requestJSON string) (string, error) { - return errorResponse("Built-in fallback has been retired. Use extension fallback through downloadByStrategy.") -} - -func GetDownloadProgress() string { - progress := getProgress() - jsonBytes, _ := json.Marshal(progress) - return string(jsonBytes) -} - -func GetAllDownloadProgress() string { - return GetMultiProgress() -} - -func GetAllDownloadProgressDelta(sinceSeq int64) string { - return GetMultiProgressDelta(sinceSeq) -} - -func InitItemProgress(itemID string) { - StartItemProgress(itemID) -} - -func FinishItemProgress(itemID string) { - CompleteItemProgress(itemID) -} - -func ClearItemProgress(itemID string) { - RemoveItemProgress(itemID) -} - -func CancelDownload(itemID string) { - cancelDownload(itemID) -} - -func CleanupConnections() { - CloseIdleConnections() -} - -func ReadFileMetadata(filePath string) (string, error) { - lower := strings.ToLower(filePath) - isFlac := strings.HasSuffix(lower, ".flac") - isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".aac") - isMp3 := strings.HasSuffix(lower, ".mp3") - isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") - isApe := strings.HasSuffix(lower, ".ape") - isWv := strings.HasSuffix(lower, ".wv") - isMpc := strings.HasSuffix(lower, ".mpc") - isWav := strings.HasSuffix(lower, ".wav") - isAiff := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc") - - result := map[string]interface{}{ - "title": "", - "artist": "", - "album": "", - "album_artist": "", - "date": "", - "track_number": 0, - "total_tracks": 0, - "disc_number": 0, - "total_discs": 0, - "isrc": "", - "lyrics": "", - "genre": "", - "label": "", - "copyright": "", - "composer": "", - "comment": "", - "duration": 0, - "format": "", - "audio_codec": "", - } - - if isFlac { - result["format"] = "flac" - result["audio_codec"] = "flac" - metadata, err := ReadMetadata(filePath) - if err != nil { - // File may have wrong extension (e.g. opus saved as .flac). - // Try Ogg/Opus parser as fallback before giving up. - GoLog("[ReadFileMetadata] FLAC parse failed for %s, trying Ogg fallback: %v\n", filePath, err) - oggMeta, oggErr := ReadOggVorbisComments(filePath) - if oggErr == nil && oggMeta != nil { - result["title"] = oggMeta.Title - result["artist"] = oggMeta.Artist - result["album"] = oggMeta.Album - result["album_artist"] = oggMeta.AlbumArtist - result["date"] = oggMeta.Date - if oggMeta.Date == "" { - result["date"] = oggMeta.Year - } - result["track_number"] = oggMeta.TrackNumber - result["total_tracks"] = oggMeta.TotalTracks - result["disc_number"] = oggMeta.DiscNumber - result["total_discs"] = oggMeta.TotalDiscs - result["isrc"] = oggMeta.ISRC - result["lyrics"] = oggMeta.Lyrics - result["genre"] = oggMeta.Genre - result["composer"] = oggMeta.Composer - result["comment"] = oggMeta.Comment - quality, qualityErr := GetOggQuality(filePath) - if qualityErr == nil { - result["sample_rate"] = quality.SampleRate - result["duration"] = quality.Duration - if quality.Bitrate > 0 { - result["bitrate"] = quality.Bitrate / 1000 - } - } - result["format"] = "opus" - result["audio_codec"] = "opus" - } else { - return "", fmt.Errorf("failed to read metadata: %w", err) - } - } else { - 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["total_tracks"] = metadata.TotalTracks - result["disc_number"] = metadata.DiscNumber - result["total_discs"] = metadata.TotalDiscs - 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 - result["replaygain_track_gain"] = metadata.ReplayGainTrackGain - result["replaygain_track_peak"] = metadata.ReplayGainTrackPeak - result["replaygain_album_gain"] = metadata.ReplayGainAlbumGain - result["replaygain_album_peak"] = metadata.ReplayGainAlbumPeak - - quality, qualityErr := GetAudioQuality(filePath) - if qualityErr == nil { - result["bit_depth"] = quality.BitDepth - result["sample_rate"] = quality.SampleRate - if quality.Codec != "" { - result["audio_codec"] = quality.Codec - } - if quality.SampleRate > 0 && quality.TotalSamples > 0 { - result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate)) - } - } - } - } else if isM4A { - result["format"] = "m4a" - meta, err := ReadM4ATags(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["total_tracks"] = meta.TotalTracks - result["disc_number"] = meta.DiscNumber - result["total_discs"] = meta.TotalDiscs - result["isrc"] = meta.ISRC - result["lyrics"] = meta.Lyrics - result["genre"] = meta.Genre - result["label"] = meta.Label - result["copyright"] = meta.Copyright - result["composer"] = meta.Composer - result["comment"] = meta.Comment - result["replaygain_track_gain"] = meta.ReplayGainTrackGain - result["replaygain_track_peak"] = meta.ReplayGainTrackPeak - result["replaygain_album_gain"] = meta.ReplayGainAlbumGain - result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak - } - quality, qualityErr := GetM4AQuality(filePath) - if qualityErr == nil { - result["bit_depth"] = quality.BitDepth - result["sample_rate"] = quality.SampleRate - result["duration"] = quality.Duration - result["audio_codec"] = quality.Codec - if format := libraryFormatForM4ACodec(quality.Codec); format != "" { - result["format"] = format - } - if quality.Bitrate > 0 && !isLosslessLibraryFormat(fmt.Sprint(result["format"])) { - result["bitrate"] = quality.Bitrate - } - } - } else if isMp3 { - result["format"] = "mp3" - result["audio_codec"] = "mp3" - 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["total_tracks"] = meta.TotalTracks - result["disc_number"] = meta.DiscNumber - result["total_discs"] = meta.TotalDiscs - result["isrc"] = meta.ISRC - result["lyrics"] = meta.Lyrics - result["genre"] = meta.Genre - result["label"] = meta.Label - result["copyright"] = meta.Copyright - result["composer"] = meta.Composer - result["comment"] = meta.Comment - result["replaygain_track_gain"] = meta.ReplayGainTrackGain - result["replaygain_track_peak"] = meta.ReplayGainTrackPeak - result["replaygain_album_gain"] = meta.ReplayGainAlbumGain - result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak - } - quality, qualityErr := GetMP3Quality(filePath) - if qualityErr == nil { - result["bit_depth"] = quality.BitDepth - result["sample_rate"] = quality.SampleRate - result["duration"] = quality.Duration - if quality.Bitrate > 0 { - result["bitrate"] = quality.Bitrate / 1000 - } - } - } else if isOgg { - result["format"] = "opus" - result["audio_codec"] = "opus" - 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["total_tracks"] = meta.TotalTracks - result["disc_number"] = meta.DiscNumber - result["total_discs"] = meta.TotalDiscs - result["isrc"] = meta.ISRC - result["lyrics"] = meta.Lyrics - result["genre"] = meta.Genre - result["label"] = meta.Label - result["copyright"] = meta.Copyright - result["composer"] = meta.Composer - result["comment"] = meta.Comment - result["replaygain_track_gain"] = meta.ReplayGainTrackGain - result["replaygain_track_peak"] = meta.ReplayGainTrackPeak - result["replaygain_album_gain"] = meta.ReplayGainAlbumGain - result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak - } - quality, qualityErr := GetOggQuality(filePath) - if qualityErr == nil { - result["sample_rate"] = quality.SampleRate - result["duration"] = quality.Duration - if quality.Bitrate > 0 { - result["bitrate"] = quality.Bitrate / 1000 - } - } - } else if isApe || isWv || isMpc { - result["format"] = strings.TrimPrefix(filepath.Ext(filePath), ".") - result["audio_codec"] = result["format"] - apeTag, apeErr := ReadAPETags(filePath) - if apeErr == nil && apeTag != nil { - meta := APETagToAudioMetadata(apeTag) - if 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["total_tracks"] = meta.TotalTracks - result["disc_number"] = meta.DiscNumber - result["total_discs"] = meta.TotalDiscs - result["isrc"] = meta.ISRC - result["lyrics"] = meta.Lyrics - result["genre"] = meta.Genre - result["label"] = meta.Label - result["copyright"] = meta.Copyright - result["composer"] = meta.Composer - result["comment"] = meta.Comment - result["replaygain_track_gain"] = meta.ReplayGainTrackGain - result["replaygain_track_peak"] = meta.ReplayGainTrackPeak - result["replaygain_album_gain"] = meta.ReplayGainAlbumGain - result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak - } - } - } else if isWav || isAiff { - var meta *AudioMetadata - var quality *WAVQuality - var qualityErr error - if isAiff { - result["format"] = "aiff" - result["audio_codec"] = "pcm" - meta, _ = ReadAIFFTags(filePath) - quality, qualityErr = GetAIFFQuality(filePath) - } else { - result["format"] = "wav" - result["audio_codec"] = "pcm" - meta, _ = ReadWAVTags(filePath) - quality, qualityErr = GetWAVQuality(filePath) - } - if 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["total_tracks"] = meta.TotalTracks - result["disc_number"] = meta.DiscNumber - result["total_discs"] = meta.TotalDiscs - result["isrc"] = meta.ISRC - result["lyrics"] = meta.Lyrics - result["genre"] = meta.Genre - result["label"] = meta.Label - result["copyright"] = meta.Copyright - result["composer"] = meta.Composer - result["comment"] = meta.Comment - result["replaygain_track_gain"] = meta.ReplayGainTrackGain - result["replaygain_track_peak"] = meta.ReplayGainTrackPeak - result["replaygain_album_gain"] = meta.ReplayGainAlbumGain - result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak - } - if qualityErr == nil && quality != nil { - result["bit_depth"] = quality.BitDepth - result["sample_rate"] = quality.SampleRate - result["duration"] = quality.Duration - } - } else { - return "", fmt.Errorf("unsupported file format: %s", filePath) - } - - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -// ParseCueSheet is called from Dart to get track listing and timing data for CUE splitting. -// audioDir, if non-empty, overrides the directory used for resolving the -// referenced audio file (useful for SAF temp file scenarios). -func ParseCueSheet(cuePath string, audioDir string) (string, error) { - return ParseCueFileJSON(cuePath, audioDir) -} - -// ScanCueSheetForLibrary parses a .cue file and returns a JSON array of -// LibraryScanResult entries (one per track). This is the SAF-friendly variant: -// - audioDir overrides where the referenced audio file is resolved -// - virtualPathPrefix replaces cuePath in filePath / id fields (e.g. a content:// URI) -// - fileModTime is stamped on every result (pass 0 to stat cuePath instead) -func ScanCueSheetForLibrary(cuePath, audioDir, virtualPathPrefix string, fileModTime int64) (string, error) { - scanTime := time.Now().UTC().Format(time.RFC3339) - results, err := ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime) - if err != nil { - return "[]", err - } - jsonBytes, err := json.Marshal(results) - if err != nil { - return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err) - } - return string(jsonBytes), nil -} - -func ScanCueSheetForLibraryWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey string) (string, error) { - scanTime := time.Now().UTC().Format(time.RFC3339) - results, err := ScanCueFileForLibraryExtWithCoverCacheKey( - cuePath, - audioDir, - virtualPathPrefix, - fileModTime, - coverCacheKey, - scanTime, - ) - if err != nil { - return "[]", err - } - jsonBytes, err := json.Marshal(results) - if err != nil { - return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err) - } - return string(jsonBytes), nil -} - -// WriteM4AFreeformTags writes ISRC and label into an M4A/MP4 file as iTunes -// freeform atoms. FFmpeg's MP4 muxer ignores these keys, so they must be -// written natively after the FFmpeg metadata pass for the values to persist. -// Only keys present in the JSON are touched; an empty value clears the tag. -func WriteM4AFreeformTags(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) - } - - if err := EditM4AFreeformText(filePath, fields); err != nil { - return "", fmt.Errorf("failed to write M4A freeform tags: %w", err) - } - - resp := map[string]any{"success": true, "method": "native_m4a_freeform"} - jsonBytes, _ := json.Marshal(resp) - return string(jsonBytes), nil -} - -// EnsureAC4Config normalizes a decrypted AC-4 file to a standards-compliant ISO -// MP4 and injects the dac4 configuration box copied from sourcePath. No-op when -// the file is not AC-4. -func EnsureAC4Config(filePath, sourcePath string) (string, error) { - if err := EnsureAC4ConfigBox(filePath, sourcePath); err != nil { - return "", fmt.Errorf("failed to finalize AC-4 container: %w", err) - } - return `{"success":true}`, nil -} - -// WriteAC4Metadata writes iTunes-style metadata into an AC-4 MP4. The JSON -// "handled" field reports whether the file was AC-4 (true) so the caller can -// skip the FFmpeg metadata pass that would re-wrap it as QuickTime. -func WriteAC4Metadata(filePath, metadataJSON, coverPath string) (string, error) { - handled, err := WriteAC4MetadataIfApplicable(filePath, metadataJSON, coverPath) - if err != nil { - return "", fmt.Errorf("failed to write AC-4 metadata: %w", err) - } - resp := map[string]any{"success": true, "handled": handled} - jsonBytes, _ := json.Marshal(resp) - return string(jsonBytes), nil -} - -// EditFileMetadata writes audio file tags: FLAC via native Go library, MP3/Opus returns map for Dart/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") - isApeFile := strings.HasSuffix(lower, ".ape") || strings.HasSuffix(lower, ".wv") || strings.HasSuffix(lower, ".mpc") - isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b") - isWavFile := strings.HasSuffix(lower, ".wav") - isAiffFile := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc") - coverPath := strings.TrimSpace(fields["cover_path"]) - - if hasOnlyM4AReplayGainFields(fields) && (isM4AFile || isMP4ContainerFile(filePath)) { - if err := EditM4AReplayGain(filePath, fields); err != nil { - return "", fmt.Errorf("failed to write M4A metadata: %w", err) - } - - resp := map[string]any{ - "success": true, - "method": "native_m4a_replaygain", - } - jsonBytes, _ := json.Marshal(resp) - return string(jsonBytes), nil - } - - if isFlac { - if err := EditFlacFields(filePath, fields); 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 - } - - // WAV / AIFF: write tags into an embedded ID3v2.4 chunk natively. - if isWavFile { - if err := WriteWAVTags(filePath, fields); err != nil { - return "", fmt.Errorf("failed to write WAV metadata: %w", err) - } - resp := map[string]any{"success": true, "method": "native_wav"} - jsonBytes, _ := json.Marshal(resp) - return string(jsonBytes), nil - } - if isAiffFile { - if err := WriteAIFFTags(filePath, fields); err != nil { - return "", fmt.Errorf("failed to write AIFF metadata: %w", err) - } - resp := map[string]any{"success": true, "method": "native_aiff"} - jsonBytes, _ := json.Marshal(resp) - return string(jsonBytes), nil - } - - if isApeFile { - trackNum := 0 - totalTracks := 0 - discNum := 0 - totalDiscs := 0 - if v, ok := fields["track_number"]; ok && v != "" { - fmt.Sscanf(v, "%d", &trackNum) - } - if v, ok := fields["track_total"]; ok && v != "" { - fmt.Sscanf(v, "%d", &totalTracks) - } - if v, ok := fields["disc_number"]; ok && v != "" { - fmt.Sscanf(v, "%d", &discNum) - } - if v, ok := fields["disc_total"]; ok && v != "" { - fmt.Sscanf(v, "%d", &totalDiscs) - } - - meta := &AudioMetadata{ - Title: fields["title"], - Artist: fields["artist"], - Album: fields["album"], - AlbumArtist: fields["album_artist"], - Date: fields["date"], - TrackNumber: trackNum, - TotalTracks: totalTracks, - DiscNumber: discNum, - TotalDiscs: totalDiscs, - ISRC: fields["isrc"], - Lyrics: fields["lyrics"], - Genre: fields["genre"], - Label: fields["label"], - Copyright: fields["copyright"], - Composer: fields["composer"], - Comment: fields["comment"], - ReplayGainTrackGain: fields["replaygain_track_gain"], - ReplayGainTrackPeak: fields["replaygain_track_peak"], - ReplayGainAlbumGain: fields["replaygain_album_gain"], - ReplayGainAlbumPeak: fields["replaygain_album_peak"], - } - - newItems := AudioMetadataToAPEItems(meta) - - // If a cover image was provided, embed it as a binary APE item. - // APEv2 cover format: "cover.jpg\0", flagged binary. - if coverPath != "" { - coverData, coverErr := os.ReadFile(coverPath) - if coverErr == nil && len(coverData) > 0 { - // The value is "filename\0" + raw bytes. We store the - // description as the Value field, but since the item is - // flagged binary, the writer serializes it verbatim. - desc := "cover.jpg\x00" - binaryValue := desc + string(coverData) - newItems = append(newItems, APETagItem{ - Key: "Cover Art (Front)", - Value: binaryValue, - Flags: apeItemFlagBinary, - }) - } - } - - // Build the set of APE keys that the edit explicitly controls. - // Even if the value is empty (user cleared the field), the old - // value must be removed during merge. - overrideKeys := apeKeysFromFields(fields) - if coverPath != "" { - overrideKeys["COVER ART (FRONT)"] = struct{}{} - } - - // Read existing tags so we can merge rather than replace. - // This preserves cover art and custom items not in the edit set. - existingTag, _ := ReadAPETags(filePath) - var finalItems []APETagItem - if existingTag != nil && len(existingTag.Items) > 0 { - finalItems = MergeAPEItems(existingTag.Items, newItems, overrideKeys) - } else { - finalItems = newItems - } - - tag := &APETag{ - Version: apeTagVersion2, - Items: finalItems, - } - - if err := WriteAPETags(filePath, tag); err != nil { - return "", fmt.Errorf("failed to write APE tags: %w", err) - } - - resp := map[string]any{ - "success": true, - "method": "native_ape", - } - jsonBytes, _ := json.Marshal(resp) - return string(jsonBytes), nil - } - - resp := map[string]any{ - "success": true, - "method": "ffmpeg", - "fields": fields, - } - jsonBytes, _ := json.Marshal(resp) - return string(jsonBytes), nil -} - -func isMP4ContainerFile(filePath string) bool { - f, err := os.Open(filePath) - if err != nil { - return false - } - defer f.Close() - - header := make([]byte, 12) - n, err := f.Read(header) - if err != nil || n < 8 { - return false - } - return string(header[4:8]) == "ftyp" -} - -func hasOnlyM4AReplayGainFields(fields map[string]string) bool { - allowed := map[string]struct{}{ - "replaygain_track_gain": {}, - "replaygain_track_peak": {}, - "replaygain_album_gain": {}, - "replaygain_album_peak": {}, - } - - hasReplayGain := false - for key, value := range fields { - if strings.TrimSpace(value) == "" { - continue - } - if _, ok := allowed[strings.ToLower(strings.TrimSpace(key))]; ok { - hasReplayGain = true - continue - } - return false - } - - return hasReplayGain -} - func SetDownloadDirectory(path string) error { return setDownloadDir(path) } @@ -1808,2334 +77,3 @@ func BuildFilename(template string, metadataJSON string) (string, error) { func SanitizeFilename(filename string) string { return sanitizeFilename(filename) } - -func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) { - client := NewLyricsClient() - durationSec := float64(durationMs) / 1000.0 - lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec) - if err != nil { - return "", err - } - - result := map[string]interface{}{ - "success": true, - "source": lyrics.Source, - "sync_type": lyrics.SyncType, - "lines": lyrics.Lines, - "instrumental": lyrics.Instrumental, - } - - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) { - if filePath != "" { - lyrics, err := ExtractLyrics(filePath) - if err == nil && lyrics != "" { - return lyrics, nil - } - return "", nil - } - - client := NewLyricsClient() - durationSec := float64(durationMs) / 1000.0 - lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec) - if err != nil { - return "", err - } - - if lyricsData.Instrumental { - return "[instrumental:true]", nil - } - - lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName) - return lrcContent, nil -} - -func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) { - if filePath != "" { - lyrics, err := ExtractLyrics(filePath) - if err == nil && lyrics != "" { - source := extractLyricsSourceFromLRC(lyrics) - if source == "" { - source = "Embedded" - } - result := map[string]interface{}{ - "lyrics": lyrics, - "source": source, - "sync_type": "EMBEDDED", - "instrumental": false, - } - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - return string(jsonBytes), nil - } - - result := map[string]interface{}{ - "lyrics": "", - "source": "", - "sync_type": "", - "instrumental": false, - } - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - return string(jsonBytes), nil - } - - client := NewLyricsClient() - durationSec := float64(durationMs) / 1000.0 - lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec) - if err != nil { - return "", err - } - - lrcContent := "" - if lyricsData.Instrumental { - lrcContent = "[instrumental:true]" - } else { - lrcContent = convertToLRCWithMetadata(lyricsData, trackName, artistName) - } - - result := map[string]interface{}{ - "lyrics": lrcContent, - "source": lyricsData.Source, - "sync_type": lyricsData.SyncType, - "instrumental": lyricsData.Instrumental, - } - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func EmbedLyricsToFile(filePath, lyrics string) (string, error) { - err := EmbedLyrics(filePath, lyrics) - if err != nil { - return errorResponse("Failed to embed lyrics: " + err.Error()) - } - - resp := map[string]interface{}{ - "success": true, - "message": "Lyrics embedded successfully", - } - - jsonBytes, _ := json.Marshal(resp) - return string(jsonBytes), nil -} - -// RewriteSplitArtistTagsExport rewrites ARTIST and ALBUMARTIST Vorbis -// comments in a FLAC file as multiple separate entries (one per artist). -// Call this after FFmpeg metadata embedding to fix split artist tags, -// since FFmpeg deduplicates -metadata keys and only keeps the last value. -func RewriteSplitArtistTagsExport(filePath, artist, albumArtist string) (string, error) { - err := RewriteSplitArtistTags(filePath, artist, albumArtist) - if err != nil { - return errorResponse("Failed to rewrite artist tags: " + err.Error()) - } - - resp := map[string]interface{}{ - "success": true, - "message": "Split artist tags written successfully", - } - - jsonBytes, _ := json.Marshal(resp) - return string(jsonBytes), nil -} - -func PreWarmTrackCacheJSON(tracksJSON string) (string, error) { - var tracks []struct { - ISRC string `json:"isrc"` - TrackName string `json:"track_name"` - ArtistName string `json:"artist_name"` - SpotifyID string `json:"spotify_id"` - Service string `json:"service"` - } - - if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil { - return errorResponse("Invalid JSON: " + err.Error()) - } - - requests := make([]PreWarmCacheRequest, len(tracks)) - for i, t := range tracks { - requests[i] = PreWarmCacheRequest{ - ISRC: t.ISRC, - TrackName: t.TrackName, - ArtistName: t.ArtistName, - SpotifyID: t.SpotifyID, - Service: t.Service, - } - } - - go PreWarmTrackCache(requests) - - resp := map[string]interface{}{ - "success": true, - "message": fmt.Sprintf("Pre-warming cache for %d tracks in background", len(tracks)), - } - - jsonBytes, _ := json.Marshal(resp) - return string(jsonBytes), nil -} - -func GetTrackCacheSize() int { - return GetCacheSize() -} - -func ClearTrackIDCache() { - ClearTrackCache() -} - -func GetDeezerRelatedArtists(artistID string, limit int) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - - client := GetDeezerClient() - artists, err := client.GetRelatedArtists(ctx, artistID, limit) - if err != nil { - return "", err - } - - resp := map[string]interface{}{ - "artists": artists, - } - jsonBytes, err := json.Marshal(resp) - if err != nil { - return "", err - } - return string(jsonBytes), nil -} - -func GetDeezerMetadata(resourceType, resourceID string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - client := GetDeezerClient() - var data interface{} - var err error - - switch resourceType { - case "track": - data, err = client.GetTrack(ctx, resourceID) - case "album": - data, err = client.GetAlbum(ctx, resourceID) - case "artist": - data, err = client.GetArtist(ctx, resourceID) - case "playlist": - data, err = client.GetPlaylist(ctx, resourceID) - default: - return "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType) - } - - if err != nil { - return "", err - } - - jsonBytes, err := json.Marshal(data) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func normalizeExtensionTrackMetadataMap( - track ExtTrackMetadata, - fallbackCover string, - fallbackTrackNumber int, -) map[string]interface{} { - coverURL := track.ResolvedCoverURL() - if coverURL == "" { - coverURL = fallbackCover - } - - trackNum := track.TrackNumber - if trackNum == 0 && fallbackTrackNumber > 0 { - trackNum = fallbackTrackNumber - } - - return map[string]interface{}{ - "id": track.ID, - "name": track.Name, - "artists": track.Artists, - "album_name": track.AlbumName, - "album_artist": track.AlbumArtist, - "album_id": track.AlbumID, - "album_url": track.AlbumURL, - "artist_id": track.ArtistID, - "artist_url": track.ArtistURL, - "external_urls": track.ExternalURL, - "duration_ms": track.DurationMS, - "images": coverURL, - "cover_url": coverURL, - "preview_url": track.PreviewURL, - "release_date": track.ReleaseDate, - "track_number": trackNum, - "total_tracks": track.TotalTracks, - "disc_number": track.DiscNumber, - "total_discs": track.TotalDiscs, - "isrc": track.ISRC, - "provider_id": track.ProviderID, - "item_type": track.ItemType, - "album_type": track.AlbumType, - "spotify_id": track.SpotifyID, - "composer": track.Composer, - "audio_quality": track.AudioQuality, - "audio_modes": track.AudioModes, - } -} - -func normalizeExtensionAlbumInfoMap(album *ExtAlbumMetadata) map[string]interface{} { - if album == nil { - return map[string]interface{}{} - } - - return map[string]interface{}{ - "id": album.ID, - "name": album.Name, - "artists": album.Artists, - "artist_id": album.ArtistID, - "images": album.CoverURL, - "cover_url": album.CoverURL, - "header_image": album.HeaderImage, - "header_video": album.HeaderVideo, - "release_date": album.ReleaseDate, - "total_tracks": album.TotalTracks, - "album_type": album.AlbumType, - "audio_traits": album.AudioTraits, - "provider_id": album.ProviderID, - } -} - -func normalizeExtensionArtistAlbumMap(album ExtAlbumMetadata) map[string]interface{} { - return map[string]interface{}{ - "id": album.ID, - "name": album.Name, - "artists": album.Artists, - "images": album.CoverURL, - "cover_url": album.CoverURL, - "release_date": album.ReleaseDate, - "total_tracks": album.TotalTracks, - "album_type": album.AlbumType, - "provider_id": album.ProviderID, - } -} - -func getExtensionProviderMetadataResponse( - providerID, - resourceType, - resourceID string, -) (map[string]interface{}, error) { - manager := getExtensionManager() - ext, err := manager.GetExtension(providerID) - if err != nil { - return nil, err - } - - if !ext.Manifest.IsMetadataProvider() { - return nil, fmt.Errorf("extension '%s' is not a metadata provider", providerID) - } - if !ext.Enabled { - return nil, fmt.Errorf("extension '%s' is disabled", providerID) - } - - provider := newExtensionProviderWrapper(ext) - - switch resourceType { - case "track": - track, err := provider.GetTrack(resourceID) - if err != nil { - return nil, err - } - if track == nil { - return nil, fmt.Errorf("track not found") - } - return map[string]interface{}{ - "track": normalizeExtensionTrackMetadataMap(*track, "", 0), - }, nil - case "album": - album, err := provider.GetAlbum(resourceID) - if err != nil { - return nil, err - } - if album == nil { - return nil, fmt.Errorf("album not found") - } - - tracks := make([]map[string]interface{}, len(album.Tracks)) - for i, track := range album.Tracks { - tracks[i] = normalizeExtensionTrackMetadataMap(track, album.CoverURL, i+1) - } - - return map[string]interface{}{ - "album_info": normalizeExtensionAlbumInfoMap(album), - "track_list": tracks, - }, nil - case "playlist": - playlist, err := provider.GetPlaylist(resourceID) - if err != nil { - return nil, err - } - if playlist == nil { - return nil, fmt.Errorf("playlist not found") - } - - tracks := make([]map[string]interface{}, len(playlist.Tracks)) - for i, track := range playlist.Tracks { - tracks[i] = normalizeExtensionTrackMetadataMap(track, playlist.CoverURL, i+1) - } - - return map[string]interface{}{ - "playlist_info": map[string]interface{}{ - "id": playlist.ID, - "name": playlist.Name, - "images": playlist.CoverURL, - "cover_url": playlist.CoverURL, - "header_image": playlist.HeaderImage, - "header_video": playlist.HeaderVideo, - "provider_id": playlist.ProviderID, - "owner": map[string]interface{}{ - "name": playlist.Artists, - "images": playlist.CoverURL, - }, - }, - "track_list": tracks, - }, nil - case "artist": - artist, err := provider.GetArtist(resourceID) - if err != nil { - return nil, err - } - if artist == nil { - return nil, fmt.Errorf("artist not found") - } - - albums := make([]map[string]interface{}, len(artist.Albums)) - for i, album := range artist.Albums { - albums[i] = normalizeExtensionArtistAlbumMap(album) - } - - response := map[string]interface{}{ - "artist_info": map[string]interface{}{ - "id": artist.ID, - "name": artist.Name, - "images": firstNonEmptyTrimmed(artist.HeaderImage, artist.ImageURL), - "cover_url": artist.ImageURL, - "header_image": artist.HeaderImage, - "header_video": artist.HeaderVideo, - "provider_id": artist.ProviderID, - }, - "albums": albums, - } - - if len(artist.Releases) > 0 { - releases := make([]map[string]interface{}, len(artist.Releases)) - for i, release := range artist.Releases { - releases[i] = normalizeExtensionArtistAlbumMap(release) - } - response["releases"] = releases - } - - if artist.Listeners > 0 { - artistInfo := response["artist_info"].(map[string]interface{}) - artistInfo["listeners"] = artist.Listeners - } - - if len(artist.TopTracks) > 0 { - topTracks := make([]map[string]interface{}, len(artist.TopTracks)) - for i, track := range artist.TopTracks { - topTracks[i] = normalizeExtensionTrackMetadataMap(track, artist.ImageURL, i+1) - } - response["top_tracks"] = topTracks - } - - return response, nil - default: - return nil, fmt.Errorf("unsupported provider resource type: %s", resourceType) - } -} - -func firstNonEmptyTrimmed(values ...string) string { - for _, value := range values { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return trimmed - } - } - return "" -} - -func GetProviderMetadataJSON(providerID, resourceType, resourceID string) (string, error) { - trimmedProviderID := strings.TrimSpace(providerID) - if trimmedProviderID == "" { - return "", fmt.Errorf("empty provider ID") - } - - switch strings.ToLower(trimmedProviderID) { - case "deezer": - if response, ok, err := getEnabledExtensionProviderMetadataResponse(trimmedProviderID, resourceType, resourceID); ok || err != nil { - if err != nil { - return "", err - } - jsonBytes, err := json.Marshal(response) - if err != nil { - return "", err - } - return string(jsonBytes), nil - } - return GetDeezerMetadata(resourceType, resourceID) - default: - response, err := getExtensionProviderMetadataResponse(trimmedProviderID, resourceType, resourceID) - if err != nil { - return "", err - } - - jsonBytes, err := json.Marshal(response) - if err != nil { - return "", err - } - return string(jsonBytes), nil - } -} - -func getEnabledExtensionProviderMetadataResponse(providerID, resourceType, resourceID string) (map[string]interface{}, bool, error) { - manager := getExtensionManager() - ext, err := manager.GetExtension(providerID) - if err != nil || ext == nil || !ext.Enabled || !ext.Manifest.IsMetadataProvider() { - return nil, false, nil - } - response, err := getExtensionProviderMetadataResponse(providerID, resourceType, resourceID) - if err != nil { - return nil, true, err - } - return response, true, nil -} - -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 := buildDeezerExtendedMetadataResult(metadata) - - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func SearchDeezerByISRC(isrc string) (string, error) { - return SearchDeezerByISRCForItemID(isrc, "") -} - -func SearchDeezerByISRCForItemID(isrc string, itemID string) (string, error) { - parentCtx := context.Background() - if itemID != "" { - parentCtx = initDownloadCancel(itemID) - defer clearDownloadCancel(itemID) - if isDownloadCancelled(itemID) { - return "", ErrDownloadCancelled - } - } - - ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second) - defer cancel() - - client := GetDeezerClient() - track, err := client.SearchByISRC(ctx, isrc) - if err != nil { - if isDownloadCancelled(itemID) { - return "", ErrDownloadCancelled - } - return "", err - } - if isDownloadCancelled(itemID) { - return "", ErrDownloadCancelled - } - - result := buildDeezerISRCSearchResult(track) - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func buildDeezerExtendedMetadataResult(metadata *AlbumExtendedMetadata) map[string]string { - if metadata == nil { - return map[string]string{ - "genre": "", - "label": "", - "copyright": "", - } - } - - return map[string]string{ - "genre": metadata.Genre, - "label": metadata.Label, - "copyright": metadata.Copyright, - } -} - -func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]interface{} { - if track == nil { - return map[string]interface{}{} - } - - result := map[string]interface{}{ - "spotify_id": track.SpotifyID, - "artists": track.Artists, - "name": track.Name, - "album_name": track.AlbumName, - "album_artist": track.AlbumArtist, - "duration_ms": track.DurationMS, - "images": track.Images, - "release_date": track.ReleaseDate, - "track_number": track.TrackNumber, - "total_tracks": track.TotalTracks, - "disc_number": track.DiscNumber, - "total_discs": track.TotalDiscs, - "external_urls": track.ExternalURL, - "isrc": track.ISRC, - "album_id": track.AlbumID, - "artist_id": track.ArtistID, - "album_type": track.AlbumType, - "composer": track.Composer, - } - - if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" { - result["id"] = deezerID - result["track_id"] = deezerID - result["success"] = true - } - - return result -} - -func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - songlink := NewSongLinkClient() - deezerClient := GetDeezerClient() - - if resourceType == "track" { - deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID) - if err != nil { - return "", fmt.Errorf("could not find Deezer equivalent: %w", err) - } - - trackResp, err := deezerClient.GetTrack(ctx, deezerID) - if err != nil { - return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err) - } - - jsonBytes, err := json.Marshal(trackResp) - if err != nil { - return "", err - } - - return string(jsonBytes), nil - } - - if resourceType == "album" { - deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID) - if err != nil { - return "", fmt.Errorf("could not find Deezer album: %w", err) - } - - albumResp, err := deezerClient.GetAlbum(ctx, deezerID) - if err != nil { - return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err) - } - - jsonBytes, err := json.Marshal(albumResp) - if err != nil { - return "", err - } - - return string(jsonBytes), nil - } - - return "", fmt.Errorf("spotify to Deezer conversion only supported for tracks and albums: please search by name for %s", resourceType) -} - -func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) { - client := NewSongLinkClient() - availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID) - if err != nil { - return "", err - } - - jsonBytes, err := json.Marshal(availability) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (string, error) { - client := NewSongLinkClient() - availability, err := client.CheckAvailabilityByPlatform(platform, entityType, entityID) - if err != nil { - return "", err - } - - jsonBytes, err := json.Marshal(availability) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func GetSpotifyIDFromDeezerTrack(deezerTrackID string) (string, error) { - client := NewSongLinkClient() - return client.GetSpotifyIDFromDeezer(deezerTrackID) -} - -func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) { - client := NewSongLinkClient() - return client.GetTidalURLFromDeezer(deezerTrackID) -} - -func errorResponse(msg string) (string, error) { - errorType := classifyDownloadErrorType(msg) - - resp := DownloadResponse{ - Success: false, - Error: msg, - ErrorType: errorType, - } - jsonBytes, _ := json.Marshal(resp) - return string(jsonBytes), nil -} - -func classifyDownloadErrorType(msg string) string { - lowerMsg := strings.ToLower(msg) - - if strings.Contains(lowerMsg, "isp blocking") || - strings.Contains(lowerMsg, "try using vpn") || - strings.Contains(lowerMsg, "change dns") { - return "isp_blocked" - } else if strings.Contains(lowerMsg, "cancel") { - return "cancelled" - } else if strings.Contains(lowerMsg, "verify_required") || - strings.Contains(lowerMsg, "verification_required") || - strings.Contains(lowerMsg, "verification required") || - strings.Contains(lowerMsg, "needs verification") || - strings.Contains(lowerMsg, "session is not authenticated") || - strings.Contains(lowerMsg, "signed session is not authenticated") || - strings.Contains(lowerMsg, "unauthorized") || - strings.Contains(lowerMsg, "precondition required") || - messageHasHTTPStatusCode(lowerMsg, "401") || - messageHasHTTPStatusCode(lowerMsg, "428") { - return "verification_required" - } else if strings.Contains(lowerMsg, "rate limit") || - messageHasHTTPStatusCode(lowerMsg, "429") || - strings.Contains(lowerMsg, "too many requests") { - return "rate_limit" - } else if strings.Contains(lowerMsg, "permission") || - strings.Contains(lowerMsg, "operation not permitted") || - strings.Contains(lowerMsg, "access denied") || - strings.Contains(lowerMsg, "failed to create file") || - strings.Contains(lowerMsg, "failed to create directory") { - return "permission" - } else if strings.Contains(lowerMsg, "not found") || - strings.Contains(lowerMsg, "not available") || - strings.Contains(lowerMsg, "no results") || - strings.Contains(lowerMsg, "track not found") || - strings.Contains(lowerMsg, "all services failed") { - return "not_found" - } else if strings.Contains(lowerMsg, "network") || - strings.Contains(lowerMsg, "connection") || - strings.Contains(lowerMsg, "timeout") || - strings.Contains(lowerMsg, "dial") { - return "network" - } - - return "unknown" -} - -func messageHasHTTPStatusCode(lowerMsg, code string) bool { - return strings.Contains(lowerMsg, "http "+code) || - strings.Contains(lowerMsg, "http status "+code) || - strings.Contains(lowerMsg, "status "+code) || - strings.Contains(lowerMsg, code+" for ") || - strings.Contains(lowerMsg, code+":") || - strings.Contains(lowerMsg, code+";") -} - -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] Downloaded cover to: %s (%d KB)\n", outputPath, len(data)/1024) - return nil -} - -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, ".m4a") || strings.HasSuffix(lower, ".aac") { - coverData, err = extractCoverFromM4A(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 -} - -func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int64, outputPath string, audioFilePath string) error { - // If the audio file already has embedded lyrics or a sidecar .lrc, - // use those directly instead of making redundant network requests. - if audioFilePath != "" { - existing, err := ExtractLyrics(audioFilePath) - if err == nil && strings.TrimSpace(existing) != "" { - if err := os.WriteFile(outputPath, []byte(existing), 0644); err != nil { - return fmt.Errorf("failed to write LRC file: %w", err) - } - GoLog("[Lyrics] Saved LRC from embedded/sidecar to: %s\n", outputPath) - return nil - } - } - - 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 -} - -func SetLyricsProvidersJSON(providersJSON string) error { - var providers []string - if err := json.Unmarshal([]byte(providersJSON), &providers); err != nil { - return err - } - - SetLyricsProviderOrder(providers) - return nil -} - -func GetLyricsProvidersJSON() (string, error) { - providers := GetLyricsProviderOrder() - jsonBytes, err := json.Marshal(providers) - if err != nil { - return "", err - } - return string(jsonBytes), nil -} - -func GetAvailableLyricsProvidersJSON() (string, error) { - providers := GetAvailableLyricsProviders() - jsonBytes, err := json.Marshal(providers) - if err != nil { - return "", err - } - return string(jsonBytes), nil -} - -func SetLyricsFetchOptionsJSON(optionsJSON string) error { - opts := GetLyricsFetchOptions() - if strings.TrimSpace(optionsJSON) != "" { - if err := json.Unmarshal([]byte(optionsJSON), &opts); err != nil { - return err - } - } - - SetLyricsFetchOptions(opts) - return nil -} - -func GetLyricsFetchOptionsJSON() (string, error) { - opts := GetLyricsFetchOptions() - jsonBytes, err := json.Marshal(opts) - if err != nil { - return "", err - } - return string(jsonBytes), 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 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]interface{}{ - "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]interface{}{ - "method": "native", - "success": true, - "enriched_metadata": enrichedMeta, - "lyrics": lyricsLRC, - "write_external_lrc": req.EmbedLyrics && - req.shouldUpdateField("lyrics") && - req.lyricsSidecarEnabled() && - strings.TrimSpace(lyricsLRC) != "", - } - jsonBytes, _ := json.Marshal(result) - return string(jsonBytes), nil - } - - // Don't cleanup cover temp — Dart needs it for FFmpeg embed - cleanupCover = false - ffmpegMetadata := buildReEnrichFFmpegMetadata(&req, lyricsLRC) - - result := map[string]interface{}{ - "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) != "", - } - - jsonBytes, _ := json.Marshal(result) - return string(jsonBytes), nil -} - -func InitExtensionSystem(extensionsDir, dataDir string) error { - manager := getExtensionManager() - if err := manager.SetDirectories(extensionsDir, dataDir); err != nil { - return err - } - - settingsStore := GetExtensionSettingsStore() - if err := settingsStore.SetDataDir(dataDir); err != nil { - return err - } - - return nil -} - -func LoadExtensionsFromDir(dirPath string) (string, error) { - manager := getExtensionManager() - loaded, errors := manager.LoadExtensionsFromDirectory(dirPath) - - result := map[string]interface{}{ - "loaded": loaded, - "errors": make([]string, len(errors)), - } - - for i, err := range errors { - result["errors"].([]string)[i] = err.Error() - } - - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func LoadExtensionFromPath(filePath string) (string, error) { - manager := getExtensionManager() - ext, err := manager.LoadExtensionFromFile(filePath) - if err != nil { - return "", err - } - - result := map[string]interface{}{ - "id": ext.ID, - "name": ext.Manifest.Name, - "display_name": ext.Manifest.DisplayName, - "version": ext.Manifest.Version, - "enabled": ext.Enabled, - } - - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func UnloadExtensionByID(extensionID string) error { - manager := getExtensionManager() - return manager.UnloadExtension(extensionID) -} - -func RemoveExtensionByID(extensionID string) error { - manager := getExtensionManager() - return manager.RemoveExtension(extensionID) -} - -func UpgradeExtensionFromPath(filePath string) (string, error) { - manager := getExtensionManager() - ext, err := manager.UpgradeExtension(filePath) - if err != nil { - return "", err - } - - result := map[string]interface{}{ - "id": ext.ID, - "display_name": ext.Manifest.DisplayName, - "version": ext.Manifest.Version, - "enabled": ext.Enabled, - } - - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func CheckExtensionUpgradeFromPath(filePath string) (string, error) { - manager := getExtensionManager() - return manager.CheckExtensionUpgradeJSON(filePath) -} - -func GetInstalledExtensions() (string, error) { - manager := getExtensionManager() - return manager.GetInstalledExtensionsJSON() -} - -func SetExtensionEnabledByID(extensionID string, enabled bool) error { - manager := getExtensionManager() - return manager.SetExtensionEnabled(extensionID, enabled) -} - -func SetProviderPriorityJSON(priorityJSON string) error { - var priority []string - if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil { - return err - } - - SetProviderPriority(priority) - return nil -} - -func GetProviderPriorityJSON() (string, error) { - priority := GetProviderPriority() - jsonBytes, err := json.Marshal(priority) - if err != nil { - return "", err - } - return string(jsonBytes), nil -} - -func SetExtensionFallbackProviderIDsJSON(providerIDsJSON string) error { - if strings.TrimSpace(providerIDsJSON) == "" { - SetExtensionFallbackProviderIDs(nil) - return nil - } - - var providerIDs []string - if err := json.Unmarshal([]byte(providerIDsJSON), &providerIDs); err != nil { - return err - } - - SetExtensionFallbackProviderIDs(providerIDs) - return nil -} - -func GetExtensionFallbackProviderIDsJSON() (string, error) { - providerIDs := GetExtensionFallbackProviderIDs() - jsonBytes, err := json.Marshal(providerIDs) - if err != nil { - return "", err - } - return string(jsonBytes), nil -} - -func SetMetadataProviderPriorityJSON(priorityJSON string) error { - var priority []string - if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil { - return err - } - - SetMetadataProviderPriority(priority) - return nil -} - -func GetMetadataProviderPriorityJSON() (string, error) { - priority := GetMetadataProviderPriority() - jsonBytes, err := json.Marshal(priority) - if err != nil { - return "", err - } - return string(jsonBytes), nil -} - -func GetExtensionSettingsJSON(extensionID string) (string, error) { - store := GetExtensionSettingsStore() - settings := store.GetAll(extensionID) - - jsonBytes, err := json.Marshal(settings) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func SetExtensionSettingsJSON(extensionID, settingsJSON string) error { - var settings map[string]interface{} - if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil { - return err - } - - store := GetExtensionSettingsStore() - if err := store.SetAll(extensionID, settings); err != nil { - return err - } - - manager := getExtensionManager() - return manager.InitializeExtension(extensionID, settings) -} - -func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) { - manager := getExtensionManager() - tracks, err := manager.SearchTracksWithExtensions(query, limit) - if err != nil { - return "", err - } - - jsonBytes, err := json.Marshal(tracks) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func SearchTracksWithMetadataProvidersJSON(query string, limit int, includeExtensions bool) (string, error) { - manager := getExtensionManager() - tracks, err := manager.SearchTracksWithMetadataProviders(query, limit, includeExtensions) - if err != nil { - return "", err - } - - jsonBytes, err := json.Marshal(tracks) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func DownloadWithExtensionsJSON(requestJSON string) (string, error) { - var req DownloadRequest - if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { - return "", fmt.Errorf("invalid request: %w", err) - } - applySongLinkRegionFromRequest(&req) - defer closeOwnedOutputFD(req.OutputFD) - if req.ItemID != "" { - initDownloadCancel(req.ItemID) - defer clearDownloadCancel(req.ItemID) - if isDownloadCancelled(req.ItemID) { - return "", ErrDownloadCancelled - } - } - - req.TrackName = strings.TrimSpace(req.TrackName) - req.ArtistName = strings.TrimSpace(req.ArtistName) - req.AlbumName = strings.TrimSpace(req.AlbumName) - req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) - req.OutputDir = strings.TrimSpace(req.OutputDir) - req.OutputPath = strings.TrimSpace(req.OutputPath) - req.OutputExt = strings.TrimSpace(req.OutputExt) - if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" { - AddAllowedDownloadDir(req.OutputDir) - } - - enrichRequestExtendedMetadata(&req) - if isDownloadCancelled(req.ItemID) { - return "", ErrDownloadCancelled - } - - result, err := DownloadWithExtensionFallback(req) - if err != nil { - return "", err - } - - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func CleanupExtensions() { - manager := getExtensionManager() - manager.UnloadAllExtensions() -} - -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 -} - -func GetExtensionPendingAuthJSON(extensionID string) (string, error) { - req := ensureExtensionPendingAuthRequest(extensionID) - if req == nil { - return "", nil - } - - result := map[string]interface{}{ - "extension_id": req.ExtensionID, - "auth_url": req.AuthURL, - "callback_url": req.CallbackURL, - } - - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func ensureExtensionPendingAuthRequest(extensionID string) *PendingAuthRequest { - extensionID = strings.TrimSpace(extensionID) - if extensionID == "" { - return nil - } - - if req := GetPendingAuthRequest(extensionID); req != nil { - return req - } - - manager := getExtensionManager() - ext, err := manager.GetExtension(extensionID) - if err != nil || ext == nil || !ext.Enabled || ext.Manifest == nil || ext.Manifest.SignedSession == nil { - return nil - } - - if err := ext.ensureRuntimeReady(); err != nil || ext.runtime == nil { - return nil - } - - config := signedSessionConfigWithDefaults(ext.Manifest.SignedSession) - if config.Namespace == "" || config.BaseURL == "" { - return nil - } - if record, err := ext.runtime.loadSignedSession(config); err == nil { - record.SessionID = "" - record.SessionSecret = "" - record.ExpiresAt = "" - _ = ext.runtime.saveSignedSession(config, record) - } - ext.runtime.startSignedSessionVerification(config, "pending-auth-request") - return GetPendingAuthRequest(extensionID) -} - -func SetExtensionAuthCodeByID(extensionID, authCode string) { - SetExtensionAuthCode(extensionID, authCode) -} - -func SetExtensionSessionGrantByID(extensionID, grant string) { - setPendingSignedSessionGrant(extensionID, grant) -} - -func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) { - var expiresAt time.Time - if expiresIn > 0 { - expiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) - } - SetExtensionTokens(extensionID, accessToken, refreshToken, expiresAt) -} - -func ClearExtensionPendingAuthByID(extensionID string) { - ClearPendingAuthRequest(extensionID) -} - -func IsExtensionAuthenticatedByID(extensionID string) bool { - extensionAuthStateMu.RLock() - defer extensionAuthStateMu.RUnlock() - - state, exists := extensionAuthState[extensionID] - if !exists { - return false - } - - if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) { - return false - } - - return state.IsAuthenticated -} - -func GetAllPendingAuthRequestsJSON() (string, error) { - pendingAuthRequestsMu.RLock() - defer pendingAuthRequestsMu.RUnlock() - - requests := make([]map[string]interface{}, 0, len(pendingAuthRequests)) - for _, req := range pendingAuthRequests { - requests = append(requests, map[string]interface{}{ - "extension_id": req.ExtensionID, - "auth_url": req.AuthURL, - "callback_url": req.CallbackURL, - }) - } - - jsonBytes, err := json.Marshal(requests) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func GetPendingFFmpegCommandJSON(commandID string) (string, error) { - cmd := GetPendingFFmpegCommand(commandID) - if cmd == nil { - return "", nil - } - - result := map[string]interface{}{ - "command_id": commandID, - "extension_id": cmd.ExtensionID, - "command": cmd.Command, - "input_path": cmd.InputPath, - "output_path": cmd.OutputPath, - } - - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func SetFFmpegCommandResultByID(commandID string, success bool, output, errorMsg string) { - SetFFmpegCommandResult(commandID, success, output, errorMsg) -} - -func GetAllPendingFFmpegCommandsJSON() (string, error) { - ffmpegCommandsMu.RLock() - defer ffmpegCommandsMu.RUnlock() - - commands := make([]map[string]interface{}, 0) - for cmdID, cmd := range ffmpegCommands { - if !cmd.Completed { - commands = append(commands, map[string]interface{}{ - "command_id": cmdID, - "extension_id": cmd.ExtensionID, - "command": cmd.Command, - }) - } - } - - jsonBytes, err := json.Marshal(commands) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) { - manager := getExtensionManager() - ext, err := manager.GetExtension(extensionID) - if err != nil { - return trackJSON, nil - } - - if !ext.Manifest.IsMetadataProvider() { - return trackJSON, nil - } - - var track ExtTrackMetadata - if err := json.Unmarshal([]byte(trackJSON), &track); err != nil { - return trackJSON, fmt.Errorf("failed to parse track: %w", err) - } - - provider := newExtensionProviderWrapper(ext) - enrichedTrack, err := provider.EnrichTrack(&track) - if err != nil { - return trackJSON, nil - } - - jsonBytes, err := json.Marshal(enrichedTrack) - if err != nil { - return trackJSON, nil - } - - return string(jsonBytes), nil -} - -func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) { - return CustomSearchWithExtensionJSONWithRequestID(extensionID, query, optionsJSON, "") -} - -func CustomSearchWithExtensionJSONWithRequestID(extensionID, query string, optionsJSON string, requestID string) (string, error) { - manager := getExtensionManager() - ext, err := manager.GetExtension(extensionID) - if err != nil { - return "", err - } - - if !ext.Manifest.HasCustomSearch() { - return "", fmt.Errorf("extension '%s' does not support custom search", extensionID) - } - - var options map[string]interface{} - if optionsJSON != "" { - if err := json.Unmarshal([]byte(optionsJSON), &options); err != nil { - options = make(map[string]interface{}) - } - } - - provider := newExtensionProviderWrapper(ext) - tracks, err := provider.CustomSearchForRequestID(query, options, requestID) - if err != nil { - return "", err - } - - result := make([]map[string]interface{}, len(tracks)) - for i, track := range tracks { - result[i] = map[string]interface{}{ - "id": track.ID, - "name": track.Name, - "artists": track.Artists, - "album_name": track.AlbumName, - "album_artist": track.AlbumArtist, - "duration_ms": track.DurationMS, - "images": track.ResolvedCoverURL(), - "preview_url": track.PreviewURL, - "release_date": track.ReleaseDate, - "track_number": track.TrackNumber, - "total_tracks": track.TotalTracks, - "disc_number": track.DiscNumber, - "total_discs": track.TotalDiscs, - "isrc": track.ISRC, - "provider_id": track.ProviderID, - "item_type": track.ItemType, - "album_type": track.AlbumType, - "composer": track.Composer, - "audio_quality": track.AudioQuality, - "audio_modes": track.AudioModes, - } - } - - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func GetSearchProvidersJSON() (string, error) { - manager := getExtensionManager() - providers := manager.GetSearchProviders() - - result := make([]map[string]interface{}, 0, len(providers)) - for _, p := range providers { - result = append(result, map[string]interface{}{ - "id": p.extension.ID, - "display_name": p.extension.Manifest.DisplayName, - "placeholder": p.extension.Manifest.SearchBehavior.Placeholder, - "primary": p.extension.Manifest.SearchBehavior.Primary, - "icon": p.extension.Manifest.SearchBehavior.Icon, - }) - } - - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func HandleURLWithExtensionJSON(url string) (string, error) { - manager := getExtensionManager() - resultWithID, err := manager.HandleURLWithExtension(url) - if err != nil { - return "", err - } - - result := resultWithID.Result - extensionID := resultWithID.ExtensionID - - if result == nil { - return "", fmt.Errorf("extension %s failed to handle URL", extensionID) - } - - response := map[string]interface{}{ - "type": result.Type, - "extension_id": extensionID, - "name": result.Name, - "cover_url": result.CoverURL, - "header_image": result.HeaderImage, - "header_video": result.HeaderVideo, - } - - if result.Track != nil { - response["track"] = map[string]interface{}{ - "id": result.Track.ID, - "name": result.Track.Name, - "artists": result.Track.Artists, - "album_name": result.Track.AlbumName, - "album_artist": result.Track.AlbumArtist, - "duration_ms": result.Track.DurationMS, - "images": result.Track.ResolvedCoverURL(), - "preview_url": result.Track.PreviewURL, - "release_date": result.Track.ReleaseDate, - "track_number": result.Track.TrackNumber, - "total_tracks": result.Track.TotalTracks, - "disc_number": result.Track.DiscNumber, - "total_discs": result.Track.TotalDiscs, - "isrc": result.Track.ISRC, - "provider_id": result.Track.ProviderID, - "composer": result.Track.Composer, - } - } - - if len(result.Tracks) > 0 { - tracks := make([]map[string]interface{}, len(result.Tracks)) - for i, track := range result.Tracks { - tracks[i] = map[string]interface{}{ - "id": track.ID, - "name": track.Name, - "artists": track.Artists, - "album_name": track.AlbumName, - "album_artist": track.AlbumArtist, - "duration_ms": track.DurationMS, - "images": track.ResolvedCoverURL(), - "preview_url": track.PreviewURL, - "release_date": track.ReleaseDate, - "track_number": track.TrackNumber, - "total_tracks": track.TotalTracks, - "disc_number": track.DiscNumber, - "total_discs": track.TotalDiscs, - "isrc": track.ISRC, - "provider_id": track.ProviderID, - "item_type": track.ItemType, - "album_type": track.AlbumType, - "composer": track.Composer, - } - } - response["tracks"] = tracks - } - - if result.Album != nil { - response["album"] = map[string]interface{}{ - "id": result.Album.ID, - "name": result.Album.Name, - "artists": result.Album.Artists, - "cover_url": result.Album.CoverURL, - "header_image": result.Album.HeaderImage, - "header_video": result.Album.HeaderVideo, - "audio_traits": result.Album.AudioTraits, - "release_date": result.Album.ReleaseDate, - "total_tracks": result.Album.TotalTracks, - "album_type": result.Album.AlbumType, - "provider_id": result.Album.ProviderID, - } - } - - if result.Artist != nil { - artistResponse := map[string]interface{}{ - "id": result.Artist.ID, - "name": result.Artist.Name, - "image_url": result.Artist.ImageURL, - "header_image": result.Artist.HeaderImage, - "header_video": result.Artist.HeaderVideo, - "listeners": result.Artist.Listeners, - "provider_id": result.Artist.ProviderID, - } - - if len(result.Artist.Albums) > 0 { - albums := make([]map[string]interface{}, len(result.Artist.Albums)) - for i, album := range result.Artist.Albums { - albumType := album.AlbumType - if albumType == "" { - albumType = "album" - } - albums[i] = map[string]interface{}{ - "id": album.ID, - "name": album.Name, - "artists": album.Artists, - "images": album.CoverURL, - "cover_url": album.CoverURL, - "release_date": album.ReleaseDate, - "total_tracks": album.TotalTracks, - "album_type": albumType, - "provider_id": album.ProviderID, - } - } - artistResponse["albums"] = albums - } - - if len(result.Artist.Releases) > 0 { - releases := make([]map[string]interface{}, len(result.Artist.Releases)) - for i, release := range result.Artist.Releases { - releaseType := release.AlbumType - if releaseType == "" { - releaseType = "album" - } - releases[i] = map[string]interface{}{ - "id": release.ID, - "name": release.Name, - "artists": release.Artists, - "images": release.CoverURL, - "cover_url": release.CoverURL, - "release_date": release.ReleaseDate, - "total_tracks": release.TotalTracks, - "album_type": releaseType, - "provider_id": release.ProviderID, - } - } - artistResponse["releases"] = releases - } - - if len(result.Artist.TopTracks) > 0 { - topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks)) - for i, track := range result.Artist.TopTracks { - topTracks[i] = map[string]interface{}{ - "id": track.ID, - "name": track.Name, - "artists": track.Artists, - "album_name": track.AlbumName, - "album_artist": track.AlbumArtist, - "duration_ms": track.DurationMS, - "images": track.ResolvedCoverURL(), - "preview_url": track.PreviewURL, - "release_date": track.ReleaseDate, - "track_number": track.TrackNumber, - "total_tracks": track.TotalTracks, - "disc_number": track.DiscNumber, - "total_discs": track.TotalDiscs, - "isrc": track.ISRC, - "provider_id": track.ProviderID, - "spotify_id": track.SpotifyID, - "composer": track.Composer, - } - } - artistResponse["top_tracks"] = topTracks - } - - response["artist"] = artistResponse - } - - jsonBytes, err := json.Marshal(response) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func FindURLHandlerJSON(url string) string { - manager := getExtensionManager() - handler := manager.FindURLHandler(url) - if handler == nil { - return "" - } - return handler.extension.ID -} - -func GetURLHandlersJSON() (string, error) { - manager := getExtensionManager() - handlers := manager.GetURLHandlers() - - result := make([]map[string]interface{}, 0, len(handlers)) - for _, h := range handlers { - result = append(result, map[string]interface{}{ - "id": h.extension.ID, - "display_name": h.extension.Manifest.DisplayName, - "patterns": h.extension.Manifest.URLHandler.Patterns, - }) - } - - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) { - var metadata map[string]interface{} - if metadataJSON != "" { - if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { - metadata = make(map[string]interface{}) - } - } - - manager := getExtensionManager() - result, err := manager.RunPostProcessing(filePath, metadata) - if err != nil { - return "", err - } - - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func RunPostProcessingV2JSON(inputJSON, metadataJSON string) (string, error) { - var metadata map[string]interface{} - if metadataJSON != "" { - if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { - metadata = make(map[string]interface{}) - } - } - - var input PostProcessInput - if inputJSON != "" { - if err := json.Unmarshal([]byte(inputJSON), &input); err != nil { - input = PostProcessInput{} - } - } - - manager := getExtensionManager() - result, err := manager.RunPostProcessingV2(input, metadata) - if err != nil { - return "", err - } - - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func GetPostProcessingProvidersJSON() (string, error) { - manager := getExtensionManager() - providers := manager.GetPostProcessingProviders() - - result := make([]map[string]interface{}, 0, len(providers)) - for _, p := range providers { - hooks := make([]map[string]interface{}, 0) - for _, h := range p.extension.Manifest.GetPostProcessingHooks() { - hooks = append(hooks, map[string]interface{}{ - "id": h.ID, - "name": h.Name, - "description": h.Description, - "default_enabled": h.DefaultEnabled, - "supported_formats": h.SupportedFormats, - }) - } - - result = append(result, map[string]interface{}{ - "id": p.extension.ID, - "display_name": p.extension.Manifest.DisplayName, - "hooks": hooks, - }) - } - - jsonBytes, err := json.Marshal(result) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func InitExtensionStoreJSON(cacheDir string) error { - initExtensionStore(cacheDir) - return nil -} - -func SetStoreRegistryURLJSON(registryURL string) error { - store := getExtensionStore() - if store == nil { - return fmt.Errorf("extension store not initialized") - } - - resolved, err := resolveRegistryURL(registryURL) - if err != nil { - return err - } - - if err := requireHTTPSURL(resolved, "registry"); err != nil { - return err - } - - store.setRegistryURL(resolved) - return nil -} - -func ClearStoreRegistryURLJSON() error { - store := getExtensionStore() - if store == nil { - return fmt.Errorf("extension store not initialized") - } - - store.setRegistryURL("") - store.clearCache() - return nil -} - -func GetStoreRegistryURLJSON() (string, error) { - store := getExtensionStore() - if store == nil { - return "", fmt.Errorf("extension store not initialized") - } - - return store.getRegistryURL(), nil -} - -func GetStoreExtensionsJSON(forceRefresh bool) (string, error) { - store := getExtensionStore() - if store == nil { - return "", fmt.Errorf("extension store not initialized") - } - - extensions, err := store.getExtensionsWithStatus(forceRefresh) - if err != nil { - return "", err - } - - jsonBytes, err := json.Marshal(extensions) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func SearchStoreExtensionsJSON(query, category string) (string, error) { - store := getExtensionStore() - if store == nil { - return "", fmt.Errorf("extension store not initialized") - } - - extensions, err := store.searchExtensions(query, category) - if err != nil { - return "", err - } - - jsonBytes, err := json.Marshal(extensions) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func GetStoreCategoriesJSON() (string, error) { - store := getExtensionStore() - if store == nil { - return "", fmt.Errorf("extension store not initialized") - } - - categories := store.getCategories() - jsonBytes, err := json.Marshal(categories) - if err != nil { - return "", err - } - - return string(jsonBytes), nil -} - -func storeExtensionPackageSuffix(downloadURL string) string { - rawPath := downloadURL - if parsed, err := url.Parse(downloadURL); err == nil { - rawPath = parsed.Path - } - - lowerPath := strings.ToLower(rawPath) - if strings.HasSuffix(lowerPath, ".sflx") { - return ".sflx" - } - if strings.HasSuffix(lowerPath, ".spotiflac-ext") { - return ".spotiflac-ext" - } - return ".spotiflac-ext" -} - -func buildStoreExtensionDestPath(destDir, extensionID, downloadURL string) (string, error) { - if strings.TrimSpace(extensionID) == "" { - return "", fmt.Errorf("invalid extension id") - } - - safeExtensionID := sanitizeFilename(extensionID) - return filepath.Join(destDir, safeExtensionID+storeExtensionPackageSuffix(downloadURL)), nil -} - -func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) { - store := getExtensionStore() - if store == nil { - return "", fmt.Errorf("extension store not initialized") - } - - ext, err := store.findExtension(extensionID) - if err != nil { - return "", err - } - - destPath, err := buildStoreExtensionDestPath(destDir, extensionID, ext.getDownloadURL()) - if err != nil { - return "", err - } - err = store.downloadExtension(extensionID, destPath) - if err != nil { - return "", err - } - - return destPath, nil -} - -func ClearStoreCacheJSON() error { - store := getExtensionStore() - if store == nil { - return fmt.Errorf("extension store not initialized") - } - - store.clearCache() - return nil -} - -func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Duration) (string, error) { - return callExtensionFunctionJSONWithRequestID(extensionID, functionName, timeout, "") -} - -func callExtensionFunctionJSONWithRequestID(extensionID, functionName string, timeout time.Duration, requestID string) (string, error) { - manager := getExtensionManager() - ext, err := manager.GetExtension(extensionID) - if err != nil { - return "", err - } - - if !ext.Enabled { - return "", fmt.Errorf("extension '%s' is disabled", extensionID) - } - perf := newExtensionCallPerf(extensionID, functionName) - defer perf.finish() - initStartedAt := time.Now() - vm, err := ext.lockReadyVM() - if err != nil { - return "", err - } - perf.recordInit(time.Since(initStartedAt)) - defer ext.VMMu.Unlock() - requestCtx := context.Background() - if requestID != "" { - if ext.runtime != nil { - ext.runtime.setActiveRequestID(requestID) - defer ext.runtime.clearActiveRequestID() - } - requestCtx = initExtensionRequestCancel(requestID) - defer clearExtensionRequestCancel(requestID) - if isExtensionRequestCancelled(requestID) { - return "", ErrExtensionRequestCancelled - } - } - - // Goja runtime is not thread-safe; guard direct extension.*() calls with VMMu - // to avoid races with other provider calls (e.g. getAlbum/getPlaylist). - script := fmt.Sprintf(` - (function() { - if (typeof extension !== 'undefined' && typeof extension.%s === 'function') { - return extension.%s(); - } - if (typeof %s === 'function') { - return %s(); - } - return null; - })() - `, functionName, functionName, functionName, functionName) - - jsStartedAt := time.Now() - result, err := RunWithTimeoutContextAndRecover(requestCtx, vm, script, timeout) - perf.recordJS(time.Since(jsStartedAt)) - if err != nil { - if isExtensionRequestCancelled(requestID) || errors.Is(err, ErrExtensionRequestCancelled) { - return "", ErrExtensionRequestCancelled - } - return "", fmt.Errorf("%s failed: %w", functionName, err) - } - if isExtensionRequestCancelled(requestID) { - return "", ErrExtensionRequestCancelled - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return "", fmt.Errorf("%s returned null", functionName) - } - - parseStartedAt := time.Now() - jsonBytes, err := json.Marshal(result) - perf.recordParse(time.Since(parseStartedAt)) - if err != nil { - return "", fmt.Errorf("failed to marshal result: %w", err) - } - perf.setPayloadBytes(len(jsonBytes)) - perf.setItems(countExtensionTopLevelItems(vm, result)) - - return string(jsonBytes), nil -} - -func GetExtensionHomeFeedJSON(extensionID string) (string, error) { - return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second) -} - -func GetExtensionHomeFeedJSONWithRequestID(extensionID, requestID string) (string, error) { - return callExtensionFunctionJSONWithRequestID(extensionID, "getHomeFeed", 60*time.Second, requestID) -} - -func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) { - return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second) -} - -func CancelExtensionRequestJSON(requestID string) { - cancelExtensionRequest(requestID) -} - -func SetLibraryCoverCacheDirJSON(cacheDir string) { - SetLibraryCoverCacheDir(cacheDir) -} - -func ScanLibraryFolderJSON(folderPath string) (string, error) { - return ScanLibraryFolder(folderPath) -} - -func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (string, error) { - return ScanLibraryFolderIncremental(folderPath, existingFilesJSON) -} - -func ScanLibraryFolderIncrementalFromSnapshotJSON(folderPath, snapshotPath string) (string, error) { - return ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath) -} - -func GetLibraryScanProgressJSON() string { - return GetLibraryScanProgress() -} - -func CancelLibraryScanJSON() { - CancelLibraryScan() -} - -func ReadAudioMetadataJSON(filePath string) (string, error) { - return ReadAudioMetadata(filePath) -} - -func ReadAudioMetadataWithHintJSON(filePath, displayName string) (string, error) { - return ReadAudioMetadataWithDisplayName(filePath, displayName) -} - -func ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filePath, displayName, coverCacheKey string) (string, error) { - return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayName, coverCacheKey) -} diff --git a/go_backend/exports_deezer.go b/go_backend/exports_deezer.go new file mode 100644 index 00000000..1c9c379e --- /dev/null +++ b/go_backend/exports_deezer.go @@ -0,0 +1,307 @@ +package gobackend + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" +) + +func PreWarmTrackCacheJSON(tracksJSON string) (string, error) { + var tracks []struct { + ISRC string `json:"isrc"` + TrackName string `json:"track_name"` + ArtistName string `json:"artist_name"` + SpotifyID string `json:"spotify_id"` + Service string `json:"service"` + } + + if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil { + return errorResponse("Invalid JSON: " + err.Error()) + } + + requests := make([]PreWarmCacheRequest, len(tracks)) + for i, t := range tracks { + requests[i] = PreWarmCacheRequest{ + ISRC: t.ISRC, + TrackName: t.TrackName, + ArtistName: t.ArtistName, + SpotifyID: t.SpotifyID, + Service: t.Service, + } + } + + go PreWarmTrackCache(requests) + + resp := map[string]interface{}{ + "success": true, + "message": fmt.Sprintf("Pre-warming cache for %d tracks in background", len(tracks)), + } + + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil +} + +func GetTrackCacheSize() int { + return GetCacheSize() +} + +func ClearTrackIDCache() { + ClearTrackCache() +} + +func GetDeezerRelatedArtists(artistID string, limit int) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + client := GetDeezerClient() + artists, err := client.GetRelatedArtists(ctx, artistID, limit) + if err != nil { + return "", err + } + + resp := map[string]interface{}{ + "artists": artists, + } + jsonBytes, err := json.Marshal(resp) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + +func GetDeezerMetadata(resourceType, resourceID string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + client := GetDeezerClient() + var data interface{} + var err error + + switch resourceType { + case "track": + data, err = client.GetTrack(ctx, resourceID) + case "album": + data, err = client.GetAlbum(ctx, resourceID) + case "artist": + data, err = client.GetArtist(ctx, resourceID) + case "playlist": + data, err = client.GetPlaylist(ctx, resourceID) + default: + return "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType) + } + + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(data) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +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 := buildDeezerExtendedMetadataResult(metadata) + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func SearchDeezerByISRC(isrc string) (string, error) { + return SearchDeezerByISRCForItemID(isrc, "") +} + +func SearchDeezerByISRCForItemID(isrc string, itemID string) (string, error) { + parentCtx := context.Background() + if itemID != "" { + parentCtx = initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) + if isDownloadCancelled(itemID) { + return "", ErrDownloadCancelled + } + } + + ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second) + defer cancel() + + client := GetDeezerClient() + track, err := client.SearchByISRC(ctx, isrc) + if err != nil { + if isDownloadCancelled(itemID) { + return "", ErrDownloadCancelled + } + return "", err + } + if isDownloadCancelled(itemID) { + return "", ErrDownloadCancelled + } + + result := buildDeezerISRCSearchResult(track) + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func buildDeezerExtendedMetadataResult(metadata *AlbumExtendedMetadata) map[string]string { + if metadata == nil { + return map[string]string{ + "genre": "", + "label": "", + "copyright": "", + } + } + + return map[string]string{ + "genre": metadata.Genre, + "label": metadata.Label, + "copyright": metadata.Copyright, + } +} + +func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]interface{} { + if track == nil { + return map[string]interface{}{} + } + + result := map[string]interface{}{ + "spotify_id": track.SpotifyID, + "artists": track.Artists, + "name": track.Name, + "album_name": track.AlbumName, + "album_artist": track.AlbumArtist, + "duration_ms": track.DurationMS, + "images": track.Images, + "release_date": track.ReleaseDate, + "track_number": track.TrackNumber, + "total_tracks": track.TotalTracks, + "disc_number": track.DiscNumber, + "total_discs": track.TotalDiscs, + "external_urls": track.ExternalURL, + "isrc": track.ISRC, + "album_id": track.AlbumID, + "artist_id": track.ArtistID, + "album_type": track.AlbumType, + "composer": track.Composer, + } + + if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" { + result["id"] = deezerID + result["track_id"] = deezerID + result["success"] = true + } + + return result +} + +func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + songlink := NewSongLinkClient() + deezerClient := GetDeezerClient() + + if resourceType == "track" { + deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID) + if err != nil { + return "", fmt.Errorf("could not find Deezer equivalent: %w", err) + } + + trackResp, err := deezerClient.GetTrack(ctx, deezerID) + if err != nil { + return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err) + } + + jsonBytes, err := json.Marshal(trackResp) + if err != nil { + return "", err + } + + return string(jsonBytes), nil + } + + if resourceType == "album" { + deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID) + if err != nil { + return "", fmt.Errorf("could not find Deezer album: %w", err) + } + + albumResp, err := deezerClient.GetAlbum(ctx, deezerID) + if err != nil { + return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err) + } + + jsonBytes, err := json.Marshal(albumResp) + if err != nil { + return "", err + } + + return string(jsonBytes), nil + } + + return "", fmt.Errorf("spotify to Deezer conversion only supported for tracks and albums: please search by name for %s", resourceType) +} + +func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) { + client := NewSongLinkClient() + availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(availability) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func CheckAvailabilityByPlatformID(platform, entityType, entityID string) (string, error) { + client := NewSongLinkClient() + availability, err := client.CheckAvailabilityByPlatform(platform, entityType, entityID) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(availability) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func GetSpotifyIDFromDeezerTrack(deezerTrackID string) (string, error) { + client := NewSongLinkClient() + return client.GetSpotifyIDFromDeezer(deezerTrackID) +} + +func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) { + client := NewSongLinkClient() + return client.GetTidalURLFromDeezer(deezerTrackID) +} diff --git a/go_backend/exports_download.go b/go_backend/exports_download.go new file mode 100644 index 00000000..906b02a1 --- /dev/null +++ b/go_backend/exports_download.go @@ -0,0 +1,491 @@ +package gobackend + +import ( + "context" + "encoding/json" + "strings" + "time" +) + +type DownloadRequest struct { + ContractVersion int `json:"contract_version,omitempty"` + ISRC string `json:"isrc"` + Service string `json:"service"` + 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"` + CoverURL string `json:"cover_url"` + OutputDir string `json:"output_dir"` + OutputPath string `json:"output_path,omitempty"` + OutputFD int `json:"output_fd,omitempty"` + OutputExt string `json:"output_ext,omitempty"` + FilenameFormat string `json:"filename_format"` + Quality string `json:"quality"` + EmbedMetadata bool `json:"embed_metadata"` + ArtistTagMode string `json:"artist_tag_mode,omitempty"` + EmbedLyrics bool `json:"embed_lyrics"` + EmbedMaxQualityCover bool `json:"embed_max_quality_cover"` + EmbedReplayGain bool `json:"embed_replaygain,omitempty"` + PostProcessingEnabled bool `json:"post_processing_enabled,omitempty"` + TidalHighFormat string `json:"tidal_high_format,omitempty"` + TrackNumber int `json:"track_number"` + PlaylistPosition int `json:"playlist_position,omitempty"` + DiscNumber int `json:"disc_number"` + TotalTracks int `json:"total_tracks"` + TotalDiscs int `json:"total_discs,omitempty"` + ReleaseDate string `json:"release_date"` + ItemID string `json:"item_id"` + DurationMS int `json:"duration_ms"` + Source string `json:"source"` + Genre string `json:"genre,omitempty"` + Label string `json:"label,omitempty"` + Copyright string `json:"copyright,omitempty"` + Composer string `json:"composer,omitempty"` + TidalID string `json:"tidal_id,omitempty"` + QobuzID string `json:"qobuz_id,omitempty"` + DeezerID string `json:"deezer_id,omitempty"` + LyricsMode string `json:"lyrics_mode,omitempty"` + UseExtensions bool `json:"use_extensions,omitempty"` + UseFallback bool `json:"use_fallback,omitempty"` + RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"` + SongLinkRegion string `json:"songlink_region,omitempty"` +} + +type DownloadResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + FilePath string `json:"file_path,omitempty"` + Error string `json:"error,omitempty"` + ErrorType string `json:"error_type,omitempty"` + RetryAfterSeconds int `json:"retry_after_seconds,omitempty"` + AlreadyExists bool `json:"already_exists,omitempty"` + ActualBitDepth int `json:"actual_bit_depth,omitempty"` + ActualSampleRate int `json:"actual_sample_rate,omitempty"` + AudioCodec string `json:"audio_codec,omitempty"` + ActualExtension string `json:"actual_extension,omitempty"` + ActualContainer string `json:"actual_container,omitempty"` + RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"` + Service string `json:"service,omitempty"` + Title string `json:"title,omitempty"` + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + AlbumArtist string `json:"album_artist,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + TrackNumber int `json:"track_number,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + TotalTracks int `json:"total_tracks,omitempty"` + TotalDiscs int `json:"total_discs,omitempty"` + ISRC string `json:"isrc,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + Genre string `json:"genre,omitempty"` + Label string `json:"label,omitempty"` + Copyright string `json:"copyright,omitempty"` + Composer string `json:"composer,omitempty"` + SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` + LyricsLRC string `json:"lyrics_lrc,omitempty"` + DecryptionKey string `json:"decryption_key,omitempty"` + Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"` +} + +type DownloadResult struct { + FilePath string + BitDepth int + SampleRate int + AudioCodec string + Title string + Artist string + Album string + ReleaseDate string + TrackNumber int + TotalTracks int + DiscNumber int + TotalDiscs int + ISRC string + CoverURL string + Genre string + Label string + Copyright string + Composer string + LyricsLRC string + DecryptionKey string + Decryption *DownloadDecryptionInfo + ActualExtension string + ActualContainer string + RequiresContainerConversion bool +} + +func buildDownloadSuccessResponse( + req DownloadRequest, + result DownloadResult, + service string, + message string, + filePath string, + alreadyExists bool, +) DownloadResponse { + title := result.Title + if title == "" { + title = req.TrackName + } + + artist := result.Artist + if artist == "" { + artist = req.ArtistName + } + + // Preserve requested release metadata when available so mixed-provider + // fallback downloads from the same source album do not get split into + // different albums just because Tidal/Qobuz report variant titles/dates. + album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata( + req, + result.Album, + result.ReleaseDate, + result.TrackNumber, + result.DiscNumber, + ) + + isrc := result.ISRC + if isrc == "" { + isrc = req.ISRC + } + + genre := result.Genre + if genre == "" { + genre = req.Genre + } + + label := result.Label + if label == "" { + label = req.Label + } + + copyright := result.Copyright + if copyright == "" { + copyright = req.Copyright + } + + composer := result.Composer + if composer == "" { + composer = req.Composer + } + + coverURL := strings.TrimSpace(result.CoverURL) + if coverURL == "" { + coverURL = strings.TrimSpace(req.CoverURL) + } + + return DownloadResponse{ + Success: true, + Message: message, + FilePath: filePath, + AlreadyExists: alreadyExists, + ActualBitDepth: result.BitDepth, + ActualSampleRate: result.SampleRate, + AudioCodec: result.AudioCodec, + ActualExtension: result.ActualExtension, + ActualContainer: result.ActualContainer, + RequiresContainerConversion: result.RequiresContainerConversion, + Service: service, + Title: title, + Artist: artist, + Album: album, + AlbumArtist: req.AlbumArtist, + ReleaseDate: releaseDate, + TrackNumber: trackNumber, + TotalTracks: req.TotalTracks, + DiscNumber: discNumber, + TotalDiscs: req.TotalDiscs, + ISRC: isrc, + CoverURL: coverURL, + Genre: genre, + Label: label, + Copyright: copyright, + Composer: composer, + LyricsLRC: result.LyricsLRC, + DecryptionKey: result.DecryptionKey, + Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey), + } +} + +func shouldSkipQualityProbe(filePath string) bool { + path := strings.TrimSpace(filePath) + if path == "" { + return true + } + if strings.HasPrefix(path, "/proc/self/fd/") { + return true + } + // Content URI and other non-filesystem schemes cannot be read directly by os.Open. + if strings.Contains(path, "://") { + return true + } + return false +} + +func enrichResultQualityFromFile(result *DownloadResult) { + if result == nil { + return + } + + path := strings.TrimSpace(result.FilePath) + if shouldSkipQualityProbe(path) { + if strings.HasPrefix(path, "/proc/self/fd/") { + LogDebug("Download", "Skipping quality probe for ephemeral SAF FD output: %s", path) + } + return + } + + quality, qErr := GetAudioQuality(path) + if qErr == nil { + result.BitDepth = quality.BitDepth + result.SampleRate = quality.SampleRate + result.AudioCodec = quality.Codec + if quality.Codec != "" { + GoLog("[Download] Actual quality from file: %s %d-bit/%dHz\n", quality.Codec, quality.BitDepth, quality.SampleRate) + } else { + GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) + } + return + } + + LogDebug("Download", "Post-download quality probe unavailable for %s: %v", path, qErr) +} + +func applyExtendedMetadataFields( + genre *string, + label *string, + copyright *string, + extMeta *AlbumExtendedMetadata, +) { + if extMeta == nil { + return + } + + if genre != nil && *genre == "" && extMeta.Genre != "" { + *genre = extMeta.Genre + } + if label != nil && *label == "" && extMeta.Label != "" { + *label = extMeta.Label + } + if copyright != nil && *copyright == "" && extMeta.Copyright != "" { + *copyright = extMeta.Copyright + } +} + +func enrichExtraMetadataByISRC( + logPrefix string, + isrc string, + genre *string, + label *string, + copyright *string, +) { + normalizedISRC := strings.TrimSpace(isrc) + if normalizedISRC == "" { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + extMeta, err := fetchDeezerExtendedMetadataByISRC(ctx, normalizedISRC) + if err != nil { + GoLog("[%s] Failed to get extended metadata from Deezer: %v\n", logPrefix, err) + } + applyExtendedMetadataFields(genre, label, copyright, extMeta) + + if genre != nil && *genre == "" { + musicBrainzGenre, err := fetchMusicBrainzGenreByISRC(normalizedISRC) + if err != nil { + GoLog("[%s] Failed to get genre from MusicBrainz: %v\n", logPrefix, err) + } else if musicBrainzGenre != "" { + *genre = musicBrainzGenre + GoLog("[%s] Genre fallback from MusicBrainz: %s\n", logPrefix, *genre) + } + } + + currentGenre := "" + currentLabel := "" + currentCopyright := "" + if genre != nil { + currentGenre = *genre + } + if label != nil { + currentLabel = *label + } + if copyright != nil { + currentCopyright = *copyright + } + if currentGenre != "" || currentLabel != "" || currentCopyright != "" { + GoLog("[%s] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", logPrefix, currentGenre, currentLabel, currentCopyright) + } +} + +func enrichRequestExtendedMetadata(req *DownloadRequest) { + if req == nil { + return + } + + if req.ISRC == "" { + return + } + + if strings.TrimSpace(req.AlbumArtist) == "" { + albumArtist, err := fetchMusicBrainzAlbumArtistByISRC(req.ISRC, req.AlbumName) + if err != nil { + GoLog("[DownloadWithFallback] Failed to get album artist from MusicBrainz: %v\n", err) + } else if strings.TrimSpace(albumArtist) != "" { + req.AlbumArtist = strings.TrimSpace(albumArtist) + GoLog("[DownloadWithFallback] Album artist fallback from MusicBrainz: %s\n", req.AlbumArtist) + } + } + + if req.Genre == "" || req.Label == "" || req.Copyright == "" { + enrichExtraMetadataByISRC( + "DownloadWithFallback", + req.ISRC, + &req.Genre, + &req.Label, + &req.Copyright, + ) + } +} + +func applySongLinkRegionFromRequest(req *DownloadRequest) { + if req == nil { + return + } + SetSongLinkRegion(req.SongLinkRegion) +} + +func DownloadTrack(requestJSON string) (string, error) { + return errorResponse("Built-in download providers have been retired. Use downloadByStrategy with extension providers.") +} + +// DownloadByStrategy routes all download requests through extension providers. +func DownloadByStrategy(requestJSON string) (string, error) { + var req DownloadRequest + if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { + return errorResponse("Invalid request: " + err.Error()) + } + normalizedBytes, err := json.Marshal(req) + if err != nil { + return errorResponse("Invalid request: " + err.Error()) + } + normalizedJSON := string(normalizedBytes) + + if req.UseExtensions { + resp, err := DownloadWithExtensionsJSON(normalizedJSON) + if err != nil { + return errorResponse(err.Error()) + } + return resp, nil + } + + return errorResponse("Extension providers are disabled; built-in download providers have been retired") +} + +func DownloadWithFallback(requestJSON string) (string, error) { + return errorResponse("Built-in fallback has been retired. Use extension fallback through downloadByStrategy.") +} + +func GetDownloadProgress() string { + progress := getProgress() + jsonBytes, _ := json.Marshal(progress) + return string(jsonBytes) +} + +func GetAllDownloadProgress() string { + return GetMultiProgress() +} + +func GetAllDownloadProgressDelta(sinceSeq int64) string { + return GetMultiProgressDelta(sinceSeq) +} + +func InitItemProgress(itemID string) { + StartItemProgress(itemID) +} + +func FinishItemProgress(itemID string) { + CompleteItemProgress(itemID) +} + +func ClearItemProgress(itemID string) { + RemoveItemProgress(itemID) +} + +func CancelDownload(itemID string) { + cancelDownload(itemID) +} + +func CleanupConnections() { + CloseIdleConnections() +} + +func errorResponse(msg string) (string, error) { + errorType := classifyDownloadErrorType(msg) + + resp := DownloadResponse{ + Success: false, + Error: msg, + ErrorType: errorType, + } + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil +} + +func classifyDownloadErrorType(msg string) string { + lowerMsg := strings.ToLower(msg) + + if strings.Contains(lowerMsg, "isp blocking") || + strings.Contains(lowerMsg, "try using vpn") || + strings.Contains(lowerMsg, "change dns") { + return "isp_blocked" + } else if strings.Contains(lowerMsg, "cancel") { + return "cancelled" + } else if strings.Contains(lowerMsg, "verify_required") || + strings.Contains(lowerMsg, "verification_required") || + strings.Contains(lowerMsg, "verification required") || + strings.Contains(lowerMsg, "needs verification") || + strings.Contains(lowerMsg, "session is not authenticated") || + strings.Contains(lowerMsg, "signed session is not authenticated") || + strings.Contains(lowerMsg, "unauthorized") || + strings.Contains(lowerMsg, "precondition required") || + messageHasHTTPStatusCode(lowerMsg, "401") || + messageHasHTTPStatusCode(lowerMsg, "428") { + return "verification_required" + } else if strings.Contains(lowerMsg, "rate limit") || + messageHasHTTPStatusCode(lowerMsg, "429") || + strings.Contains(lowerMsg, "too many requests") { + return "rate_limit" + } else if strings.Contains(lowerMsg, "permission") || + strings.Contains(lowerMsg, "operation not permitted") || + strings.Contains(lowerMsg, "access denied") || + strings.Contains(lowerMsg, "failed to create file") || + strings.Contains(lowerMsg, "failed to create directory") { + return "permission" + } else if strings.Contains(lowerMsg, "not found") || + strings.Contains(lowerMsg, "not available") || + strings.Contains(lowerMsg, "no results") || + strings.Contains(lowerMsg, "track not found") || + strings.Contains(lowerMsg, "all services failed") { + return "not_found" + } else if strings.Contains(lowerMsg, "network") || + strings.Contains(lowerMsg, "connection") || + strings.Contains(lowerMsg, "timeout") || + strings.Contains(lowerMsg, "dial") { + return "network" + } + + return "unknown" +} + +func messageHasHTTPStatusCode(lowerMsg, code string) bool { + return strings.Contains(lowerMsg, "http "+code) || + strings.Contains(lowerMsg, "http status "+code) || + strings.Contains(lowerMsg, "status "+code) || + strings.Contains(lowerMsg, code+" for ") || + strings.Contains(lowerMsg, code+":") || + strings.Contains(lowerMsg, code+";") +} diff --git a/go_backend/exports_extensions.go b/go_backend/exports_extensions.go new file mode 100644 index 00000000..b8414d66 --- /dev/null +++ b/go_backend/exports_extensions.go @@ -0,0 +1,1233 @@ +package gobackend + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/dop251/goja" +) + +func normalizeExtensionTrackMetadataMap( + track ExtTrackMetadata, + fallbackCover string, + fallbackTrackNumber int, +) map[string]interface{} { + coverURL := track.ResolvedCoverURL() + if coverURL == "" { + coverURL = fallbackCover + } + + trackNum := track.TrackNumber + if trackNum == 0 && fallbackTrackNumber > 0 { + trackNum = fallbackTrackNumber + } + + return map[string]interface{}{ + "id": track.ID, + "name": track.Name, + "artists": track.Artists, + "album_name": track.AlbumName, + "album_artist": track.AlbumArtist, + "album_id": track.AlbumID, + "album_url": track.AlbumURL, + "artist_id": track.ArtistID, + "artist_url": track.ArtistURL, + "external_urls": track.ExternalURL, + "duration_ms": track.DurationMS, + "images": coverURL, + "cover_url": coverURL, + "preview_url": track.PreviewURL, + "release_date": track.ReleaseDate, + "track_number": trackNum, + "total_tracks": track.TotalTracks, + "disc_number": track.DiscNumber, + "total_discs": track.TotalDiscs, + "isrc": track.ISRC, + "provider_id": track.ProviderID, + "item_type": track.ItemType, + "album_type": track.AlbumType, + "spotify_id": track.SpotifyID, + "composer": track.Composer, + "audio_quality": track.AudioQuality, + "audio_modes": track.AudioModes, + } +} + +func normalizeExtensionAlbumInfoMap(album *ExtAlbumMetadata) map[string]interface{} { + if album == nil { + return map[string]interface{}{} + } + + return map[string]interface{}{ + "id": album.ID, + "name": album.Name, + "artists": album.Artists, + "artist_id": album.ArtistID, + "images": album.CoverURL, + "cover_url": album.CoverURL, + "header_image": album.HeaderImage, + "header_video": album.HeaderVideo, + "release_date": album.ReleaseDate, + "total_tracks": album.TotalTracks, + "album_type": album.AlbumType, + "audio_traits": album.AudioTraits, + "provider_id": album.ProviderID, + } +} + +func normalizeExtensionArtistAlbumMap(album ExtAlbumMetadata) map[string]interface{} { + return map[string]interface{}{ + "id": album.ID, + "name": album.Name, + "artists": album.Artists, + "images": album.CoverURL, + "cover_url": album.CoverURL, + "release_date": album.ReleaseDate, + "total_tracks": album.TotalTracks, + "album_type": album.AlbumType, + "provider_id": album.ProviderID, + } +} + +func getExtensionProviderMetadataResponse( + providerID, + resourceType, + resourceID string, +) (map[string]interface{}, error) { + manager := getExtensionManager() + ext, err := manager.GetExtension(providerID) + if err != nil { + return nil, err + } + + if !ext.Manifest.IsMetadataProvider() { + return nil, fmt.Errorf("extension '%s' is not a metadata provider", providerID) + } + if !ext.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", providerID) + } + + provider := newExtensionProviderWrapper(ext) + + switch resourceType { + case "track": + track, err := provider.GetTrack(resourceID) + if err != nil { + return nil, err + } + if track == nil { + return nil, fmt.Errorf("track not found") + } + return map[string]interface{}{ + "track": normalizeExtensionTrackMetadataMap(*track, "", 0), + }, nil + case "album": + album, err := provider.GetAlbum(resourceID) + if err != nil { + return nil, err + } + if album == nil { + return nil, fmt.Errorf("album not found") + } + + tracks := make([]map[string]interface{}, len(album.Tracks)) + for i, track := range album.Tracks { + tracks[i] = normalizeExtensionTrackMetadataMap(track, album.CoverURL, i+1) + } + + return map[string]interface{}{ + "album_info": normalizeExtensionAlbumInfoMap(album), + "track_list": tracks, + }, nil + case "playlist": + playlist, err := provider.GetPlaylist(resourceID) + if err != nil { + return nil, err + } + if playlist == nil { + return nil, fmt.Errorf("playlist not found") + } + + tracks := make([]map[string]interface{}, len(playlist.Tracks)) + for i, track := range playlist.Tracks { + tracks[i] = normalizeExtensionTrackMetadataMap(track, playlist.CoverURL, i+1) + } + + return map[string]interface{}{ + "playlist_info": map[string]interface{}{ + "id": playlist.ID, + "name": playlist.Name, + "images": playlist.CoverURL, + "cover_url": playlist.CoverURL, + "header_image": playlist.HeaderImage, + "header_video": playlist.HeaderVideo, + "provider_id": playlist.ProviderID, + "owner": map[string]interface{}{ + "name": playlist.Artists, + "images": playlist.CoverURL, + }, + }, + "track_list": tracks, + }, nil + case "artist": + artist, err := provider.GetArtist(resourceID) + if err != nil { + return nil, err + } + if artist == nil { + return nil, fmt.Errorf("artist not found") + } + + albums := make([]map[string]interface{}, len(artist.Albums)) + for i, album := range artist.Albums { + albums[i] = normalizeExtensionArtistAlbumMap(album) + } + + response := map[string]interface{}{ + "artist_info": map[string]interface{}{ + "id": artist.ID, + "name": artist.Name, + "images": firstNonEmptyTrimmed(artist.HeaderImage, artist.ImageURL), + "cover_url": artist.ImageURL, + "header_image": artist.HeaderImage, + "header_video": artist.HeaderVideo, + "provider_id": artist.ProviderID, + }, + "albums": albums, + } + + if len(artist.Releases) > 0 { + releases := make([]map[string]interface{}, len(artist.Releases)) + for i, release := range artist.Releases { + releases[i] = normalizeExtensionArtistAlbumMap(release) + } + response["releases"] = releases + } + + if artist.Listeners > 0 { + artistInfo := response["artist_info"].(map[string]interface{}) + artistInfo["listeners"] = artist.Listeners + } + + if len(artist.TopTracks) > 0 { + topTracks := make([]map[string]interface{}, len(artist.TopTracks)) + for i, track := range artist.TopTracks { + topTracks[i] = normalizeExtensionTrackMetadataMap(track, artist.ImageURL, i+1) + } + response["top_tracks"] = topTracks + } + + return response, nil + default: + return nil, fmt.Errorf("unsupported provider resource type: %s", resourceType) + } +} + +func firstNonEmptyTrimmed(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func GetProviderMetadataJSON(providerID, resourceType, resourceID string) (string, error) { + trimmedProviderID := strings.TrimSpace(providerID) + if trimmedProviderID == "" { + return "", fmt.Errorf("empty provider ID") + } + + switch strings.ToLower(trimmedProviderID) { + case "deezer": + if response, ok, err := getEnabledExtensionProviderMetadataResponse(trimmedProviderID, resourceType, resourceID); ok || err != nil { + if err != nil { + return "", err + } + jsonBytes, err := json.Marshal(response) + if err != nil { + return "", err + } + return string(jsonBytes), nil + } + return GetDeezerMetadata(resourceType, resourceID) + default: + response, err := getExtensionProviderMetadataResponse(trimmedProviderID, resourceType, resourceID) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(response) + if err != nil { + return "", err + } + return string(jsonBytes), nil + } +} + +func getEnabledExtensionProviderMetadataResponse(providerID, resourceType, resourceID string) (map[string]interface{}, bool, error) { + manager := getExtensionManager() + ext, err := manager.GetExtension(providerID) + if err != nil || ext == nil || !ext.Enabled || !ext.Manifest.IsMetadataProvider() { + return nil, false, nil + } + response, err := getExtensionProviderMetadataResponse(providerID, resourceType, resourceID) + if err != nil { + return nil, true, err + } + return response, true, nil +} + +func InitExtensionSystem(extensionsDir, dataDir string) error { + manager := getExtensionManager() + if err := manager.SetDirectories(extensionsDir, dataDir); err != nil { + return err + } + + settingsStore := GetExtensionSettingsStore() + if err := settingsStore.SetDataDir(dataDir); err != nil { + return err + } + + return nil +} + +func LoadExtensionsFromDir(dirPath string) (string, error) { + manager := getExtensionManager() + loaded, errors := manager.LoadExtensionsFromDirectory(dirPath) + + result := map[string]interface{}{ + "loaded": loaded, + "errors": make([]string, len(errors)), + } + + for i, err := range errors { + result["errors"].([]string)[i] = err.Error() + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func LoadExtensionFromPath(filePath string) (string, error) { + manager := getExtensionManager() + ext, err := manager.LoadExtensionFromFile(filePath) + if err != nil { + return "", err + } + + result := map[string]interface{}{ + "id": ext.ID, + "name": ext.Manifest.Name, + "display_name": ext.Manifest.DisplayName, + "version": ext.Manifest.Version, + "enabled": ext.Enabled, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func UnloadExtensionByID(extensionID string) error { + manager := getExtensionManager() + return manager.UnloadExtension(extensionID) +} + +func RemoveExtensionByID(extensionID string) error { + manager := getExtensionManager() + return manager.RemoveExtension(extensionID) +} + +func UpgradeExtensionFromPath(filePath string) (string, error) { + manager := getExtensionManager() + ext, err := manager.UpgradeExtension(filePath) + if err != nil { + return "", err + } + + result := map[string]interface{}{ + "id": ext.ID, + "display_name": ext.Manifest.DisplayName, + "version": ext.Manifest.Version, + "enabled": ext.Enabled, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func CheckExtensionUpgradeFromPath(filePath string) (string, error) { + manager := getExtensionManager() + return manager.CheckExtensionUpgradeJSON(filePath) +} + +func GetInstalledExtensions() (string, error) { + manager := getExtensionManager() + return manager.GetInstalledExtensionsJSON() +} + +func SetExtensionEnabledByID(extensionID string, enabled bool) error { + manager := getExtensionManager() + return manager.SetExtensionEnabled(extensionID, enabled) +} + +func SetProviderPriorityJSON(priorityJSON string) error { + var priority []string + if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil { + return err + } + + SetProviderPriority(priority) + return nil +} + +func GetProviderPriorityJSON() (string, error) { + priority := GetProviderPriority() + jsonBytes, err := json.Marshal(priority) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + +func SetExtensionFallbackProviderIDsJSON(providerIDsJSON string) error { + if strings.TrimSpace(providerIDsJSON) == "" { + SetExtensionFallbackProviderIDs(nil) + return nil + } + + var providerIDs []string + if err := json.Unmarshal([]byte(providerIDsJSON), &providerIDs); err != nil { + return err + } + + SetExtensionFallbackProviderIDs(providerIDs) + return nil +} + +func GetExtensionFallbackProviderIDsJSON() (string, error) { + providerIDs := GetExtensionFallbackProviderIDs() + jsonBytes, err := json.Marshal(providerIDs) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + +func SetMetadataProviderPriorityJSON(priorityJSON string) error { + var priority []string + if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil { + return err + } + + SetMetadataProviderPriority(priority) + return nil +} + +func GetMetadataProviderPriorityJSON() (string, error) { + priority := GetMetadataProviderPriority() + jsonBytes, err := json.Marshal(priority) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + +func GetExtensionSettingsJSON(extensionID string) (string, error) { + store := GetExtensionSettingsStore() + settings := store.GetAll(extensionID) + + jsonBytes, err := json.Marshal(settings) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func SetExtensionSettingsJSON(extensionID, settingsJSON string) error { + var settings map[string]interface{} + if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil { + return err + } + + store := GetExtensionSettingsStore() + if err := store.SetAll(extensionID, settings); err != nil { + return err + } + + manager := getExtensionManager() + return manager.InitializeExtension(extensionID, settings) +} + +func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) { + manager := getExtensionManager() + tracks, err := manager.SearchTracksWithExtensions(query, limit) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(tracks) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func SearchTracksWithMetadataProvidersJSON(query string, limit int, includeExtensions bool) (string, error) { + manager := getExtensionManager() + tracks, err := manager.SearchTracksWithMetadataProviders(query, limit, includeExtensions) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(tracks) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func DownloadWithExtensionsJSON(requestJSON string) (string, error) { + var req DownloadRequest + if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { + return "", fmt.Errorf("invalid request: %w", err) + } + applySongLinkRegionFromRequest(&req) + defer closeOwnedOutputFD(req.OutputFD) + if req.ItemID != "" { + initDownloadCancel(req.ItemID) + defer clearDownloadCancel(req.ItemID) + if isDownloadCancelled(req.ItemID) { + return "", ErrDownloadCancelled + } + } + + req.TrackName = strings.TrimSpace(req.TrackName) + req.ArtistName = strings.TrimSpace(req.ArtistName) + req.AlbumName = strings.TrimSpace(req.AlbumName) + req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) + req.OutputDir = strings.TrimSpace(req.OutputDir) + req.OutputPath = strings.TrimSpace(req.OutputPath) + req.OutputExt = strings.TrimSpace(req.OutputExt) + if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" { + AddAllowedDownloadDir(req.OutputDir) + } + + enrichRequestExtendedMetadata(&req) + if isDownloadCancelled(req.ItemID) { + return "", ErrDownloadCancelled + } + + result, err := DownloadWithExtensionFallback(req) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func CleanupExtensions() { + manager := getExtensionManager() + manager.UnloadAllExtensions() +} + +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 +} + +func GetExtensionPendingAuthJSON(extensionID string) (string, error) { + req := ensureExtensionPendingAuthRequest(extensionID) + if req == nil { + return "", nil + } + + result := map[string]interface{}{ + "extension_id": req.ExtensionID, + "auth_url": req.AuthURL, + "callback_url": req.CallbackURL, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func ensureExtensionPendingAuthRequest(extensionID string) *PendingAuthRequest { + extensionID = strings.TrimSpace(extensionID) + if extensionID == "" { + return nil + } + + if req := GetPendingAuthRequest(extensionID); req != nil { + return req + } + + manager := getExtensionManager() + ext, err := manager.GetExtension(extensionID) + if err != nil || ext == nil || !ext.Enabled || ext.Manifest == nil || ext.Manifest.SignedSession == nil { + return nil + } + + if err := ext.ensureRuntimeReady(); err != nil || ext.runtime == nil { + return nil + } + + config := signedSessionConfigWithDefaults(ext.Manifest.SignedSession) + if config.Namespace == "" || config.BaseURL == "" { + return nil + } + if record, err := ext.runtime.loadSignedSession(config); err == nil { + record.SessionID = "" + record.SessionSecret = "" + record.ExpiresAt = "" + _ = ext.runtime.saveSignedSession(config, record) + } + ext.runtime.startSignedSessionVerification(config, "pending-auth-request") + return GetPendingAuthRequest(extensionID) +} + +func SetExtensionAuthCodeByID(extensionID, authCode string) { + SetExtensionAuthCode(extensionID, authCode) +} + +func SetExtensionSessionGrantByID(extensionID, grant string) { + setPendingSignedSessionGrant(extensionID, grant) +} + +func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) { + var expiresAt time.Time + if expiresIn > 0 { + expiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second) + } + SetExtensionTokens(extensionID, accessToken, refreshToken, expiresAt) +} + +func ClearExtensionPendingAuthByID(extensionID string) { + ClearPendingAuthRequest(extensionID) +} + +func IsExtensionAuthenticatedByID(extensionID string) bool { + extensionAuthStateMu.RLock() + defer extensionAuthStateMu.RUnlock() + + state, exists := extensionAuthState[extensionID] + if !exists { + return false + } + + if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) { + return false + } + + return state.IsAuthenticated +} + +func GetAllPendingAuthRequestsJSON() (string, error) { + pendingAuthRequestsMu.RLock() + defer pendingAuthRequestsMu.RUnlock() + + requests := make([]map[string]interface{}, 0, len(pendingAuthRequests)) + for _, req := range pendingAuthRequests { + requests = append(requests, map[string]interface{}{ + "extension_id": req.ExtensionID, + "auth_url": req.AuthURL, + "callback_url": req.CallbackURL, + }) + } + + jsonBytes, err := json.Marshal(requests) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func GetPendingFFmpegCommandJSON(commandID string) (string, error) { + cmd := GetPendingFFmpegCommand(commandID) + if cmd == nil { + return "", nil + } + + result := map[string]interface{}{ + "command_id": commandID, + "extension_id": cmd.ExtensionID, + "command": cmd.Command, + "input_path": cmd.InputPath, + "output_path": cmd.OutputPath, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func SetFFmpegCommandResultByID(commandID string, success bool, output, errorMsg string) { + SetFFmpegCommandResult(commandID, success, output, errorMsg) +} + +func GetAllPendingFFmpegCommandsJSON() (string, error) { + ffmpegCommandsMu.RLock() + defer ffmpegCommandsMu.RUnlock() + + commands := make([]map[string]interface{}, 0) + for cmdID, cmd := range ffmpegCommands { + if !cmd.Completed { + commands = append(commands, map[string]interface{}{ + "command_id": cmdID, + "extension_id": cmd.ExtensionID, + "command": cmd.Command, + }) + } + } + + jsonBytes, err := json.Marshal(commands) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) { + manager := getExtensionManager() + ext, err := manager.GetExtension(extensionID) + if err != nil { + return trackJSON, nil + } + + if !ext.Manifest.IsMetadataProvider() { + return trackJSON, nil + } + + var track ExtTrackMetadata + if err := json.Unmarshal([]byte(trackJSON), &track); err != nil { + return trackJSON, fmt.Errorf("failed to parse track: %w", err) + } + + provider := newExtensionProviderWrapper(ext) + enrichedTrack, err := provider.EnrichTrack(&track) + if err != nil { + return trackJSON, nil + } + + jsonBytes, err := json.Marshal(enrichedTrack) + if err != nil { + return trackJSON, nil + } + + return string(jsonBytes), nil +} + +func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) { + return CustomSearchWithExtensionJSONWithRequestID(extensionID, query, optionsJSON, "") +} + +func CustomSearchWithExtensionJSONWithRequestID(extensionID, query string, optionsJSON string, requestID string) (string, error) { + manager := getExtensionManager() + ext, err := manager.GetExtension(extensionID) + if err != nil { + return "", err + } + + if !ext.Manifest.HasCustomSearch() { + return "", fmt.Errorf("extension '%s' does not support custom search", extensionID) + } + + var options map[string]interface{} + if optionsJSON != "" { + if err := json.Unmarshal([]byte(optionsJSON), &options); err != nil { + options = make(map[string]interface{}) + } + } + + provider := newExtensionProviderWrapper(ext) + tracks, err := provider.CustomSearchForRequestID(query, options, requestID) + if err != nil { + return "", err + } + + result := make([]map[string]interface{}, len(tracks)) + for i, track := range tracks { + result[i] = map[string]interface{}{ + "id": track.ID, + "name": track.Name, + "artists": track.Artists, + "album_name": track.AlbumName, + "album_artist": track.AlbumArtist, + "duration_ms": track.DurationMS, + "images": track.ResolvedCoverURL(), + "preview_url": track.PreviewURL, + "release_date": track.ReleaseDate, + "track_number": track.TrackNumber, + "total_tracks": track.TotalTracks, + "disc_number": track.DiscNumber, + "total_discs": track.TotalDiscs, + "isrc": track.ISRC, + "provider_id": track.ProviderID, + "item_type": track.ItemType, + "album_type": track.AlbumType, + "composer": track.Composer, + "audio_quality": track.AudioQuality, + "audio_modes": track.AudioModes, + } + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func GetSearchProvidersJSON() (string, error) { + manager := getExtensionManager() + providers := manager.GetSearchProviders() + + result := make([]map[string]interface{}, 0, len(providers)) + for _, p := range providers { + result = append(result, map[string]interface{}{ + "id": p.extension.ID, + "display_name": p.extension.Manifest.DisplayName, + "placeholder": p.extension.Manifest.SearchBehavior.Placeholder, + "primary": p.extension.Manifest.SearchBehavior.Primary, + "icon": p.extension.Manifest.SearchBehavior.Icon, + }) + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func HandleURLWithExtensionJSON(url string) (string, error) { + manager := getExtensionManager() + resultWithID, err := manager.HandleURLWithExtension(url) + if err != nil { + return "", err + } + + result := resultWithID.Result + extensionID := resultWithID.ExtensionID + + if result == nil { + return "", fmt.Errorf("extension %s failed to handle URL", extensionID) + } + + response := map[string]interface{}{ + "type": result.Type, + "extension_id": extensionID, + "name": result.Name, + "cover_url": result.CoverURL, + "header_image": result.HeaderImage, + "header_video": result.HeaderVideo, + } + + if result.Track != nil { + response["track"] = map[string]interface{}{ + "id": result.Track.ID, + "name": result.Track.Name, + "artists": result.Track.Artists, + "album_name": result.Track.AlbumName, + "album_artist": result.Track.AlbumArtist, + "duration_ms": result.Track.DurationMS, + "images": result.Track.ResolvedCoverURL(), + "preview_url": result.Track.PreviewURL, + "release_date": result.Track.ReleaseDate, + "track_number": result.Track.TrackNumber, + "total_tracks": result.Track.TotalTracks, + "disc_number": result.Track.DiscNumber, + "total_discs": result.Track.TotalDiscs, + "isrc": result.Track.ISRC, + "provider_id": result.Track.ProviderID, + "composer": result.Track.Composer, + } + } + + if len(result.Tracks) > 0 { + tracks := make([]map[string]interface{}, len(result.Tracks)) + for i, track := range result.Tracks { + tracks[i] = map[string]interface{}{ + "id": track.ID, + "name": track.Name, + "artists": track.Artists, + "album_name": track.AlbumName, + "album_artist": track.AlbumArtist, + "duration_ms": track.DurationMS, + "images": track.ResolvedCoverURL(), + "preview_url": track.PreviewURL, + "release_date": track.ReleaseDate, + "track_number": track.TrackNumber, + "total_tracks": track.TotalTracks, + "disc_number": track.DiscNumber, + "total_discs": track.TotalDiscs, + "isrc": track.ISRC, + "provider_id": track.ProviderID, + "item_type": track.ItemType, + "album_type": track.AlbumType, + "composer": track.Composer, + } + } + response["tracks"] = tracks + } + + if result.Album != nil { + response["album"] = map[string]interface{}{ + "id": result.Album.ID, + "name": result.Album.Name, + "artists": result.Album.Artists, + "cover_url": result.Album.CoverURL, + "header_image": result.Album.HeaderImage, + "header_video": result.Album.HeaderVideo, + "audio_traits": result.Album.AudioTraits, + "release_date": result.Album.ReleaseDate, + "total_tracks": result.Album.TotalTracks, + "album_type": result.Album.AlbumType, + "provider_id": result.Album.ProviderID, + } + } + + if result.Artist != nil { + artistResponse := map[string]interface{}{ + "id": result.Artist.ID, + "name": result.Artist.Name, + "image_url": result.Artist.ImageURL, + "header_image": result.Artist.HeaderImage, + "header_video": result.Artist.HeaderVideo, + "listeners": result.Artist.Listeners, + "provider_id": result.Artist.ProviderID, + } + + if len(result.Artist.Albums) > 0 { + albums := make([]map[string]interface{}, len(result.Artist.Albums)) + for i, album := range result.Artist.Albums { + albumType := album.AlbumType + if albumType == "" { + albumType = "album" + } + albums[i] = map[string]interface{}{ + "id": album.ID, + "name": album.Name, + "artists": album.Artists, + "images": album.CoverURL, + "cover_url": album.CoverURL, + "release_date": album.ReleaseDate, + "total_tracks": album.TotalTracks, + "album_type": albumType, + "provider_id": album.ProviderID, + } + } + artistResponse["albums"] = albums + } + + if len(result.Artist.Releases) > 0 { + releases := make([]map[string]interface{}, len(result.Artist.Releases)) + for i, release := range result.Artist.Releases { + releaseType := release.AlbumType + if releaseType == "" { + releaseType = "album" + } + releases[i] = map[string]interface{}{ + "id": release.ID, + "name": release.Name, + "artists": release.Artists, + "images": release.CoverURL, + "cover_url": release.CoverURL, + "release_date": release.ReleaseDate, + "total_tracks": release.TotalTracks, + "album_type": releaseType, + "provider_id": release.ProviderID, + } + } + artistResponse["releases"] = releases + } + + if len(result.Artist.TopTracks) > 0 { + topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks)) + for i, track := range result.Artist.TopTracks { + topTracks[i] = map[string]interface{}{ + "id": track.ID, + "name": track.Name, + "artists": track.Artists, + "album_name": track.AlbumName, + "album_artist": track.AlbumArtist, + "duration_ms": track.DurationMS, + "images": track.ResolvedCoverURL(), + "preview_url": track.PreviewURL, + "release_date": track.ReleaseDate, + "track_number": track.TrackNumber, + "total_tracks": track.TotalTracks, + "disc_number": track.DiscNumber, + "total_discs": track.TotalDiscs, + "isrc": track.ISRC, + "provider_id": track.ProviderID, + "spotify_id": track.SpotifyID, + "composer": track.Composer, + } + } + artistResponse["top_tracks"] = topTracks + } + + response["artist"] = artistResponse + } + + jsonBytes, err := json.Marshal(response) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func FindURLHandlerJSON(url string) string { + manager := getExtensionManager() + handler := manager.FindURLHandler(url) + if handler == nil { + return "" + } + return handler.extension.ID +} + +func GetURLHandlersJSON() (string, error) { + manager := getExtensionManager() + handlers := manager.GetURLHandlers() + + result := make([]map[string]interface{}, 0, len(handlers)) + for _, h := range handlers { + result = append(result, map[string]interface{}{ + "id": h.extension.ID, + "display_name": h.extension.Manifest.DisplayName, + "patterns": h.extension.Manifest.URLHandler.Patterns, + }) + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) { + var metadata map[string]interface{} + if metadataJSON != "" { + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + metadata = make(map[string]interface{}) + } + } + + manager := getExtensionManager() + result, err := manager.RunPostProcessing(filePath, metadata) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func RunPostProcessingV2JSON(inputJSON, metadataJSON string) (string, error) { + var metadata map[string]interface{} + if metadataJSON != "" { + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + metadata = make(map[string]interface{}) + } + } + + var input PostProcessInput + if inputJSON != "" { + if err := json.Unmarshal([]byte(inputJSON), &input); err != nil { + input = PostProcessInput{} + } + } + + manager := getExtensionManager() + result, err := manager.RunPostProcessingV2(input, metadata) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func GetPostProcessingProvidersJSON() (string, error) { + manager := getExtensionManager() + providers := manager.GetPostProcessingProviders() + + result := make([]map[string]interface{}, 0, len(providers)) + for _, p := range providers { + hooks := make([]map[string]interface{}, 0) + for _, h := range p.extension.Manifest.GetPostProcessingHooks() { + hooks = append(hooks, map[string]interface{}{ + "id": h.ID, + "name": h.Name, + "description": h.Description, + "default_enabled": h.DefaultEnabled, + "supported_formats": h.SupportedFormats, + }) + } + + result = append(result, map[string]interface{}{ + "id": p.extension.ID, + "display_name": p.extension.Manifest.DisplayName, + "hooks": hooks, + }) + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Duration) (string, error) { + return callExtensionFunctionJSONWithRequestID(extensionID, functionName, timeout, "") +} + +func callExtensionFunctionJSONWithRequestID(extensionID, functionName string, timeout time.Duration, requestID string) (string, error) { + manager := getExtensionManager() + ext, err := manager.GetExtension(extensionID) + if err != nil { + return "", err + } + + if !ext.Enabled { + return "", fmt.Errorf("extension '%s' is disabled", extensionID) + } + perf := newExtensionCallPerf(extensionID, functionName) + defer perf.finish() + initStartedAt := time.Now() + vm, err := ext.lockReadyVM() + if err != nil { + return "", err + } + perf.recordInit(time.Since(initStartedAt)) + defer ext.VMMu.Unlock() + requestCtx := context.Background() + if requestID != "" { + if ext.runtime != nil { + ext.runtime.setActiveRequestID(requestID) + defer ext.runtime.clearActiveRequestID() + } + requestCtx = initExtensionRequestCancel(requestID) + defer clearExtensionRequestCancel(requestID) + if isExtensionRequestCancelled(requestID) { + return "", ErrExtensionRequestCancelled + } + } + + // Goja runtime is not thread-safe; guard direct extension.*() calls with VMMu + // to avoid races with other provider calls (e.g. getAlbum/getPlaylist). + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.%s === 'function') { + return extension.%s(); + } + if (typeof %s === 'function') { + return %s(); + } + return null; + })() + `, functionName, functionName, functionName, functionName) + + jsStartedAt := time.Now() + result, err := RunWithTimeoutContextAndRecover(requestCtx, vm, script, timeout) + perf.recordJS(time.Since(jsStartedAt)) + if err != nil { + if isExtensionRequestCancelled(requestID) || errors.Is(err, ErrExtensionRequestCancelled) { + return "", ErrExtensionRequestCancelled + } + return "", fmt.Errorf("%s failed: %w", functionName, err) + } + if isExtensionRequestCancelled(requestID) { + return "", ErrExtensionRequestCancelled + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return "", fmt.Errorf("%s returned null", functionName) + } + + parseStartedAt := time.Now() + jsonBytes, err := json.Marshal(result) + perf.recordParse(time.Since(parseStartedAt)) + if err != nil { + return "", fmt.Errorf("failed to marshal result: %w", err) + } + perf.setPayloadBytes(len(jsonBytes)) + perf.setItems(countExtensionTopLevelItems(vm, result)) + + return string(jsonBytes), nil +} + +func GetExtensionHomeFeedJSON(extensionID string) (string, error) { + return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second) +} + +func GetExtensionHomeFeedJSONWithRequestID(extensionID, requestID string) (string, error) { + return callExtensionFunctionJSONWithRequestID(extensionID, "getHomeFeed", 60*time.Second, requestID) +} + +func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) { + return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second) +} + +func CancelExtensionRequestJSON(requestID string) { + cancelExtensionRequest(requestID) +} diff --git a/go_backend/exports_library.go b/go_backend/exports_library.go new file mode 100644 index 00000000..f14bee9f --- /dev/null +++ b/go_backend/exports_library.go @@ -0,0 +1,37 @@ +package gobackend + +func SetLibraryCoverCacheDirJSON(cacheDir string) { + SetLibraryCoverCacheDir(cacheDir) +} + +func ScanLibraryFolderJSON(folderPath string) (string, error) { + return ScanLibraryFolder(folderPath) +} + +func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (string, error) { + return ScanLibraryFolderIncremental(folderPath, existingFilesJSON) +} + +func ScanLibraryFolderIncrementalFromSnapshotJSON(folderPath, snapshotPath string) (string, error) { + return ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath) +} + +func GetLibraryScanProgressJSON() string { + return GetLibraryScanProgress() +} + +func CancelLibraryScanJSON() { + CancelLibraryScan() +} + +func ReadAudioMetadataJSON(filePath string) (string, error) { + return ReadAudioMetadata(filePath) +} + +func ReadAudioMetadataWithHintJSON(filePath, displayName string) (string, error) { + return ReadAudioMetadataWithDisplayName(filePath, displayName) +} + +func ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filePath, displayName, coverCacheKey string) (string, error) { + return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayName, coverCacheKey) +} diff --git a/go_backend/exports_lyrics.go b/go_backend/exports_lyrics.go new file mode 100644 index 00000000..78867381 --- /dev/null +++ b/go_backend/exports_lyrics.go @@ -0,0 +1,221 @@ +package gobackend + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) { + client := NewLyricsClient() + durationSec := float64(durationMs) / 1000.0 + lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec) + if err != nil { + return "", err + } + + result := map[string]interface{}{ + "success": true, + "source": lyrics.Source, + "sync_type": lyrics.SyncType, + "lines": lyrics.Lines, + "instrumental": lyrics.Instrumental, + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) { + if filePath != "" { + lyrics, err := ExtractLyrics(filePath) + if err == nil && lyrics != "" { + return lyrics, nil + } + return "", nil + } + + client := NewLyricsClient() + durationSec := float64(durationMs) / 1000.0 + lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec) + if err != nil { + return "", err + } + + if lyricsData.Instrumental { + return "[instrumental:true]", nil + } + + lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName) + return lrcContent, nil +} + +func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) { + if filePath != "" { + lyrics, err := ExtractLyrics(filePath) + if err == nil && lyrics != "" { + source := extractLyricsSourceFromLRC(lyrics) + if source == "" { + source = "Embedded" + } + result := map[string]interface{}{ + "lyrics": lyrics, + "source": source, + "sync_type": "EMBEDDED", + "instrumental": false, + } + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + return string(jsonBytes), nil + } + + result := map[string]interface{}{ + "lyrics": "", + "source": "", + "sync_type": "", + "instrumental": false, + } + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + return string(jsonBytes), nil + } + + client := NewLyricsClient() + durationSec := float64(durationMs) / 1000.0 + lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec) + if err != nil { + return "", err + } + + lrcContent := "" + if lyricsData.Instrumental { + lrcContent = "[instrumental:true]" + } else { + lrcContent = convertToLRCWithMetadata(lyricsData, trackName, artistName) + } + + result := map[string]interface{}{ + "lyrics": lrcContent, + "source": lyricsData.Source, + "sync_type": lyricsData.SyncType, + "instrumental": lyricsData.Instrumental, + } + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func EmbedLyricsToFile(filePath, lyrics string) (string, error) { + err := EmbedLyrics(filePath, lyrics) + if err != nil { + return errorResponse("Failed to embed lyrics: " + err.Error()) + } + + resp := map[string]interface{}{ + "success": true, + "message": "Lyrics embedded successfully", + } + + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil +} + +func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int64, outputPath string, audioFilePath string) error { + // If the audio file already has embedded lyrics or a sidecar .lrc, + // use those directly instead of making redundant network requests. + if audioFilePath != "" { + existing, err := ExtractLyrics(audioFilePath) + if err == nil && strings.TrimSpace(existing) != "" { + if err := os.WriteFile(outputPath, []byte(existing), 0644); err != nil { + return fmt.Errorf("failed to write LRC file: %w", err) + } + GoLog("[Lyrics] Saved LRC from embedded/sidecar to: %s\n", outputPath) + return nil + } + } + + 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 +} + +func SetLyricsProvidersJSON(providersJSON string) error { + var providers []string + if err := json.Unmarshal([]byte(providersJSON), &providers); err != nil { + return err + } + + SetLyricsProviderOrder(providers) + return nil +} + +func GetLyricsProvidersJSON() (string, error) { + providers := GetLyricsProviderOrder() + jsonBytes, err := json.Marshal(providers) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + +func GetAvailableLyricsProvidersJSON() (string, error) { + providers := GetAvailableLyricsProviders() + jsonBytes, err := json.Marshal(providers) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + +func SetLyricsFetchOptionsJSON(optionsJSON string) error { + opts := GetLyricsFetchOptions() + if strings.TrimSpace(optionsJSON) != "" { + if err := json.Unmarshal([]byte(optionsJSON), &opts); err != nil { + return err + } + } + + SetLyricsFetchOptions(opts) + return nil +} + +func GetLyricsFetchOptionsJSON() (string, error) { + opts := GetLyricsFetchOptions() + jsonBytes, err := json.Marshal(opts) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} diff --git a/go_backend/exports_metadata.go b/go_backend/exports_metadata.go new file mode 100644 index 00000000..6f3e13cf --- /dev/null +++ b/go_backend/exports_metadata.go @@ -0,0 +1,679 @@ +package gobackend + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +func ReadFileMetadata(filePath string) (string, error) { + lower := strings.ToLower(filePath) + isFlac := strings.HasSuffix(lower, ".flac") + isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".aac") + isMp3 := strings.HasSuffix(lower, ".mp3") + isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") + isApe := strings.HasSuffix(lower, ".ape") + isWv := strings.HasSuffix(lower, ".wv") + isMpc := strings.HasSuffix(lower, ".mpc") + isWav := strings.HasSuffix(lower, ".wav") + isAiff := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc") + + result := map[string]interface{}{ + "title": "", + "artist": "", + "album": "", + "album_artist": "", + "date": "", + "track_number": 0, + "total_tracks": 0, + "disc_number": 0, + "total_discs": 0, + "isrc": "", + "lyrics": "", + "genre": "", + "label": "", + "copyright": "", + "composer": "", + "comment": "", + "duration": 0, + "format": "", + "audio_codec": "", + } + + if isFlac { + result["format"] = "flac" + result["audio_codec"] = "flac" + metadata, err := ReadMetadata(filePath) + if err != nil { + // File may have wrong extension (e.g. opus saved as .flac). + // Try Ogg/Opus parser as fallback before giving up. + GoLog("[ReadFileMetadata] FLAC parse failed for %s, trying Ogg fallback: %v\n", filePath, err) + oggMeta, oggErr := ReadOggVorbisComments(filePath) + if oggErr == nil && oggMeta != nil { + result["title"] = oggMeta.Title + result["artist"] = oggMeta.Artist + result["album"] = oggMeta.Album + result["album_artist"] = oggMeta.AlbumArtist + result["date"] = oggMeta.Date + if oggMeta.Date == "" { + result["date"] = oggMeta.Year + } + result["track_number"] = oggMeta.TrackNumber + result["total_tracks"] = oggMeta.TotalTracks + result["disc_number"] = oggMeta.DiscNumber + result["total_discs"] = oggMeta.TotalDiscs + result["isrc"] = oggMeta.ISRC + result["lyrics"] = oggMeta.Lyrics + result["genre"] = oggMeta.Genre + result["composer"] = oggMeta.Composer + result["comment"] = oggMeta.Comment + quality, qualityErr := GetOggQuality(filePath) + if qualityErr == nil { + result["sample_rate"] = quality.SampleRate + result["duration"] = quality.Duration + if quality.Bitrate > 0 { + result["bitrate"] = quality.Bitrate / 1000 + } + } + result["format"] = "opus" + result["audio_codec"] = "opus" + } else { + return "", fmt.Errorf("failed to read metadata: %w", err) + } + } else { + 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["total_tracks"] = metadata.TotalTracks + result["disc_number"] = metadata.DiscNumber + result["total_discs"] = metadata.TotalDiscs + 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 + result["replaygain_track_gain"] = metadata.ReplayGainTrackGain + result["replaygain_track_peak"] = metadata.ReplayGainTrackPeak + result["replaygain_album_gain"] = metadata.ReplayGainAlbumGain + result["replaygain_album_peak"] = metadata.ReplayGainAlbumPeak + + quality, qualityErr := GetAudioQuality(filePath) + if qualityErr == nil { + result["bit_depth"] = quality.BitDepth + result["sample_rate"] = quality.SampleRate + if quality.Codec != "" { + result["audio_codec"] = quality.Codec + } + if quality.SampleRate > 0 && quality.TotalSamples > 0 { + result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate)) + } + } + } + } else if isM4A { + result["format"] = "m4a" + meta, err := ReadM4ATags(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["total_tracks"] = meta.TotalTracks + result["disc_number"] = meta.DiscNumber + result["total_discs"] = meta.TotalDiscs + result["isrc"] = meta.ISRC + result["lyrics"] = meta.Lyrics + result["genre"] = meta.Genre + result["label"] = meta.Label + result["copyright"] = meta.Copyright + result["composer"] = meta.Composer + result["comment"] = meta.Comment + result["replaygain_track_gain"] = meta.ReplayGainTrackGain + result["replaygain_track_peak"] = meta.ReplayGainTrackPeak + result["replaygain_album_gain"] = meta.ReplayGainAlbumGain + result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak + } + quality, qualityErr := GetM4AQuality(filePath) + if qualityErr == nil { + result["bit_depth"] = quality.BitDepth + result["sample_rate"] = quality.SampleRate + result["duration"] = quality.Duration + result["audio_codec"] = quality.Codec + if format := libraryFormatForM4ACodec(quality.Codec); format != "" { + result["format"] = format + } + if quality.Bitrate > 0 && !isLosslessLibraryFormat(fmt.Sprint(result["format"])) { + result["bitrate"] = quality.Bitrate + } + } + } else if isMp3 { + result["format"] = "mp3" + result["audio_codec"] = "mp3" + 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["total_tracks"] = meta.TotalTracks + result["disc_number"] = meta.DiscNumber + result["total_discs"] = meta.TotalDiscs + result["isrc"] = meta.ISRC + result["lyrics"] = meta.Lyrics + result["genre"] = meta.Genre + result["label"] = meta.Label + result["copyright"] = meta.Copyright + result["composer"] = meta.Composer + result["comment"] = meta.Comment + result["replaygain_track_gain"] = meta.ReplayGainTrackGain + result["replaygain_track_peak"] = meta.ReplayGainTrackPeak + result["replaygain_album_gain"] = meta.ReplayGainAlbumGain + result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak + } + quality, qualityErr := GetMP3Quality(filePath) + if qualityErr == nil { + result["bit_depth"] = quality.BitDepth + result["sample_rate"] = quality.SampleRate + result["duration"] = quality.Duration + if quality.Bitrate > 0 { + result["bitrate"] = quality.Bitrate / 1000 + } + } + } else if isOgg { + result["format"] = "opus" + result["audio_codec"] = "opus" + 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["total_tracks"] = meta.TotalTracks + result["disc_number"] = meta.DiscNumber + result["total_discs"] = meta.TotalDiscs + result["isrc"] = meta.ISRC + result["lyrics"] = meta.Lyrics + result["genre"] = meta.Genre + result["label"] = meta.Label + result["copyright"] = meta.Copyright + result["composer"] = meta.Composer + result["comment"] = meta.Comment + result["replaygain_track_gain"] = meta.ReplayGainTrackGain + result["replaygain_track_peak"] = meta.ReplayGainTrackPeak + result["replaygain_album_gain"] = meta.ReplayGainAlbumGain + result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak + } + quality, qualityErr := GetOggQuality(filePath) + if qualityErr == nil { + result["sample_rate"] = quality.SampleRate + result["duration"] = quality.Duration + if quality.Bitrate > 0 { + result["bitrate"] = quality.Bitrate / 1000 + } + } + } else if isApe || isWv || isMpc { + result["format"] = strings.TrimPrefix(filepath.Ext(filePath), ".") + result["audio_codec"] = result["format"] + apeTag, apeErr := ReadAPETags(filePath) + if apeErr == nil && apeTag != nil { + meta := APETagToAudioMetadata(apeTag) + if 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["total_tracks"] = meta.TotalTracks + result["disc_number"] = meta.DiscNumber + result["total_discs"] = meta.TotalDiscs + result["isrc"] = meta.ISRC + result["lyrics"] = meta.Lyrics + result["genre"] = meta.Genre + result["label"] = meta.Label + result["copyright"] = meta.Copyright + result["composer"] = meta.Composer + result["comment"] = meta.Comment + result["replaygain_track_gain"] = meta.ReplayGainTrackGain + result["replaygain_track_peak"] = meta.ReplayGainTrackPeak + result["replaygain_album_gain"] = meta.ReplayGainAlbumGain + result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak + } + } + } else if isWav || isAiff { + var meta *AudioMetadata + var quality *WAVQuality + var qualityErr error + if isAiff { + result["format"] = "aiff" + result["audio_codec"] = "pcm" + meta, _ = ReadAIFFTags(filePath) + quality, qualityErr = GetAIFFQuality(filePath) + } else { + result["format"] = "wav" + result["audio_codec"] = "pcm" + meta, _ = ReadWAVTags(filePath) + quality, qualityErr = GetWAVQuality(filePath) + } + if 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["total_tracks"] = meta.TotalTracks + result["disc_number"] = meta.DiscNumber + result["total_discs"] = meta.TotalDiscs + result["isrc"] = meta.ISRC + result["lyrics"] = meta.Lyrics + result["genre"] = meta.Genre + result["label"] = meta.Label + result["copyright"] = meta.Copyright + result["composer"] = meta.Composer + result["comment"] = meta.Comment + result["replaygain_track_gain"] = meta.ReplayGainTrackGain + result["replaygain_track_peak"] = meta.ReplayGainTrackPeak + result["replaygain_album_gain"] = meta.ReplayGainAlbumGain + result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak + } + if qualityErr == nil && quality != nil { + result["bit_depth"] = quality.BitDepth + result["sample_rate"] = quality.SampleRate + result["duration"] = quality.Duration + } + } else { + return "", fmt.Errorf("unsupported file format: %s", filePath) + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +// ParseCueSheet is called from Dart to get track listing and timing data for CUE splitting. +// audioDir, if non-empty, overrides the directory used for resolving the +// referenced audio file (useful for SAF temp file scenarios). +func ParseCueSheet(cuePath string, audioDir string) (string, error) { + return ParseCueFileJSON(cuePath, audioDir) +} + +// ScanCueSheetForLibrary parses a .cue file and returns a JSON array of +// LibraryScanResult entries (one per track). This is the SAF-friendly variant: +// - audioDir overrides where the referenced audio file is resolved +// - virtualPathPrefix replaces cuePath in filePath / id fields (e.g. a content:// URI) +// - fileModTime is stamped on every result (pass 0 to stat cuePath instead) +func ScanCueSheetForLibrary(cuePath, audioDir, virtualPathPrefix string, fileModTime int64) (string, error) { + scanTime := time.Now().UTC().Format(time.RFC3339) + results, err := ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime) + if err != nil { + return "[]", err + } + jsonBytes, err := json.Marshal(results) + if err != nil { + return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err) + } + return string(jsonBytes), nil +} + +func ScanCueSheetForLibraryWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey string) (string, error) { + scanTime := time.Now().UTC().Format(time.RFC3339) + results, err := ScanCueFileForLibraryExtWithCoverCacheKey( + cuePath, + audioDir, + virtualPathPrefix, + fileModTime, + coverCacheKey, + scanTime, + ) + if err != nil { + return "[]", err + } + jsonBytes, err := json.Marshal(results) + if err != nil { + return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err) + } + return string(jsonBytes), nil +} + +// WriteM4AFreeformTags writes ISRC and label into an M4A/MP4 file as iTunes +// freeform atoms. FFmpeg's MP4 muxer ignores these keys, so they must be +// written natively after the FFmpeg metadata pass for the values to persist. +// Only keys present in the JSON are touched; an empty value clears the tag. +func WriteM4AFreeformTags(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) + } + + if err := EditM4AFreeformText(filePath, fields); err != nil { + return "", fmt.Errorf("failed to write M4A freeform tags: %w", err) + } + + resp := map[string]any{"success": true, "method": "native_m4a_freeform"} + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil +} + +// EnsureAC4Config normalizes a decrypted AC-4 file to a standards-compliant ISO +// MP4 and injects the dac4 configuration box copied from sourcePath. No-op when +// the file is not AC-4. +func EnsureAC4Config(filePath, sourcePath string) (string, error) { + if err := EnsureAC4ConfigBox(filePath, sourcePath); err != nil { + return "", fmt.Errorf("failed to finalize AC-4 container: %w", err) + } + return `{"success":true}`, nil +} + +// WriteAC4Metadata writes iTunes-style metadata into an AC-4 MP4. The JSON +// "handled" field reports whether the file was AC-4 (true) so the caller can +// skip the FFmpeg metadata pass that would re-wrap it as QuickTime. +func WriteAC4Metadata(filePath, metadataJSON, coverPath string) (string, error) { + handled, err := WriteAC4MetadataIfApplicable(filePath, metadataJSON, coverPath) + if err != nil { + return "", fmt.Errorf("failed to write AC-4 metadata: %w", err) + } + resp := map[string]any{"success": true, "handled": handled} + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil +} + +// EditFileMetadata writes audio file tags: FLAC via native Go library, MP3/Opus returns map for Dart/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") + isApeFile := strings.HasSuffix(lower, ".ape") || strings.HasSuffix(lower, ".wv") || strings.HasSuffix(lower, ".mpc") + isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b") + isWavFile := strings.HasSuffix(lower, ".wav") + isAiffFile := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc") + coverPath := strings.TrimSpace(fields["cover_path"]) + + if hasOnlyM4AReplayGainFields(fields) && (isM4AFile || isMP4ContainerFile(filePath)) { + if err := EditM4AReplayGain(filePath, fields); err != nil { + return "", fmt.Errorf("failed to write M4A metadata: %w", err) + } + + resp := map[string]any{ + "success": true, + "method": "native_m4a_replaygain", + } + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil + } + + if isFlac { + if err := EditFlacFields(filePath, fields); 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 + } + + // WAV / AIFF: write tags into an embedded ID3v2.4 chunk natively. + if isWavFile { + if err := WriteWAVTags(filePath, fields); err != nil { + return "", fmt.Errorf("failed to write WAV metadata: %w", err) + } + resp := map[string]any{"success": true, "method": "native_wav"} + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil + } + if isAiffFile { + if err := WriteAIFFTags(filePath, fields); err != nil { + return "", fmt.Errorf("failed to write AIFF metadata: %w", err) + } + resp := map[string]any{"success": true, "method": "native_aiff"} + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil + } + + if isApeFile { + trackNum := 0 + totalTracks := 0 + discNum := 0 + totalDiscs := 0 + if v, ok := fields["track_number"]; ok && v != "" { + fmt.Sscanf(v, "%d", &trackNum) + } + if v, ok := fields["track_total"]; ok && v != "" { + fmt.Sscanf(v, "%d", &totalTracks) + } + if v, ok := fields["disc_number"]; ok && v != "" { + fmt.Sscanf(v, "%d", &discNum) + } + if v, ok := fields["disc_total"]; ok && v != "" { + fmt.Sscanf(v, "%d", &totalDiscs) + } + + meta := &AudioMetadata{ + Title: fields["title"], + Artist: fields["artist"], + Album: fields["album"], + AlbumArtist: fields["album_artist"], + Date: fields["date"], + TrackNumber: trackNum, + TotalTracks: totalTracks, + DiscNumber: discNum, + TotalDiscs: totalDiscs, + ISRC: fields["isrc"], + Lyrics: fields["lyrics"], + Genre: fields["genre"], + Label: fields["label"], + Copyright: fields["copyright"], + Composer: fields["composer"], + Comment: fields["comment"], + ReplayGainTrackGain: fields["replaygain_track_gain"], + ReplayGainTrackPeak: fields["replaygain_track_peak"], + ReplayGainAlbumGain: fields["replaygain_album_gain"], + ReplayGainAlbumPeak: fields["replaygain_album_peak"], + } + + newItems := AudioMetadataToAPEItems(meta) + + // If a cover image was provided, embed it as a binary APE item. + // APEv2 cover format: "cover.jpg\0", flagged binary. + if coverPath != "" { + coverData, coverErr := os.ReadFile(coverPath) + if coverErr == nil && len(coverData) > 0 { + // The value is "filename\0" + raw bytes. We store the + // description as the Value field, but since the item is + // flagged binary, the writer serializes it verbatim. + desc := "cover.jpg\x00" + binaryValue := desc + string(coverData) + newItems = append(newItems, APETagItem{ + Key: "Cover Art (Front)", + Value: binaryValue, + Flags: apeItemFlagBinary, + }) + } + } + + // Build the set of APE keys that the edit explicitly controls. + // Even if the value is empty (user cleared the field), the old + // value must be removed during merge. + overrideKeys := apeKeysFromFields(fields) + if coverPath != "" { + overrideKeys["COVER ART (FRONT)"] = struct{}{} + } + + // Read existing tags so we can merge rather than replace. + // This preserves cover art and custom items not in the edit set. + existingTag, _ := ReadAPETags(filePath) + var finalItems []APETagItem + if existingTag != nil && len(existingTag.Items) > 0 { + finalItems = MergeAPEItems(existingTag.Items, newItems, overrideKeys) + } else { + finalItems = newItems + } + + tag := &APETag{ + Version: apeTagVersion2, + Items: finalItems, + } + + if err := WriteAPETags(filePath, tag); err != nil { + return "", fmt.Errorf("failed to write APE tags: %w", err) + } + + resp := map[string]any{ + "success": true, + "method": "native_ape", + } + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil + } + + resp := map[string]any{ + "success": true, + "method": "ffmpeg", + "fields": fields, + } + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil +} + +func isMP4ContainerFile(filePath string) bool { + f, err := os.Open(filePath) + if err != nil { + return false + } + defer f.Close() + + header := make([]byte, 12) + n, err := f.Read(header) + if err != nil || n < 8 { + return false + } + return string(header[4:8]) == "ftyp" +} + +func hasOnlyM4AReplayGainFields(fields map[string]string) bool { + allowed := map[string]struct{}{ + "replaygain_track_gain": {}, + "replaygain_track_peak": {}, + "replaygain_album_gain": {}, + "replaygain_album_peak": {}, + } + + hasReplayGain := false + for key, value := range fields { + if strings.TrimSpace(value) == "" { + continue + } + if _, ok := allowed[strings.ToLower(strings.TrimSpace(key))]; ok { + hasReplayGain = true + continue + } + return false + } + + return hasReplayGain +} + +// RewriteSplitArtistTagsExport rewrites ARTIST and ALBUMARTIST Vorbis +// comments in a FLAC file as multiple separate entries (one per artist). +// Call this after FFmpeg metadata embedding to fix split artist tags, +// since FFmpeg deduplicates -metadata keys and only keeps the last value. +func RewriteSplitArtistTagsExport(filePath, artist, albumArtist string) (string, error) { + err := RewriteSplitArtistTags(filePath, artist, albumArtist) + if err != nil { + return errorResponse("Failed to rewrite artist tags: " + err.Error()) + } + + resp := map[string]interface{}{ + "success": true, + "message": "Split artist tags written successfully", + } + + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil +} + +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] Downloaded cover to: %s (%d KB)\n", outputPath, len(data)/1024) + return nil +} + +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, ".m4a") || strings.HasSuffix(lower, ".aac") { + coverData, err = extractCoverFromM4A(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 +} diff --git a/go_backend/exports_musicbrainz.go b/go_backend/exports_musicbrainz.go new file mode 100644 index 00000000..331463b2 --- /dev/null +++ b/go_backend/exports_musicbrainz.go @@ -0,0 +1,235 @@ +package gobackend + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +const musicBrainzAPIBase = "https://musicbrainz.org/ws/2" + +type musicBrainzTag struct { + Count int `json:"count"` + Name string `json:"name"` +} + +type musicBrainzRecordingResponse struct { + Recordings []struct { + Tags []musicBrainzTag `json:"tags"` + } `json:"recordings"` +} + +type musicBrainzArtistCredit struct { + Name string `json:"name"` + JoinPhrase string `json:"joinphrase"` +} + +type musicBrainzRelease struct { + Title string `json:"title"` + ArtistCredit []musicBrainzArtistCredit `json:"artist-credit"` +} + +type musicBrainzAlbumArtistResponse struct { + Recordings []struct { + Releases []musicBrainzRelease `json:"releases"` + } `json:"recordings"` +} + +func formatMusicBrainzGenre(tags []musicBrainzTag) string { + if len(tags) == 0 { + return "" + } + + caser := cases.Title(language.English) + seen := make(map[string]struct{}, len(tags)) + maxCount := -1 + bestTag := "" + + for _, tag := range tags { + name := strings.TrimSpace(tag.Name) + if name == "" { + continue + } + + key := strings.ToLower(name) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + + formatted := caser.String(name) + if tag.Count > maxCount { + maxCount = tag.Count + bestTag = formatted + } + } + + return bestTag +} + +func formatMusicBrainzArtistCredit(credits []musicBrainzArtistCredit) string { + var builder strings.Builder + for _, credit := range credits { + name := strings.TrimSpace(credit.Name) + if name == "" { + continue + } + builder.WriteString(name) + builder.WriteString(credit.JoinPhrase) + } + return strings.TrimSpace(builder.String()) +} + +func selectMusicBrainzAlbumArtist(releases []musicBrainzRelease, albumName string) string { + if len(releases) == 0 { + return "" + } + + normalizedAlbum := strings.ToLower(strings.TrimSpace(albumName)) + if normalizedAlbum != "" { + for _, release := range releases { + if strings.ToLower(strings.TrimSpace(release.Title)) != normalizedAlbum { + continue + } + if albumArtist := formatMusicBrainzArtistCredit(release.ArtistCredit); albumArtist != "" { + return albumArtist + } + } + } + + for _, release := range releases { + if albumArtist := formatMusicBrainzArtistCredit(release.ArtistCredit); albumArtist != "" { + return albumArtist + } + } + + return "" +} + +func FetchMusicBrainzAlbumArtistByISRC(isrc string, albumName string) (string, error) { + normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc)) + if normalizedISRC == "" { + return "", fmt.Errorf("no ISRC provided") + } + + client := NewMetadataHTTPClient(10 * time.Second) + query := fmt.Sprintf("isrc:%s", normalizedISRC) + reqURL := fmt.Sprintf( + "%s/recording?query=%s&fmt=json&inc=releases+artist-credits", + musicBrainzAPIBase, + url.QueryEscape(query), + ) + + req, err := http.NewRequest(http.MethodGet, reqURL, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", getRandomUserAgent()) + + var resp *http.Response + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + resp, lastErr = client.Do(req) + if lastErr == nil && resp.StatusCode == http.StatusOK { + break + } + if resp != nil { + resp.Body.Close() + } + if attempt < 2 { + time.Sleep(2 * time.Second) + } + } + + if lastErr != nil { + return "", lastErr + } + if resp == nil { + return "", fmt.Errorf("MusicBrainz request failed without response") + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return "", fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode) + } + defer resp.Body.Close() + + var payload musicBrainzAlbumArtistResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", err + } + for _, recording := range payload.Recordings { + if albumArtist := selectMusicBrainzAlbumArtist(recording.Releases, albumName); albumArtist != "" { + return albumArtist, nil + } + } + + return "", fmt.Errorf("no MusicBrainz album artist found for ISRC: %s", normalizedISRC) +} + +func FetchMusicBrainzGenreByISRC(isrc string) (string, error) { + normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc)) + if normalizedISRC == "" { + return "", fmt.Errorf("no ISRC provided") + } + + client := NewMetadataHTTPClient(10 * time.Second) + query := fmt.Sprintf("isrc:%s", normalizedISRC) + reqURL := fmt.Sprintf( + "%s/recording?query=%s&fmt=json&inc=tags", + musicBrainzAPIBase, + url.QueryEscape(query), + ) + + req, err := http.NewRequest(http.MethodGet, reqURL, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", getRandomUserAgent()) + + var resp *http.Response + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + resp, lastErr = client.Do(req) + if lastErr == nil && resp.StatusCode == http.StatusOK { + break + } + if resp != nil { + resp.Body.Close() + } + if attempt < 2 { + time.Sleep(2 * time.Second) + } + } + + if lastErr != nil { + return "", lastErr + } + if resp == nil { + return "", fmt.Errorf("MusicBrainz request failed without response") + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return "", fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode) + } + defer resp.Body.Close() + + var payload musicBrainzRecordingResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", err + } + if len(payload.Recordings) == 0 { + return "", fmt.Errorf("no recordings found for ISRC: %s", normalizedISRC) + } + + genre := formatMusicBrainzGenre(payload.Recordings[0].Tags) + if genre == "" { + return "", fmt.Errorf("no MusicBrainz genre tags found for ISRC: %s", normalizedISRC) + } + return genre, nil +} diff --git a/go_backend/exports_reenrich.go b/go_backend/exports_reenrich.go new file mode 100644 index 00000000..1e048a84 --- /dev/null +++ b/go_backend/exports_reenrich.go @@ -0,0 +1,764 @@ +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]interface{}{ + "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]interface{}{ + "method": "native", + "success": true, + "enriched_metadata": enrichedMeta, + "lyrics": lyricsLRC, + "write_external_lrc": req.EmbedLyrics && + req.shouldUpdateField("lyrics") && + req.lyricsSidecarEnabled() && + strings.TrimSpace(lyricsLRC) != "", + } + jsonBytes, _ := json.Marshal(result) + return string(jsonBytes), nil + } + + // Don't cleanup cover temp — Dart needs it for FFmpeg embed + cleanupCover = false + ffmpegMetadata := buildReEnrichFFmpegMetadata(&req, lyricsLRC) + + result := map[string]interface{}{ + "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) != "", + } + + jsonBytes, _ := json.Marshal(result) + return string(jsonBytes), nil +} diff --git a/go_backend/exports_store.go b/go_backend/exports_store.go new file mode 100644 index 00000000..59f6c82b --- /dev/null +++ b/go_backend/exports_store.go @@ -0,0 +1,164 @@ +package gobackend + +import ( + "encoding/json" + "fmt" + "net/url" + "path/filepath" + "strings" +) + +func InitExtensionStoreJSON(cacheDir string) error { + initExtensionStore(cacheDir) + return nil +} + +func SetStoreRegistryURLJSON(registryURL string) error { + store := getExtensionStore() + if store == nil { + return fmt.Errorf("extension store not initialized") + } + + resolved, err := resolveRegistryURL(registryURL) + if err != nil { + return err + } + + if err := requireHTTPSURL(resolved, "registry"); err != nil { + return err + } + + store.setRegistryURL(resolved) + return nil +} + +func ClearStoreRegistryURLJSON() error { + store := getExtensionStore() + if store == nil { + return fmt.Errorf("extension store not initialized") + } + + store.setRegistryURL("") + store.clearCache() + return nil +} + +func GetStoreRegistryURLJSON() (string, error) { + store := getExtensionStore() + if store == nil { + return "", fmt.Errorf("extension store not initialized") + } + + return store.getRegistryURL(), nil +} + +func GetStoreExtensionsJSON(forceRefresh bool) (string, error) { + store := getExtensionStore() + if store == nil { + return "", fmt.Errorf("extension store not initialized") + } + + extensions, err := store.getExtensionsWithStatus(forceRefresh) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(extensions) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func SearchStoreExtensionsJSON(query, category string) (string, error) { + store := getExtensionStore() + if store == nil { + return "", fmt.Errorf("extension store not initialized") + } + + extensions, err := store.searchExtensions(query, category) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(extensions) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func GetStoreCategoriesJSON() (string, error) { + store := getExtensionStore() + if store == nil { + return "", fmt.Errorf("extension store not initialized") + } + + categories := store.getCategories() + jsonBytes, err := json.Marshal(categories) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func storeExtensionPackageSuffix(downloadURL string) string { + rawPath := downloadURL + if parsed, err := url.Parse(downloadURL); err == nil { + rawPath = parsed.Path + } + + lowerPath := strings.ToLower(rawPath) + if strings.HasSuffix(lowerPath, ".sflx") { + return ".sflx" + } + if strings.HasSuffix(lowerPath, ".spotiflac-ext") { + return ".spotiflac-ext" + } + return ".spotiflac-ext" +} + +func buildStoreExtensionDestPath(destDir, extensionID, downloadURL string) (string, error) { + if strings.TrimSpace(extensionID) == "" { + return "", fmt.Errorf("invalid extension id") + } + + safeExtensionID := sanitizeFilename(extensionID) + return filepath.Join(destDir, safeExtensionID+storeExtensionPackageSuffix(downloadURL)), nil +} + +func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) { + store := getExtensionStore() + if store == nil { + return "", fmt.Errorf("extension store not initialized") + } + + ext, err := store.findExtension(extensionID) + if err != nil { + return "", err + } + + destPath, err := buildStoreExtensionDestPath(destDir, extensionID, ext.getDownloadURL()) + if err != nil { + return "", err + } + err = store.downloadExtension(extensionID, destPath) + if err != nil { + return "", err + } + + return destPath, nil +} + +func ClearStoreCacheJSON() error { + store := getExtensionStore() + if store == nil { + return fmt.Errorf("extension store not initialized") + } + + store.clearCache() + return nil +}