mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
GetM4AQuality now recognizes fLaC, alac, ec-3, ac-3, and ac-4 sample entries and parses the MP4 FLACSpecificBox so library entries carry the real codec rather than the container extension. The AudioQuality struct exposes Codec and Bitrate fields (with an estimator for compressed streams), and ReadFileMetadata publishes format + audio_codec so Flutter and Kotlin can make format decisions based on the actual stream. Downstream: library_scan labels M4A-family items as flac/alac/eac3/ac3/ac4/m4a, zeroes the bitrate for lossless formats, and the filter UI + quality badges use the codec-derived format instead of only the file extension. Scans and SAF importers also accept .mp4 and .aac file extensions. New unit tests cover codec name mapping and MP4 FLACSpecificBox decoding.
3819 lines
108 KiB
Go
3819 lines
108 KiB
Go
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) {
|
|
client := NewSongLinkClient()
|
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
jsonBytes, err := json.Marshal(availability)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return string(jsonBytes), nil
|
|
}
|
|
|
|
// SetSongLinkNetworkOptions is kept for backward compatibility.
|
|
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"`
|
|
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"`
|
|
AlreadyExists bool `json:"already_exists,omitempty"`
|
|
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
|
ActualSampleRate int `json:"actual_sample_rate,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
|
|
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"`
|
|
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
|
|
}
|
|
|
|
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 != "" {
|
|
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)
|
|
var best *ExtTrackMetadata
|
|
bestScore := -1 << 30
|
|
|
|
for i := range tracks {
|
|
track := &tracks[i]
|
|
score := 0
|
|
|
|
resolved := resolvedTrackInfo{
|
|
Title: track.Name,
|
|
ArtistName: track.Artists,
|
|
ISRC: track.ISRC,
|
|
Duration: track.DurationMS / 1000,
|
|
}
|
|
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
|
|
score += 2000
|
|
}
|
|
|
|
if currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC)) {
|
|
score += 10000
|
|
}
|
|
if req.TrackName != "" && track.Name != "" && titlesMatch(req.TrackName, track.Name) {
|
|
score += 400
|
|
}
|
|
if req.ArtistName != "" && track.Artists != "" && artistsMatch(req.ArtistName, track.Artists) {
|
|
score += 320
|
|
}
|
|
if currentAlbum != "" && track.AlbumName != "" {
|
|
switch {
|
|
case titlesMatch(currentAlbum, track.AlbumName):
|
|
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,
|
|
}
|
|
}
|
|
|
|
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,
|
|
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
|
|
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")
|
|
|
|
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"]
|
|
// APE, WavPack, Musepack: read APEv2 tags
|
|
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 {
|
|
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
|
|
}
|
|
|
|
// 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")
|
|
coverPath := strings.TrimSpace(fields["cover_path"])
|
|
|
|
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
|
|
}
|
|
|
|
// APE/WV/MPC: write APEv2 tags natively
|
|
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<binary image data>", 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
|
|
}
|
|
|
|
if isM4AFile && hasOnlyM4AReplayGainFields(fields) {
|
|
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
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"success": true,
|
|
"method": "ffmpeg",
|
|
"fields": fields,
|
|
}
|
|
jsonBytes, _ := json.Marshal(resp)
|
|
return string(jsonBytes), nil
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func AllowDownloadDir(path string) {
|
|
if strings.TrimSpace(path) == "" {
|
|
return
|
|
}
|
|
AddAllowedDownloadDir(path)
|
|
}
|
|
|
|
func CheckDuplicate(outputDir, isrc string) (string, error) {
|
|
existingFile, exists := CheckISRCExists(outputDir, isrc)
|
|
|
|
result := map[string]interface{}{
|
|
"exists": exists,
|
|
"filepath": existingFile,
|
|
}
|
|
|
|
jsonBytes, err := json.Marshal(result)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return string(jsonBytes), nil
|
|
}
|
|
|
|
func CheckDuplicatesBatch(outputDir, tracksJSON string) (string, error) {
|
|
return CheckFilesExistParallel(outputDir, tracksJSON)
|
|
}
|
|
|
|
func PreBuildDuplicateIndex(outputDir string) error {
|
|
return PreBuildISRCIndex(outputDir)
|
|
}
|
|
|
|
func InvalidateDuplicateIndex(outputDir string) {
|
|
InvalidateISRCCache(outputDir)
|
|
}
|
|
|
|
func BuildFilename(template string, metadataJSON string) (string, error) {
|
|
var metadata map[string]interface{}
|
|
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
filename := buildFilenameFromTemplate(template, metadata)
|
|
return filename, nil
|
|
}
|
|
|
|
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 != "" {
|
|
result := map[string]interface{}{
|
|
"lyrics": lyrics,
|
|
"source": "Embedded",
|
|
"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,
|
|
"duration_ms": track.DurationMS,
|
|
"images": coverURL,
|
|
"cover_url": coverURL,
|
|
"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,
|
|
"release_date": album.ReleaseDate,
|
|
"total_tracks": album.TotalTracks,
|
|
"album_type": album.AlbumType,
|
|
"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,
|
|
"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,
|
|
"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":
|
|
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 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 := "unknown"
|
|
lowerMsg := strings.ToLower(msg)
|
|
|
|
if strings.Contains(lowerMsg, "isp blocking") ||
|
|
strings.Contains(lowerMsg, "try using vpn") ||
|
|
strings.Contains(lowerMsg, "change dns") {
|
|
errorType = "isp_blocked"
|
|
} else if strings.Contains(lowerMsg, "cancel") {
|
|
errorType = "cancelled"
|
|
} 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") {
|
|
errorType = "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") {
|
|
errorType = "not_found"
|
|
} else if strings.Contains(lowerMsg, "rate limit") ||
|
|
strings.Contains(lowerMsg, "429") ||
|
|
strings.Contains(lowerMsg, "too many requests") {
|
|
errorType = "rate_limit"
|
|
} else if strings.Contains(lowerMsg, "network") ||
|
|
strings.Contains(lowerMsg, "connection") ||
|
|
strings.Contains(lowerMsg, "timeout") ||
|
|
strings.Contains(lowerMsg, "dial") {
|
|
errorType = "network"
|
|
}
|
|
|
|
resp := DownloadResponse{
|
|
Success: false,
|
|
Error: msg,
|
|
ErrorType: errorType,
|
|
}
|
|
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
|
|
}
|
|
|
|
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)
|
|
|
|
// When search_online is true, search for metadata from internet using the
|
|
// configured metadata-provider priority.
|
|
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 {
|
|
// Native Go FLAC metadata embedding.
|
|
// 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") {
|
|
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,
|
|
}
|
|
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,
|
|
}
|
|
|
|
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 := GetPendingAuthRequest(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 SetExtensionAuthCodeByID(extensionID, authCode string) {
|
|
SetExtensionAuthCode(extensionID, authCode)
|
|
}
|
|
|
|
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(),
|
|
"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,
|
|
}
|
|
|
|
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(),
|
|
"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(),
|
|
"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,
|
|
"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,
|
|
"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(),
|
|
"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 buildStoreExtensionDestPath(destDir, extensionID string) (string, error) {
|
|
if strings.TrimSpace(extensionID) == "" {
|
|
return "", fmt.Errorf("invalid extension id")
|
|
}
|
|
|
|
safeExtensionID := sanitizeFilename(extensionID)
|
|
return filepath.Join(destDir, safeExtensionID+".spotiflac-ext"), nil
|
|
}
|
|
|
|
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
|
store := getExtensionStore()
|
|
if store == nil {
|
|
return "", fmt.Errorf("extension store not initialized")
|
|
}
|
|
|
|
destPath, err := buildStoreExtensionDestPath(destDir, extensionID)
|
|
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();
|
|
}
|
|
return null;
|
|
})()
|
|
`, 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)
|
|
}
|