Files
SpotiFLAC-Mobile/go_backend/exports.go
zarzet 67daefdf60 feat: add artist tag mode setting with split Vorbis support and improve library scan progress
- Add artist_tag_mode setting (joined / split_vorbis) for FLAC/Opus multi-artist tags
- Split 'Artist A, Artist B' into separate ARTIST= Vorbis comments when split mode is enabled
- Join repeated ARTIST/ALBUMARTIST Vorbis comments when reading metadata
- Propagate artistTagMode through download pipeline, re-enrich, and metadata editor
- Improve library scan progress: separate polling intervals, finalizing state, indeterminate progress
- Add initial progress snapshot on library scan stream connect
- Use req.ArtistName consistently for Qobuz downloads instead of track.Performer.Name
- Add l10n keys for artist tag mode, library files unit, and scan finalizing status
2026-03-30 12:38:42 +07:00

3520 lines
94 KiB
Go

package gobackend
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/dop251/goja"
)
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)
}
type DownloadRequest struct {
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"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
TotalTracks int `json:"total_tracks"`
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"`
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"`
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"`
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"`
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"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
LyricsLRC string `json:"lyrics_lrc,omitempty"`
DecryptionKey string `json:"decryption_key,omitempty"`
}
type DownloadResult struct {
FilePath string
BitDepth int
SampleRate int
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
CoverURL string
Genre string
Label string
Copyright string
LyricsLRC string
DecryptionKey string
}
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"`
ReleaseDate string `json:"release_date"`
ISRC string `json:"isrc"`
Genre string `json:"genre"`
Label string `json:"label"`
Copyright string `json:"copyright"`
DurationMs int64 `json:"duration_ms"`
SearchOnline bool `json:"search_online"`
}
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 track.AlbumName != "" {
req.AlbumName = track.AlbumName
}
if track.AlbumArtist != "" {
req.AlbumArtist = track.AlbumArtist
}
if track.TrackNumber > 0 {
req.TrackNumber = track.TrackNumber
}
if track.DiscNumber > 0 {
req.DiscNumber = track.DiscNumber
}
if track.ReleaseDate != "" {
req.ReleaseDate = track.ReleaseDate
}
if track.ISRC != "" {
req.ISRC = track.ISRC
}
if coverURL := track.ResolvedCoverURL(); coverURL != "" {
req.CoverURL = coverURL
}
if track.DurationMS > 0 {
req.DurationMs = int64(track.DurationMS)
}
if track.Genre != "" {
req.Genre = track.Genre
}
if track.Label != "" {
req.Label = track.Label
}
if track.Copyright != "" {
req.Copyright = track.Copyright
}
}
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,
}
}
func buildReEnrichFFmpegMetadata(req reEnrichRequest, lyricsLRC string) map[string]string {
metadata := map[string]string{}
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.ReleaseDate != "" {
metadata["DATE"] = req.ReleaseDate
}
if req.ISRC != "" {
metadata["ISRC"] = req.ISRC
}
if req.Genre != "" {
metadata["GENRE"] = req.Genre
}
if req.TrackNumber > 0 {
metadata["TRACKNUMBER"] = fmt.Sprintf("%d", req.TrackNumber)
}
if req.DiscNumber > 0 {
metadata["DISCNUMBER"] = fmt.Sprintf("%d", req.DiscNumber)
}
if req.Label != "" {
metadata["ORGANIZATION"] = req.Label
}
if req.Copyright != "" {
metadata["COPYRIGHT"] = req.Copyright
}
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,
DiscNumber: track.DiscNumber,
ISRC: track.ISRC,
ProviderID: providerID,
DeezerID: deezerID,
SpotifyID: track.SpotifyID,
}
}
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
}
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,
Service: service,
Title: title,
Artist: artist,
Album: album,
AlbumArtist: req.AlbumArtist,
ReleaseDate: releaseDate,
TrackNumber: trackNumber,
DiscNumber: discNumber,
ISRC: isrc,
CoverURL: coverURL,
Genre: genre,
Label: label,
Copyright: copyright,
LyricsLRC: result.LyricsLRC,
DecryptionKey: 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 enrichRequestExtendedMetadata(req *DownloadRequest) {
if req == nil {
return
}
if req.ISRC == "" || (req.Genre != "" && req.Label != "" && req.Copyright != "") {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
deezerClient := GetDeezerClient()
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
if err != nil || extMeta == nil {
if err != nil {
GoLog("[DownloadWithFallback] Failed to get extended metadata from Deezer: %v\n", err)
}
return
}
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
}
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
}
if req.Genre != "" || req.Label != "" || req.Copyright != "" {
GoLog("[DownloadWithFallback] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", req.Genre, req.Label, req.Copyright)
}
}
func applySongLinkRegionFromRequest(req *DownloadRequest) {
if req == nil {
return
}
SetSongLinkRegion(req.SongLinkRegion)
}
func DownloadTrack(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error())
}
applySongLinkRegionFromRequest(&req)
defer closeOwnedOutputFD(req.OutputFD)
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)
var result DownloadResult
var err error
switch req.Service {
case "tidal":
tidalResult, tidalErr := downloadFromTidal(req)
if tidalErr == nil {
result = DownloadResult{
FilePath: tidalResult.FilePath,
BitDepth: tidalResult.BitDepth,
SampleRate: tidalResult.SampleRate,
Title: tidalResult.Title,
Artist: tidalResult.Artist,
Album: tidalResult.Album,
ReleaseDate: tidalResult.ReleaseDate,
TrackNumber: tidalResult.TrackNumber,
DiscNumber: tidalResult.DiscNumber,
ISRC: tidalResult.ISRC,
LyricsLRC: tidalResult.LyricsLRC,
}
}
err = tidalErr
case "qobuz":
qobuzResult, qobuzErr := downloadFromQobuz(req)
if qobuzErr == nil {
result = DownloadResult{
FilePath: qobuzResult.FilePath,
BitDepth: qobuzResult.BitDepth,
SampleRate: qobuzResult.SampleRate,
Title: qobuzResult.Title,
Artist: qobuzResult.Artist,
Album: qobuzResult.Album,
ReleaseDate: qobuzResult.ReleaseDate,
TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC,
CoverURL: qobuzResult.CoverURL,
LyricsLRC: qobuzResult.LyricsLRC,
}
}
err = qobuzErr
case "deezer":
deezerResult, deezerErr := downloadFromDeezer(req)
if deezerErr == nil {
result = DownloadResult{
FilePath: deezerResult.FilePath,
BitDepth: deezerResult.BitDepth,
SampleRate: deezerResult.SampleRate,
Title: deezerResult.Title,
Artist: deezerResult.Artist,
Album: deezerResult.Album,
ReleaseDate: deezerResult.ReleaseDate,
TrackNumber: deezerResult.TrackNumber,
DiscNumber: deezerResult.DiscNumber,
ISRC: deezerResult.ISRC,
LyricsLRC: deezerResult.LyricsLRC,
}
}
err = deezerErr
default:
return errorResponse("Unknown service: " + req.Service)
}
if err != nil {
return errorResponse(err.Error())
}
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
result.FilePath = actualPath
enrichResultQualityFromFile(&result)
resp := buildDownloadSuccessResponse(
req,
result,
req.Service,
"File already exists",
actualPath,
true,
)
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
enrichResultQualityFromFile(&result)
resp := buildDownloadSuccessResponse(
req,
result,
req.Service,
"Download complete",
result.FilePath,
false,
)
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
// DownloadByStrategy routes a unified download request to the appropriate flow.
// Routing priority: YouTube service > extension fallback > built-in fallback > direct service.
func DownloadByStrategy(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error())
}
serviceRaw := strings.TrimSpace(req.Service)
serviceNormalized := strings.ToLower(serviceRaw)
normalizedReq := req
if isBuiltInProvider(serviceNormalized) {
normalizedReq.Service = serviceNormalized
}
normalizedBytes, err := json.Marshal(normalizedReq)
if err != nil {
return errorResponse("Invalid request: " + err.Error())
}
normalizedJSON := string(normalizedBytes)
if req.UseExtensions {
// Respect strict mode when auto fallback is disabled:
// for built-in providers, route directly to selected service only.
if !req.UseFallback && isBuiltInProvider(serviceNormalized) {
return DownloadTrack(normalizedJSON)
}
resp, err := DownloadWithExtensionsJSON(normalizedJSON)
if err != nil {
return errorResponse(err.Error())
}
return resp, nil
}
if req.UseFallback {
return DownloadWithFallback(normalizedJSON)
}
return DownloadTrack(normalizedJSON)
}
func DownloadWithFallback(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error())
}
applySongLinkRegionFromRequest(&req)
defer closeOwnedOutputFD(req.OutputFD)
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)
allServices := []string{"tidal", "qobuz", "deezer"}
preferredService := req.Service
if preferredService == "" {
preferredService = "tidal"
}
GoLog("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
services := []string{preferredService}
for _, s := range allServices {
if s != preferredService {
services = append(services, s)
}
}
GoLog("[DownloadWithFallback] Service order: %v\n", services)
var lastErr error
for _, service := range services {
GoLog("[DownloadWithFallback] Trying service: %s\n", service)
req.Service = service
var result DownloadResult
var err error
switch service {
case "tidal":
tidalResult, tidalErr := downloadFromTidal(req)
if tidalErr == nil {
result = DownloadResult{
FilePath: tidalResult.FilePath,
BitDepth: tidalResult.BitDepth,
SampleRate: tidalResult.SampleRate,
Title: tidalResult.Title,
Artist: tidalResult.Artist,
Album: tidalResult.Album,
ReleaseDate: tidalResult.ReleaseDate,
TrackNumber: tidalResult.TrackNumber,
DiscNumber: tidalResult.DiscNumber,
ISRC: tidalResult.ISRC,
LyricsLRC: tidalResult.LyricsLRC,
}
} else if !errors.Is(tidalErr, ErrDownloadCancelled) {
GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
}
err = tidalErr
case "qobuz":
qobuzResult, qobuzErr := downloadFromQobuz(req)
if qobuzErr == nil {
result = DownloadResult{
FilePath: qobuzResult.FilePath,
BitDepth: qobuzResult.BitDepth,
SampleRate: qobuzResult.SampleRate,
Title: qobuzResult.Title,
Artist: qobuzResult.Artist,
Album: qobuzResult.Album,
ReleaseDate: qobuzResult.ReleaseDate,
TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC,
CoverURL: qobuzResult.CoverURL,
LyricsLRC: qobuzResult.LyricsLRC,
}
} else if !errors.Is(qobuzErr, ErrDownloadCancelled) {
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
}
err = qobuzErr
case "deezer":
deezerResult, deezerErr := downloadFromDeezer(req)
if deezerErr == nil {
result = DownloadResult{
FilePath: deezerResult.FilePath,
BitDepth: deezerResult.BitDepth,
SampleRate: deezerResult.SampleRate,
Title: deezerResult.Title,
Artist: deezerResult.Artist,
Album: deezerResult.Album,
ReleaseDate: deezerResult.ReleaseDate,
TrackNumber: deezerResult.TrackNumber,
DiscNumber: deezerResult.DiscNumber,
ISRC: deezerResult.ISRC,
LyricsLRC: deezerResult.LyricsLRC,
}
} else if !errors.Is(deezerErr, ErrDownloadCancelled) {
GoLog("[DownloadWithFallback] Deezer error: %v\n", deezerErr)
}
err = deezerErr
}
if err != nil && errors.Is(err, ErrDownloadCancelled) {
return errorResponse("Download cancelled")
}
if err == nil {
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
actualPath := result.FilePath[7:]
result.FilePath = actualPath
enrichResultQualityFromFile(&result)
resp := buildDownloadSuccessResponse(
req,
result,
service,
"File already exists",
actualPath,
true,
)
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
enrichResultQualityFromFile(&result)
resp := buildDownloadSuccessResponse(
req,
result,
service,
"Downloaded from "+service,
result.FilePath,
false,
)
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
lastErr = err
}
return errorResponse("All services failed. Last error: " + lastErr.Error())
}
func GetDownloadProgress() string {
progress := getProgress()
jsonBytes, _ := json.Marshal(progress)
return string(jsonBytes)
}
func GetAllDownloadProgress() string {
return GetMultiProgress()
}
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, ".aac")
isMp3 := strings.HasSuffix(lower, ".mp3")
isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
result := map[string]interface{}{
"title": "",
"artist": "",
"album": "",
"album_artist": "",
"date": "",
"track_number": 0,
"disc_number": 0,
"isrc": "",
"lyrics": "",
"genre": "",
"label": "",
"copyright": "",
"composer": "",
"comment": "",
"duration": 0,
}
if isFlac {
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["disc_number"] = oggMeta.DiscNumber
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
}
} 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["disc_number"] = metadata.DiscNumber
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
quality, qualityErr := GetAudioQuality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
}
}
}
} else if isM4A {
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["disc_number"] = meta.DiscNumber
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
}
quality, qualityErr := GetM4AQuality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
}
} else if isMp3 {
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["disc_number"] = meta.DiscNumber
result["isrc"] = meta.ISRC
result["lyrics"] = meta.Lyrics
result["genre"] = meta.Genre
result["composer"] = meta.Composer
result["comment"] = meta.Comment
}
quality, qualityErr := GetMP3Quality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
}
} else if isOgg {
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["disc_number"] = meta.DiscNumber
result["isrc"] = meta.ISRC
result["lyrics"] = meta.Lyrics
result["genre"] = meta.Genre
result["composer"] = meta.Composer
result["comment"] = meta.Comment
}
quality, qualityErr := GetOggQuality(filePath)
if qualityErr == nil {
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
}
} else {
return "", fmt.Errorf("unsupported file format: %s", filePath)
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
// ParseCueSheet parses a .cue file and returns JSON with split information.
// This 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 metadata to an audio file.
// For FLAC files, uses native Go FLAC library.
// For MP3/Opus, returns the metadata map so Dart can use 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")
coverPath := strings.TrimSpace(fields["cover_path"])
if isFlac {
trackNum := 0
discNum := 0
if v, ok := fields["track_number"]; ok && v != "" {
fmt.Sscanf(v, "%d", &trackNum)
}
if v, ok := fields["disc_number"]; ok && v != "" {
fmt.Sscanf(v, "%d", &discNum)
}
meta := Metadata{
Title: fields["title"],
Artist: fields["artist"],
Album: fields["album"],
AlbumArtist: fields["album_artist"],
ArtistTagMode: fields["artist_tag_mode"],
Date: fields["date"],
TrackNumber: trackNum,
DiscNumber: discNum,
ISRC: fields["isrc"],
Genre: fields["genre"],
Label: fields["label"],
Copyright: fields["copyright"],
Composer: fields["composer"],
Comment: fields["comment"],
}
if err := EmbedMetadata(filePath, meta, coverPath); 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
}
resp := map[string]any{
"success": true,
"method": "ffmpeg",
"fields": fields,
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
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
}
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 SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client := GetDeezerClient()
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit, filter)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func SearchTidalAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
downloader := NewTidalDownloader()
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func SearchQobuzAll(query string, trackLimit, artistLimit int, filter string) (string, error) {
downloader := NewQobuzDownloader()
results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
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 GetQobuzMetadata(resourceType, resourceID string) (string, error) {
downloader := NewQobuzDownloader()
var data interface{}
var err error
switch resourceType {
case "track":
data, err = downloader.GetTrackMetadata(resourceID)
case "album":
data, err = downloader.GetAlbumMetadata(resourceID)
case "artist":
data, err = downloader.GetArtistMetadata(resourceID)
case "playlist":
data, err = downloader.GetPlaylistMetadata(resourceID)
default:
return "", fmt.Errorf("unsupported Qobuz resource type: %s", resourceType)
}
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(data)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func GetTidalMetadata(resourceType, resourceID string) (string, error) {
downloader := NewTidalDownloader()
var data interface{}
var err error
switch resourceType {
case "track":
data, err = downloader.GetTrackMetadata(resourceID)
case "album":
data, err = downloader.GetAlbumMetadata(resourceID)
case "artist":
data, err = downloader.GetArtistMetadata(resourceID)
case "playlist":
data, err = downloader.GetPlaylistMetadata(resourceID)
default:
return "", fmt.Errorf("unsupported Tidal resource type: %s", resourceType)
}
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(data)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func ParseDeezerURLExport(url string) (string, error) {
resourceType, resourceID, err := parseDeezerURL(url)
if err != nil {
return "", err
}
result := map[string]string{
"type": resourceType,
"id": resourceID,
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func ParseQobuzURLExport(url string) (string, error) {
resourceType, resourceID, err := parseQobuzURL(url)
if err != nil {
return "", err
}
result := map[string]string{
"type": resourceType,
"id": resourceID,
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func ParseTidalURLExport(url string) (string, error) {
resourceType, resourceID, err := parseTidalURL(url)
if err != nil {
return "", err
}
result := map[string]string{
"type": resourceType,
"id": resourceID,
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func ConvertTidalToSpotifyDeezer(tidalURL string) (string, error) {
client := NewSongLinkClient()
availability, err := client.CheckAvailabilityFromURL(tidalURL)
if err != nil {
return "", err
}
result := map[string]string{
"spotify_id": availability.SpotifyID,
"deezer_id": availability.DeezerID,
"deezer_url": availability.DeezerURL,
"spotify_url": "",
}
if availability.SpotifyID != "" {
result["spotify_url"] = "https://open.spotify.com/track/" + availability.SpotifyID
}
jsonBytes, err := json.Marshal(result)
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) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client := GetDeezerClient()
track, err := client.SearchByISRC(ctx, isrc)
if err != nil {
return "", err
}
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,
"external_urls": track.ExternalURL,
"isrc": track.ISRC,
"album_id": track.AlbumID,
"artist_id": track.ArtistID,
"album_type": track.AlbumType,
}
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] Saved cover art 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) error {
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 && req.TrackName != "" && req.ArtistName != "" {
GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName)
searchQuery := req.TrackName + " " + req.ArtistName
found := false
deezerClient := GetDeezerClient()
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
}
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)
}
// Try to get extended metadata from Deezer if not already set
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
if err == nil && extMeta != nil {
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
}
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
if req.Copyright == "" && extMeta.Copyright != "" {
req.Copyright = extMeta.Copyright
}
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s, copyright=%s\n", 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")
// Download cover art to temp file
var coverTempPath string
var coverDataBytes []byte
if req.CoverURL != "" {
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
existingLyrics, existingLyricsErr := ExtractLyrics(req.FilePath)
if existingLyricsErr == nil && strings.TrimSpace(existingLyrics) != "" {
lyricsLRC = existingLyrics
GoLog("[ReEnrich] Preserving existing embedded/sidecar lyrics\n")
}
// Fetch lyrics
if req.EmbedLyrics {
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")
}
}
enrichedMeta := map[string]interface{}{
"track_name": req.TrackName,
"artist_name": req.ArtistName,
"album_name": req.AlbumName,
"album_artist": req.AlbumArtist,
"release_date": req.ReleaseDate,
"track_number": req.TrackNumber,
"disc_number": req.DiscNumber,
"isrc": req.ISRC,
"genre": req.Genre,
"label": req.Label,
"copyright": req.Copyright,
"cover_url": req.CoverURL,
"spotify_id": req.SpotifyID,
"duration_ms": req.DurationMs,
}
if isFlac {
// Native Go FLAC metadata embedding
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
ArtistTagMode: req.ArtistTagMode,
Date: req.ReleaseDate,
TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
Lyrics: lyricsLRC,
}
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 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)
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)
}
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) {
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.CustomSearch(query, options)
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(), // Use helper to get cover URL from either field
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"item_type": track.ItemType,
"album_type": track.AlbumType,
}
}
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,
"disc_number": result.Track.DiscNumber,
"isrc": result.Track.ISRC,
"provider_id": result.Track.ProviderID,
}
}
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,
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"item_type": track.ItemType,
"album_type": track.AlbumType,
}
}
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,
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"spotify_id": track.SpotifyID,
}
}
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 GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
return "", err
}
if !ext.Manifest.IsMetadataProvider() {
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
}
if !ext.Enabled {
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
}
provider := NewExtensionProviderWrapper(ext)
album, err := provider.GetAlbum(albumID)
if err != nil {
return "", err
}
if album == nil {
return "", fmt.Errorf("album not found")
}
tracks := make([]map[string]interface{}, len(album.Tracks))
for i, track := range album.Tracks {
trackCover := track.ResolvedCoverURL()
if trackCover == "" {
trackCover = album.CoverURL
}
trackNum := track.TrackNumber
if trackNum == 0 {
trackNum = i + 1
}
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,
"cover_url": trackCover,
"release_date": track.ReleaseDate,
"track_number": trackNum,
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"item_type": track.ItemType,
"album_type": track.AlbumType,
}
}
response := map[string]interface{}{
"id": album.ID,
"name": album.Name,
"artists": album.Artists,
"artist_id": album.ArtistID,
"cover_url": album.CoverURL,
"release_date": album.ReleaseDate,
"total_tracks": album.TotalTracks,
"album_type": album.AlbumType,
"tracks": tracks,
"provider_id": album.ProviderID,
}
jsonBytes, err := json.Marshal(response)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
return "", err
}
if !ext.Manifest.IsMetadataProvider() {
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
}
provider := NewExtensionProviderWrapper(ext)
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.getPlaylist === 'function') {
return extension.getPlaylist(%q);
}
if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') {
return extension.getAlbum(%q);
}
return null;
})()
`, playlistID, playlistID)
result, err := RunWithTimeoutAndRecover(provider.vm, script, DefaultJSTimeout)
if err != nil {
return "", fmt.Errorf("getPlaylist failed: %w", err)
}
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return "", fmt.Errorf("playlist not found")
}
exported := result.Export()
jsonBytes, err := json.Marshal(exported)
if err != nil {
return "", fmt.Errorf("failed to marshal result: %w", err)
}
var album ExtAlbumMetadata
if err := json.Unmarshal(jsonBytes, &album); err != nil {
return "", fmt.Errorf("failed to parse playlist: %w", err)
}
album.ProviderID = ext.ID
for i := range album.Tracks {
album.Tracks[i].ProviderID = ext.ID
}
tracks := make([]map[string]interface{}, len(album.Tracks))
for i, track := range album.Tracks {
trackCover := track.ResolvedCoverURL()
if trackCover == "" {
trackCover = album.CoverURL
}
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,
"cover_url": trackCover,
"release_date": track.ReleaseDate,
"track_number": track.TrackNumber,
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"item_type": track.ItemType,
"album_type": track.AlbumType,
}
}
response := map[string]interface{}{
"id": album.ID,
"name": album.Name,
"owner": album.Artists,
"cover_url": album.CoverURL,
"total_tracks": album.TotalTracks,
"tracks": tracks,
"provider_id": album.ProviderID,
}
jsonBytes, err = json.Marshal(response)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
return "", err
}
if !ext.Manifest.IsMetadataProvider() {
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
}
provider := NewExtensionProviderWrapper(ext)
artist, err := provider.GetArtist(artistID)
if err != nil {
return "", err
}
if artist == nil {
return "", fmt.Errorf("artist not found")
}
albums := make([]map[string]interface{}, len(artist.Albums))
for i, album := range artist.Albums {
albums[i] = map[string]interface{}{
"id": album.ID,
"name": album.Name,
"artists": album.Artists,
"cover_url": album.CoverURL,
"release_date": album.ReleaseDate,
"total_tracks": album.TotalTracks,
"album_type": album.AlbumType,
"provider_id": album.ProviderID,
}
}
response := map[string]interface{}{
"id": artist.ID,
"name": artist.Name,
"cover_url": artist.ImageURL,
"albums": albums,
"provider_id": artist.ProviderID,
}
if len(artist.Releases) > 0 {
releases := make([]map[string]interface{}, len(artist.Releases))
for i, release := range artist.Releases {
releaseType := release.AlbumType
if releaseType == "" {
releaseType = "album"
}
releases[i] = map[string]interface{}{
"id": release.ID,
"name": release.Name,
"artists": release.Artists,
"cover_url": release.CoverURL,
"release_date": release.ReleaseDate,
"total_tracks": release.TotalTracks,
"album_type": releaseType,
"provider_id": release.ProviderID,
}
}
response["releases"] = releases
}
if artist.HeaderImage != "" {
response["header_image"] = artist.HeaderImage
}
if artist.Listeners > 0 {
response["listeners"] = artist.Listeners
}
if len(artist.TopTracks) > 0 {
topTracks := make([]map[string]interface{}, len(artist.TopTracks))
for i, track := range 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,
"disc_number": track.DiscNumber,
"isrc": track.ISRC,
"provider_id": track.ProviderID,
"spotify_id": track.SpotifyID,
}
}
response["top_tracks"] = topTracks
}
jsonBytes, err := json.Marshal(response)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
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) {
manager := GetExtensionManager()
ext, err := manager.GetExtension(extensionID)
if err != nil {
return "", err
}
if !ext.Enabled {
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
}
vm, err := ext.lockReadyVM()
if err != nil {
return "", err
}
defer ext.VMMu.Unlock()
// 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)
result, err := RunWithTimeoutAndRecover(vm, script, timeout)
if err != nil {
return "", fmt.Errorf("%s failed: %w", functionName, err)
}
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return "", fmt.Errorf("%s returned null", functionName)
}
exported := result.Export()
jsonBytes, err := json.Marshal(exported)
if err != nil {
return "", fmt.Errorf("failed to marshal result: %w", err)
}
return string(jsonBytes), nil
}
func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second)
}
func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
}
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)
}