diff --git a/go_backend/extension_fallback.go b/go_backend/extension_fallback.go new file mode 100644 index 00000000..8e11df26 --- /dev/null +++ b/go_backend/extension_fallback.go @@ -0,0 +1,1177 @@ +package gobackend + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +func manifestCapabilityStringList(manifest *ExtensionManifest, key string) []string { + if manifest == nil || manifest.Capabilities == nil { + return nil + } + + raw, ok := manifest.Capabilities[key] + if !ok { + return nil + } + + values, ok := raw.([]interface{}) + if !ok { + return nil + } + + result := make([]string, 0, len(values)) + for _, value := range values { + str, ok := value.(string) + if !ok { + continue + } + trimmed := strings.ToLower(strings.TrimSpace(str)) + if trimmed == "" { + continue + } + result = append(result, trimmed) + } + return result +} + +func extensionReplacesBuiltInProvider(ext *loadedExtension, providerID string) bool { + if ext == nil { + return false + } + + normalized := strings.ToLower(strings.TrimSpace(providerID)) + if normalized == "" { + return false + } + + for _, replaced := range manifestCapabilityStringList(ext.Manifest, "replacesBuiltInProviders") { + if replaced == normalized { + return true + } + } + + return false +} + +func trimKnownProviderPrefix(trackID, providerID string) string { + trimmedID := strings.TrimSpace(trackID) + normalizedProvider := strings.ToLower(strings.TrimSpace(providerID)) + if trimmedID == "" || normalizedProvider == "" { + return trimmedID + } + + prefix := normalizedProvider + ":" + if strings.HasPrefix(strings.ToLower(trimmedID), prefix) { + return trimmedID[len(prefix):] + } + + return trimmedID +} + +func resolvePreferredTrackIDForExtension(ext *loadedExtension, req DownloadRequest, explicitTrackID string) string { + candidates := make([]string, 0, 8) + appendCandidate := func(value string) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return + } + for _, existing := range candidates { + if existing == trimmed { + return + } + } + candidates = append(candidates, trimmed) + } + + appendCandidate(explicitTrackID) + + if extensionReplacesBuiltInProvider(ext, "tidal") { + appendCandidate(req.TidalID) + appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "tidal")) + } + if extensionReplacesBuiltInProvider(ext, "qobuz") { + appendCandidate(req.QobuzID) + appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "qobuz")) + } + if extensionReplacesBuiltInProvider(ext, "deezer") { + appendCandidate(req.DeezerID) + appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "deezer")) + } + if extensionReplacesBuiltInProvider(ext, "spotify") { + appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "spotify")) + appendCandidate(req.SpotifyID) + } + + appendCandidate(req.SpotifyID) + appendCandidate(req.TidalID) + appendCandidate(req.QobuzID) + appendCandidate(req.DeezerID) + + if len(candidates) == 0 { + return "" + } + return candidates[0] +} + +func normalizeDownloadResultExtension(candidates ...string) string { + for _, candidate := range candidates { + ext := strings.TrimSpace(strings.ToLower(candidate)) + if ext == "" { + continue + } + if !strings.HasPrefix(ext, ".") { + ext = "." + ext + } + if ext == ".mp4" { + return ".m4a" + } + return ext + } + return "" +} + +func normalizeExtensionDownloadResult(result *ExtDownloadResult) (DownloadResult, bool) { + if result == nil { + return DownloadResult{}, false + } + + downloadResult := DownloadResult{ + FilePath: strings.TrimSpace(result.FilePath), + BitDepth: result.BitDepth, + SampleRate: result.SampleRate, + AudioCodec: strings.TrimSpace(result.AudioCodec), + Title: result.Title, + Artist: result.Artist, + Album: result.Album, + ReleaseDate: result.ReleaseDate, + TrackNumber: result.TrackNumber, + TotalTracks: result.TotalTracks, + DiscNumber: result.DiscNumber, + TotalDiscs: result.TotalDiscs, + ISRC: result.ISRC, + CoverURL: result.CoverURL, + Genre: result.Genre, + Label: result.Label, + Copyright: result.Copyright, + Composer: result.Composer, + LyricsLRC: result.LyricsLRC, + DecryptionKey: result.DecryptionKey, + Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey), + ActualExtension: normalizeDownloadResultExtension(result.ActualExtension, result.OutputExtension), + ActualContainer: strings.TrimSpace(result.ActualContainer), + RequiresContainerConversion: result.RequiresContainerConversion, + } + + alreadyExists := result.AlreadyExists + if strings.HasPrefix(downloadResult.FilePath, "EXISTS:") { + alreadyExists = true + downloadResult.FilePath = strings.TrimPrefix(downloadResult.FilePath, "EXISTS:") + } + + enrichResultQualityFromFile(&downloadResult) + return downloadResult, alreadyExists +} + +func overlayExtensionDownloadMetadata(resp *DownloadResponse, result *ExtDownloadResult) { + if resp == nil || result == nil { + return + } + + if strings.TrimSpace(resp.Title) == "" && result.Title != "" { + resp.Title = result.Title + } + if strings.TrimSpace(resp.Artist) == "" && result.Artist != "" { + resp.Artist = result.Artist + } + if strings.TrimSpace(resp.Album) == "" && result.Album != "" { + resp.Album = result.Album + } + if strings.TrimSpace(resp.AlbumArtist) == "" && result.AlbumArtist != "" { + resp.AlbumArtist = result.AlbumArtist + } + if resp.TrackNumber == 0 && result.TrackNumber > 0 { + resp.TrackNumber = result.TrackNumber + } + if resp.DiscNumber == 0 && result.DiscNumber > 0 { + resp.DiscNumber = result.DiscNumber + } + if resp.TotalTracks == 0 && result.TotalTracks > 0 { + resp.TotalTracks = result.TotalTracks + } + if resp.TotalDiscs == 0 && result.TotalDiscs > 0 { + resp.TotalDiscs = result.TotalDiscs + } + if strings.TrimSpace(resp.ReleaseDate) == "" && result.ReleaseDate != "" { + resp.ReleaseDate = result.ReleaseDate + } + if strings.TrimSpace(resp.CoverURL) == "" && result.CoverURL != "" { + resp.CoverURL = result.CoverURL + } + if strings.TrimSpace(resp.ISRC) == "" && result.ISRC != "" { + resp.ISRC = result.ISRC + } + if strings.TrimSpace(resp.Genre) == "" && result.Genre != "" { + resp.Genre = result.Genre + } + if strings.TrimSpace(resp.Label) == "" && result.Label != "" { + resp.Label = result.Label + } + if strings.TrimSpace(resp.Copyright) == "" && result.Copyright != "" { + resp.Copyright = result.Copyright + } + if strings.TrimSpace(resp.Composer) == "" && result.Composer != "" { + resp.Composer = result.Composer + } + if result.LyricsLRC != "" { + resp.LyricsLRC = result.LyricsLRC + } + if result.DecryptionKey != "" { + resp.DecryptionKey = result.DecryptionKey + } + if normalized := normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey); normalized != nil { + resp.Decryption = normalized + } + if ext := normalizeDownloadResultExtension(result.ActualExtension, result.OutputExtension); ext != "" { + resp.ActualExtension = ext + } + if container := strings.TrimSpace(result.ActualContainer); container != "" { + resp.ActualContainer = container + } + if result.RequiresContainerConversion { + resp.RequiresContainerConversion = true + } +} + +func applyExtensionRequestFallbacks(resp *DownloadResponse, req DownloadRequest) { + if resp == nil { + return + } + + if req.AlbumName != "" && resp.Album == "" { + resp.Album = req.AlbumName + } + if req.AlbumArtist != "" && resp.AlbumArtist == "" { + resp.AlbumArtist = req.AlbumArtist + } + if req.ReleaseDate != "" && resp.ReleaseDate == "" { + resp.ReleaseDate = req.ReleaseDate + } + if req.ISRC != "" && resp.ISRC == "" { + resp.ISRC = req.ISRC + } + if req.TrackNumber > 0 && resp.TrackNumber == 0 { + resp.TrackNumber = req.TrackNumber + } + if req.TotalTracks > 0 && resp.TotalTracks == 0 { + resp.TotalTracks = req.TotalTracks + } + if req.DiscNumber > 0 && resp.DiscNumber == 0 { + resp.DiscNumber = req.DiscNumber + } + if req.TotalDiscs > 0 && resp.TotalDiscs == 0 { + resp.TotalDiscs = req.TotalDiscs + } + if req.CoverURL != "" && resp.CoverURL == "" { + resp.CoverURL = req.CoverURL + } +} + +func shouldStopProviderFallback(availability *ExtAvailabilityResult) bool { + return availability != nil && availability.SkipFallback +} + +func fallbackRuntimeHealthStatus(ext *loadedExtension) string { + if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 { + return "unknown" + } + + status := strings.ToLower(strings.TrimSpace(CheckExtensionHealthCached(ext).Status)) + switch status { + case "online", "degraded", "offline": + return status + default: + return "unknown" + } +} + +func prioritizeFallbackProvidersByHealth(priority []string, extManager *extensionManager, sourceProvider string) []string { + if len(priority) == 0 || extManager == nil { + return priority + } + + online := make([]string, 0, len(priority)) + degraded := make([]string, 0, len(priority)) + unknown := make([]string, 0, len(priority)) + + for _, rawProviderID := range priority { + providerID := strings.TrimSpace(rawProviderID) + if providerID == "" { + continue + } + if strings.EqualFold(providerID, sourceProvider) || !isExtensionFallbackAllowed(providerID) { + unknown = append(unknown, providerID) + continue + } + + ext, err := extManager.GetExtension(providerID) + if err != nil || ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil || !ext.Manifest.IsDownloadProvider() { + unknown = append(unknown, providerID) + continue + } + + switch fallbackRuntimeHealthStatus(ext) { + case "online": + online = append(online, providerID) + case "degraded": + degraded = append(degraded, providerID) + case "offline": + GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (service health offline)\n", providerID) + default: + unknown = append(unknown, providerID) + } + } + + result := make([]string, 0, len(online)+len(degraded)+len(unknown)) + result = append(result, online...) + result = append(result, degraded...) + result = append(result, unknown...) + return result +} + +func resolveExtensionAvailabilityReason(availability *ExtAvailabilityResult, err error) string { + if availability != nil { + if reason := strings.TrimSpace(availability.Reason); reason != "" { + return reason + } + } + if err != nil { + return err.Error() + } + return "extension requested no further fallback" +} + +func buildExtensionFallbackStoppedResponse(providerID string, availability *ExtAvailabilityResult, err error) *DownloadResponse { + reason := resolveExtensionAvailabilityReason(availability, err) + errorType := classifyDownloadErrorType(reason) + if errorType == "unknown" { + errorType = "extension_error" + } + return &DownloadResponse{ + Success: false, + Error: fmt.Sprintf("Fallback stopped by %s: %s", providerID, reason), + ErrorType: errorType, + Service: providerID, + } +} + +func shouldAbortCancelledFallback(itemID string, err error) bool { + if errors.Is(err, ErrDownloadCancelled) { + return true + } + return itemID != "" && isDownloadCancelled(itemID) +} + +func normalizeExtensionDownloadErrorType(errorType, message string) string { + normalized := strings.TrimSpace(errorType) + classified := classifyDownloadErrorType(message) + if classified != "" && classified != "unknown" { + switch strings.ToLower(normalized) { + case "", "unknown", "runtime_error", "api_error", "download_error", "extension_error": + return classified + } + } + return normalized +} + +func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) { + priority := GetProviderPriority() + extManager := getExtensionManager() + strictMode := !req.UseFallback + selectedProvider := strings.TrimSpace(req.Service) + + if isDownloadCancelled(req.ItemID) { + return nil, ErrDownloadCancelled + } + + if strictMode { + if selectedProvider == "" { + selectedProvider = strings.TrimSpace(req.Source) + } + if selectedProvider != "" { + priority = []string{selectedProvider} + GoLog("[DownloadWithExtensionFallback] Strict mode enabled, provider locked to: %s\n", selectedProvider) + } + } + + if !strictMode && req.Service != "" { + found := false + for _, p := range priority { + if strings.EqualFold(p, req.Service) { + found = true + break + } + } + newPriority := []string{req.Service} + for _, p := range priority { + if !strings.EqualFold(p, req.Service) { + newPriority = append(newPriority, p) + } + } + priority = newPriority + if !found { + GoLog("[DownloadWithExtensionFallback] Extension service '%s' added to priority front\n", req.Service) + } else { + GoLog("[DownloadWithExtensionFallback] Extension service '%s' moved to priority front\n", req.Service) + } + GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority) + } + + var lastErr error + var lastErrType string + var lastRetryAfterSeconds int + var stopProviderFallback bool + var sourceExtensionLocked bool + var sourceExtensionAvailability *ExtAvailabilityResult + var sourceExtensionTrackID string + + if req.Source != "" && selectedProvider != req.Source { + ext, err := extManager.GetExtension(req.Source) + if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() { + provider := newExtensionProviderWrapper(ext) + availability, availErr := provider.CheckAvailabilityForItemID(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID, req.TidalID, req.QobuzID, req.DurationMS, req.ItemID) + if shouldAbortCancelledFallback(req.ItemID, availErr) { + return nil, ErrDownloadCancelled + } + if availErr != nil { + GoLog("[DownloadWithExtensionFallback] Source extension %s preflight failed (non-fatal): %v\n", req.Source, availErr) + } else if shouldStopProviderFallback(availability) { + sourceExtensionLocked = true + sourceExtensionAvailability = availability + sourceExtensionTrackID = strings.TrimSpace(availability.TrackID) + selectedProvider = req.Source + GoLog("[DownloadWithExtensionFallback] Source extension %s requested skip_fallback (available=%v), locking download to source extension\n", req.Source, availability.Available) + } + } + } + + if req.Source != "" { + ext, err := extManager.GetExtension(req.Source) + if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() { + GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source) + + provider := newExtensionProviderWrapper(ext) + trackMeta := &ExtTrackMetadata{ + ID: req.SpotifyID, + Name: req.TrackName, + Artists: req.ArtistName, + AlbumName: req.AlbumName, + DurationMS: req.DurationMS, + ISRC: req.ISRC, + ReleaseDate: req.ReleaseDate, + TrackNumber: req.TrackNumber, + TotalTracks: req.TotalTracks, + DiscNumber: req.DiscNumber, + TotalDiscs: req.TotalDiscs, + ProviderID: req.Source, + Composer: req.Composer, + } + + enrichedTrack, err := provider.EnrichTrackForItemID(trackMeta, req.ItemID) + if shouldAbortCancelledFallback(req.ItemID, err) { + return nil, ErrDownloadCancelled + } + if err == nil && enrichedTrack != nil { + if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC { + GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC) + req.ISRC = enrichedTrack.ISRC + } + if enrichedTrack.TidalID != "" { + GoLog("[DownloadWithExtensionFallback] Tidal ID from Odesli: %s\n", enrichedTrack.TidalID) + req.TidalID = enrichedTrack.TidalID + } + if enrichedTrack.QobuzID != "" { + GoLog("[DownloadWithExtensionFallback] Qobuz ID from Odesli: %s\n", enrichedTrack.QobuzID) + req.QobuzID = enrichedTrack.QobuzID + } + if enrichedTrack.DeezerID != "" { + GoLog("[DownloadWithExtensionFallback] Deezer ID from Odesli: %s\n", enrichedTrack.DeezerID) + req.DeezerID = enrichedTrack.DeezerID + } + if enrichedTrack.Name != "" { + req.TrackName = enrichedTrack.Name + } + if enrichedTrack.Artists != "" { + req.ArtistName = enrichedTrack.Artists + } + if enrichedTrack.AlbumName != "" && req.AlbumName == "" { + GoLog("[DownloadWithExtensionFallback] AlbumName from enrichment: %s\n", enrichedTrack.AlbumName) + req.AlbumName = enrichedTrack.AlbumName + } + if enrichedTrack.AlbumArtist != "" && req.AlbumArtist == "" { + req.AlbumArtist = enrichedTrack.AlbumArtist + } + if enrichedTrack.DurationMS > 0 && req.DurationMS == 0 { + GoLog("[DownloadWithExtensionFallback] DurationMS from enrichment: %d\n", enrichedTrack.DurationMS) + req.DurationMS = enrichedTrack.DurationMS + } + if enrichedTrack.CoverURL != "" && req.CoverURL == "" { + req.CoverURL = enrichedTrack.CoverURL + } + if enrichedTrack.ID != "" && req.SpotifyID == "" { + GoLog("[DownloadWithExtensionFallback] Track ID from enrichment: %s\n", enrichedTrack.ID) + req.SpotifyID = enrichedTrack.ID + } + if enrichedTrack.Label != "" && req.Label == "" { + GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label) + req.Label = enrichedTrack.Label + } + if enrichedTrack.Copyright != "" && req.Copyright == "" { + GoLog("[DownloadWithExtensionFallback] Copyright from enrichment: %s\n", enrichedTrack.Copyright) + req.Copyright = enrichedTrack.Copyright + } + if enrichedTrack.Genre != "" && req.Genre == "" { + GoLog("[DownloadWithExtensionFallback] Genre from enrichment: %s\n", enrichedTrack.Genre) + req.Genre = enrichedTrack.Genre + } + if enrichedTrack.ReleaseDate != "" && req.ReleaseDate == "" { + GoLog("[DownloadWithExtensionFallback] ReleaseDate from enrichment: %s\n", enrichedTrack.ReleaseDate) + req.ReleaseDate = enrichedTrack.ReleaseDate + } + if enrichedTrack.TrackNumber > 0 && req.TrackNumber == 0 { + GoLog("[DownloadWithExtensionFallback] TrackNumber from enrichment: %d\n", enrichedTrack.TrackNumber) + req.TrackNumber = enrichedTrack.TrackNumber + } + if enrichedTrack.TotalTracks > 0 && req.TotalTracks == 0 { + GoLog("[DownloadWithExtensionFallback] TotalTracks from enrichment: %d\n", enrichedTrack.TotalTracks) + req.TotalTracks = enrichedTrack.TotalTracks + } + if enrichedTrack.DiscNumber > 0 && req.DiscNumber == 0 { + GoLog("[DownloadWithExtensionFallback] DiscNumber from enrichment: %d\n", enrichedTrack.DiscNumber) + req.DiscNumber = enrichedTrack.DiscNumber + } + if enrichedTrack.TotalDiscs > 0 && req.TotalDiscs == 0 { + GoLog("[DownloadWithExtensionFallback] TotalDiscs from enrichment: %d\n", enrichedTrack.TotalDiscs) + req.TotalDiscs = enrichedTrack.TotalDiscs + } + if enrichedTrack.Composer != "" && req.Composer == "" { + GoLog("[DownloadWithExtensionFallback] Composer from enrichment: %s\n", enrichedTrack.Composer) + req.Composer = enrichedTrack.Composer + } + } + } + } + + if req.Source != "" && + req.TrackName != "" && req.ArtistName != "" && + (req.AlbumName == "" || req.ReleaseDate == "" || req.ISRC == "") { + + searchQuery := req.TrackName + " " + req.ArtistName + GoLog("[DownloadWithExtensionFallback] Metadata incomplete, searching providers for: %s\n", searchQuery) + + tracks, searchErr := extManager.SearchTracksWithMetadataProvidersForItemID(searchQuery, 5, true, req.ItemID) + if shouldAbortCancelledFallback(req.ItemID, searchErr) { + return nil, ErrDownloadCancelled + } + if searchErr == nil && len(tracks) > 0 { + track := tracks[0] + GoLog("[DownloadWithExtensionFallback] Metadata match (%s): %s - %s (album: %s, date: %s, isrc: %s)\n", + track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate, track.ISRC) + + if track.AlbumName != "" && req.AlbumName == "" { + req.AlbumName = track.AlbumName + } + if track.AlbumArtist != "" && req.AlbumArtist == "" { + req.AlbumArtist = track.AlbumArtist + } + if track.ReleaseDate != "" && req.ReleaseDate == "" { + req.ReleaseDate = track.ReleaseDate + } + if track.ISRC != "" && req.ISRC == "" { + req.ISRC = track.ISRC + } + if track.TrackNumber > 0 && req.TrackNumber == 0 { + req.TrackNumber = track.TrackNumber + } + if track.TotalTracks > 0 && req.TotalTracks == 0 { + req.TotalTracks = track.TotalTracks + } + if track.DiscNumber > 0 && req.DiscNumber == 0 { + req.DiscNumber = track.DiscNumber + } + if track.TotalDiscs > 0 && req.TotalDiscs == 0 { + req.TotalDiscs = track.TotalDiscs + } + if track.Composer != "" && req.Composer == "" { + req.Composer = track.Composer + } + if track.CoverURL != "" && req.CoverURL == "" { + req.CoverURL = track.CoverURL + } + if track.Genre != "" && req.Genre == "" { + req.Genre = track.Genre + } + if track.Label != "" && req.Label == "" { + req.Label = track.Label + } + if track.Copyright != "" && req.Copyright == "" { + req.Copyright = track.Copyright + } + } else if searchErr != nil { + GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr) + } + + if req.ISRC != "" && + (req.Genre == "" || req.Label == "" || req.Copyright == "") { + enrichExtraMetadataByISRC("DownloadWithExtensionFallback", req.ISRC, &req.Genre, &req.Label, &req.Copyright) + if isDownloadCancelled(req.ItemID) { + return nil, ErrDownloadCancelled + } + } + } + + if req.Source != "" && selectedProvider == req.Source { + if isDownloadCancelled(req.ItemID) { + return nil, ErrDownloadCancelled + } + + if sourceExtensionLocked && (sourceExtensionAvailability == nil || !sourceExtensionAvailability.Available) { + GoLog("[DownloadWithExtensionFallback] Source extension %s stopped fallback before download (reason: %s)\n", req.Source, resolveExtensionAvailabilityReason(sourceExtensionAvailability, nil)) + return buildExtensionFallbackStoppedResponse(req.Source, sourceExtensionAvailability, nil), nil + } + + GoLog("[DownloadWithExtensionFallback] Track source is extension '%s' matching selected provider, trying it first\n", req.Source) + + ext, err := extManager.GetExtension(req.Source) + if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() { + stopProviderFallback = ext.Manifest.StopsProviderFallback() + + provider := newExtensionProviderWrapper(ext) + + trackID := resolvePreferredTrackIDForExtension(ext, req, sourceExtensionTrackID) + + GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (stopProviderFallback: %v)\n", trackID, stopProviderFallback) + + outputPath := buildOutputPathForExtension(req, ext) + if req.ItemID != "" { + StartItemProgress(req.ItemID) + } + + result, err := provider.Download(trackID, req.Quality, outputPath, req.ItemID, func(percent int) { + if req.ItemID != "" { + normalized := float64(percent) / 100.0 + if normalized < 0 { + normalized = 0 + } + if normalized > 1 { + normalized = 1 + } + SetItemProgress(req.ItemID, normalized, 0, 0) + } + }) + if req.ItemID != "" { + if err == nil && result != nil && result.Success { + CompleteItemProgress(req.ItemID) + } else { + RemoveItemProgress(req.ItemID) + } + } + if shouldAbortCancelledFallback(req.ItemID, err) { + return nil, ErrDownloadCancelled + } + + if err == nil && result.Success { + normalizedResult, alreadyExists := normalizeExtensionDownloadResult(result) + message := "Downloaded from " + req.Source + if alreadyExists { + message = "File already exists" + } + + resp := buildDownloadSuccessResponse( + req, + normalizedResult, + req.Source, + message, + normalizedResult.FilePath, + alreadyExists, + ) + overlayExtensionDownloadMetadata(&resp, result) + if ext.Manifest.SkipMetadataEnrichment { + resp.SkipMetadataEnrichment = true + } + applyExtensionRequestFallbacks(&resp, req) + + if req.TrackName != "" && resp.Title == "" { + resp.Title = req.TrackName + } + if req.ArtistName != "" && resp.Artist == "" { + resp.Artist = req.ArtistName + } + if req.Composer != "" && resp.Composer == "" { + resp.Composer = req.Composer + } + + embedExtensionDownloadMetadata(resp, req, alreadyExists) + + if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" { + indexISRC := strings.TrimSpace(resp.ISRC) + if indexISRC == "" { + indexISRC = strings.TrimSpace(req.ISRC) + } + if indexISRC != "" && strings.TrimSpace(resp.FilePath) != "" { + AddToISRCIndex(req.OutputDir, indexISRC, resp.FilePath) + } + } + + return &resp, nil + } + + if err != nil { + if errors.Is(err, ErrDownloadCancelled) { + return &DownloadResponse{ + Success: false, + Error: "Download cancelled", + ErrorType: "cancelled", + Service: req.Source, + }, nil + } + lastErr = err + lastErrType = "" + } else if result.ErrorMessage != "" { + lastErr = fmt.Errorf("%s", result.ErrorMessage) + lastErrType = normalizeExtensionDownloadErrorType(result.ErrorType, result.ErrorMessage) + lastRetryAfterSeconds = result.RetryAfterSeconds + } + GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr) + + if strings.EqualFold(lastErrType, "verification_required") { + GoLog("[DownloadWithExtensionFallback] Source extension %s requires verification, not trying other providers\n", req.Source) + return &DownloadResponse{ + Success: false, + Error: "Download failed: " + lastErr.Error(), + ErrorType: "verification_required", + Service: req.Source, + }, nil + } + + if stopProviderFallback || sourceExtensionLocked { + if sourceExtensionLocked { + GoLog("[DownloadWithExtensionFallback] Source extension %s requested skip_fallback, not trying other providers\n", req.Source) + return buildExtensionFallbackStoppedResponse(req.Source, sourceExtensionAvailability, lastErr), nil + } + GoLog("[DownloadWithExtensionFallback] stopProviderFallback is true, not trying other providers\n") + return &DownloadResponse{ + Success: false, + Error: "Download failed: " + lastErr.Error(), + ErrorType: firstNonEmptyString(lastErrType, "extension_error"), + RetryAfterSeconds: lastRetryAfterSeconds, + Service: req.Source, + }, nil + } + } else { + GoLog("[DownloadWithExtensionFallback] Source extension %s not available or not a download provider\n", req.Source) + } + } + + priority = prioritizeFallbackProvidersByHealth(priority, extManager, req.Source) + + for _, providerID := range priority { + if isDownloadCancelled(req.ItemID) { + return nil, ErrDownloadCancelled + } + + providerID = strings.TrimSpace(providerID) + if providerID == "" { + continue + } + // Skip the origin extension only when it differs from the explicitly + // selected provider; otherwise it must still be attempted here. + if providerID == req.Source && req.Source != selectedProvider { + continue + } + + if providerID != selectedProvider && !isExtensionFallbackAllowed(providerID) { + GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (not enabled for fallback)\n", providerID) + continue + } + + GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID) + + { + ext, err := extManager.GetExtension(providerID) + if err != nil || !ext.Enabled || ext.Error != "" { + GoLog("[DownloadWithExtensionFallback] Extension %s not available\n", providerID) + continue + } + + if !ext.Manifest.IsDownloadProvider() { + continue + } + + provider := newExtensionProviderWrapper(ext) + + availability, err := provider.CheckAvailabilityForItemID(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID, req.TidalID, req.QobuzID, req.DurationMS, req.ItemID) + if shouldAbortCancelledFallback(req.ItemID, err) { + return nil, ErrDownloadCancelled + } + terminalAvailability := shouldStopProviderFallback(availability) + if err != nil || !availability.Available { + GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID) + if err != nil { + lastErr = err + if strings.EqualFold(classifyDownloadErrorType(err.Error()), "verification_required") { + GoLog("[DownloadWithExtensionFallback] %s requires verification (availability); pausing fallback to open the challenge\n", providerID) + return &DownloadResponse{ + Success: false, + Error: "Download failed: " + err.Error(), + ErrorType: "verification_required", + Service: providerID, + }, nil + } + } + if terminalAvailability { + GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after availability check\n", providerID) + return buildExtensionFallbackStoppedResponse(providerID, availability, err), nil + } + continue + } + + req.OutputExt = "" + outputPath := buildOutputPathForExtension(req, ext) + if req.ItemID != "" { + StartItemProgress(req.ItemID) + } + + // Honor the requested quality when this provider recognizes it + // (e.g. an explicit user selection). Only when the token is not + // one of this provider's own options do we fall back to its + // highest quality, since a source provider's token may not map. + fallbackQuality := req.Quality + if len(ext.Manifest.QualityOptions) > 0 { + requested := strings.TrimSpace(req.Quality) + recognized := false + if requested != "" { + for _, opt := range ext.Manifest.QualityOptions { + if strings.EqualFold(strings.TrimSpace(opt.ID), requested) { + recognized = true + break + } + } + } + if !recognized { + if best := strings.TrimSpace(ext.Manifest.QualityOptions[0].ID); best != "" { + fallbackQuality = best + } + } + } + + result, err := provider.Download(availability.TrackID, fallbackQuality, outputPath, req.ItemID, func(percent int) { + if req.ItemID != "" { + normalized := float64(percent) / 100.0 + if normalized < 0 { + normalized = 0 + } + if normalized > 1 { + normalized = 1 + } + SetItemProgress(req.ItemID, normalized, 0, 0) + } + }) + if req.ItemID != "" { + if err == nil && result != nil && result.Success { + CompleteItemProgress(req.ItemID) + } else { + RemoveItemProgress(req.ItemID) + } + } + if shouldAbortCancelledFallback(req.ItemID, err) { + return nil, ErrDownloadCancelled + } + + if err == nil && result.Success { + normalizedResult, alreadyExists := normalizeExtensionDownloadResult(result) + message := "Downloaded from " + providerID + if alreadyExists { + message = "File already exists" + } + + resp := buildDownloadSuccessResponse( + req, + normalizedResult, + providerID, + message, + normalizedResult.FilePath, + alreadyExists, + ) + overlayExtensionDownloadMetadata(&resp, result) + if ext.Manifest.SkipMetadataEnrichment { + resp.SkipMetadataEnrichment = true + } + applyExtensionRequestFallbacks(&resp, req) + + embedExtensionDownloadMetadata(resp, req, alreadyExists) + + if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" { + indexISRC := strings.TrimSpace(resp.ISRC) + if indexISRC == "" { + indexISRC = strings.TrimSpace(req.ISRC) + } + if indexISRC != "" && strings.TrimSpace(resp.FilePath) != "" { + AddToISRCIndex(req.OutputDir, indexISRC, resp.FilePath) + } + } + + return &resp, nil + } + + if err != nil { + if errors.Is(err, ErrDownloadCancelled) { + return &DownloadResponse{ + Success: false, + Error: "Download cancelled", + ErrorType: "cancelled", + Service: providerID, + }, nil + } + lastErr = err + lastErrType = "" + } else if result.ErrorMessage != "" { + lastErr = fmt.Errorf("%s", result.ErrorMessage) + lastErrType = normalizeExtensionDownloadErrorType(result.ErrorType, result.ErrorMessage) + lastRetryAfterSeconds = result.RetryAfterSeconds + } + GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, lastErr) + + if lastErr != nil { + effType := lastErrType + if effType == "" { + effType = classifyDownloadErrorType(lastErr.Error()) + } + if strings.EqualFold(effType, "verification_required") { + GoLog("[DownloadWithExtensionFallback] %s requires verification; pausing fallback to open the challenge\n", providerID) + return &DownloadResponse{ + Success: false, + Error: "Download failed: " + lastErr.Error(), + ErrorType: "verification_required", + RetryAfterSeconds: lastRetryAfterSeconds, + Service: providerID, + }, nil + } + } + + if terminalAvailability { + GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after download failure\n", providerID) + return buildExtensionFallbackStoppedResponse(providerID, availability, lastErr), nil + } + } + } + + if lastErr != nil { + errorType := firstNonEmptyString(lastErrType, classifyDownloadErrorType(lastErr.Error())) + if errorType == "unknown" { + errorType = "not_found" + } + return &DownloadResponse{ + Success: false, + Error: "All providers failed. Last error: " + lastErr.Error(), + ErrorType: errorType, + RetryAfterSeconds: lastRetryAfterSeconds, + }, nil + } + + return &DownloadResponse{ + Success: false, + Error: "No extension download providers available", + ErrorType: "not_found", + }, nil +} + +func buildOutputPath(req DownloadRequest) string { + if strings.TrimSpace(req.OutputPath) != "" { + return strings.TrimSpace(req.OutputPath) + } + + metadata := map[string]interface{}{ + "title": req.TrackName, + "artist": req.ArtistName, + "album": req.AlbumName, + "album_artist": req.AlbumArtist, + "track": req.TrackNumber, + "track_number": req.TrackNumber, + "total_tracks": req.TotalTracks, + "playlist_position": req.PlaylistPosition, + "disc": req.DiscNumber, + "disc_number": req.DiscNumber, + "total_discs": req.TotalDiscs, + "year": extractYear(req.ReleaseDate), + "date": req.ReleaseDate, + "release_date": req.ReleaseDate, + "isrc": req.ISRC, + "composer": req.Composer, + } + + filename := buildFilenameFromTemplate(req.FilenameFormat, metadata) + if strings.TrimSpace(filename) == "" { + filename = fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName) + } + filename = sanitizeFilename(filename) + + ext := strings.TrimSpace(req.OutputExt) + if ext == "" { + ext = ".flac" + } else if !strings.HasPrefix(ext, ".") { + ext = "." + ext + } + + outputDir := req.OutputDir + if strings.TrimSpace(outputDir) == "" { + outputDir = filepath.Join(os.TempDir(), "spotiflac-downloads") + } + os.MkdirAll(outputDir, 0755) + AddAllowedDownloadDir(outputDir) + + return filepath.Join(outputDir, filename+ext) +} + +func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) string { + if strings.TrimSpace(req.OutputPath) != "" { + outputPath := strings.TrimSpace(req.OutputPath) + AddAllowedDownloadDir(filepath.Dir(outputPath)) + return outputPath + } + + // SAF downloads hand extensions a detached output FD owned by the host. + // Extensions still need a real local temp file so Android can copy it into + // the target document after provider-specific post-processing completes. + if !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" { + return buildOutputPath(req) + } + + tempDir := filepath.Join(ext.DataDir, "downloads") + os.MkdirAll(tempDir, 0755) + AddAllowedDownloadDir(tempDir) + + metadata := map[string]interface{}{ + "title": req.TrackName, + "artist": req.ArtistName, + "album": req.AlbumName, + "album_artist": req.AlbumArtist, + "track": req.TrackNumber, + "track_number": req.TrackNumber, + "total_tracks": req.TotalTracks, + "playlist_position": req.PlaylistPosition, + "disc": req.DiscNumber, + "disc_number": req.DiscNumber, + "total_discs": req.TotalDiscs, + "year": extractYear(req.ReleaseDate), + "date": req.ReleaseDate, + "release_date": req.ReleaseDate, + "isrc": req.ISRC, + "composer": req.Composer, + } + + filename := buildFilenameFromTemplate(req.FilenameFormat, metadata) + if strings.TrimSpace(filename) == "" { + filename = fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName) + } + filename = sanitizeFilename(filename) + + outputExt := strings.TrimSpace(req.OutputExt) + if outputExt == "" { + outputExt = ".flac" + } else if !strings.HasPrefix(outputExt, ".") { + outputExt = "." + outputExt + } + + return filepath.Join(tempDir, filename+outputExt) +} + +func canEmbedGenreLabel(filePath string) bool { + path := strings.TrimSpace(filePath) + if path == "" || strings.HasPrefix(path, "content://") || strings.HasPrefix(path, "/proc/self/fd/") { + return false + } + if strings.ToLower(filepath.Ext(path)) != ".flac" { + return false + } + if !filepath.IsAbs(path) { + return false + } + info, err := os.Stat(path) + return err == nil && !info.IsDir() && info.Size() > 0 +} + +func embedExtensionDownloadMetadata(resp DownloadResponse, req DownloadRequest, alreadyExists bool) { + if alreadyExists || !req.EmbedMetadata { + return + } + + filePath := strings.TrimSpace(resp.FilePath) + if !canEmbedGenreLabel(filePath) { + if req.Genre != "" || req.Label != "" || resp.CoverURL != "" || req.CoverURL != "" { + GoLog("[DownloadWithExtensionFallback] Skipping metadata/cover embed for non-local FLAC output path: %q\n", filePath) + } + return + } + + coverURL := firstNonEmptyTrimmed(resp.CoverURL, req.CoverURL) + var coverData []byte + if coverURL != "" { + data, err := downloadCoverToMemory(coverURL, req.EmbedMaxQualityCover) + if err != nil { + GoLog("[DownloadWithExtensionFallback] Warning: failed to download cover for metadata embed: %v\n", err) + } else if len(data) > 0 { + coverData = data + } + } + + metadata := Metadata{ + Title: firstNonEmptyTrimmed(resp.Title, req.TrackName), + Artist: firstNonEmptyTrimmed(resp.Artist, req.ArtistName), + Album: firstNonEmptyTrimmed(resp.Album, req.AlbumName), + AlbumArtist: firstNonEmptyTrimmed(resp.AlbumArtist, req.AlbumArtist), + ArtistTagMode: req.ArtistTagMode, + Date: firstNonEmptyTrimmed(resp.ReleaseDate, req.ReleaseDate), + TrackNumber: firstPositiveInt(resp.TrackNumber, req.TrackNumber), + TotalTracks: firstPositiveInt(resp.TotalTracks, req.TotalTracks), + DiscNumber: firstPositiveInt(resp.DiscNumber, req.DiscNumber), + TotalDiscs: firstPositiveInt(resp.TotalDiscs, req.TotalDiscs), + ISRC: firstNonEmptyTrimmed(resp.ISRC, req.ISRC), + Genre: firstNonEmptyTrimmed(resp.Genre, req.Genre), + Label: firstNonEmptyTrimmed(resp.Label, req.Label), + Copyright: firstNonEmptyTrimmed(resp.Copyright, req.Copyright), + Composer: firstNonEmptyTrimmed(resp.Composer, req.Composer), + } + if req.EmbedLyrics { + metadata.Lyrics = resp.LyricsLRC + } + + var err error + if len(coverData) > 0 { + err = EmbedMetadataWithCoverData(filePath, metadata, coverData) + } else { + err = EmbedMetadata(filePath, metadata, "") + } + if err != nil { + GoLog("[DownloadWithExtensionFallback] Warning: failed to embed metadata/cover: %v\n", err) + return + } + + if len(coverData) > 0 { + GoLog("[DownloadWithExtensionFallback] Embedded metadata and cover from %q\n", coverURL) + } else { + GoLog("[DownloadWithExtensionFallback] Embedded metadata without cover\n") + } +} + +func firstPositiveInt(values ...int) int { + for _, value := range values { + if value > 0 { + return value + } + } + return 0 +} diff --git a/go_backend/extension_goja_convert.go b/go_backend/extension_goja_convert.go new file mode 100644 index 00000000..1b66f5d9 --- /dev/null +++ b/go_backend/extension_goja_convert.go @@ -0,0 +1,678 @@ +package gobackend + +import ( + "fmt" + "strconv" + "strings" + + "github.com/dop251/goja" +) + +func gojaValueIsEmpty(value goja.Value) bool { + return value == nil || goja.IsUndefined(value) || goja.IsNull(value) +} + +func gojaObjectString(obj *goja.Object, keys ...string) string { + for _, key := range keys { + value := obj.Get(key) + if gojaValueIsEmpty(value) { + continue + } + if str, ok := value.Export().(string); ok { + return str + } + } + return "" +} + +func gojaObjectValue(obj *goja.Object, keys ...string) goja.Value { + for _, key := range keys { + value := obj.Get(key) + if !gojaValueIsEmpty(value) { + return value + } + } + return nil +} + +func gojaObjectInt(obj *goja.Object, keys ...string) int { + for _, key := range keys { + value := obj.Get(key) + if gojaValueIsEmpty(value) { + continue + } + return int(value.ToInteger()) + } + return 0 +} + +func gojaObjectInt64(obj *goja.Object, keys ...string) int64 { + for _, key := range keys { + value := obj.Get(key) + if gojaValueIsEmpty(value) { + continue + } + return value.ToInteger() + } + return 0 +} + +func gojaObjectFloat(obj *goja.Object, keys ...string) float64 { + for _, key := range keys { + value := obj.Get(key) + if gojaValueIsEmpty(value) { + continue + } + return value.ToFloat() + } + return 0 +} + +func gojaObjectBool(obj *goja.Object, keys ...string) bool { + for _, key := range keys { + value := obj.Get(key) + if gojaValueIsEmpty(value) { + continue + } + return value.ToBoolean() + } + return false +} + +func gojaObjectInterfaceMap(obj *goja.Object, keys ...string) map[string]interface{} { + value := gojaObjectValue(obj, keys...) + if gojaValueIsEmpty(value) { + return nil + } + + exported, ok := value.Export().(map[string]interface{}) + if !ok || len(exported) == 0 { + return nil + } + return exported +} + +func gojaObjectStringMap(vm *goja.Runtime, obj *goja.Object, keys ...string) map[string]string { + value := gojaObjectValue(obj, keys...) + if gojaValueIsEmpty(value) { + return nil + } + + valueObj := value.ToObject(vm) + objectKeys := valueObj.Keys() + if len(objectKeys) == 0 { + return nil + } + + result := make(map[string]string, len(objectKeys)) + for _, childKey := range objectKeys { + childValue := valueObj.Get(childKey) + if gojaValueIsEmpty(childValue) { + continue + } + result[childKey] = childValue.String() + } + if len(result) == 0 { + return nil + } + return result +} + +func gojaObjectStringSlice(obj *goja.Object, keys ...string) []string { + value := gojaObjectValue(obj, keys...) + if gojaValueIsEmpty(value) { + return nil + } + exported, ok := value.Export().([]interface{}) + if !ok || len(exported) == 0 { + return nil + } + result := make([]string, 0, len(exported)) + for _, item := range exported { + str, ok := item.(string) + if !ok { + continue + } + str = strings.TrimSpace(str) + if str != "" { + result = append(result, str) + } + } + if len(result) == 0 { + return nil + } + return result +} + +func gojaArrayLength(value goja.Value, vm *goja.Runtime) (int, error) { + if gojaValueIsEmpty(value) { + return 0, nil + } + lengthValue := value.ToObject(vm).Get("length") + if gojaValueIsEmpty(lengthValue) { + return 0, fmt.Errorf("value is not an array") + } + length := lengthValue.ToInteger() + if length <= 0 { + return 0, nil + } + return int(length), nil +} + +func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetadata { + obj := value.ToObject(vm) + return ExtTrackMetadata{ + ID: gojaObjectString(obj, "id"), + Name: gojaObjectString(obj, "name"), + Artists: gojaObjectString(obj, "artists"), + AlbumName: gojaObjectString(obj, "album_name", "albumName"), + AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"), + AlbumID: gojaObjectString(obj, "album_id", "albumId"), + AlbumURL: gojaObjectString(obj, "album_url", "albumUrl"), + ArtistID: gojaObjectString(obj, "artist_id", "artistId"), + ArtistURL: gojaObjectString(obj, "artist_url", "artistUrl"), + ExternalURL: gojaObjectString(obj, "external_urls", "externalUrls", "external_url", "externalUrl", "url"), + DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"), + CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"), + PreviewURL: gojaObjectString(obj, "preview_url", "previewUrl"), + Images: gojaObjectString(obj, "images"), + ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"), + TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"), + TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"), + DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"), + TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"), + ISRC: gojaObjectString(obj, "isrc"), + ProviderID: gojaObjectString(obj, "provider_id", "providerId"), + ItemType: gojaObjectString(obj, "item_type", "itemType"), + AlbumType: gojaObjectString(obj, "album_type", "albumType"), + Explicit: gojaObjectBool(obj, "explicit", "is_explicit", "isExplicit"), + TidalID: gojaObjectString(obj, "tidal_id", "tidalId"), + QobuzID: gojaObjectString(obj, "qobuz_id", "qobuzId"), + DeezerID: gojaObjectString(obj, "deezer_id", "deezerId"), + SpotifyID: gojaObjectString(obj, "spotify_id", "spotifyId"), + ExternalLinks: gojaObjectStringMap(vm, obj, "external_links", "externalLinks"), + Label: gojaObjectString(obj, "label"), + Copyright: gojaObjectString(obj, "copyright"), + Genre: gojaObjectString(obj, "genre"), + Composer: gojaObjectString(obj, "composer"), + AudioQuality: gojaObjectString(obj, "audio_quality", "audioQuality"), + AudioModes: gojaObjectString(obj, "audio_modes", "audioModes"), + } +} + +func parseExtensionTrackArray(vm *goja.Runtime, value goja.Value) ([]ExtTrackMetadata, error) { + length, err := gojaArrayLength(value, vm) + if err != nil { + return nil, err + } + if length == 0 { + return []ExtTrackMetadata{}, nil + } + + arrayObj := value.ToObject(vm) + tracks := make([]ExtTrackMetadata, 0, length) + for i := 0; i < length; i++ { + trackValue := arrayObj.Get(strconv.Itoa(i)) + if gojaValueIsEmpty(trackValue) { + continue + } + tracks = append(tracks, parseExtensionTrackValue(vm, trackValue)) + } + return tracks, nil +} + +func parseExtensionAlbumValue(vm *goja.Runtime, value goja.Value) (ExtAlbumMetadata, error) { + if gojaValueIsEmpty(value) { + return ExtAlbumMetadata{}, nil + } + + obj := value.ToObject(vm) + tracks := []ExtTrackMetadata{} + if tracksValue := gojaObjectValue(obj, "tracks"); !gojaValueIsEmpty(tracksValue) { + parsedTracks, err := parseExtensionTrackArray(vm, tracksValue) + if err != nil { + return ExtAlbumMetadata{}, err + } + tracks = parsedTracks + } + + return ExtAlbumMetadata{ + ID: gojaObjectString(obj, "id"), + Name: gojaObjectString(obj, "name"), + Artists: gojaObjectString(obj, "artists"), + ArtistID: gojaObjectString(obj, "artist_id", "artistId"), + CoverURL: gojaObjectString(obj, "cover_url", "coverUrl", "images"), + HeaderImage: gojaObjectString(obj, "header_image", "headerImage"), + HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"), + ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"), + TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"), + AlbumType: gojaObjectString(obj, "album_type", "albumType"), + AudioTraits: gojaObjectStringSlice(obj, "audio_traits", "audioTraits"), + Tracks: tracks, + ProviderID: gojaObjectString(obj, "provider_id", "providerId"), + }.withTrackFallbacks(), nil +} + +// withTrackFallbacks fills the album-level artist and release date from the +// album's tracks when the extension did not provide them at the album level. +// This is a generic mechanism so any extension benefits, without per-extension +// special-casing in the app. +func (a ExtAlbumMetadata) withTrackFallbacks() ExtAlbumMetadata { + if strings.TrimSpace(a.Artists) == "" { + a.Artists = albumArtistFromTracks(a.Tracks) + } + if strings.TrimSpace(a.ReleaseDate) == "" { + a.ReleaseDate = albumReleaseDateFromTracks(a.Tracks) + } + if len(a.AudioTraits) == 0 { + a.AudioTraits = albumAudioTraitsFromTracks(a.Tracks) + } + return a +} + +// albumArtistFromTracks prefers an explicit per-track album artist, then falls +// back to the most common track artist across the album. +func albumArtistFromTracks(tracks []ExtTrackMetadata) string { + for _, t := range tracks { + if s := strings.TrimSpace(t.AlbumArtist); s != "" { + return s + } + } + counts := map[string]int{} + order := []string{} + for _, t := range tracks { + artist := strings.TrimSpace(t.Artists) + if artist == "" { + continue + } + if _, ok := counts[artist]; !ok { + order = append(order, artist) + } + counts[artist]++ + } + best := "" + bestCount := 0 + for _, artist := range order { + if counts[artist] > bestCount { + best = artist + bestCount = counts[artist] + } + } + return best +} + +// albumReleaseDateFromTracks returns the first non-empty track release date. +func albumReleaseDateFromTracks(tracks []ExtTrackMetadata) string { + for _, t := range tracks { + if s := strings.TrimSpace(t.ReleaseDate); s != "" { + return s + } + } + return "" +} + +// albumAudioTraitsFromTracks derives album-level audio badges (Dolby Atmos, +// Hi-Res Lossless, Lossless) from the per-track audio quality/mode fields that +// extensions like Tidal and Qobuz already provide. Tokens match what the album +// header understands ("dolby_atmos", "hi_res_lossless", "lossless"). +func albumAudioTraitsFromTracks(tracks []ExtTrackMetadata) []string { + atmos := false + hiRes := false + lossless := false + + for _, t := range tracks { + modes := strings.ToUpper(t.AudioModes) + quality := strings.ToUpper(t.AudioQuality) + if strings.Contains(modes, "ATMOS") || strings.Contains(quality, "ATMOS") { + atmos = true + } + if strings.Contains(quality, "HI_RES") || + strings.Contains(quality, "HIRES") || + strings.Contains(quality, "MASTER") || + strings.Contains(quality, "MQA") { + hiRes = true + } + if strings.Contains(quality, "LOSSLESS") || + strings.Contains(quality, "FLAC") { + lossless = true + } + if bd, sr := parseBitDepthSampleRate(quality); bd > 0 { + if bd > 16 || sr > 48 { + hiRes = true + } else { + lossless = true + } + } + } + + traits := []string{} + if atmos { + traits = append(traits, "dolby_atmos") + } + if hiRes { + traits = append(traits, "hi_res_lossless") + } else if lossless { + traits = append(traits, "lossless") + } + return traits +} + +// parseBitDepthSampleRate extracts a bit depth and sample rate (in kHz) from +// labels such as "24bit/96kHz", "16bit/44.1kHz" or "24bit". +func parseBitDepthSampleRate(quality string) (int, float64) { + lower := strings.ToLower(quality) + bitDepth := 0 + sampleRate := 0.0 + + if idx := strings.Index(lower, "bit"); idx > 0 { + j := idx + for j > 0 && lower[j-1] >= '0' && lower[j-1] <= '9' { + j-- + } + if n, err := strconv.Atoi(lower[j:idx]); err == nil { + bitDepth = n + } + } + if idx := strings.Index(lower, "khz"); idx > 0 { + j := idx + for j > 0 && ((lower[j-1] >= '0' && lower[j-1] <= '9') || lower[j-1] == '.') { + j-- + } + if f, err := strconv.ParseFloat(lower[j:idx], 64); err == nil { + sampleRate = f + } + } + return bitDepth, sampleRate +} + +func parseExtensionAlbumArray(vm *goja.Runtime, value goja.Value) ([]ExtAlbumMetadata, error) { + length, err := gojaArrayLength(value, vm) + if err != nil { + return nil, err + } + if length == 0 { + return []ExtAlbumMetadata{}, nil + } + + arrayObj := value.ToObject(vm) + albums := make([]ExtAlbumMetadata, 0, length) + for i := 0; i < length; i++ { + albumValue := arrayObj.Get(strconv.Itoa(i)) + if gojaValueIsEmpty(albumValue) { + continue + } + album, err := parseExtensionAlbumValue(vm, albumValue) + if err != nil { + return nil, err + } + albums = append(albums, album) + } + return albums, nil +} + +func parseExtensionArtistValue(vm *goja.Runtime, value goja.Value) (ExtArtistMetadata, error) { + if gojaValueIsEmpty(value) { + return ExtArtistMetadata{}, nil + } + + obj := value.ToObject(vm) + albums := []ExtAlbumMetadata{} + if albumsValue := gojaObjectValue(obj, "albums"); !gojaValueIsEmpty(albumsValue) { + parsedAlbums, err := parseExtensionAlbumArray(vm, albumsValue) + if err != nil { + return ExtArtistMetadata{}, err + } + albums = parsedAlbums + } + + releases := []ExtAlbumMetadata{} + if releasesValue := gojaObjectValue(obj, "releases"); !gojaValueIsEmpty(releasesValue) { + parsedReleases, err := parseExtensionAlbumArray(vm, releasesValue) + if err != nil { + return ExtArtistMetadata{}, err + } + releases = parsedReleases + } + + topTracks := []ExtTrackMetadata{} + if topTracksValue := gojaObjectValue(obj, "top_tracks", "topTracks"); !gojaValueIsEmpty(topTracksValue) { + parsedTopTracks, err := parseExtensionTrackArray(vm, topTracksValue) + if err != nil { + return ExtArtistMetadata{}, err + } + topTracks = parsedTopTracks + } + + return ExtArtistMetadata{ + ID: gojaObjectString(obj, "id"), + Name: gojaObjectString(obj, "name"), + ImageURL: gojaObjectString(obj, "image_url", "imageUrl"), + HeaderImage: gojaObjectString(obj, "header_image", "headerImage"), + HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"), + Listeners: gojaObjectInt(obj, "listeners"), + Albums: albums, + Releases: releases, + TopTracks: topTracks, + ProviderID: gojaObjectString(obj, "provider_id", "providerId"), + }, nil +} + +func parseExtensionAvailabilityValue(vm *goja.Runtime, value goja.Value) ExtAvailabilityResult { + obj := value.ToObject(vm) + return ExtAvailabilityResult{ + Available: gojaObjectBool(obj, "available"), + Reason: gojaObjectString(obj, "reason"), + TrackID: gojaObjectString(obj, "track_id", "trackId"), + SkipFallback: gojaObjectBool(obj, "skip_fallback", "skipFallback"), + } +} + +func parseExtensionDownloadURLValue(vm *goja.Runtime, value goja.Value) ExtDownloadURLResult { + obj := value.ToObject(vm) + return ExtDownloadURLResult{ + URL: gojaObjectString(obj, "url"), + Format: gojaObjectString(obj, "format"), + BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"), + SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"), + } +} + +func parseExtensionDownloadDecryptionValue(vm *goja.Runtime, value goja.Value) *DownloadDecryptionInfo { + if gojaValueIsEmpty(value) { + return nil + } + + obj := value.ToObject(vm) + info := &DownloadDecryptionInfo{ + Strategy: gojaObjectString(obj, "strategy"), + Key: gojaObjectString(obj, "key"), + IV: gojaObjectString(obj, "iv"), + InputFormat: gojaObjectString(obj, "input_format", "inputFormat"), + OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"), + Options: gojaObjectInterfaceMap(obj, "options"), + } + if info.Strategy == "" && info.Key == "" && info.IV == "" && info.InputFormat == "" && info.OutputExtension == "" && len(info.Options) == 0 { + return nil + } + return info +} + +func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDownloadResult { + obj := value.ToObject(vm) + return ExtDownloadResult{ + Success: gojaObjectBool(obj, "success"), + FilePath: gojaObjectString(obj, "file_path", "filePath", "path"), + AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"), + BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"), + SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"), + AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"), + ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"), + ErrorType: gojaObjectString(obj, "error_type", "errorType"), + RetryAfterSeconds: gojaObjectInt(obj, "retry_after_seconds", "retryAfterSeconds"), + Title: gojaObjectString(obj, "title"), + Artist: gojaObjectString(obj, "artist"), + Album: gojaObjectString(obj, "album"), + AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"), + TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"), + DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"), + TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"), + TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"), + ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"), + CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"), + ISRC: gojaObjectString(obj, "isrc"), + Genre: gojaObjectString(obj, "genre"), + Label: gojaObjectString(obj, "label"), + Copyright: gojaObjectString(obj, "copyright"), + Composer: gojaObjectString(obj, "composer"), + LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"), + DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"), + Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")), + ActualExtension: gojaObjectString(obj, "actual_extension", "actualExtension"), + OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"), + ActualContainer: gojaObjectString(obj, "actual_container", "actualContainer", "container"), + RequiresContainerConversion: gojaObjectBool( + obj, + "requires_container_conversion", + "requiresContainerConversion", + ), + } +} + +func parseExtensionURLHandleValue(vm *goja.Runtime, value goja.Value) (ExtURLHandleResult, error) { + obj := value.ToObject(vm) + handleResult := ExtURLHandleResult{ + Type: gojaObjectString(obj, "type"), + Name: gojaObjectString(obj, "name"), + CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"), + HeaderImage: gojaObjectString(obj, "header_image", "headerImage"), + HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"), + } + + if trackValue := gojaObjectValue(obj, "track"); !gojaValueIsEmpty(trackValue) { + track := parseExtensionTrackValue(vm, trackValue) + handleResult.Track = &track + } + if tracksValue := gojaObjectValue(obj, "tracks"); !gojaValueIsEmpty(tracksValue) { + tracks, err := parseExtensionTrackArray(vm, tracksValue) + if err != nil { + return ExtURLHandleResult{}, err + } + handleResult.Tracks = tracks + } + if albumValue := gojaObjectValue(obj, "album"); !gojaValueIsEmpty(albumValue) { + album, err := parseExtensionAlbumValue(vm, albumValue) + if err != nil { + return ExtURLHandleResult{}, err + } + handleResult.Album = &album + } + if artistValue := gojaObjectValue(obj, "artist"); !gojaValueIsEmpty(artistValue) { + artist, err := parseExtensionArtistValue(vm, artistValue) + if err != nil { + return ExtURLHandleResult{}, err + } + handleResult.Artist = &artist + } + + return handleResult, nil +} + +func parseExtensionMatchTrackValue(vm *goja.Runtime, value goja.Value) MatchTrackResult { + obj := value.ToObject(vm) + return MatchTrackResult{ + Matched: gojaObjectBool(obj, "matched"), + TrackID: gojaObjectString(obj, "track_id", "trackId"), + Confidence: gojaObjectFloat(obj, "confidence"), + Reason: gojaObjectString(obj, "reason"), + } +} + +func parseExtensionPostProcessValue(vm *goja.Runtime, value goja.Value) PostProcessResult { + obj := value.ToObject(vm) + return PostProcessResult{ + Success: gojaObjectBool(obj, "success"), + NewFilePath: gojaObjectString(obj, "new_file_path", "newFilePath"), + NewFileURI: gojaObjectString(obj, "new_file_uri", "newFileUri"), + Error: gojaObjectString(obj, "error"), + BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"), + SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"), + } +} + +func parseExtensionLyricsLineArray(vm *goja.Runtime, value goja.Value) ([]ExtLyricsLine, error) { + length, err := gojaArrayLength(value, vm) + if err != nil { + return nil, err + } + if length == 0 { + return []ExtLyricsLine{}, nil + } + + arrayObj := value.ToObject(vm) + lines := make([]ExtLyricsLine, 0, length) + for i := 0; i < length; i++ { + lineValue := arrayObj.Get(strconv.Itoa(i)) + if gojaValueIsEmpty(lineValue) { + continue + } + lineObj := lineValue.ToObject(vm) + lines = append(lines, ExtLyricsLine{ + StartTimeMs: gojaObjectInt64(lineObj, "startTimeMs", "start_time_ms"), + Words: gojaObjectString(lineObj, "words"), + EndTimeMs: gojaObjectInt64(lineObj, "endTimeMs", "end_time_ms"), + }) + } + return lines, nil +} + +func parseExtensionLyricsValue(vm *goja.Runtime, value goja.Value) (ExtLyricsResult, error) { + obj := value.ToObject(vm) + lines := []ExtLyricsLine{} + if linesValue := gojaObjectValue(obj, "lines"); !gojaValueIsEmpty(linesValue) { + parsedLines, err := parseExtensionLyricsLineArray(vm, linesValue) + if err != nil { + return ExtLyricsResult{}, err + } + lines = parsedLines + } + + return ExtLyricsResult{ + Lines: lines, + SyncType: gojaObjectString(obj, "syncType", "sync_type"), + Instrumental: gojaObjectBool(obj, "instrumental"), + PlainLyrics: gojaObjectString(obj, "plainLyrics", "plain_lyrics"), + Provider: gojaObjectString(obj, "provider"), + }, nil +} + +func parseExtensionSearchResult(vm *goja.Runtime, value goja.Value) (ExtSearchResult, error) { + if gojaValueIsEmpty(value) { + return ExtSearchResult{}, nil + } + + resultObj := value.ToObject(vm) + tracksValue := resultObj.Get("tracks") + if gojaValueIsEmpty(tracksValue) { + tracks, err := parseExtensionTrackArray(vm, value) + if err != nil { + return ExtSearchResult{}, err + } + return ExtSearchResult{ + Tracks: tracks, + Total: len(tracks), + }, nil + } + + tracks, err := parseExtensionTrackArray(vm, tracksValue) + if err != nil { + return ExtSearchResult{}, err + } + total := gojaObjectInt(resultObj, "total") + if total == 0 { + total = len(tracks) + } + return ExtSearchResult{ + Tracks: tracks, + Total: total, + }, nil +} diff --git a/go_backend/extension_priority.go b/go_backend/extension_priority.go new file mode 100644 index 00000000..2a2b0832 --- /dev/null +++ b/go_backend/extension_priority.go @@ -0,0 +1,201 @@ +package gobackend + +import ( + "strings" + "sync" +) + +var providerPriority []string +var providerPriorityMu sync.RWMutex + +var extensionFallbackProviderIDs []string +var extensionFallbackProviderIDsMu sync.RWMutex + +var metadataProviderPriority []string +var metadataProviderPriorityMu sync.RWMutex + +func SetProviderPriority(providerIDs []string) { + providerPriorityMu.Lock() + defer providerPriorityMu.Unlock() + providerPriority = sanitizeDownloadProviderPriority(providerIDs) + GoLog("[Extension] Download provider priority set: %v\n", providerPriority) +} + +func GetProviderPriority() []string { + providerPriorityMu.RLock() + defer providerPriorityMu.RUnlock() + + if len(providerPriority) == 0 { + return []string{} + } + + result := make([]string, len(providerPriority)) + copy(result, providerPriority) + return result +} + +func sanitizeDownloadProviderPriority(providerIDs []string) []string { + sanitized := make([]string, 0, len(providerIDs)) + seen := map[string]struct{}{} + + for _, providerID := range providerIDs { + providerID = strings.TrimSpace(providerID) + if providerID == "" { + continue + } + + if isRetiredBuiltInDownloadProvider(providerID) { + continue + } + + seenKey := strings.ToLower(providerID) + if _, exists := seen[seenKey]; exists { + continue + } + seen[seenKey] = struct{}{} + sanitized = append(sanitized, providerID) + } + + return sanitized +} + +func isRetiredBuiltInDownloadProvider(providerID string) bool { + normalized := strings.ToLower(strings.TrimSpace(providerID)) + if normalized == "" { + return false + } + switch normalized { + case "deezer", "qobuz", "tidal": + return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool { + return manifest.IsDownloadProvider() + }) + default: + return false + } +} + +func isRetiredBuiltInMetadataProvider(providerID string) bool { + normalized := strings.ToLower(strings.TrimSpace(providerID)) + if normalized == "" { + return false + } + switch normalized { + case "deezer", "spotify", "qobuz", "tidal": + return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool { + return manifest.IsMetadataProvider() + }) + default: + return false + } +} + +func hasEnabledExtensionProvider(providerID string, matches func(*ExtensionManifest) bool) bool { + if providerID == "" || matches == nil { + return false + } + + manager := getExtensionManager() + manager.mu.RLock() + defer manager.mu.RUnlock() + + for id, ext := range manager.extensions { + if !strings.EqualFold(strings.TrimSpace(id), providerID) { + continue + } + if ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil { + return false + } + return matches(ext.Manifest) + } + + return false +} + +func SetExtensionFallbackProviderIDs(providerIDs []string) { + extensionFallbackProviderIDsMu.Lock() + defer extensionFallbackProviderIDsMu.Unlock() + + if providerIDs == nil { + extensionFallbackProviderIDs = nil + GoLog("[Extension] Extension fallback providers reset to default (all enabled download extensions)\n") + return + } + + sanitized := make([]string, 0, len(providerIDs)) + seen := map[string]struct{}{} + for _, providerID := range providerIDs { + providerID = strings.TrimSpace(providerID) + if providerID == "" { + continue + } + if _, exists := seen[providerID]; exists { + continue + } + seen[providerID] = struct{}{} + sanitized = append(sanitized, providerID) + } + + extensionFallbackProviderIDs = sanitized + GoLog("[Extension] Extension fallback providers set: %v\n", sanitized) +} + +func GetExtensionFallbackProviderIDs() []string { + extensionFallbackProviderIDsMu.RLock() + defer extensionFallbackProviderIDsMu.RUnlock() + + if extensionFallbackProviderIDs == nil { + return nil + } + + result := make([]string, len(extensionFallbackProviderIDs)) + copy(result, extensionFallbackProviderIDs) + return result +} + +func isExtensionFallbackAllowed(providerID string) bool { + allowed := GetExtensionFallbackProviderIDs() + if allowed == nil { + return true + } + + for _, allowedProviderID := range allowed { + if allowedProviderID == providerID { + return true + } + } + return false +} + +func SetMetadataProviderPriority(providerIDs []string) { + metadataProviderPriorityMu.Lock() + defer metadataProviderPriorityMu.Unlock() + + sanitized := make([]string, 0, len(providerIDs)) + seen := map[string]struct{}{} + for _, providerID := range providerIDs { + providerID = strings.TrimSpace(providerID) + if providerID == "" || isRetiredBuiltInMetadataProvider(providerID) { + continue + } + if _, exists := seen[providerID]; exists { + continue + } + seen[providerID] = struct{}{} + sanitized = append(sanitized, providerID) + } + metadataProviderPriority = sanitized + GoLog("[Extension] Metadata provider priority set: %v\n", sanitized) +} + +func GetMetadataProviderPriority() []string { + metadataProviderPriorityMu.RLock() + defer metadataProviderPriorityMu.RUnlock() + + if len(metadataProviderPriority) == 0 { + return []string{} + } + + result := make([]string, len(metadataProviderPriority)) + copy(result, metadataProviderPriority) + return result +} diff --git a/go_backend/extension_provider_types.go b/go_backend/extension_provider_types.go new file mode 100644 index 00000000..bb882596 --- /dev/null +++ b/go_backend/extension_provider_types.go @@ -0,0 +1,216 @@ +package gobackend + +import "strings" + +type ExtTrackMetadata struct { + ID string `json:"id"` + Name string `json:"name"` + Artists string `json:"artists"` + AlbumName string `json:"album_name"` + AlbumArtist string `json:"album_artist,omitempty"` + AlbumID string `json:"album_id,omitempty"` + AlbumURL string `json:"album_url,omitempty"` + ArtistID string `json:"artist_id,omitempty"` + ArtistURL string `json:"artist_url,omitempty"` + ExternalURL string `json:"external_urls,omitempty"` + DurationMS int `json:"duration_ms"` + CoverURL string `json:"cover_url,omitempty"` + PreviewURL string `json:"preview_url,omitempty"` + Images string `json:"images,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + TrackNumber int `json:"track_number,omitempty"` + TotalTracks int `json:"total_tracks,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + TotalDiscs int `json:"total_discs,omitempty"` + ISRC string `json:"isrc,omitempty"` + ProviderID string `json:"provider_id"` + ItemType string `json:"item_type,omitempty"` + AlbumType string `json:"album_type,omitempty"` + Explicit bool `json:"explicit,omitempty"` + + TidalID string `json:"tidal_id,omitempty"` + QobuzID string `json:"qobuz_id,omitempty"` + DeezerID string `json:"deezer_id,omitempty"` + SpotifyID string `json:"spotify_id,omitempty"` + ExternalLinks map[string]string `json:"external_links,omitempty"` + + Label string `json:"label,omitempty"` + Copyright string `json:"copyright,omitempty"` + Genre string `json:"genre,omitempty"` + Composer string `json:"composer,omitempty"` + + AudioQuality string `json:"audio_quality,omitempty"` + AudioModes string `json:"audio_modes,omitempty"` +} + +func (t *ExtTrackMetadata) ResolvedCoverURL() string { + if t.CoverURL != "" { + return t.CoverURL + } + return t.Images +} + +type ExtAlbumMetadata struct { + ID string `json:"id"` + Name string `json:"name"` + Artists string `json:"artists"` + ArtistID string `json:"artist_id,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + HeaderImage string `json:"header_image,omitempty"` + HeaderVideo string `json:"header_video,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + TotalTracks int `json:"total_tracks"` + AlbumType string `json:"album_type,omitempty"` + AudioTraits []string `json:"audio_traits,omitempty"` + Tracks []ExtTrackMetadata `json:"tracks"` + ProviderID string `json:"provider_id"` +} + +type ExtArtistMetadata struct { + ID string `json:"id"` + Name string `json:"name"` + ImageURL string `json:"image_url,omitempty"` + HeaderImage string `json:"header_image,omitempty"` + HeaderVideo string `json:"header_video,omitempty"` + Listeners int `json:"listeners,omitempty"` + Albums []ExtAlbumMetadata `json:"albums,omitempty"` + Releases []ExtAlbumMetadata `json:"releases,omitempty"` + TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"` + ProviderID string `json:"provider_id"` +} + +type ExtSearchResult struct { + Tracks []ExtTrackMetadata `json:"tracks"` + Total int `json:"total"` +} + +type ExtAvailabilityResult struct { + Available bool `json:"available"` + Reason string `json:"reason,omitempty"` + TrackID string `json:"track_id,omitempty"` + SkipFallback bool `json:"skip_fallback,omitempty"` +} + +type ExtDownloadURLResult struct { + URL string `json:"url"` + Format string `json:"format"` + BitDepth int `json:"bit_depth,omitempty"` + SampleRate int `json:"sample_rate,omitempty"` +} + +type DownloadDecryptionInfo struct { + Strategy string `json:"strategy,omitempty"` + Key string `json:"key,omitempty"` + IV string `json:"iv,omitempty"` + InputFormat string `json:"input_format,omitempty"` + OutputExtension string `json:"output_extension,omitempty"` + Options map[string]interface{} `json:"options,omitempty"` +} + +type ExtDownloadResult struct { + Success bool `json:"success"` + FilePath string `json:"file_path,omitempty"` + AlreadyExists bool `json:"already_exists,omitempty"` + BitDepth int `json:"bit_depth,omitempty"` + SampleRate int `json:"sample_rate,omitempty"` + AudioCodec string `json:"audio_codec,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + ErrorType string `json:"error_type,omitempty"` + RetryAfterSeconds int `json:"retry_after_seconds,omitempty"` + + Title string `json:"title,omitempty"` + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + AlbumArtist string `json:"album_artist,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"` + ReleaseDate string `json:"release_date,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + ISRC string `json:"isrc,omitempty"` + Genre string `json:"genre,omitempty"` + Label string `json:"label,omitempty"` + Copyright string `json:"copyright,omitempty"` + Composer string `json:"composer,omitempty"` + LyricsLRC string `json:"lyrics_lrc,omitempty"` + DecryptionKey string `json:"decryption_key,omitempty"` + Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"` + ActualExtension string `json:"actual_extension,omitempty"` + OutputExtension string `json:"output_extension,omitempty"` + ActualContainer string `json:"actual_container,omitempty"` + RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"` +} + +const genericFFmpegMOVDecryptionStrategy = "ffmpeg.mov_key" + +func cloneDownloadDecryptionInfo(info *DownloadDecryptionInfo) *DownloadDecryptionInfo { + if info == nil { + return nil + } + + cloned := &DownloadDecryptionInfo{ + Strategy: strings.TrimSpace(info.Strategy), + Key: strings.TrimSpace(info.Key), + IV: strings.TrimSpace(info.IV), + InputFormat: strings.TrimSpace(info.InputFormat), + OutputExtension: strings.TrimSpace(info.OutputExtension), + } + if len(info.Options) > 0 { + cloned.Options = make(map[string]interface{}, len(info.Options)) + for key, value := range info.Options { + cloned.Options[key] = value + } + } + return cloned +} + +func normalizeDownloadDecryptionStrategy(strategy string) string { + switch strings.ToLower(strings.TrimSpace(strategy)) { + case "", "ffmpeg.mov_key", "ffmpeg_mov_key", "mov_decryption_key", "mp4_decryption_key", "ffmpeg.mp4_decryption_key": + return genericFFmpegMOVDecryptionStrategy + default: + return strings.TrimSpace(strategy) + } +} + +func normalizeDownloadDecryptionInfo(info *DownloadDecryptionInfo, legacyKey string) *DownloadDecryptionInfo { + normalized := cloneDownloadDecryptionInfo(info) + trimmedLegacyKey := strings.TrimSpace(legacyKey) + + if normalized == nil { + if trimmedLegacyKey == "" { + return nil + } + return &DownloadDecryptionInfo{ + Strategy: genericFFmpegMOVDecryptionStrategy, + Key: trimmedLegacyKey, + InputFormat: "mov", + } + } + + normalized.Strategy = normalizeDownloadDecryptionStrategy(normalized.Strategy) + if normalized.Key == "" && trimmedLegacyKey != "" { + normalized.Key = trimmedLegacyKey + } + if normalized.Strategy == "" && normalized.Key != "" { + normalized.Strategy = genericFFmpegMOVDecryptionStrategy + } + if normalized.Strategy == genericFFmpegMOVDecryptionStrategy && normalized.InputFormat == "" { + normalized.InputFormat = "mov" + } + if normalized.Strategy == genericFFmpegMOVDecryptionStrategy && normalized.Key == "" { + return nil + } + + return normalized +} + +func normalizedDownloadDecryptionKey(info *DownloadDecryptionInfo, legacyKey string) string { + if normalized := normalizeDownloadDecryptionInfo(info, legacyKey); normalized != nil { + if normalized.Strategy == genericFFmpegMOVDecryptionStrategy { + return normalized.Key + } + } + return strings.TrimSpace(legacyKey) +} diff --git a/go_backend/extension_provider_wrapper.go b/go_backend/extension_provider_wrapper.go new file mode 100644 index 00000000..d0cf1d4b --- /dev/null +++ b/go_backend/extension_provider_wrapper.go @@ -0,0 +1,1187 @@ +package gobackend + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/dop251/goja" +) + +type extensionProviderWrapper struct { + extension *loadedExtension + vm *goja.Runtime +} + +func newExtensionProviderWrapper(ext *loadedExtension) *extensionProviderWrapper { + return &extensionProviderWrapper{ + extension: ext, + vm: ext.VM, + } +} + +func (p *extensionProviderWrapper) lockReadyVM() error { + vm, err := p.extension.lockReadyVM() + if err != nil { + return err + } + p.vm = vm + return nil +} + +func (p *extensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) { + return p.SearchTracksForItemID(query, limit, "") +} + +func (p *extensionProviderWrapper) SearchTracksForItemID(query string, limit int, itemID string) (*ExtSearchResult, error) { + if !p.extension.Manifest.IsMetadataProvider() { + return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + perf := newExtensionCallPerf(p.extension.ID, "searchTracks") + defer perf.finish() + initStartedAt := time.Now() + if err := p.lockReadyVM(); err != nil { + return nil, err + } + perf.recordInit(time.Since(initStartedAt)) + defer p.extension.VMMu.Unlock() + if itemID != "" { + if p.extension.runtime != nil { + p.extension.runtime.setActiveDownloadItemID(itemID) + defer p.extension.runtime.clearActiveDownloadItemID() + } + initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } + } + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.searchTracks === 'function') { + return extension.searchTracks(%q, %d); + } + return null; + })() + `, query, limit) + + jsStartedAt := time.Now() + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) + if err != nil { + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } + if IsTimeoutError(err) { + return nil, fmt.Errorf("searchTracks timeout: extension took too long to respond") + } + return nil, fmt.Errorf("searchTracks failed: %w", err) + } + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("searchTracks returned null") + } + + parseStartedAt := time.Now() + searchResult, err := parseExtensionSearchResult(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + if err != nil { + return nil, fmt.Errorf("failed to parse search result: %w", err) + } + perf.setItems(len(searchResult.Tracks)) + + for i := range searchResult.Tracks { + searchResult.Tracks[i].ProviderID = p.extension.ID + } + + return &searchResult, nil +} + +func (p *extensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) { + if !p.extension.Manifest.IsMetadataProvider() { + return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + perf := newExtensionCallPerf(p.extension.ID, "getTrack") + defer perf.finish() + initStartedAt := time.Now() + if err := p.lockReadyVM(); err != nil { + return nil, err + } + perf.recordInit(time.Since(initStartedAt)) + defer p.extension.VMMu.Unlock() + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.getTrack === 'function') { + return extension.getTrack(%q); + } + return null; + })() + `, trackID) + + jsStartedAt := time.Now() + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) + if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("getTrack timeout: extension took too long to respond") + } + return nil, fmt.Errorf("getTrack failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("getTrack returned null") + } + + parseStartedAt := time.Now() + track := parseExtensionTrackValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + perf.setItems(1) + track.ProviderID = p.extension.ID + return &track, nil +} + +func (p *extensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) { + if !p.extension.Manifest.IsMetadataProvider() { + return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + perf := newExtensionCallPerf(p.extension.ID, "getAlbum") + defer perf.finish() + initStartedAt := time.Now() + if err := p.lockReadyVM(); err != nil { + return nil, err + } + perf.recordInit(time.Since(initStartedAt)) + defer p.extension.VMMu.Unlock() + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') { + return extension.getAlbum(%q); + } + return null; + })() + `, albumID) + + jsStartedAt := time.Now() + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) + if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("getAlbum timeout: extension took too long to respond") + } + return nil, fmt.Errorf("getAlbum failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("getAlbum returned null") + } + + parseStartedAt := time.Now() + album, err := parseExtensionAlbumValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + if err != nil { + return nil, fmt.Errorf("failed to parse album: %w", err) + } + perf.setItems(len(album.Tracks)) + + album.ProviderID = p.extension.ID + for i := range album.Tracks { + album.Tracks[i].ProviderID = p.extension.ID + } + return &album, nil +} + +func (p *extensionProviderWrapper) GetPlaylist(playlistID string) (*ExtAlbumMetadata, error) { + if !p.extension.Manifest.IsMetadataProvider() { + return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + perf := newExtensionCallPerf(p.extension.ID, "getPlaylist") + defer perf.finish() + initStartedAt := time.Now() + if err := p.lockReadyVM(); err != nil { + return nil, err + } + perf.recordInit(time.Since(initStartedAt)) + defer p.extension.VMMu.Unlock() + + 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) + + jsStartedAt := time.Now() + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) + if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("getPlaylist timeout: extension took too long to respond") + } + return nil, fmt.Errorf("getPlaylist failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("getPlaylist returned null") + } + + parseStartedAt := time.Now() + playlist, err := parseExtensionAlbumValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + if err != nil { + return nil, fmt.Errorf("failed to parse playlist: %w", err) + } + perf.setItems(len(playlist.Tracks)) + + playlist.ProviderID = p.extension.ID + for i := range playlist.Tracks { + playlist.Tracks[i].ProviderID = p.extension.ID + } + return &playlist, nil +} + +func (p *extensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) { + if !p.extension.Manifest.IsMetadataProvider() { + return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + perf := newExtensionCallPerf(p.extension.ID, "getArtist") + defer perf.finish() + initStartedAt := time.Now() + if err := p.lockReadyVM(); err != nil { + return nil, err + } + perf.recordInit(time.Since(initStartedAt)) + defer p.extension.VMMu.Unlock() + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.getArtist === 'function') { + return extension.getArtist(%q); + } + return null; + })() + `, artistID) + + jsStartedAt := time.Now() + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) + if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("getArtist timeout: extension took too long to respond") + } + return nil, fmt.Errorf("getArtist failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("getArtist returned null") + } + + parseStartedAt := time.Now() + artist, err := parseExtensionArtistValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + if err != nil { + return nil, fmt.Errorf("failed to parse artist: %w", err) + } + perf.setItems(len(artist.Albums) + len(artist.Releases) + len(artist.TopTracks)) + + artist.ProviderID = p.extension.ID + for i := range artist.Releases { + artist.Releases[i].ProviderID = p.extension.ID + for j := range artist.Releases[i].Tracks { + artist.Releases[i].Tracks[j].ProviderID = p.extension.ID + } + } + return &artist, nil +} + +func (p *extensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) { + return p.EnrichTrackForItemID(track, "") +} + +func (p *extensionProviderWrapper) EnrichTrackForItemID(track *ExtTrackMetadata, itemID string) (*ExtTrackMetadata, error) { + if !p.extension.Manifest.IsMetadataProvider() { + return track, nil + } + + if !p.extension.Enabled { + return track, nil + } + perf := newExtensionCallPerf(p.extension.ID, "enrichTrack") + defer perf.finish() + initStartedAt := time.Now() + if err := p.lockReadyVM(); err != nil { + GoLog("[Extension] EnrichTrack init error for %s: %v\n", p.extension.ID, err) + return track, nil + } + perf.recordInit(time.Since(initStartedAt)) + defer p.extension.VMMu.Unlock() + if itemID != "" { + if p.extension.runtime != nil { + p.extension.runtime.setActiveDownloadItemID(itemID) + defer p.extension.runtime.clearActiveDownloadItemID() + } + initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) + if isDownloadCancelled(itemID) { + return track, ErrDownloadCancelled + } + } + + trackJSON, err := json.Marshal(track) + if err != nil { + GoLog("[Extension] EnrichTrack: failed to marshal track: %v\n", err) + return track, nil + } + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.enrichTrack === 'function') { + var track = %s; + return extension.enrichTrack(track); + } + return null; + })() + `, string(trackJSON)) + + jsStartedAt := time.Now() + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) + if err != nil { + if isDownloadCancelled(itemID) { + return track, ErrDownloadCancelled + } + if IsTimeoutError(err) { + GoLog("[Extension] EnrichTrack timeout for %s\n", p.extension.ID) + } else { + GoLog("[Extension] EnrichTrack error for %s: %v\n", p.extension.ID, err) + } + return track, nil + } + if isDownloadCancelled(itemID) { + return track, ErrDownloadCancelled + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return track, nil + } + + parseStartedAt := time.Now() + enrichedTrack := parseExtensionTrackValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + perf.setItems(1) + enrichedTrack.ProviderID = track.ProviderID + + return &enrichedTrack, nil +} + +func (p *extensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID string) (*ExtAvailabilityResult, error) { + return p.CheckAvailabilityForItemID(isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID, 0, "") +} + +func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID string, durationMS int, itemID string) (*ExtAvailabilityResult, error) { + if !p.extension.Manifest.IsDownloadProvider() { + return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + perf := newExtensionCallPerf(p.extension.ID, "checkAvailability") + defer perf.finish() + initStartedAt := time.Now() + if err := p.lockReadyVM(); err != nil { + return nil, err + } + perf.recordInit(time.Since(initStartedAt)) + defer p.extension.VMMu.Unlock() + if itemID != "" { + if p.extension.runtime != nil { + p.extension.runtime.setActiveDownloadItemID(itemID) + defer p.extension.runtime.clearActiveDownloadItemID() + } + initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } + } + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') { + return extension.checkAvailability(%q, %q, %q, { + spotify_id: %q, + deezer_id: %q, + tidal_id: %q, + qobuz_id: %q, + duration_ms: %d + }); + } + return null; + })() + `, isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID, durationMS) + + jsStartedAt := time.Now() + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) + if err != nil { + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } + if IsTimeoutError(err) { + return nil, fmt.Errorf("checkAvailability timeout: extension took too long to respond") + } + return nil, fmt.Errorf("checkAvailability failed: %w", err) + } + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return &ExtAvailabilityResult{Available: false, Reason: "not implemented"}, nil + } + + parseStartedAt := time.Now() + availability := parseExtensionAvailabilityValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + perf.setItems(1) + return &availability, nil +} + +func (p *extensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) { + if !p.extension.Manifest.IsDownloadProvider() { + return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + perf := newExtensionCallPerf(p.extension.ID, "getDownloadUrl") + defer perf.finish() + initStartedAt := time.Now() + if err := p.lockReadyVM(); err != nil { + return nil, err + } + perf.recordInit(time.Since(initStartedAt)) + defer p.extension.VMMu.Unlock() + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.getDownloadUrl === 'function') { + return extension.getDownloadUrl(%q, %q); + } + return null; + })() + `, trackID, quality) + + jsStartedAt := time.Now() + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) + if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("getDownloadUrl timeout: extension took too long to respond") + } + return nil, fmt.Errorf("getDownloadUrl failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("getDownloadUrl returned null") + } + + parseStartedAt := time.Now() + urlResult := parseExtensionDownloadURLValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + perf.setItems(1) + return &urlResult, nil +} + +const ExtDownloadTimeout = DownloadTimeout + +func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) { + if !p.extension.Manifest.IsDownloadProvider() { + return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + perf := newExtensionCallPerf(p.extension.ID, "download") + defer perf.finish() + initStartedAt := time.Now() + p.extension.VMMu.Lock() + vm, runtime, err := newIsolatedExtensionRuntime(p.extension) + p.extension.VMMu.Unlock() + perf.recordInit(time.Since(initStartedAt)) + if err != nil { + return &ExtDownloadResult{ + Success: false, + ErrorMessage: err.Error(), + ErrorType: "init_error", + }, nil + } + defer func() { + if cleanupErr := runCleanupOnVM(vm); cleanupErr != nil { + GoLog("[Extension:%s] isolated download cleanup failed: %v\n", p.extension.ID, cleanupErr) + } + if runtime != nil { + if flushErr := runtime.flushStorageNow(); flushErr != nil { + GoLog("[Extension:%s] isolated download storage flush failed: %v\n", p.extension.ID, flushErr) + } + runtime.closeStorageFlusher() + } + }() + if runtime != nil { + runtime.setActiveDownloadItemID(itemID) + defer runtime.clearActiveDownloadItemID() + } + if itemID != "" { + initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) + SetItemPreparing(itemID) + } + + vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value { + if len(call.Arguments) > 0 { + percent := int(call.Arguments[0].ToInteger()) + if percent < 0 { + percent = 0 + } + if percent > 100 { + percent = 100 + } + if onProgress != nil { + onProgress(percent) + } + } + return goja.Undefined() + }) + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.download === 'function') { + return extension.download(%q, %q, %q, __onProgress); + } + return null; + })() + `, trackID, quality, outputPath) + + jsStartedAt := time.Now() + result, err := RunWithTimeoutAndRecover(vm, script, ExtDownloadTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) + if err != nil { + errMsg := err.Error() + errType := "script_error" + if IsTimeoutError(err) { + errMsg = "download timeout: extension took too long to complete" + errType = "timeout" + } + return &ExtDownloadResult{ + Success: false, + ErrorMessage: errMsg, + ErrorType: errType, + }, nil + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return &ExtDownloadResult{ + Success: false, + ErrorMessage: "download returned null", + ErrorType: "not_implemented", + }, nil + } + + parseStartedAt := time.Now() + downloadResult := parseExtensionDownloadResultValue(vm, result) + perf.recordParse(time.Since(parseStartedAt)) + perf.setItems(1) + downloadResult.Decryption = normalizeDownloadDecryptionInfo( + downloadResult.Decryption, + downloadResult.DecryptionKey, + ) + downloadResult.DecryptionKey = normalizedDownloadDecryptionKey( + downloadResult.Decryption, + downloadResult.DecryptionKey, + ) + + return &downloadResult, nil +} + +func (p *extensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) { + return p.customSearch(query, options, "", "") +} + +func (p *extensionProviderWrapper) CustomSearchForRequestID(query string, options map[string]interface{}, requestID string) ([]ExtTrackMetadata, error) { + return p.customSearch(query, options, "", requestID) +} + +func (p *extensionProviderWrapper) CustomSearchForItemID(query string, options map[string]interface{}, itemID string) ([]ExtTrackMetadata, error) { + return p.customSearch(query, options, itemID, "") +} + +func (p *extensionProviderWrapper) customSearch(query string, options map[string]interface{}, itemID, requestID string) ([]ExtTrackMetadata, error) { + if !p.extension.Manifest.HasCustomSearch() { + return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + perf := newExtensionCallPerf(p.extension.ID, "customSearch") + defer perf.finish() + initStartedAt := time.Now() + if err := p.lockReadyVM(); err != nil { + return nil, err + } + perf.recordInit(time.Since(initStartedAt)) + defer p.extension.VMMu.Unlock() + if itemID != "" { + if p.extension.runtime != nil { + p.extension.runtime.setActiveDownloadItemID(itemID) + defer p.extension.runtime.clearActiveDownloadItemID() + } + initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } + } + requestCtx := context.Background() + if requestID != "" { + if p.extension.runtime != nil { + p.extension.runtime.setActiveRequestID(requestID) + defer p.extension.runtime.clearActiveRequestID() + } + requestCtx = initExtensionRequestCancel(requestID) + defer clearExtensionRequestCancel(requestID) + if isExtensionRequestCancelled(requestID) { + return nil, ErrExtensionRequestCancelled + } + } + + if options == nil { + options = map[string]interface{}{} + } + + // Avoid embedding user input directly into JS source. Some inputs can trigger + // parser/runtime edge cases on specific devices/Goja builds. + const queryVar = "__sf_custom_search_query" + const optionsVar = "__sf_custom_search_options" + global := p.vm.GlobalObject() + _ = global.Set(queryVar, query) + _ = global.Set(optionsVar, options) + defer func() { + global.Delete(queryVar) + global.Delete(optionsVar) + }() + + const script = ` + (function() { + if (typeof extension !== 'undefined' && typeof extension.customSearch === 'function') { + return extension.customSearch(__sf_custom_search_query, __sf_custom_search_options); + } + return null; + })() + ` + + jsStartedAt := time.Now() + result, err := RunWithTimeoutContextAndRecover(requestCtx, p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) + if err != nil { + if isExtensionRequestCancelled(requestID) { + return nil, ErrExtensionRequestCancelled + } + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } + if errors.Is(err, ErrExtensionRequestCancelled) { + return nil, ErrExtensionRequestCancelled + } + if IsTimeoutError(err) { + return nil, fmt.Errorf("customSearch timeout: extension took too long to respond") + } + return nil, fmt.Errorf("customSearch failed: %w", err) + } + if isDownloadCancelled(itemID) { + return nil, ErrDownloadCancelled + } + if isExtensionRequestCancelled(requestID) { + return nil, ErrExtensionRequestCancelled + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return []ExtTrackMetadata{}, nil + } + + parseStartedAt := time.Now() + tracks, err := parseExtensionTrackArray(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + if err != nil { + return nil, fmt.Errorf("failed to parse search result: %w", err) + } + perf.setItems(len(tracks)) + + for i := range tracks { + tracks[i].ProviderID = p.extension.ID + } + + return tracks, nil +} + +type ExtURLHandleResult struct { + Type string `json:"type"` + Track *ExtTrackMetadata `json:"track,omitempty"` + Tracks []ExtTrackMetadata `json:"tracks,omitempty"` + Album *ExtAlbumMetadata `json:"album,omitempty"` + Artist *ExtArtistMetadata `json:"artist,omitempty"` + Name string `json:"name,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + HeaderImage string `json:"header_image,omitempty"` + HeaderVideo string `json:"header_video,omitempty"` +} + +func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) { + if !p.extension.Manifest.HasURLHandler() { + return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + perf := newExtensionCallPerf(p.extension.ID, "handleUrl") + defer perf.finish() + initStartedAt := time.Now() + if err := p.lockReadyVM(); err != nil { + return nil, err + } + perf.recordInit(time.Since(initStartedAt)) + defer p.extension.VMMu.Unlock() + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.handleUrl === 'function') { + return extension.handleUrl(%q); + } + return null; + })() + `, url) + + jsStartedAt := time.Now() + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) + if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("handleUrl timeout: extension took too long to respond") + } + return nil, fmt.Errorf("handleUrl failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("handleUrl returned null - URL not recognized") + } + + parseStartedAt := time.Now() + handleResult, err := parseExtensionURLHandleValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + if err != nil { + return nil, fmt.Errorf("failed to parse URL handle result: %w", err) + } + urlItems := len(handleResult.Tracks) + if handleResult.Track != nil { + urlItems++ + } + if handleResult.Album != nil { + urlItems += 1 + len(handleResult.Album.Tracks) + } + if handleResult.Artist != nil { + urlItems += 1 + len(handleResult.Artist.Albums) + len(handleResult.Artist.Releases) + len(handleResult.Artist.TopTracks) + } + perf.setItems(urlItems) + + if handleResult.Track != nil { + handleResult.Track.ProviderID = p.extension.ID + } + for i := range handleResult.Tracks { + handleResult.Tracks[i].ProviderID = p.extension.ID + } + if handleResult.Album != nil { + handleResult.Album.ProviderID = p.extension.ID + for i := range handleResult.Album.Tracks { + handleResult.Album.Tracks[i].ProviderID = p.extension.ID + } + } + if handleResult.Artist != nil { + handleResult.Artist.ProviderID = p.extension.ID + for i := range handleResult.Artist.Albums { + handleResult.Artist.Albums[i].ProviderID = p.extension.ID + for j := range handleResult.Artist.Albums[i].Tracks { + handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID + } + } + for i := range handleResult.Artist.Releases { + handleResult.Artist.Releases[i].ProviderID = p.extension.ID + for j := range handleResult.Artist.Releases[i].Tracks { + handleResult.Artist.Releases[i].Tracks[j].ProviderID = p.extension.ID + } + } + for i := range handleResult.Artist.TopTracks { + handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID + } + } + + return &handleResult, nil +} + +type MatchTrackResult struct { + Matched bool `json:"matched"` + TrackID string `json:"track_id,omitempty"` + Confidence float64 `json:"confidence,omitempty"` + Reason string `json:"reason,omitempty"` +} + +func (p *extensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}, candidates []map[string]interface{}) (*MatchTrackResult, error) { + if !p.extension.Manifest.HasCustomMatching() { + return nil, fmt.Errorf("extension '%s' does not support custom matching", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + perf := newExtensionCallPerf(p.extension.ID, "matchTrack") + defer perf.finish() + initStartedAt := time.Now() + if err := p.lockReadyVM(); err != nil { + return nil, err + } + perf.recordInit(time.Since(initStartedAt)) + defer p.extension.VMMu.Unlock() + + sourceJSON, _ := json.Marshal(sourceTrack) + candidatesJSON, _ := json.Marshal(candidates) + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.matchTrack === 'function') { + return extension.matchTrack(%s, %s); + } + return null; + })() + `, string(sourceJSON), string(candidatesJSON)) + + jsStartedAt := time.Now() + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) + if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("matchTrack timeout: extension took too long to respond") + } + return nil, fmt.Errorf("matchTrack failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return &MatchTrackResult{Matched: false, Reason: "not implemented"}, nil + } + + parseStartedAt := time.Now() + matchResult := parseExtensionMatchTrackValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + perf.setItems(1) + return &matchResult, nil +} + +type PostProcessResult struct { + Success bool `json:"success"` + NewFilePath string `json:"new_file_path,omitempty"` + NewFileURI string `json:"new_file_uri,omitempty"` + Error string `json:"error,omitempty"` + BitDepth int `json:"bit_depth,omitempty"` + SampleRate int `json:"sample_rate,omitempty"` +} + +type PostProcessInput struct { + Path string `json:"path,omitempty"` + URI string `json:"uri,omitempty"` + Name string `json:"name,omitempty"` + MimeType string `json:"mime_type,omitempty"` + Size int64 `json:"size,omitempty"` + IsSAF bool `json:"is_saf,omitempty"` +} + +const PostProcessTimeout = 2 * time.Minute + +func (p *extensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) { + if !p.extension.Manifest.HasPostProcessing() { + return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + perf := newExtensionCallPerf(p.extension.ID, "postProcess") + defer perf.finish() + initStartedAt := time.Now() + if err := p.lockReadyVM(); err != nil { + return &PostProcessResult{Success: false, Error: err.Error()}, nil + } + perf.recordInit(time.Since(initStartedAt)) + defer p.extension.VMMu.Unlock() + + metadataJSON, _ := json.Marshal(metadata) + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined' && typeof extension.postProcess === 'function') { + return extension.postProcess(%q, %s, %q); + } + return null; + })() + `, filePath, string(metadataJSON), hookID) + + jsStartedAt := time.Now() + result, err := RunWithTimeoutAndRecover(p.vm, script, PostProcessTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) + if err != nil { + errMsg := err.Error() + if IsTimeoutError(err) { + errMsg = "postProcess timeout: extension took too long to complete" + } + return &PostProcessResult{ + Success: false, + Error: errMsg, + }, nil + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return &PostProcessResult{ + Success: false, + Error: "postProcess returned null", + }, nil + } + + parseStartedAt := time.Now() + postResult := parseExtensionPostProcessValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + perf.setItems(1) + return &postResult, nil +} + +func (p *extensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) { + if !p.extension.Manifest.HasPostProcessing() { + return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + perf := newExtensionCallPerf(p.extension.ID, "postProcessV2") + defer perf.finish() + initStartedAt := time.Now() + if err := p.lockReadyVM(); err != nil { + return &PostProcessResult{Success: false, Error: err.Error()}, nil + } + perf.recordInit(time.Since(initStartedAt)) + defer p.extension.VMMu.Unlock() + + metadataJSON, _ := json.Marshal(metadata) + inputJSON, _ := json.Marshal(input) + filePath := input.Path + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined') { + if (typeof extension.postProcessV2 === 'function') { + return extension.postProcessV2(%s, %s, %q); + } + if (typeof extension.postProcess === 'function') { + return extension.postProcess(%q, %s, %q); + } + } + return null; + })() + `, string(inputJSON), string(metadataJSON), hookID, filePath, string(metadataJSON), hookID) + + jsStartedAt := time.Now() + result, err := RunWithTimeoutAndRecover(p.vm, script, PostProcessTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) + if err != nil { + errMsg := err.Error() + if IsTimeoutError(err) { + errMsg = "postProcess timeout: extension took too long to complete" + } + return &PostProcessResult{ + Success: false, + Error: errMsg, + }, nil + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return &PostProcessResult{ + Success: false, + Error: "postProcess returned null", + }, nil + } + + parseStartedAt := time.Now() + postResult := parseExtensionPostProcessValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + perf.setItems(1) + return &postResult, nil +} + +type ExtLyricsResult struct { + Lines []ExtLyricsLine `json:"lines"` + SyncType string `json:"syncType"` + Instrumental bool `json:"instrumental"` + PlainLyrics string `json:"plainLyrics"` + Provider string `json:"provider"` +} + +type ExtLyricsLine struct { + StartTimeMs int64 `json:"startTimeMs"` + Words string `json:"words"` + EndTimeMs int64 `json:"endTimeMs"` +} + +func (p *extensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) { + if !p.extension.Manifest.IsLyricsProvider() { + return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + perf := newExtensionCallPerf(p.extension.ID, "fetchLyrics") + defer perf.finish() + initStartedAt := time.Now() + if err := p.lockReadyVM(); err != nil { + return nil, err + } + perf.recordInit(time.Since(initStartedAt)) + defer p.extension.VMMu.Unlock() + + // Use global variables to avoid JS injection issues with special characters in track/artist names + const trackVar = "__sf_lyrics_track" + const artistVar = "__sf_lyrics_artist" + const albumVar = "__sf_lyrics_album" + const durationVar = "__sf_lyrics_duration" + global := p.vm.GlobalObject() + _ = global.Set(trackVar, trackName) + _ = global.Set(artistVar, artistName) + _ = global.Set(albumVar, albumName) + _ = global.Set(durationVar, durationSec) + defer func() { + global.Delete(trackVar) + global.Delete(artistVar) + global.Delete(albumVar) + global.Delete(durationVar) + }() + + const script = ` + (function() { + if (typeof extension !== 'undefined' && typeof extension.fetchLyrics === 'function') { + return extension.fetchLyrics(__sf_lyrics_track, __sf_lyrics_artist, __sf_lyrics_album, __sf_lyrics_duration); + } + return null; + })() + ` + + jsStartedAt := time.Now() + result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) + if err != nil { + if IsTimeoutError(err) { + return nil, fmt.Errorf("fetchLyrics timeout: extension took too long to respond") + } + return nil, fmt.Errorf("fetchLyrics failed: %w", err) + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return nil, fmt.Errorf("fetchLyrics returned null") + } + + parseStartedAt := time.Now() + extResult, err := parseExtensionLyricsValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + if err != nil { + return nil, fmt.Errorf("failed to parse lyrics result: %w", err) + } + perf.setItems(len(extResult.Lines)) + + response := &LyricsResponse{ + SyncType: extResult.SyncType, + Instrumental: extResult.Instrumental, + PlainLyrics: extResult.PlainLyrics, + Provider: extResult.Provider, + Source: "Extension: " + p.extension.ID, + } + + if response.Provider == "" { + response.Provider = p.extension.Manifest.DisplayName + } + + for _, line := range extResult.Lines { + response.Lines = append(response.Lines, LyricsLine(line)) + } + + if len(response.Lines) == 0 && response.PlainLyrics != "" && !response.Instrumental { + response.SyncType = "UNSYNCED" + for _, line := range strings.Split(response.PlainLyrics, "\n") { + if strings.TrimSpace(line) != "" { + response.Lines = append(response.Lines, LyricsLine{ + StartTimeMs: 0, + Words: line, + EndTimeMs: 0, + }) + } + } + } + + return response, nil +} diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 27092d2e..7e5ab025 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -1,1919 +1,13 @@ package gobackend import ( - "context" - "encoding/json" "errors" "fmt" - "os" "path/filepath" "sort" - "strconv" "strings" - "sync" - "time" - - "github.com/dop251/goja" ) -type ExtTrackMetadata struct { - ID string `json:"id"` - Name string `json:"name"` - Artists string `json:"artists"` - AlbumName string `json:"album_name"` - AlbumArtist string `json:"album_artist,omitempty"` - AlbumID string `json:"album_id,omitempty"` - AlbumURL string `json:"album_url,omitempty"` - ArtistID string `json:"artist_id,omitempty"` - ArtistURL string `json:"artist_url,omitempty"` - ExternalURL string `json:"external_urls,omitempty"` - DurationMS int `json:"duration_ms"` - CoverURL string `json:"cover_url,omitempty"` - PreviewURL string `json:"preview_url,omitempty"` - Images string `json:"images,omitempty"` - ReleaseDate string `json:"release_date,omitempty"` - TrackNumber int `json:"track_number,omitempty"` - TotalTracks int `json:"total_tracks,omitempty"` - DiscNumber int `json:"disc_number,omitempty"` - TotalDiscs int `json:"total_discs,omitempty"` - ISRC string `json:"isrc,omitempty"` - ProviderID string `json:"provider_id"` - ItemType string `json:"item_type,omitempty"` - AlbumType string `json:"album_type,omitempty"` - Explicit bool `json:"explicit,omitempty"` - - TidalID string `json:"tidal_id,omitempty"` - QobuzID string `json:"qobuz_id,omitempty"` - DeezerID string `json:"deezer_id,omitempty"` - SpotifyID string `json:"spotify_id,omitempty"` - ExternalLinks map[string]string `json:"external_links,omitempty"` - - Label string `json:"label,omitempty"` - Copyright string `json:"copyright,omitempty"` - Genre string `json:"genre,omitempty"` - Composer string `json:"composer,omitempty"` - - AudioQuality string `json:"audio_quality,omitempty"` - AudioModes string `json:"audio_modes,omitempty"` -} - -func (t *ExtTrackMetadata) ResolvedCoverURL() string { - if t.CoverURL != "" { - return t.CoverURL - } - return t.Images -} - -type ExtAlbumMetadata struct { - ID string `json:"id"` - Name string `json:"name"` - Artists string `json:"artists"` - ArtistID string `json:"artist_id,omitempty"` - CoverURL string `json:"cover_url,omitempty"` - HeaderImage string `json:"header_image,omitempty"` - HeaderVideo string `json:"header_video,omitempty"` - ReleaseDate string `json:"release_date,omitempty"` - TotalTracks int `json:"total_tracks"` - AlbumType string `json:"album_type,omitempty"` - AudioTraits []string `json:"audio_traits,omitempty"` - Tracks []ExtTrackMetadata `json:"tracks"` - ProviderID string `json:"provider_id"` -} - -type ExtArtistMetadata struct { - ID string `json:"id"` - Name string `json:"name"` - ImageURL string `json:"image_url,omitempty"` - HeaderImage string `json:"header_image,omitempty"` - HeaderVideo string `json:"header_video,omitempty"` - Listeners int `json:"listeners,omitempty"` - Albums []ExtAlbumMetadata `json:"albums,omitempty"` - Releases []ExtAlbumMetadata `json:"releases,omitempty"` - TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"` - ProviderID string `json:"provider_id"` -} - -type ExtSearchResult struct { - Tracks []ExtTrackMetadata `json:"tracks"` - Total int `json:"total"` -} - -type ExtAvailabilityResult struct { - Available bool `json:"available"` - Reason string `json:"reason,omitempty"` - TrackID string `json:"track_id,omitempty"` - SkipFallback bool `json:"skip_fallback,omitempty"` -} - -type ExtDownloadURLResult struct { - URL string `json:"url"` - Format string `json:"format"` - BitDepth int `json:"bit_depth,omitempty"` - SampleRate int `json:"sample_rate,omitempty"` -} - -func manifestCapabilityStringList(manifest *ExtensionManifest, key string) []string { - if manifest == nil || manifest.Capabilities == nil { - return nil - } - - raw, ok := manifest.Capabilities[key] - if !ok { - return nil - } - - values, ok := raw.([]interface{}) - if !ok { - return nil - } - - result := make([]string, 0, len(values)) - for _, value := range values { - str, ok := value.(string) - if !ok { - continue - } - trimmed := strings.ToLower(strings.TrimSpace(str)) - if trimmed == "" { - continue - } - result = append(result, trimmed) - } - return result -} - -func extensionReplacesBuiltInProvider(ext *loadedExtension, providerID string) bool { - if ext == nil { - return false - } - - normalized := strings.ToLower(strings.TrimSpace(providerID)) - if normalized == "" { - return false - } - - for _, replaced := range manifestCapabilityStringList(ext.Manifest, "replacesBuiltInProviders") { - if replaced == normalized { - return true - } - } - - return false -} - -func trimKnownProviderPrefix(trackID, providerID string) string { - trimmedID := strings.TrimSpace(trackID) - normalizedProvider := strings.ToLower(strings.TrimSpace(providerID)) - if trimmedID == "" || normalizedProvider == "" { - return trimmedID - } - - prefix := normalizedProvider + ":" - if strings.HasPrefix(strings.ToLower(trimmedID), prefix) { - return trimmedID[len(prefix):] - } - - return trimmedID -} - -func resolvePreferredTrackIDForExtension(ext *loadedExtension, req DownloadRequest, explicitTrackID string) string { - candidates := make([]string, 0, 8) - appendCandidate := func(value string) { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return - } - for _, existing := range candidates { - if existing == trimmed { - return - } - } - candidates = append(candidates, trimmed) - } - - appendCandidate(explicitTrackID) - - if extensionReplacesBuiltInProvider(ext, "tidal") { - appendCandidate(req.TidalID) - appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "tidal")) - } - if extensionReplacesBuiltInProvider(ext, "qobuz") { - appendCandidate(req.QobuzID) - appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "qobuz")) - } - if extensionReplacesBuiltInProvider(ext, "deezer") { - appendCandidate(req.DeezerID) - appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "deezer")) - } - if extensionReplacesBuiltInProvider(ext, "spotify") { - appendCandidate(trimKnownProviderPrefix(req.SpotifyID, "spotify")) - appendCandidate(req.SpotifyID) - } - - appendCandidate(req.SpotifyID) - appendCandidate(req.TidalID) - appendCandidate(req.QobuzID) - appendCandidate(req.DeezerID) - - if len(candidates) == 0 { - return "" - } - return candidates[0] -} - -func normalizeDownloadResultExtension(candidates ...string) string { - for _, candidate := range candidates { - ext := strings.TrimSpace(strings.ToLower(candidate)) - if ext == "" { - continue - } - if !strings.HasPrefix(ext, ".") { - ext = "." + ext - } - if ext == ".mp4" { - return ".m4a" - } - return ext - } - return "" -} - -func normalizeExtensionDownloadResult(result *ExtDownloadResult) (DownloadResult, bool) { - if result == nil { - return DownloadResult{}, false - } - - downloadResult := DownloadResult{ - FilePath: strings.TrimSpace(result.FilePath), - BitDepth: result.BitDepth, - SampleRate: result.SampleRate, - AudioCodec: strings.TrimSpace(result.AudioCodec), - Title: result.Title, - Artist: result.Artist, - Album: result.Album, - ReleaseDate: result.ReleaseDate, - TrackNumber: result.TrackNumber, - TotalTracks: result.TotalTracks, - DiscNumber: result.DiscNumber, - TotalDiscs: result.TotalDiscs, - ISRC: result.ISRC, - CoverURL: result.CoverURL, - Genre: result.Genre, - Label: result.Label, - Copyright: result.Copyright, - Composer: result.Composer, - LyricsLRC: result.LyricsLRC, - DecryptionKey: result.DecryptionKey, - Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey), - ActualExtension: normalizeDownloadResultExtension(result.ActualExtension, result.OutputExtension), - ActualContainer: strings.TrimSpace(result.ActualContainer), - RequiresContainerConversion: result.RequiresContainerConversion, - } - - alreadyExists := result.AlreadyExists - if strings.HasPrefix(downloadResult.FilePath, "EXISTS:") { - alreadyExists = true - downloadResult.FilePath = strings.TrimPrefix(downloadResult.FilePath, "EXISTS:") - } - - enrichResultQualityFromFile(&downloadResult) - return downloadResult, alreadyExists -} - -func overlayExtensionDownloadMetadata(resp *DownloadResponse, result *ExtDownloadResult) { - if resp == nil || result == nil { - return - } - - if strings.TrimSpace(resp.Title) == "" && result.Title != "" { - resp.Title = result.Title - } - if strings.TrimSpace(resp.Artist) == "" && result.Artist != "" { - resp.Artist = result.Artist - } - if strings.TrimSpace(resp.Album) == "" && result.Album != "" { - resp.Album = result.Album - } - if strings.TrimSpace(resp.AlbumArtist) == "" && result.AlbumArtist != "" { - resp.AlbumArtist = result.AlbumArtist - } - if resp.TrackNumber == 0 && result.TrackNumber > 0 { - resp.TrackNumber = result.TrackNumber - } - if resp.DiscNumber == 0 && result.DiscNumber > 0 { - resp.DiscNumber = result.DiscNumber - } - if resp.TotalTracks == 0 && result.TotalTracks > 0 { - resp.TotalTracks = result.TotalTracks - } - if resp.TotalDiscs == 0 && result.TotalDiscs > 0 { - resp.TotalDiscs = result.TotalDiscs - } - if strings.TrimSpace(resp.ReleaseDate) == "" && result.ReleaseDate != "" { - resp.ReleaseDate = result.ReleaseDate - } - if strings.TrimSpace(resp.CoverURL) == "" && result.CoverURL != "" { - resp.CoverURL = result.CoverURL - } - if strings.TrimSpace(resp.ISRC) == "" && result.ISRC != "" { - resp.ISRC = result.ISRC - } - if strings.TrimSpace(resp.Genre) == "" && result.Genre != "" { - resp.Genre = result.Genre - } - if strings.TrimSpace(resp.Label) == "" && result.Label != "" { - resp.Label = result.Label - } - if strings.TrimSpace(resp.Copyright) == "" && result.Copyright != "" { - resp.Copyright = result.Copyright - } - if strings.TrimSpace(resp.Composer) == "" && result.Composer != "" { - resp.Composer = result.Composer - } - if result.LyricsLRC != "" { - resp.LyricsLRC = result.LyricsLRC - } - if result.DecryptionKey != "" { - resp.DecryptionKey = result.DecryptionKey - } - if normalized := normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey); normalized != nil { - resp.Decryption = normalized - } - if ext := normalizeDownloadResultExtension(result.ActualExtension, result.OutputExtension); ext != "" { - resp.ActualExtension = ext - } - if container := strings.TrimSpace(result.ActualContainer); container != "" { - resp.ActualContainer = container - } - if result.RequiresContainerConversion { - resp.RequiresContainerConversion = true - } -} - -func applyExtensionRequestFallbacks(resp *DownloadResponse, req DownloadRequest) { - if resp == nil { - return - } - - if req.AlbumName != "" && resp.Album == "" { - resp.Album = req.AlbumName - } - if req.AlbumArtist != "" && resp.AlbumArtist == "" { - resp.AlbumArtist = req.AlbumArtist - } - if req.ReleaseDate != "" && resp.ReleaseDate == "" { - resp.ReleaseDate = req.ReleaseDate - } - if req.ISRC != "" && resp.ISRC == "" { - resp.ISRC = req.ISRC - } - if req.TrackNumber > 0 && resp.TrackNumber == 0 { - resp.TrackNumber = req.TrackNumber - } - if req.TotalTracks > 0 && resp.TotalTracks == 0 { - resp.TotalTracks = req.TotalTracks - } - if req.DiscNumber > 0 && resp.DiscNumber == 0 { - resp.DiscNumber = req.DiscNumber - } - if req.TotalDiscs > 0 && resp.TotalDiscs == 0 { - resp.TotalDiscs = req.TotalDiscs - } - if req.CoverURL != "" && resp.CoverURL == "" { - resp.CoverURL = req.CoverURL - } -} - -func shouldStopProviderFallback(availability *ExtAvailabilityResult) bool { - return availability != nil && availability.SkipFallback -} - -func fallbackRuntimeHealthStatus(ext *loadedExtension) string { - if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 { - return "unknown" - } - - status := strings.ToLower(strings.TrimSpace(CheckExtensionHealthCached(ext).Status)) - switch status { - case "online", "degraded", "offline": - return status - default: - return "unknown" - } -} - -func prioritizeFallbackProvidersByHealth(priority []string, extManager *extensionManager, sourceProvider string) []string { - if len(priority) == 0 || extManager == nil { - return priority - } - - online := make([]string, 0, len(priority)) - degraded := make([]string, 0, len(priority)) - unknown := make([]string, 0, len(priority)) - - for _, rawProviderID := range priority { - providerID := strings.TrimSpace(rawProviderID) - if providerID == "" { - continue - } - if strings.EqualFold(providerID, sourceProvider) || !isExtensionFallbackAllowed(providerID) { - unknown = append(unknown, providerID) - continue - } - - ext, err := extManager.GetExtension(providerID) - if err != nil || ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil || !ext.Manifest.IsDownloadProvider() { - unknown = append(unknown, providerID) - continue - } - - switch fallbackRuntimeHealthStatus(ext) { - case "online": - online = append(online, providerID) - case "degraded": - degraded = append(degraded, providerID) - case "offline": - GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (service health offline)\n", providerID) - default: - unknown = append(unknown, providerID) - } - } - - result := make([]string, 0, len(online)+len(degraded)+len(unknown)) - result = append(result, online...) - result = append(result, degraded...) - result = append(result, unknown...) - return result -} - -func resolveExtensionAvailabilityReason(availability *ExtAvailabilityResult, err error) string { - if availability != nil { - if reason := strings.TrimSpace(availability.Reason); reason != "" { - return reason - } - } - if err != nil { - return err.Error() - } - return "extension requested no further fallback" -} - -func buildExtensionFallbackStoppedResponse(providerID string, availability *ExtAvailabilityResult, err error) *DownloadResponse { - reason := resolveExtensionAvailabilityReason(availability, err) - errorType := classifyDownloadErrorType(reason) - if errorType == "unknown" { - errorType = "extension_error" - } - return &DownloadResponse{ - Success: false, - Error: fmt.Sprintf("Fallback stopped by %s: %s", providerID, reason), - ErrorType: errorType, - Service: providerID, - } -} - -func shouldAbortCancelledFallback(itemID string, err error) bool { - if errors.Is(err, ErrDownloadCancelled) { - return true - } - return itemID != "" && isDownloadCancelled(itemID) -} - -func normalizeExtensionDownloadErrorType(errorType, message string) string { - normalized := strings.TrimSpace(errorType) - classified := classifyDownloadErrorType(message) - if classified != "" && classified != "unknown" { - switch strings.ToLower(normalized) { - case "", "unknown", "runtime_error", "api_error", "download_error", "extension_error": - return classified - } - } - return normalized -} - -type DownloadDecryptionInfo struct { - Strategy string `json:"strategy,omitempty"` - Key string `json:"key,omitempty"` - IV string `json:"iv,omitempty"` - InputFormat string `json:"input_format,omitempty"` - OutputExtension string `json:"output_extension,omitempty"` - Options map[string]interface{} `json:"options,omitempty"` -} - -type ExtDownloadResult struct { - Success bool `json:"success"` - FilePath string `json:"file_path,omitempty"` - AlreadyExists bool `json:"already_exists,omitempty"` - BitDepth int `json:"bit_depth,omitempty"` - SampleRate int `json:"sample_rate,omitempty"` - AudioCodec string `json:"audio_codec,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` - ErrorType string `json:"error_type,omitempty"` - RetryAfterSeconds int `json:"retry_after_seconds,omitempty"` - - Title string `json:"title,omitempty"` - Artist string `json:"artist,omitempty"` - Album string `json:"album,omitempty"` - AlbumArtist string `json:"album_artist,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"` - ReleaseDate string `json:"release_date,omitempty"` - CoverURL string `json:"cover_url,omitempty"` - ISRC string `json:"isrc,omitempty"` - Genre string `json:"genre,omitempty"` - Label string `json:"label,omitempty"` - Copyright string `json:"copyright,omitempty"` - Composer string `json:"composer,omitempty"` - LyricsLRC string `json:"lyrics_lrc,omitempty"` - DecryptionKey string `json:"decryption_key,omitempty"` - Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"` - ActualExtension string `json:"actual_extension,omitempty"` - OutputExtension string `json:"output_extension,omitempty"` - ActualContainer string `json:"actual_container,omitempty"` - RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"` -} - -const genericFFmpegMOVDecryptionStrategy = "ffmpeg.mov_key" - -func cloneDownloadDecryptionInfo(info *DownloadDecryptionInfo) *DownloadDecryptionInfo { - if info == nil { - return nil - } - - cloned := &DownloadDecryptionInfo{ - Strategy: strings.TrimSpace(info.Strategy), - Key: strings.TrimSpace(info.Key), - IV: strings.TrimSpace(info.IV), - InputFormat: strings.TrimSpace(info.InputFormat), - OutputExtension: strings.TrimSpace(info.OutputExtension), - } - if len(info.Options) > 0 { - cloned.Options = make(map[string]interface{}, len(info.Options)) - for key, value := range info.Options { - cloned.Options[key] = value - } - } - return cloned -} - -func normalizeDownloadDecryptionStrategy(strategy string) string { - switch strings.ToLower(strings.TrimSpace(strategy)) { - case "", "ffmpeg.mov_key", "ffmpeg_mov_key", "mov_decryption_key", "mp4_decryption_key", "ffmpeg.mp4_decryption_key": - return genericFFmpegMOVDecryptionStrategy - default: - return strings.TrimSpace(strategy) - } -} - -func normalizeDownloadDecryptionInfo(info *DownloadDecryptionInfo, legacyKey string) *DownloadDecryptionInfo { - normalized := cloneDownloadDecryptionInfo(info) - trimmedLegacyKey := strings.TrimSpace(legacyKey) - - if normalized == nil { - if trimmedLegacyKey == "" { - return nil - } - return &DownloadDecryptionInfo{ - Strategy: genericFFmpegMOVDecryptionStrategy, - Key: trimmedLegacyKey, - InputFormat: "mov", - } - } - - normalized.Strategy = normalizeDownloadDecryptionStrategy(normalized.Strategy) - if normalized.Key == "" && trimmedLegacyKey != "" { - normalized.Key = trimmedLegacyKey - } - if normalized.Strategy == "" && normalized.Key != "" { - normalized.Strategy = genericFFmpegMOVDecryptionStrategy - } - if normalized.Strategy == genericFFmpegMOVDecryptionStrategy && normalized.InputFormat == "" { - normalized.InputFormat = "mov" - } - if normalized.Strategy == genericFFmpegMOVDecryptionStrategy && normalized.Key == "" { - return nil - } - - return normalized -} - -func normalizedDownloadDecryptionKey(info *DownloadDecryptionInfo, legacyKey string) string { - if normalized := normalizeDownloadDecryptionInfo(info, legacyKey); normalized != nil { - if normalized.Strategy == genericFFmpegMOVDecryptionStrategy { - return normalized.Key - } - } - return strings.TrimSpace(legacyKey) -} - -type extensionProviderWrapper struct { - extension *loadedExtension - vm *goja.Runtime -} - -func newExtensionProviderWrapper(ext *loadedExtension) *extensionProviderWrapper { - return &extensionProviderWrapper{ - extension: ext, - vm: ext.VM, - } -} - -func (p *extensionProviderWrapper) lockReadyVM() error { - vm, err := p.extension.lockReadyVM() - if err != nil { - return err - } - p.vm = vm - return nil -} - -func (p *extensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) { - return p.SearchTracksForItemID(query, limit, "") -} - -func gojaValueIsEmpty(value goja.Value) bool { - return value == nil || goja.IsUndefined(value) || goja.IsNull(value) -} - -func gojaObjectString(obj *goja.Object, keys ...string) string { - for _, key := range keys { - value := obj.Get(key) - if gojaValueIsEmpty(value) { - continue - } - if str, ok := value.Export().(string); ok { - return str - } - } - return "" -} - -func gojaObjectValue(obj *goja.Object, keys ...string) goja.Value { - for _, key := range keys { - value := obj.Get(key) - if !gojaValueIsEmpty(value) { - return value - } - } - return nil -} - -func gojaObjectInt(obj *goja.Object, keys ...string) int { - for _, key := range keys { - value := obj.Get(key) - if gojaValueIsEmpty(value) { - continue - } - return int(value.ToInteger()) - } - return 0 -} - -func gojaObjectInt64(obj *goja.Object, keys ...string) int64 { - for _, key := range keys { - value := obj.Get(key) - if gojaValueIsEmpty(value) { - continue - } - return value.ToInteger() - } - return 0 -} - -func gojaObjectFloat(obj *goja.Object, keys ...string) float64 { - for _, key := range keys { - value := obj.Get(key) - if gojaValueIsEmpty(value) { - continue - } - return value.ToFloat() - } - return 0 -} - -func gojaObjectBool(obj *goja.Object, keys ...string) bool { - for _, key := range keys { - value := obj.Get(key) - if gojaValueIsEmpty(value) { - continue - } - return value.ToBoolean() - } - return false -} - -func gojaObjectInterfaceMap(obj *goja.Object, keys ...string) map[string]interface{} { - value := gojaObjectValue(obj, keys...) - if gojaValueIsEmpty(value) { - return nil - } - - exported, ok := value.Export().(map[string]interface{}) - if !ok || len(exported) == 0 { - return nil - } - return exported -} - -func gojaObjectStringMap(vm *goja.Runtime, obj *goja.Object, keys ...string) map[string]string { - value := gojaObjectValue(obj, keys...) - if gojaValueIsEmpty(value) { - return nil - } - - valueObj := value.ToObject(vm) - objectKeys := valueObj.Keys() - if len(objectKeys) == 0 { - return nil - } - - result := make(map[string]string, len(objectKeys)) - for _, childKey := range objectKeys { - childValue := valueObj.Get(childKey) - if gojaValueIsEmpty(childValue) { - continue - } - result[childKey] = childValue.String() - } - if len(result) == 0 { - return nil - } - return result -} - -func gojaObjectStringSlice(obj *goja.Object, keys ...string) []string { - value := gojaObjectValue(obj, keys...) - if gojaValueIsEmpty(value) { - return nil - } - exported, ok := value.Export().([]interface{}) - if !ok || len(exported) == 0 { - return nil - } - result := make([]string, 0, len(exported)) - for _, item := range exported { - str, ok := item.(string) - if !ok { - continue - } - str = strings.TrimSpace(str) - if str != "" { - result = append(result, str) - } - } - if len(result) == 0 { - return nil - } - return result -} - -func gojaArrayLength(value goja.Value, vm *goja.Runtime) (int, error) { - if gojaValueIsEmpty(value) { - return 0, nil - } - lengthValue := value.ToObject(vm).Get("length") - if gojaValueIsEmpty(lengthValue) { - return 0, fmt.Errorf("value is not an array") - } - length := lengthValue.ToInteger() - if length <= 0 { - return 0, nil - } - return int(length), nil -} - -func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetadata { - obj := value.ToObject(vm) - return ExtTrackMetadata{ - ID: gojaObjectString(obj, "id"), - Name: gojaObjectString(obj, "name"), - Artists: gojaObjectString(obj, "artists"), - AlbumName: gojaObjectString(obj, "album_name", "albumName"), - AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"), - AlbumID: gojaObjectString(obj, "album_id", "albumId"), - AlbumURL: gojaObjectString(obj, "album_url", "albumUrl"), - ArtistID: gojaObjectString(obj, "artist_id", "artistId"), - ArtistURL: gojaObjectString(obj, "artist_url", "artistUrl"), - ExternalURL: gojaObjectString(obj, "external_urls", "externalUrls", "external_url", "externalUrl", "url"), - DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"), - CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"), - PreviewURL: gojaObjectString(obj, "preview_url", "previewUrl"), - Images: gojaObjectString(obj, "images"), - ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"), - TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"), - TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"), - DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"), - TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"), - ISRC: gojaObjectString(obj, "isrc"), - ProviderID: gojaObjectString(obj, "provider_id", "providerId"), - ItemType: gojaObjectString(obj, "item_type", "itemType"), - AlbumType: gojaObjectString(obj, "album_type", "albumType"), - Explicit: gojaObjectBool(obj, "explicit", "is_explicit", "isExplicit"), - TidalID: gojaObjectString(obj, "tidal_id", "tidalId"), - QobuzID: gojaObjectString(obj, "qobuz_id", "qobuzId"), - DeezerID: gojaObjectString(obj, "deezer_id", "deezerId"), - SpotifyID: gojaObjectString(obj, "spotify_id", "spotifyId"), - ExternalLinks: gojaObjectStringMap(vm, obj, "external_links", "externalLinks"), - Label: gojaObjectString(obj, "label"), - Copyright: gojaObjectString(obj, "copyright"), - Genre: gojaObjectString(obj, "genre"), - Composer: gojaObjectString(obj, "composer"), - AudioQuality: gojaObjectString(obj, "audio_quality", "audioQuality"), - AudioModes: gojaObjectString(obj, "audio_modes", "audioModes"), - } -} - -func parseExtensionTrackArray(vm *goja.Runtime, value goja.Value) ([]ExtTrackMetadata, error) { - length, err := gojaArrayLength(value, vm) - if err != nil { - return nil, err - } - if length == 0 { - return []ExtTrackMetadata{}, nil - } - - arrayObj := value.ToObject(vm) - tracks := make([]ExtTrackMetadata, 0, length) - for i := 0; i < length; i++ { - trackValue := arrayObj.Get(strconv.Itoa(i)) - if gojaValueIsEmpty(trackValue) { - continue - } - tracks = append(tracks, parseExtensionTrackValue(vm, trackValue)) - } - return tracks, nil -} - -func parseExtensionAlbumValue(vm *goja.Runtime, value goja.Value) (ExtAlbumMetadata, error) { - if gojaValueIsEmpty(value) { - return ExtAlbumMetadata{}, nil - } - - obj := value.ToObject(vm) - tracks := []ExtTrackMetadata{} - if tracksValue := gojaObjectValue(obj, "tracks"); !gojaValueIsEmpty(tracksValue) { - parsedTracks, err := parseExtensionTrackArray(vm, tracksValue) - if err != nil { - return ExtAlbumMetadata{}, err - } - tracks = parsedTracks - } - - return ExtAlbumMetadata{ - ID: gojaObjectString(obj, "id"), - Name: gojaObjectString(obj, "name"), - Artists: gojaObjectString(obj, "artists"), - ArtistID: gojaObjectString(obj, "artist_id", "artistId"), - CoverURL: gojaObjectString(obj, "cover_url", "coverUrl", "images"), - HeaderImage: gojaObjectString(obj, "header_image", "headerImage"), - HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"), - ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"), - TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"), - AlbumType: gojaObjectString(obj, "album_type", "albumType"), - AudioTraits: gojaObjectStringSlice(obj, "audio_traits", "audioTraits"), - Tracks: tracks, - ProviderID: gojaObjectString(obj, "provider_id", "providerId"), - }.withTrackFallbacks(), nil -} - -// withTrackFallbacks fills the album-level artist and release date from the -// album's tracks when the extension did not provide them at the album level. -// This is a generic mechanism so any extension benefits, without per-extension -// special-casing in the app. -func (a ExtAlbumMetadata) withTrackFallbacks() ExtAlbumMetadata { - if strings.TrimSpace(a.Artists) == "" { - a.Artists = albumArtistFromTracks(a.Tracks) - } - if strings.TrimSpace(a.ReleaseDate) == "" { - a.ReleaseDate = albumReleaseDateFromTracks(a.Tracks) - } - if len(a.AudioTraits) == 0 { - a.AudioTraits = albumAudioTraitsFromTracks(a.Tracks) - } - return a -} - -// albumArtistFromTracks prefers an explicit per-track album artist, then falls -// back to the most common track artist across the album. -func albumArtistFromTracks(tracks []ExtTrackMetadata) string { - for _, t := range tracks { - if s := strings.TrimSpace(t.AlbumArtist); s != "" { - return s - } - } - counts := map[string]int{} - order := []string{} - for _, t := range tracks { - artist := strings.TrimSpace(t.Artists) - if artist == "" { - continue - } - if _, ok := counts[artist]; !ok { - order = append(order, artist) - } - counts[artist]++ - } - best := "" - bestCount := 0 - for _, artist := range order { - if counts[artist] > bestCount { - best = artist - bestCount = counts[artist] - } - } - return best -} - -// albumReleaseDateFromTracks returns the first non-empty track release date. -func albumReleaseDateFromTracks(tracks []ExtTrackMetadata) string { - for _, t := range tracks { - if s := strings.TrimSpace(t.ReleaseDate); s != "" { - return s - } - } - return "" -} - -// albumAudioTraitsFromTracks derives album-level audio badges (Dolby Atmos, -// Hi-Res Lossless, Lossless) from the per-track audio quality/mode fields that -// extensions like Tidal and Qobuz already provide. Tokens match what the album -// header understands ("dolby_atmos", "hi_res_lossless", "lossless"). -func albumAudioTraitsFromTracks(tracks []ExtTrackMetadata) []string { - atmos := false - hiRes := false - lossless := false - - for _, t := range tracks { - modes := strings.ToUpper(t.AudioModes) - quality := strings.ToUpper(t.AudioQuality) - if strings.Contains(modes, "ATMOS") || strings.Contains(quality, "ATMOS") { - atmos = true - } - if strings.Contains(quality, "HI_RES") || - strings.Contains(quality, "HIRES") || - strings.Contains(quality, "MASTER") || - strings.Contains(quality, "MQA") { - hiRes = true - } - if strings.Contains(quality, "LOSSLESS") || - strings.Contains(quality, "FLAC") { - lossless = true - } - if bd, sr := parseBitDepthSampleRate(quality); bd > 0 { - if bd > 16 || sr > 48 { - hiRes = true - } else { - lossless = true - } - } - } - - traits := []string{} - if atmos { - traits = append(traits, "dolby_atmos") - } - if hiRes { - traits = append(traits, "hi_res_lossless") - } else if lossless { - traits = append(traits, "lossless") - } - return traits -} - -// parseBitDepthSampleRate extracts a bit depth and sample rate (in kHz) from -// labels such as "24bit/96kHz", "16bit/44.1kHz" or "24bit". -func parseBitDepthSampleRate(quality string) (int, float64) { - lower := strings.ToLower(quality) - bitDepth := 0 - sampleRate := 0.0 - - if idx := strings.Index(lower, "bit"); idx > 0 { - j := idx - for j > 0 && lower[j-1] >= '0' && lower[j-1] <= '9' { - j-- - } - if n, err := strconv.Atoi(lower[j:idx]); err == nil { - bitDepth = n - } - } - if idx := strings.Index(lower, "khz"); idx > 0 { - j := idx - for j > 0 && ((lower[j-1] >= '0' && lower[j-1] <= '9') || lower[j-1] == '.') { - j-- - } - if f, err := strconv.ParseFloat(lower[j:idx], 64); err == nil { - sampleRate = f - } - } - return bitDepth, sampleRate -} - -func parseExtensionAlbumArray(vm *goja.Runtime, value goja.Value) ([]ExtAlbumMetadata, error) { - length, err := gojaArrayLength(value, vm) - if err != nil { - return nil, err - } - if length == 0 { - return []ExtAlbumMetadata{}, nil - } - - arrayObj := value.ToObject(vm) - albums := make([]ExtAlbumMetadata, 0, length) - for i := 0; i < length; i++ { - albumValue := arrayObj.Get(strconv.Itoa(i)) - if gojaValueIsEmpty(albumValue) { - continue - } - album, err := parseExtensionAlbumValue(vm, albumValue) - if err != nil { - return nil, err - } - albums = append(albums, album) - } - return albums, nil -} - -func parseExtensionArtistValue(vm *goja.Runtime, value goja.Value) (ExtArtistMetadata, error) { - if gojaValueIsEmpty(value) { - return ExtArtistMetadata{}, nil - } - - obj := value.ToObject(vm) - albums := []ExtAlbumMetadata{} - if albumsValue := gojaObjectValue(obj, "albums"); !gojaValueIsEmpty(albumsValue) { - parsedAlbums, err := parseExtensionAlbumArray(vm, albumsValue) - if err != nil { - return ExtArtistMetadata{}, err - } - albums = parsedAlbums - } - - releases := []ExtAlbumMetadata{} - if releasesValue := gojaObjectValue(obj, "releases"); !gojaValueIsEmpty(releasesValue) { - parsedReleases, err := parseExtensionAlbumArray(vm, releasesValue) - if err != nil { - return ExtArtistMetadata{}, err - } - releases = parsedReleases - } - - topTracks := []ExtTrackMetadata{} - if topTracksValue := gojaObjectValue(obj, "top_tracks", "topTracks"); !gojaValueIsEmpty(topTracksValue) { - parsedTopTracks, err := parseExtensionTrackArray(vm, topTracksValue) - if err != nil { - return ExtArtistMetadata{}, err - } - topTracks = parsedTopTracks - } - - return ExtArtistMetadata{ - ID: gojaObjectString(obj, "id"), - Name: gojaObjectString(obj, "name"), - ImageURL: gojaObjectString(obj, "image_url", "imageUrl"), - HeaderImage: gojaObjectString(obj, "header_image", "headerImage"), - HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"), - Listeners: gojaObjectInt(obj, "listeners"), - Albums: albums, - Releases: releases, - TopTracks: topTracks, - ProviderID: gojaObjectString(obj, "provider_id", "providerId"), - }, nil -} - -func parseExtensionAvailabilityValue(vm *goja.Runtime, value goja.Value) ExtAvailabilityResult { - obj := value.ToObject(vm) - return ExtAvailabilityResult{ - Available: gojaObjectBool(obj, "available"), - Reason: gojaObjectString(obj, "reason"), - TrackID: gojaObjectString(obj, "track_id", "trackId"), - SkipFallback: gojaObjectBool(obj, "skip_fallback", "skipFallback"), - } -} - -func parseExtensionDownloadURLValue(vm *goja.Runtime, value goja.Value) ExtDownloadURLResult { - obj := value.ToObject(vm) - return ExtDownloadURLResult{ - URL: gojaObjectString(obj, "url"), - Format: gojaObjectString(obj, "format"), - BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"), - SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"), - } -} - -func parseExtensionDownloadDecryptionValue(vm *goja.Runtime, value goja.Value) *DownloadDecryptionInfo { - if gojaValueIsEmpty(value) { - return nil - } - - obj := value.ToObject(vm) - info := &DownloadDecryptionInfo{ - Strategy: gojaObjectString(obj, "strategy"), - Key: gojaObjectString(obj, "key"), - IV: gojaObjectString(obj, "iv"), - InputFormat: gojaObjectString(obj, "input_format", "inputFormat"), - OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"), - Options: gojaObjectInterfaceMap(obj, "options"), - } - if info.Strategy == "" && info.Key == "" && info.IV == "" && info.InputFormat == "" && info.OutputExtension == "" && len(info.Options) == 0 { - return nil - } - return info -} - -func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDownloadResult { - obj := value.ToObject(vm) - return ExtDownloadResult{ - Success: gojaObjectBool(obj, "success"), - FilePath: gojaObjectString(obj, "file_path", "filePath", "path"), - AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"), - BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"), - SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"), - AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"), - ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"), - ErrorType: gojaObjectString(obj, "error_type", "errorType"), - RetryAfterSeconds: gojaObjectInt(obj, "retry_after_seconds", "retryAfterSeconds"), - Title: gojaObjectString(obj, "title"), - Artist: gojaObjectString(obj, "artist"), - Album: gojaObjectString(obj, "album"), - AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"), - TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"), - DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"), - TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"), - TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"), - ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"), - CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"), - ISRC: gojaObjectString(obj, "isrc"), - Genre: gojaObjectString(obj, "genre"), - Label: gojaObjectString(obj, "label"), - Copyright: gojaObjectString(obj, "copyright"), - Composer: gojaObjectString(obj, "composer"), - LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"), - DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"), - Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")), - ActualExtension: gojaObjectString(obj, "actual_extension", "actualExtension"), - OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"), - ActualContainer: gojaObjectString(obj, "actual_container", "actualContainer", "container"), - RequiresContainerConversion: gojaObjectBool( - obj, - "requires_container_conversion", - "requiresContainerConversion", - ), - } -} - -func parseExtensionURLHandleValue(vm *goja.Runtime, value goja.Value) (ExtURLHandleResult, error) { - obj := value.ToObject(vm) - handleResult := ExtURLHandleResult{ - Type: gojaObjectString(obj, "type"), - Name: gojaObjectString(obj, "name"), - CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"), - HeaderImage: gojaObjectString(obj, "header_image", "headerImage"), - HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"), - } - - if trackValue := gojaObjectValue(obj, "track"); !gojaValueIsEmpty(trackValue) { - track := parseExtensionTrackValue(vm, trackValue) - handleResult.Track = &track - } - if tracksValue := gojaObjectValue(obj, "tracks"); !gojaValueIsEmpty(tracksValue) { - tracks, err := parseExtensionTrackArray(vm, tracksValue) - if err != nil { - return ExtURLHandleResult{}, err - } - handleResult.Tracks = tracks - } - if albumValue := gojaObjectValue(obj, "album"); !gojaValueIsEmpty(albumValue) { - album, err := parseExtensionAlbumValue(vm, albumValue) - if err != nil { - return ExtURLHandleResult{}, err - } - handleResult.Album = &album - } - if artistValue := gojaObjectValue(obj, "artist"); !gojaValueIsEmpty(artistValue) { - artist, err := parseExtensionArtistValue(vm, artistValue) - if err != nil { - return ExtURLHandleResult{}, err - } - handleResult.Artist = &artist - } - - return handleResult, nil -} - -func parseExtensionMatchTrackValue(vm *goja.Runtime, value goja.Value) MatchTrackResult { - obj := value.ToObject(vm) - return MatchTrackResult{ - Matched: gojaObjectBool(obj, "matched"), - TrackID: gojaObjectString(obj, "track_id", "trackId"), - Confidence: gojaObjectFloat(obj, "confidence"), - Reason: gojaObjectString(obj, "reason"), - } -} - -func parseExtensionPostProcessValue(vm *goja.Runtime, value goja.Value) PostProcessResult { - obj := value.ToObject(vm) - return PostProcessResult{ - Success: gojaObjectBool(obj, "success"), - NewFilePath: gojaObjectString(obj, "new_file_path", "newFilePath"), - NewFileURI: gojaObjectString(obj, "new_file_uri", "newFileUri"), - Error: gojaObjectString(obj, "error"), - BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"), - SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"), - } -} - -func parseExtensionLyricsLineArray(vm *goja.Runtime, value goja.Value) ([]ExtLyricsLine, error) { - length, err := gojaArrayLength(value, vm) - if err != nil { - return nil, err - } - if length == 0 { - return []ExtLyricsLine{}, nil - } - - arrayObj := value.ToObject(vm) - lines := make([]ExtLyricsLine, 0, length) - for i := 0; i < length; i++ { - lineValue := arrayObj.Get(strconv.Itoa(i)) - if gojaValueIsEmpty(lineValue) { - continue - } - lineObj := lineValue.ToObject(vm) - lines = append(lines, ExtLyricsLine{ - StartTimeMs: gojaObjectInt64(lineObj, "startTimeMs", "start_time_ms"), - Words: gojaObjectString(lineObj, "words"), - EndTimeMs: gojaObjectInt64(lineObj, "endTimeMs", "end_time_ms"), - }) - } - return lines, nil -} - -func parseExtensionLyricsValue(vm *goja.Runtime, value goja.Value) (ExtLyricsResult, error) { - obj := value.ToObject(vm) - lines := []ExtLyricsLine{} - if linesValue := gojaObjectValue(obj, "lines"); !gojaValueIsEmpty(linesValue) { - parsedLines, err := parseExtensionLyricsLineArray(vm, linesValue) - if err != nil { - return ExtLyricsResult{}, err - } - lines = parsedLines - } - - return ExtLyricsResult{ - Lines: lines, - SyncType: gojaObjectString(obj, "syncType", "sync_type"), - Instrumental: gojaObjectBool(obj, "instrumental"), - PlainLyrics: gojaObjectString(obj, "plainLyrics", "plain_lyrics"), - Provider: gojaObjectString(obj, "provider"), - }, nil -} - -func parseExtensionSearchResult(vm *goja.Runtime, value goja.Value) (ExtSearchResult, error) { - if gojaValueIsEmpty(value) { - return ExtSearchResult{}, nil - } - - resultObj := value.ToObject(vm) - tracksValue := resultObj.Get("tracks") - if gojaValueIsEmpty(tracksValue) { - tracks, err := parseExtensionTrackArray(vm, value) - if err != nil { - return ExtSearchResult{}, err - } - return ExtSearchResult{ - Tracks: tracks, - Total: len(tracks), - }, nil - } - - tracks, err := parseExtensionTrackArray(vm, tracksValue) - if err != nil { - return ExtSearchResult{}, err - } - total := gojaObjectInt(resultObj, "total") - if total == 0 { - total = len(tracks) - } - return ExtSearchResult{ - Tracks: tracks, - Total: total, - }, nil -} - -func (p *extensionProviderWrapper) SearchTracksForItemID(query string, limit int, itemID string) (*ExtSearchResult, error) { - if !p.extension.Manifest.IsMetadataProvider() { - return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) - } - - if !p.extension.Enabled { - return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) - } - perf := newExtensionCallPerf(p.extension.ID, "searchTracks") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return nil, err - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() - if itemID != "" { - if p.extension.runtime != nil { - p.extension.runtime.setActiveDownloadItemID(itemID) - defer p.extension.runtime.clearActiveDownloadItemID() - } - initDownloadCancel(itemID) - defer clearDownloadCancel(itemID) - if isDownloadCancelled(itemID) { - return nil, ErrDownloadCancelled - } - } - - script := fmt.Sprintf(` - (function() { - if (typeof extension !== 'undefined' && typeof extension.searchTracks === 'function') { - return extension.searchTracks(%q, %d); - } - return null; - })() - `, query, limit) - - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - if isDownloadCancelled(itemID) { - return nil, ErrDownloadCancelled - } - if IsTimeoutError(err) { - return nil, fmt.Errorf("searchTracks timeout: extension took too long to respond") - } - return nil, fmt.Errorf("searchTracks failed: %w", err) - } - if isDownloadCancelled(itemID) { - return nil, ErrDownloadCancelled - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return nil, fmt.Errorf("searchTracks returned null") - } - - parseStartedAt := time.Now() - searchResult, err := parseExtensionSearchResult(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - if err != nil { - return nil, fmt.Errorf("failed to parse search result: %w", err) - } - perf.setItems(len(searchResult.Tracks)) - - for i := range searchResult.Tracks { - searchResult.Tracks[i].ProviderID = p.extension.ID - } - - return &searchResult, nil -} - -func (p *extensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) { - if !p.extension.Manifest.IsMetadataProvider() { - return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) - } - - if !p.extension.Enabled { - return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) - } - perf := newExtensionCallPerf(p.extension.ID, "getTrack") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return nil, err - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() - - script := fmt.Sprintf(` - (function() { - if (typeof extension !== 'undefined' && typeof extension.getTrack === 'function') { - return extension.getTrack(%q); - } - return null; - })() - `, trackID) - - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - if IsTimeoutError(err) { - return nil, fmt.Errorf("getTrack timeout: extension took too long to respond") - } - return nil, fmt.Errorf("getTrack failed: %w", err) - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return nil, fmt.Errorf("getTrack returned null") - } - - parseStartedAt := time.Now() - track := parseExtensionTrackValue(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - perf.setItems(1) - track.ProviderID = p.extension.ID - return &track, nil -} - -func (p *extensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) { - if !p.extension.Manifest.IsMetadataProvider() { - return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) - } - - if !p.extension.Enabled { - return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) - } - perf := newExtensionCallPerf(p.extension.ID, "getAlbum") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return nil, err - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() - - script := fmt.Sprintf(` - (function() { - if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') { - return extension.getAlbum(%q); - } - return null; - })() - `, albumID) - - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - if IsTimeoutError(err) { - return nil, fmt.Errorf("getAlbum timeout: extension took too long to respond") - } - return nil, fmt.Errorf("getAlbum failed: %w", err) - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return nil, fmt.Errorf("getAlbum returned null") - } - - parseStartedAt := time.Now() - album, err := parseExtensionAlbumValue(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - if err != nil { - return nil, fmt.Errorf("failed to parse album: %w", err) - } - perf.setItems(len(album.Tracks)) - - album.ProviderID = p.extension.ID - for i := range album.Tracks { - album.Tracks[i].ProviderID = p.extension.ID - } - return &album, nil -} - -func (p *extensionProviderWrapper) GetPlaylist(playlistID string) (*ExtAlbumMetadata, error) { - if !p.extension.Manifest.IsMetadataProvider() { - return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) - } - - if !p.extension.Enabled { - return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) - } - perf := newExtensionCallPerf(p.extension.ID, "getPlaylist") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return nil, err - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() - - 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) - - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - if IsTimeoutError(err) { - return nil, fmt.Errorf("getPlaylist timeout: extension took too long to respond") - } - return nil, fmt.Errorf("getPlaylist failed: %w", err) - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return nil, fmt.Errorf("getPlaylist returned null") - } - - parseStartedAt := time.Now() - playlist, err := parseExtensionAlbumValue(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - if err != nil { - return nil, fmt.Errorf("failed to parse playlist: %w", err) - } - perf.setItems(len(playlist.Tracks)) - - playlist.ProviderID = p.extension.ID - for i := range playlist.Tracks { - playlist.Tracks[i].ProviderID = p.extension.ID - } - return &playlist, nil -} - -func (p *extensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) { - if !p.extension.Manifest.IsMetadataProvider() { - return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) - } - - if !p.extension.Enabled { - return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) - } - perf := newExtensionCallPerf(p.extension.ID, "getArtist") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return nil, err - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() - - script := fmt.Sprintf(` - (function() { - if (typeof extension !== 'undefined' && typeof extension.getArtist === 'function') { - return extension.getArtist(%q); - } - return null; - })() - `, artistID) - - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - if IsTimeoutError(err) { - return nil, fmt.Errorf("getArtist timeout: extension took too long to respond") - } - return nil, fmt.Errorf("getArtist failed: %w", err) - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return nil, fmt.Errorf("getArtist returned null") - } - - parseStartedAt := time.Now() - artist, err := parseExtensionArtistValue(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - if err != nil { - return nil, fmt.Errorf("failed to parse artist: %w", err) - } - perf.setItems(len(artist.Albums) + len(artist.Releases) + len(artist.TopTracks)) - - artist.ProviderID = p.extension.ID - for i := range artist.Releases { - artist.Releases[i].ProviderID = p.extension.ID - for j := range artist.Releases[i].Tracks { - artist.Releases[i].Tracks[j].ProviderID = p.extension.ID - } - } - return &artist, nil -} - -func (p *extensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) { - return p.EnrichTrackForItemID(track, "") -} - -func (p *extensionProviderWrapper) EnrichTrackForItemID(track *ExtTrackMetadata, itemID string) (*ExtTrackMetadata, error) { - if !p.extension.Manifest.IsMetadataProvider() { - return track, nil - } - - if !p.extension.Enabled { - return track, nil - } - perf := newExtensionCallPerf(p.extension.ID, "enrichTrack") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - GoLog("[Extension] EnrichTrack init error for %s: %v\n", p.extension.ID, err) - return track, nil - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() - if itemID != "" { - if p.extension.runtime != nil { - p.extension.runtime.setActiveDownloadItemID(itemID) - defer p.extension.runtime.clearActiveDownloadItemID() - } - initDownloadCancel(itemID) - defer clearDownloadCancel(itemID) - if isDownloadCancelled(itemID) { - return track, ErrDownloadCancelled - } - } - - trackJSON, err := json.Marshal(track) - if err != nil { - GoLog("[Extension] EnrichTrack: failed to marshal track: %v\n", err) - return track, nil - } - - script := fmt.Sprintf(` - (function() { - if (typeof extension !== 'undefined' && typeof extension.enrichTrack === 'function') { - var track = %s; - return extension.enrichTrack(track); - } - return null; - })() - `, string(trackJSON)) - - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - if isDownloadCancelled(itemID) { - return track, ErrDownloadCancelled - } - if IsTimeoutError(err) { - GoLog("[Extension] EnrichTrack timeout for %s\n", p.extension.ID) - } else { - GoLog("[Extension] EnrichTrack error for %s: %v\n", p.extension.ID, err) - } - return track, nil - } - if isDownloadCancelled(itemID) { - return track, ErrDownloadCancelled - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return track, nil - } - - parseStartedAt := time.Now() - enrichedTrack := parseExtensionTrackValue(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - perf.setItems(1) - enrichedTrack.ProviderID = track.ProviderID - - return &enrichedTrack, nil -} - -func (p *extensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID string) (*ExtAvailabilityResult, error) { - return p.CheckAvailabilityForItemID(isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID, 0, "") -} - -func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID string, durationMS int, itemID string) (*ExtAvailabilityResult, error) { - if !p.extension.Manifest.IsDownloadProvider() { - return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) - } - - if !p.extension.Enabled { - return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) - } - perf := newExtensionCallPerf(p.extension.ID, "checkAvailability") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return nil, err - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() - if itemID != "" { - if p.extension.runtime != nil { - p.extension.runtime.setActiveDownloadItemID(itemID) - defer p.extension.runtime.clearActiveDownloadItemID() - } - initDownloadCancel(itemID) - defer clearDownloadCancel(itemID) - if isDownloadCancelled(itemID) { - return nil, ErrDownloadCancelled - } - } - - script := fmt.Sprintf(` - (function() { - if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') { - return extension.checkAvailability(%q, %q, %q, { - spotify_id: %q, - deezer_id: %q, - tidal_id: %q, - qobuz_id: %q, - duration_ms: %d - }); - } - return null; - })() - `, isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID, durationMS) - - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - if isDownloadCancelled(itemID) { - return nil, ErrDownloadCancelled - } - if IsTimeoutError(err) { - return nil, fmt.Errorf("checkAvailability timeout: extension took too long to respond") - } - return nil, fmt.Errorf("checkAvailability failed: %w", err) - } - if isDownloadCancelled(itemID) { - return nil, ErrDownloadCancelled - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return &ExtAvailabilityResult{Available: false, Reason: "not implemented"}, nil - } - - parseStartedAt := time.Now() - availability := parseExtensionAvailabilityValue(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - perf.setItems(1) - return &availability, nil -} - -func (p *extensionProviderWrapper) GetDownloadURL(trackID, quality string) (*ExtDownloadURLResult, error) { - if !p.extension.Manifest.IsDownloadProvider() { - return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) - } - - if !p.extension.Enabled { - return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) - } - perf := newExtensionCallPerf(p.extension.ID, "getDownloadUrl") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return nil, err - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() - - script := fmt.Sprintf(` - (function() { - if (typeof extension !== 'undefined' && typeof extension.getDownloadUrl === 'function') { - return extension.getDownloadUrl(%q, %q); - } - return null; - })() - `, trackID, quality) - - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - if IsTimeoutError(err) { - return nil, fmt.Errorf("getDownloadUrl timeout: extension took too long to respond") - } - return nil, fmt.Errorf("getDownloadUrl failed: %w", err) - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return nil, fmt.Errorf("getDownloadUrl returned null") - } - - parseStartedAt := time.Now() - urlResult := parseExtensionDownloadURLValue(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - perf.setItems(1) - return &urlResult, nil -} - -const ExtDownloadTimeout = DownloadTimeout - -func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) { - if !p.extension.Manifest.IsDownloadProvider() { - return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) - } - - if !p.extension.Enabled { - return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) - } - perf := newExtensionCallPerf(p.extension.ID, "download") - defer perf.finish() - initStartedAt := time.Now() - p.extension.VMMu.Lock() - vm, runtime, err := newIsolatedExtensionRuntime(p.extension) - p.extension.VMMu.Unlock() - perf.recordInit(time.Since(initStartedAt)) - if err != nil { - return &ExtDownloadResult{ - Success: false, - ErrorMessage: err.Error(), - ErrorType: "init_error", - }, nil - } - defer func() { - if cleanupErr := runCleanupOnVM(vm); cleanupErr != nil { - GoLog("[Extension:%s] isolated download cleanup failed: %v\n", p.extension.ID, cleanupErr) - } - if runtime != nil { - if flushErr := runtime.flushStorageNow(); flushErr != nil { - GoLog("[Extension:%s] isolated download storage flush failed: %v\n", p.extension.ID, flushErr) - } - runtime.closeStorageFlusher() - } - }() - if runtime != nil { - runtime.setActiveDownloadItemID(itemID) - defer runtime.clearActiveDownloadItemID() - } - if itemID != "" { - initDownloadCancel(itemID) - defer clearDownloadCancel(itemID) - SetItemPreparing(itemID) - } - - vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) > 0 { - percent := int(call.Arguments[0].ToInteger()) - if percent < 0 { - percent = 0 - } - if percent > 100 { - percent = 100 - } - if onProgress != nil { - onProgress(percent) - } - } - return goja.Undefined() - }) - - script := fmt.Sprintf(` - (function() { - if (typeof extension !== 'undefined' && typeof extension.download === 'function') { - return extension.download(%q, %q, %q, __onProgress); - } - return null; - })() - `, trackID, quality, outputPath) - - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(vm, script, ExtDownloadTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - errMsg := err.Error() - errType := "script_error" - if IsTimeoutError(err) { - errMsg = "download timeout: extension took too long to complete" - errType = "timeout" - } - return &ExtDownloadResult{ - Success: false, - ErrorMessage: errMsg, - ErrorType: errType, - }, nil - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return &ExtDownloadResult{ - Success: false, - ErrorMessage: "download returned null", - ErrorType: "not_implemented", - }, nil - } - - parseStartedAt := time.Now() - downloadResult := parseExtensionDownloadResultValue(vm, result) - perf.recordParse(time.Since(parseStartedAt)) - perf.setItems(1) - downloadResult.Decryption = normalizeDownloadDecryptionInfo( - downloadResult.Decryption, - downloadResult.DecryptionKey, - ) - downloadResult.DecryptionKey = normalizedDownloadDecryptionKey( - downloadResult.Decryption, - downloadResult.DecryptionKey, - ) - - return &downloadResult, nil -} - func (m *extensionManager) GetMetadataProviders() []*extensionProviderWrapper { m.mu.RLock() defer m.mu.RUnlock() @@ -1983,201 +77,6 @@ func (m *extensionManager) SearchTracksWithExtensions(query string, limit int) ( return allTracks, nil } -var providerPriority []string -var providerPriorityMu sync.RWMutex - -var extensionFallbackProviderIDs []string -var extensionFallbackProviderIDsMu sync.RWMutex - -var metadataProviderPriority []string -var metadataProviderPriorityMu sync.RWMutex - -func SetProviderPriority(providerIDs []string) { - providerPriorityMu.Lock() - defer providerPriorityMu.Unlock() - providerPriority = sanitizeDownloadProviderPriority(providerIDs) - GoLog("[Extension] Download provider priority set: %v\n", providerPriority) -} - -func GetProviderPriority() []string { - providerPriorityMu.RLock() - defer providerPriorityMu.RUnlock() - - if len(providerPriority) == 0 { - return []string{} - } - - result := make([]string, len(providerPriority)) - copy(result, providerPriority) - return result -} - -func sanitizeDownloadProviderPriority(providerIDs []string) []string { - sanitized := make([]string, 0, len(providerIDs)) - seen := map[string]struct{}{} - - for _, providerID := range providerIDs { - providerID = strings.TrimSpace(providerID) - if providerID == "" { - continue - } - - if isRetiredBuiltInDownloadProvider(providerID) { - continue - } - - seenKey := strings.ToLower(providerID) - if _, exists := seen[seenKey]; exists { - continue - } - seen[seenKey] = struct{}{} - sanitized = append(sanitized, providerID) - } - - return sanitized -} - -func isRetiredBuiltInDownloadProvider(providerID string) bool { - normalized := strings.ToLower(strings.TrimSpace(providerID)) - if normalized == "" { - return false - } - switch normalized { - case "deezer", "qobuz", "tidal": - return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool { - return manifest.IsDownloadProvider() - }) - default: - return false - } -} - -func isRetiredBuiltInMetadataProvider(providerID string) bool { - normalized := strings.ToLower(strings.TrimSpace(providerID)) - if normalized == "" { - return false - } - switch normalized { - case "deezer", "spotify", "qobuz", "tidal": - return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool { - return manifest.IsMetadataProvider() - }) - default: - return false - } -} - -func hasEnabledExtensionProvider(providerID string, matches func(*ExtensionManifest) bool) bool { - if providerID == "" || matches == nil { - return false - } - - manager := getExtensionManager() - manager.mu.RLock() - defer manager.mu.RUnlock() - - for id, ext := range manager.extensions { - if !strings.EqualFold(strings.TrimSpace(id), providerID) { - continue - } - if ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil { - return false - } - return matches(ext.Manifest) - } - - return false -} - -func SetExtensionFallbackProviderIDs(providerIDs []string) { - extensionFallbackProviderIDsMu.Lock() - defer extensionFallbackProviderIDsMu.Unlock() - - if providerIDs == nil { - extensionFallbackProviderIDs = nil - GoLog("[Extension] Extension fallback providers reset to default (all enabled download extensions)\n") - return - } - - sanitized := make([]string, 0, len(providerIDs)) - seen := map[string]struct{}{} - for _, providerID := range providerIDs { - providerID = strings.TrimSpace(providerID) - if providerID == "" { - continue - } - if _, exists := seen[providerID]; exists { - continue - } - seen[providerID] = struct{}{} - sanitized = append(sanitized, providerID) - } - - extensionFallbackProviderIDs = sanitized - GoLog("[Extension] Extension fallback providers set: %v\n", sanitized) -} - -func GetExtensionFallbackProviderIDs() []string { - extensionFallbackProviderIDsMu.RLock() - defer extensionFallbackProviderIDsMu.RUnlock() - - if extensionFallbackProviderIDs == nil { - return nil - } - - result := make([]string, len(extensionFallbackProviderIDs)) - copy(result, extensionFallbackProviderIDs) - return result -} - -func isExtensionFallbackAllowed(providerID string) bool { - allowed := GetExtensionFallbackProviderIDs() - if allowed == nil { - return true - } - - for _, allowedProviderID := range allowed { - if allowedProviderID == providerID { - return true - } - } - return false -} - -func SetMetadataProviderPriority(providerIDs []string) { - metadataProviderPriorityMu.Lock() - defer metadataProviderPriorityMu.Unlock() - - sanitized := make([]string, 0, len(providerIDs)) - seen := map[string]struct{}{} - for _, providerID := range providerIDs { - providerID = strings.TrimSpace(providerID) - if providerID == "" || isRetiredBuiltInMetadataProvider(providerID) { - continue - } - if _, exists := seen[providerID]; exists { - continue - } - seen[providerID] = struct{}{} - sanitized = append(sanitized, providerID) - } - metadataProviderPriority = sanitized - GoLog("[Extension] Metadata provider priority set: %v\n", sanitized) -} - -func GetMetadataProviderPriority() []string { - metadataProviderPriorityMu.RLock() - defer metadataProviderPriorityMu.RUnlock() - - if len(metadataProviderPriority) == 0 { - return []string{} - } - - result := make([]string, len(metadataProviderPriority)) - copy(result, metadataProviderPriority) - return result -} - func metadataTrackDedupKey(track ExtTrackMetadata) string { if isrc := strings.TrimSpace(track.ISRC); isrc != "" { return "isrc:" + strings.ToUpper(isrc) @@ -2277,1221 +176,6 @@ func (m *extensionManager) SearchTracksWithMetadataProvidersForItemID(query stri return tracks, nil } -func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, error) { - priority := GetProviderPriority() - extManager := getExtensionManager() - strictMode := !req.UseFallback - selectedProvider := strings.TrimSpace(req.Service) - - if isDownloadCancelled(req.ItemID) { - return nil, ErrDownloadCancelled - } - - if strictMode { - if selectedProvider == "" { - selectedProvider = strings.TrimSpace(req.Source) - } - if selectedProvider != "" { - priority = []string{selectedProvider} - GoLog("[DownloadWithExtensionFallback] Strict mode enabled, provider locked to: %s\n", selectedProvider) - } - } - - if !strictMode && req.Service != "" { - found := false - for _, p := range priority { - if strings.EqualFold(p, req.Service) { - found = true - break - } - } - newPriority := []string{req.Service} - for _, p := range priority { - if !strings.EqualFold(p, req.Service) { - newPriority = append(newPriority, p) - } - } - priority = newPriority - if !found { - GoLog("[DownloadWithExtensionFallback] Extension service '%s' added to priority front\n", req.Service) - } else { - GoLog("[DownloadWithExtensionFallback] Extension service '%s' moved to priority front\n", req.Service) - } - GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority) - } - - var lastErr error - var lastErrType string - var lastRetryAfterSeconds int - var stopProviderFallback bool - var sourceExtensionLocked bool - var sourceExtensionAvailability *ExtAvailabilityResult - var sourceExtensionTrackID string - - if req.Source != "" && selectedProvider != req.Source { - ext, err := extManager.GetExtension(req.Source) - if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() { - provider := newExtensionProviderWrapper(ext) - availability, availErr := provider.CheckAvailabilityForItemID(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID, req.TidalID, req.QobuzID, req.DurationMS, req.ItemID) - if shouldAbortCancelledFallback(req.ItemID, availErr) { - return nil, ErrDownloadCancelled - } - if availErr != nil { - GoLog("[DownloadWithExtensionFallback] Source extension %s preflight failed (non-fatal): %v\n", req.Source, availErr) - } else if shouldStopProviderFallback(availability) { - sourceExtensionLocked = true - sourceExtensionAvailability = availability - sourceExtensionTrackID = strings.TrimSpace(availability.TrackID) - selectedProvider = req.Source - GoLog("[DownloadWithExtensionFallback] Source extension %s requested skip_fallback (available=%v), locking download to source extension\n", req.Source, availability.Available) - } - } - } - - if req.Source != "" { - ext, err := extManager.GetExtension(req.Source) - if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() { - GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source) - - provider := newExtensionProviderWrapper(ext) - trackMeta := &ExtTrackMetadata{ - ID: req.SpotifyID, - Name: req.TrackName, - Artists: req.ArtistName, - AlbumName: req.AlbumName, - DurationMS: req.DurationMS, - ISRC: req.ISRC, - ReleaseDate: req.ReleaseDate, - TrackNumber: req.TrackNumber, - TotalTracks: req.TotalTracks, - DiscNumber: req.DiscNumber, - TotalDiscs: req.TotalDiscs, - ProviderID: req.Source, - Composer: req.Composer, - } - - enrichedTrack, err := provider.EnrichTrackForItemID(trackMeta, req.ItemID) - if shouldAbortCancelledFallback(req.ItemID, err) { - return nil, ErrDownloadCancelled - } - if err == nil && enrichedTrack != nil { - if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC { - GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC) - req.ISRC = enrichedTrack.ISRC - } - if enrichedTrack.TidalID != "" { - GoLog("[DownloadWithExtensionFallback] Tidal ID from Odesli: %s\n", enrichedTrack.TidalID) - req.TidalID = enrichedTrack.TidalID - } - if enrichedTrack.QobuzID != "" { - GoLog("[DownloadWithExtensionFallback] Qobuz ID from Odesli: %s\n", enrichedTrack.QobuzID) - req.QobuzID = enrichedTrack.QobuzID - } - if enrichedTrack.DeezerID != "" { - GoLog("[DownloadWithExtensionFallback] Deezer ID from Odesli: %s\n", enrichedTrack.DeezerID) - req.DeezerID = enrichedTrack.DeezerID - } - if enrichedTrack.Name != "" { - req.TrackName = enrichedTrack.Name - } - if enrichedTrack.Artists != "" { - req.ArtistName = enrichedTrack.Artists - } - if enrichedTrack.AlbumName != "" && req.AlbumName == "" { - GoLog("[DownloadWithExtensionFallback] AlbumName from enrichment: %s\n", enrichedTrack.AlbumName) - req.AlbumName = enrichedTrack.AlbumName - } - if enrichedTrack.AlbumArtist != "" && req.AlbumArtist == "" { - req.AlbumArtist = enrichedTrack.AlbumArtist - } - if enrichedTrack.DurationMS > 0 && req.DurationMS == 0 { - GoLog("[DownloadWithExtensionFallback] DurationMS from enrichment: %d\n", enrichedTrack.DurationMS) - req.DurationMS = enrichedTrack.DurationMS - } - if enrichedTrack.CoverURL != "" && req.CoverURL == "" { - req.CoverURL = enrichedTrack.CoverURL - } - if enrichedTrack.ID != "" && req.SpotifyID == "" { - GoLog("[DownloadWithExtensionFallback] Track ID from enrichment: %s\n", enrichedTrack.ID) - req.SpotifyID = enrichedTrack.ID - } - if enrichedTrack.Label != "" && req.Label == "" { - GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label) - req.Label = enrichedTrack.Label - } - if enrichedTrack.Copyright != "" && req.Copyright == "" { - GoLog("[DownloadWithExtensionFallback] Copyright from enrichment: %s\n", enrichedTrack.Copyright) - req.Copyright = enrichedTrack.Copyright - } - if enrichedTrack.Genre != "" && req.Genre == "" { - GoLog("[DownloadWithExtensionFallback] Genre from enrichment: %s\n", enrichedTrack.Genre) - req.Genre = enrichedTrack.Genre - } - if enrichedTrack.ReleaseDate != "" && req.ReleaseDate == "" { - GoLog("[DownloadWithExtensionFallback] ReleaseDate from enrichment: %s\n", enrichedTrack.ReleaseDate) - req.ReleaseDate = enrichedTrack.ReleaseDate - } - if enrichedTrack.TrackNumber > 0 && req.TrackNumber == 0 { - GoLog("[DownloadWithExtensionFallback] TrackNumber from enrichment: %d\n", enrichedTrack.TrackNumber) - req.TrackNumber = enrichedTrack.TrackNumber - } - if enrichedTrack.TotalTracks > 0 && req.TotalTracks == 0 { - GoLog("[DownloadWithExtensionFallback] TotalTracks from enrichment: %d\n", enrichedTrack.TotalTracks) - req.TotalTracks = enrichedTrack.TotalTracks - } - if enrichedTrack.DiscNumber > 0 && req.DiscNumber == 0 { - GoLog("[DownloadWithExtensionFallback] DiscNumber from enrichment: %d\n", enrichedTrack.DiscNumber) - req.DiscNumber = enrichedTrack.DiscNumber - } - if enrichedTrack.TotalDiscs > 0 && req.TotalDiscs == 0 { - GoLog("[DownloadWithExtensionFallback] TotalDiscs from enrichment: %d\n", enrichedTrack.TotalDiscs) - req.TotalDiscs = enrichedTrack.TotalDiscs - } - if enrichedTrack.Composer != "" && req.Composer == "" { - GoLog("[DownloadWithExtensionFallback] Composer from enrichment: %s\n", enrichedTrack.Composer) - req.Composer = enrichedTrack.Composer - } - } - } - } - - if req.Source != "" && - req.TrackName != "" && req.ArtistName != "" && - (req.AlbumName == "" || req.ReleaseDate == "" || req.ISRC == "") { - - searchQuery := req.TrackName + " " + req.ArtistName - GoLog("[DownloadWithExtensionFallback] Metadata incomplete, searching providers for: %s\n", searchQuery) - - tracks, searchErr := extManager.SearchTracksWithMetadataProvidersForItemID(searchQuery, 5, true, req.ItemID) - if shouldAbortCancelledFallback(req.ItemID, searchErr) { - return nil, ErrDownloadCancelled - } - if searchErr == nil && len(tracks) > 0 { - track := tracks[0] - GoLog("[DownloadWithExtensionFallback] Metadata match (%s): %s - %s (album: %s, date: %s, isrc: %s)\n", - track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate, track.ISRC) - - if track.AlbumName != "" && req.AlbumName == "" { - req.AlbumName = track.AlbumName - } - if track.AlbumArtist != "" && req.AlbumArtist == "" { - req.AlbumArtist = track.AlbumArtist - } - if track.ReleaseDate != "" && req.ReleaseDate == "" { - req.ReleaseDate = track.ReleaseDate - } - if track.ISRC != "" && req.ISRC == "" { - req.ISRC = track.ISRC - } - if track.TrackNumber > 0 && req.TrackNumber == 0 { - req.TrackNumber = track.TrackNumber - } - if track.TotalTracks > 0 && req.TotalTracks == 0 { - req.TotalTracks = track.TotalTracks - } - if track.DiscNumber > 0 && req.DiscNumber == 0 { - req.DiscNumber = track.DiscNumber - } - if track.TotalDiscs > 0 && req.TotalDiscs == 0 { - req.TotalDiscs = track.TotalDiscs - } - if track.Composer != "" && req.Composer == "" { - req.Composer = track.Composer - } - if track.CoverURL != "" && req.CoverURL == "" { - req.CoverURL = track.CoverURL - } - if track.Genre != "" && req.Genre == "" { - req.Genre = track.Genre - } - if track.Label != "" && req.Label == "" { - req.Label = track.Label - } - if track.Copyright != "" && req.Copyright == "" { - req.Copyright = track.Copyright - } - } else if searchErr != nil { - GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr) - } - - if req.ISRC != "" && - (req.Genre == "" || req.Label == "" || req.Copyright == "") { - enrichExtraMetadataByISRC("DownloadWithExtensionFallback", req.ISRC, &req.Genre, &req.Label, &req.Copyright) - if isDownloadCancelled(req.ItemID) { - return nil, ErrDownloadCancelled - } - } - } - - if req.Source != "" && selectedProvider == req.Source { - if isDownloadCancelled(req.ItemID) { - return nil, ErrDownloadCancelled - } - - if sourceExtensionLocked && (sourceExtensionAvailability == nil || !sourceExtensionAvailability.Available) { - GoLog("[DownloadWithExtensionFallback] Source extension %s stopped fallback before download (reason: %s)\n", req.Source, resolveExtensionAvailabilityReason(sourceExtensionAvailability, nil)) - return buildExtensionFallbackStoppedResponse(req.Source, sourceExtensionAvailability, nil), nil - } - - GoLog("[DownloadWithExtensionFallback] Track source is extension '%s' matching selected provider, trying it first\n", req.Source) - - ext, err := extManager.GetExtension(req.Source) - if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() { - stopProviderFallback = ext.Manifest.StopsProviderFallback() - - provider := newExtensionProviderWrapper(ext) - - trackID := resolvePreferredTrackIDForExtension(ext, req, sourceExtensionTrackID) - - GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (stopProviderFallback: %v)\n", trackID, stopProviderFallback) - - outputPath := buildOutputPathForExtension(req, ext) - if req.ItemID != "" { - StartItemProgress(req.ItemID) - } - - result, err := provider.Download(trackID, req.Quality, outputPath, req.ItemID, func(percent int) { - if req.ItemID != "" { - normalized := float64(percent) / 100.0 - if normalized < 0 { - normalized = 0 - } - if normalized > 1 { - normalized = 1 - } - SetItemProgress(req.ItemID, normalized, 0, 0) - } - }) - if req.ItemID != "" { - if err == nil && result != nil && result.Success { - CompleteItemProgress(req.ItemID) - } else { - RemoveItemProgress(req.ItemID) - } - } - if shouldAbortCancelledFallback(req.ItemID, err) { - return nil, ErrDownloadCancelled - } - - if err == nil && result.Success { - normalizedResult, alreadyExists := normalizeExtensionDownloadResult(result) - message := "Downloaded from " + req.Source - if alreadyExists { - message = "File already exists" - } - - resp := buildDownloadSuccessResponse( - req, - normalizedResult, - req.Source, - message, - normalizedResult.FilePath, - alreadyExists, - ) - overlayExtensionDownloadMetadata(&resp, result) - if ext.Manifest.SkipMetadataEnrichment { - resp.SkipMetadataEnrichment = true - } - applyExtensionRequestFallbacks(&resp, req) - - if req.TrackName != "" && resp.Title == "" { - resp.Title = req.TrackName - } - if req.ArtistName != "" && resp.Artist == "" { - resp.Artist = req.ArtistName - } - if req.Composer != "" && resp.Composer == "" { - resp.Composer = req.Composer - } - - embedExtensionDownloadMetadata(resp, req, alreadyExists) - - if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" { - indexISRC := strings.TrimSpace(resp.ISRC) - if indexISRC == "" { - indexISRC = strings.TrimSpace(req.ISRC) - } - if indexISRC != "" && strings.TrimSpace(resp.FilePath) != "" { - AddToISRCIndex(req.OutputDir, indexISRC, resp.FilePath) - } - } - - return &resp, nil - } - - if err != nil { - if errors.Is(err, ErrDownloadCancelled) { - return &DownloadResponse{ - Success: false, - Error: "Download cancelled", - ErrorType: "cancelled", - Service: req.Source, - }, nil - } - lastErr = err - lastErrType = "" - } else if result.ErrorMessage != "" { - lastErr = fmt.Errorf("%s", result.ErrorMessage) - lastErrType = normalizeExtensionDownloadErrorType(result.ErrorType, result.ErrorMessage) - lastRetryAfterSeconds = result.RetryAfterSeconds - } - GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr) - - if strings.EqualFold(lastErrType, "verification_required") { - GoLog("[DownloadWithExtensionFallback] Source extension %s requires verification, not trying other providers\n", req.Source) - return &DownloadResponse{ - Success: false, - Error: "Download failed: " + lastErr.Error(), - ErrorType: "verification_required", - Service: req.Source, - }, nil - } - - if stopProviderFallback || sourceExtensionLocked { - if sourceExtensionLocked { - GoLog("[DownloadWithExtensionFallback] Source extension %s requested skip_fallback, not trying other providers\n", req.Source) - return buildExtensionFallbackStoppedResponse(req.Source, sourceExtensionAvailability, lastErr), nil - } - GoLog("[DownloadWithExtensionFallback] stopProviderFallback is true, not trying other providers\n") - return &DownloadResponse{ - Success: false, - Error: "Download failed: " + lastErr.Error(), - ErrorType: firstNonEmptyString(lastErrType, "extension_error"), - RetryAfterSeconds: lastRetryAfterSeconds, - Service: req.Source, - }, nil - } - } else { - GoLog("[DownloadWithExtensionFallback] Source extension %s not available or not a download provider\n", req.Source) - } - } - - priority = prioritizeFallbackProvidersByHealth(priority, extManager, req.Source) - - for _, providerID := range priority { - if isDownloadCancelled(req.ItemID) { - return nil, ErrDownloadCancelled - } - - providerID = strings.TrimSpace(providerID) - if providerID == "" { - continue - } - // Skip the origin extension only when it differs from the explicitly - // selected provider; otherwise it must still be attempted here. - if providerID == req.Source && req.Source != selectedProvider { - continue - } - - if providerID != selectedProvider && !isExtensionFallbackAllowed(providerID) { - GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (not enabled for fallback)\n", providerID) - continue - } - - GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID) - - { - ext, err := extManager.GetExtension(providerID) - if err != nil || !ext.Enabled || ext.Error != "" { - GoLog("[DownloadWithExtensionFallback] Extension %s not available\n", providerID) - continue - } - - if !ext.Manifest.IsDownloadProvider() { - continue - } - - provider := newExtensionProviderWrapper(ext) - - availability, err := provider.CheckAvailabilityForItemID(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID, req.TidalID, req.QobuzID, req.DurationMS, req.ItemID) - if shouldAbortCancelledFallback(req.ItemID, err) { - return nil, ErrDownloadCancelled - } - terminalAvailability := shouldStopProviderFallback(availability) - if err != nil || !availability.Available { - GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID) - if err != nil { - lastErr = err - if strings.EqualFold(classifyDownloadErrorType(err.Error()), "verification_required") { - GoLog("[DownloadWithExtensionFallback] %s requires verification (availability); pausing fallback to open the challenge\n", providerID) - return &DownloadResponse{ - Success: false, - Error: "Download failed: " + err.Error(), - ErrorType: "verification_required", - Service: providerID, - }, nil - } - } - if terminalAvailability { - GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after availability check\n", providerID) - return buildExtensionFallbackStoppedResponse(providerID, availability, err), nil - } - continue - } - - req.OutputExt = "" - outputPath := buildOutputPathForExtension(req, ext) - if req.ItemID != "" { - StartItemProgress(req.ItemID) - } - - // Honor the requested quality when this provider recognizes it - // (e.g. an explicit user selection). Only when the token is not - // one of this provider's own options do we fall back to its - // highest quality, since a source provider's token may not map. - fallbackQuality := req.Quality - if len(ext.Manifest.QualityOptions) > 0 { - requested := strings.TrimSpace(req.Quality) - recognized := false - if requested != "" { - for _, opt := range ext.Manifest.QualityOptions { - if strings.EqualFold(strings.TrimSpace(opt.ID), requested) { - recognized = true - break - } - } - } - if !recognized { - if best := strings.TrimSpace(ext.Manifest.QualityOptions[0].ID); best != "" { - fallbackQuality = best - } - } - } - - result, err := provider.Download(availability.TrackID, fallbackQuality, outputPath, req.ItemID, func(percent int) { - if req.ItemID != "" { - normalized := float64(percent) / 100.0 - if normalized < 0 { - normalized = 0 - } - if normalized > 1 { - normalized = 1 - } - SetItemProgress(req.ItemID, normalized, 0, 0) - } - }) - if req.ItemID != "" { - if err == nil && result != nil && result.Success { - CompleteItemProgress(req.ItemID) - } else { - RemoveItemProgress(req.ItemID) - } - } - if shouldAbortCancelledFallback(req.ItemID, err) { - return nil, ErrDownloadCancelled - } - - if err == nil && result.Success { - normalizedResult, alreadyExists := normalizeExtensionDownloadResult(result) - message := "Downloaded from " + providerID - if alreadyExists { - message = "File already exists" - } - - resp := buildDownloadSuccessResponse( - req, - normalizedResult, - providerID, - message, - normalizedResult.FilePath, - alreadyExists, - ) - overlayExtensionDownloadMetadata(&resp, result) - if ext.Manifest.SkipMetadataEnrichment { - resp.SkipMetadataEnrichment = true - } - applyExtensionRequestFallbacks(&resp, req) - - embedExtensionDownloadMetadata(resp, req, alreadyExists) - - if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" { - indexISRC := strings.TrimSpace(resp.ISRC) - if indexISRC == "" { - indexISRC = strings.TrimSpace(req.ISRC) - } - if indexISRC != "" && strings.TrimSpace(resp.FilePath) != "" { - AddToISRCIndex(req.OutputDir, indexISRC, resp.FilePath) - } - } - - return &resp, nil - } - - if err != nil { - if errors.Is(err, ErrDownloadCancelled) { - return &DownloadResponse{ - Success: false, - Error: "Download cancelled", - ErrorType: "cancelled", - Service: providerID, - }, nil - } - lastErr = err - lastErrType = "" - } else if result.ErrorMessage != "" { - lastErr = fmt.Errorf("%s", result.ErrorMessage) - lastErrType = normalizeExtensionDownloadErrorType(result.ErrorType, result.ErrorMessage) - lastRetryAfterSeconds = result.RetryAfterSeconds - } - GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, lastErr) - - if lastErr != nil { - effType := lastErrType - if effType == "" { - effType = classifyDownloadErrorType(lastErr.Error()) - } - if strings.EqualFold(effType, "verification_required") { - GoLog("[DownloadWithExtensionFallback] %s requires verification; pausing fallback to open the challenge\n", providerID) - return &DownloadResponse{ - Success: false, - Error: "Download failed: " + lastErr.Error(), - ErrorType: "verification_required", - RetryAfterSeconds: lastRetryAfterSeconds, - Service: providerID, - }, nil - } - } - - if terminalAvailability { - GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after download failure\n", providerID) - return buildExtensionFallbackStoppedResponse(providerID, availability, lastErr), nil - } - } - } - - if lastErr != nil { - errorType := firstNonEmptyString(lastErrType, classifyDownloadErrorType(lastErr.Error())) - if errorType == "unknown" { - errorType = "not_found" - } - return &DownloadResponse{ - Success: false, - Error: "All providers failed. Last error: " + lastErr.Error(), - ErrorType: errorType, - RetryAfterSeconds: lastRetryAfterSeconds, - }, nil - } - - return &DownloadResponse{ - Success: false, - Error: "No extension download providers available", - ErrorType: "not_found", - }, nil -} - -func buildOutputPath(req DownloadRequest) string { - if strings.TrimSpace(req.OutputPath) != "" { - return strings.TrimSpace(req.OutputPath) - } - - metadata := map[string]interface{}{ - "title": req.TrackName, - "artist": req.ArtistName, - "album": req.AlbumName, - "album_artist": req.AlbumArtist, - "track": req.TrackNumber, - "track_number": req.TrackNumber, - "total_tracks": req.TotalTracks, - "playlist_position": req.PlaylistPosition, - "disc": req.DiscNumber, - "disc_number": req.DiscNumber, - "total_discs": req.TotalDiscs, - "year": extractYear(req.ReleaseDate), - "date": req.ReleaseDate, - "release_date": req.ReleaseDate, - "isrc": req.ISRC, - "composer": req.Composer, - } - - filename := buildFilenameFromTemplate(req.FilenameFormat, metadata) - if strings.TrimSpace(filename) == "" { - filename = fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName) - } - filename = sanitizeFilename(filename) - - ext := strings.TrimSpace(req.OutputExt) - if ext == "" { - ext = ".flac" - } else if !strings.HasPrefix(ext, ".") { - ext = "." + ext - } - - outputDir := req.OutputDir - if strings.TrimSpace(outputDir) == "" { - outputDir = filepath.Join(os.TempDir(), "spotiflac-downloads") - } - os.MkdirAll(outputDir, 0755) - AddAllowedDownloadDir(outputDir) - - return filepath.Join(outputDir, filename+ext) -} - -func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) string { - if strings.TrimSpace(req.OutputPath) != "" { - outputPath := strings.TrimSpace(req.OutputPath) - AddAllowedDownloadDir(filepath.Dir(outputPath)) - return outputPath - } - - // SAF downloads hand extensions a detached output FD owned by the host. - // Extensions still need a real local temp file so Android can copy it into - // the target document after provider-specific post-processing completes. - if !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" { - return buildOutputPath(req) - } - - tempDir := filepath.Join(ext.DataDir, "downloads") - os.MkdirAll(tempDir, 0755) - AddAllowedDownloadDir(tempDir) - - metadata := map[string]interface{}{ - "title": req.TrackName, - "artist": req.ArtistName, - "album": req.AlbumName, - "album_artist": req.AlbumArtist, - "track": req.TrackNumber, - "track_number": req.TrackNumber, - "total_tracks": req.TotalTracks, - "playlist_position": req.PlaylistPosition, - "disc": req.DiscNumber, - "disc_number": req.DiscNumber, - "total_discs": req.TotalDiscs, - "year": extractYear(req.ReleaseDate), - "date": req.ReleaseDate, - "release_date": req.ReleaseDate, - "isrc": req.ISRC, - "composer": req.Composer, - } - - filename := buildFilenameFromTemplate(req.FilenameFormat, metadata) - if strings.TrimSpace(filename) == "" { - filename = fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName) - } - filename = sanitizeFilename(filename) - - outputExt := strings.TrimSpace(req.OutputExt) - if outputExt == "" { - outputExt = ".flac" - } else if !strings.HasPrefix(outputExt, ".") { - outputExt = "." + outputExt - } - - return filepath.Join(tempDir, filename+outputExt) -} - -func canEmbedGenreLabel(filePath string) bool { - path := strings.TrimSpace(filePath) - if path == "" || strings.HasPrefix(path, "content://") || strings.HasPrefix(path, "/proc/self/fd/") { - return false - } - if strings.ToLower(filepath.Ext(path)) != ".flac" { - return false - } - if !filepath.IsAbs(path) { - return false - } - info, err := os.Stat(path) - return err == nil && !info.IsDir() && info.Size() > 0 -} - -func embedExtensionDownloadMetadata(resp DownloadResponse, req DownloadRequest, alreadyExists bool) { - if alreadyExists || !req.EmbedMetadata { - return - } - - filePath := strings.TrimSpace(resp.FilePath) - if !canEmbedGenreLabel(filePath) { - if req.Genre != "" || req.Label != "" || resp.CoverURL != "" || req.CoverURL != "" { - GoLog("[DownloadWithExtensionFallback] Skipping metadata/cover embed for non-local FLAC output path: %q\n", filePath) - } - return - } - - coverURL := firstNonEmptyTrimmed(resp.CoverURL, req.CoverURL) - var coverData []byte - if coverURL != "" { - data, err := downloadCoverToMemory(coverURL, req.EmbedMaxQualityCover) - if err != nil { - GoLog("[DownloadWithExtensionFallback] Warning: failed to download cover for metadata embed: %v\n", err) - } else if len(data) > 0 { - coverData = data - } - } - - metadata := Metadata{ - Title: firstNonEmptyTrimmed(resp.Title, req.TrackName), - Artist: firstNonEmptyTrimmed(resp.Artist, req.ArtistName), - Album: firstNonEmptyTrimmed(resp.Album, req.AlbumName), - AlbumArtist: firstNonEmptyTrimmed(resp.AlbumArtist, req.AlbumArtist), - ArtistTagMode: req.ArtistTagMode, - Date: firstNonEmptyTrimmed(resp.ReleaseDate, req.ReleaseDate), - TrackNumber: firstPositiveInt(resp.TrackNumber, req.TrackNumber), - TotalTracks: firstPositiveInt(resp.TotalTracks, req.TotalTracks), - DiscNumber: firstPositiveInt(resp.DiscNumber, req.DiscNumber), - TotalDiscs: firstPositiveInt(resp.TotalDiscs, req.TotalDiscs), - ISRC: firstNonEmptyTrimmed(resp.ISRC, req.ISRC), - Genre: firstNonEmptyTrimmed(resp.Genre, req.Genre), - Label: firstNonEmptyTrimmed(resp.Label, req.Label), - Copyright: firstNonEmptyTrimmed(resp.Copyright, req.Copyright), - Composer: firstNonEmptyTrimmed(resp.Composer, req.Composer), - } - if req.EmbedLyrics { - metadata.Lyrics = resp.LyricsLRC - } - - var err error - if len(coverData) > 0 { - err = EmbedMetadataWithCoverData(filePath, metadata, coverData) - } else { - err = EmbedMetadata(filePath, metadata, "") - } - if err != nil { - GoLog("[DownloadWithExtensionFallback] Warning: failed to embed metadata/cover: %v\n", err) - return - } - - if len(coverData) > 0 { - GoLog("[DownloadWithExtensionFallback] Embedded metadata and cover from %q\n", coverURL) - } else { - GoLog("[DownloadWithExtensionFallback] Embedded metadata without cover\n") - } -} - -func firstPositiveInt(values ...int) int { - for _, value := range values { - if value > 0 { - return value - } - } - return 0 -} - -func (p *extensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) { - return p.customSearch(query, options, "", "") -} - -func (p *extensionProviderWrapper) CustomSearchForRequestID(query string, options map[string]interface{}, requestID string) ([]ExtTrackMetadata, error) { - return p.customSearch(query, options, "", requestID) -} - -func (p *extensionProviderWrapper) CustomSearchForItemID(query string, options map[string]interface{}, itemID string) ([]ExtTrackMetadata, error) { - return p.customSearch(query, options, itemID, "") -} - -func (p *extensionProviderWrapper) customSearch(query string, options map[string]interface{}, itemID, requestID string) ([]ExtTrackMetadata, error) { - if !p.extension.Manifest.HasCustomSearch() { - return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID) - } - - if !p.extension.Enabled { - return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) - } - perf := newExtensionCallPerf(p.extension.ID, "customSearch") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return nil, err - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() - if itemID != "" { - if p.extension.runtime != nil { - p.extension.runtime.setActiveDownloadItemID(itemID) - defer p.extension.runtime.clearActiveDownloadItemID() - } - initDownloadCancel(itemID) - defer clearDownloadCancel(itemID) - if isDownloadCancelled(itemID) { - return nil, ErrDownloadCancelled - } - } - requestCtx := context.Background() - if requestID != "" { - if p.extension.runtime != nil { - p.extension.runtime.setActiveRequestID(requestID) - defer p.extension.runtime.clearActiveRequestID() - } - requestCtx = initExtensionRequestCancel(requestID) - defer clearExtensionRequestCancel(requestID) - if isExtensionRequestCancelled(requestID) { - return nil, ErrExtensionRequestCancelled - } - } - - if options == nil { - options = map[string]interface{}{} - } - - // Avoid embedding user input directly into JS source. Some inputs can trigger - // parser/runtime edge cases on specific devices/Goja builds. - const queryVar = "__sf_custom_search_query" - const optionsVar = "__sf_custom_search_options" - global := p.vm.GlobalObject() - _ = global.Set(queryVar, query) - _ = global.Set(optionsVar, options) - defer func() { - global.Delete(queryVar) - global.Delete(optionsVar) - }() - - const script = ` - (function() { - if (typeof extension !== 'undefined' && typeof extension.customSearch === 'function') { - return extension.customSearch(__sf_custom_search_query, __sf_custom_search_options); - } - return null; - })() - ` - - jsStartedAt := time.Now() - result, err := RunWithTimeoutContextAndRecover(requestCtx, p.vm, script, DefaultJSTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - if isExtensionRequestCancelled(requestID) { - return nil, ErrExtensionRequestCancelled - } - if isDownloadCancelled(itemID) { - return nil, ErrDownloadCancelled - } - if errors.Is(err, ErrExtensionRequestCancelled) { - return nil, ErrExtensionRequestCancelled - } - if IsTimeoutError(err) { - return nil, fmt.Errorf("customSearch timeout: extension took too long to respond") - } - return nil, fmt.Errorf("customSearch failed: %w", err) - } - if isDownloadCancelled(itemID) { - return nil, ErrDownloadCancelled - } - if isExtensionRequestCancelled(requestID) { - return nil, ErrExtensionRequestCancelled - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return []ExtTrackMetadata{}, nil - } - - parseStartedAt := time.Now() - tracks, err := parseExtensionTrackArray(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - if err != nil { - return nil, fmt.Errorf("failed to parse search result: %w", err) - } - perf.setItems(len(tracks)) - - for i := range tracks { - tracks[i].ProviderID = p.extension.ID - } - - return tracks, nil -} - -type ExtURLHandleResult struct { - Type string `json:"type"` - Track *ExtTrackMetadata `json:"track,omitempty"` - Tracks []ExtTrackMetadata `json:"tracks,omitempty"` - Album *ExtAlbumMetadata `json:"album,omitempty"` - Artist *ExtArtistMetadata `json:"artist,omitempty"` - Name string `json:"name,omitempty"` - CoverURL string `json:"cover_url,omitempty"` - HeaderImage string `json:"header_image,omitempty"` - HeaderVideo string `json:"header_video,omitempty"` -} - -func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) { - if !p.extension.Manifest.HasURLHandler() { - return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID) - } - - if !p.extension.Enabled { - return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) - } - perf := newExtensionCallPerf(p.extension.ID, "handleUrl") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return nil, err - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() - - script := fmt.Sprintf(` - (function() { - if (typeof extension !== 'undefined' && typeof extension.handleUrl === 'function') { - return extension.handleUrl(%q); - } - return null; - })() - `, url) - - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - if IsTimeoutError(err) { - return nil, fmt.Errorf("handleUrl timeout: extension took too long to respond") - } - return nil, fmt.Errorf("handleUrl failed: %w", err) - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return nil, fmt.Errorf("handleUrl returned null - URL not recognized") - } - - parseStartedAt := time.Now() - handleResult, err := parseExtensionURLHandleValue(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - if err != nil { - return nil, fmt.Errorf("failed to parse URL handle result: %w", err) - } - urlItems := len(handleResult.Tracks) - if handleResult.Track != nil { - urlItems++ - } - if handleResult.Album != nil { - urlItems += 1 + len(handleResult.Album.Tracks) - } - if handleResult.Artist != nil { - urlItems += 1 + len(handleResult.Artist.Albums) + len(handleResult.Artist.Releases) + len(handleResult.Artist.TopTracks) - } - perf.setItems(urlItems) - - if handleResult.Track != nil { - handleResult.Track.ProviderID = p.extension.ID - } - for i := range handleResult.Tracks { - handleResult.Tracks[i].ProviderID = p.extension.ID - } - if handleResult.Album != nil { - handleResult.Album.ProviderID = p.extension.ID - for i := range handleResult.Album.Tracks { - handleResult.Album.Tracks[i].ProviderID = p.extension.ID - } - } - if handleResult.Artist != nil { - handleResult.Artist.ProviderID = p.extension.ID - for i := range handleResult.Artist.Albums { - handleResult.Artist.Albums[i].ProviderID = p.extension.ID - for j := range handleResult.Artist.Albums[i].Tracks { - handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID - } - } - for i := range handleResult.Artist.Releases { - handleResult.Artist.Releases[i].ProviderID = p.extension.ID - for j := range handleResult.Artist.Releases[i].Tracks { - handleResult.Artist.Releases[i].Tracks[j].ProviderID = p.extension.ID - } - } - for i := range handleResult.Artist.TopTracks { - handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID - } - } - - return &handleResult, nil -} - -type MatchTrackResult struct { - Matched bool `json:"matched"` - TrackID string `json:"track_id,omitempty"` - Confidence float64 `json:"confidence,omitempty"` - Reason string `json:"reason,omitempty"` -} - -func (p *extensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}, candidates []map[string]interface{}) (*MatchTrackResult, error) { - if !p.extension.Manifest.HasCustomMatching() { - return nil, fmt.Errorf("extension '%s' does not support custom matching", p.extension.ID) - } - - if !p.extension.Enabled { - return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) - } - perf := newExtensionCallPerf(p.extension.ID, "matchTrack") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return nil, err - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() - - sourceJSON, _ := json.Marshal(sourceTrack) - candidatesJSON, _ := json.Marshal(candidates) - - script := fmt.Sprintf(` - (function() { - if (typeof extension !== 'undefined' && typeof extension.matchTrack === 'function') { - return extension.matchTrack(%s, %s); - } - return null; - })() - `, string(sourceJSON), string(candidatesJSON)) - - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - if IsTimeoutError(err) { - return nil, fmt.Errorf("matchTrack timeout: extension took too long to respond") - } - return nil, fmt.Errorf("matchTrack failed: %w", err) - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return &MatchTrackResult{Matched: false, Reason: "not implemented"}, nil - } - - parseStartedAt := time.Now() - matchResult := parseExtensionMatchTrackValue(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - perf.setItems(1) - return &matchResult, nil -} - -type PostProcessResult struct { - Success bool `json:"success"` - NewFilePath string `json:"new_file_path,omitempty"` - NewFileURI string `json:"new_file_uri,omitempty"` - Error string `json:"error,omitempty"` - BitDepth int `json:"bit_depth,omitempty"` - SampleRate int `json:"sample_rate,omitempty"` -} - -type PostProcessInput struct { - Path string `json:"path,omitempty"` - URI string `json:"uri,omitempty"` - Name string `json:"name,omitempty"` - MimeType string `json:"mime_type,omitempty"` - Size int64 `json:"size,omitempty"` - IsSAF bool `json:"is_saf,omitempty"` -} - -const PostProcessTimeout = 2 * time.Minute - -func (p *extensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) { - if !p.extension.Manifest.HasPostProcessing() { - return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID) - } - - if !p.extension.Enabled { - return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) - } - perf := newExtensionCallPerf(p.extension.ID, "postProcess") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return &PostProcessResult{Success: false, Error: err.Error()}, nil - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() - - metadataJSON, _ := json.Marshal(metadata) - - script := fmt.Sprintf(` - (function() { - if (typeof extension !== 'undefined' && typeof extension.postProcess === 'function') { - return extension.postProcess(%q, %s, %q); - } - return null; - })() - `, filePath, string(metadataJSON), hookID) - - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, PostProcessTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - errMsg := err.Error() - if IsTimeoutError(err) { - errMsg = "postProcess timeout: extension took too long to complete" - } - return &PostProcessResult{ - Success: false, - Error: errMsg, - }, nil - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return &PostProcessResult{ - Success: false, - Error: "postProcess returned null", - }, nil - } - - parseStartedAt := time.Now() - postResult := parseExtensionPostProcessValue(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - perf.setItems(1) - return &postResult, nil -} - -func (p *extensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) { - if !p.extension.Manifest.HasPostProcessing() { - return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID) - } - - if !p.extension.Enabled { - return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) - } - perf := newExtensionCallPerf(p.extension.ID, "postProcessV2") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return &PostProcessResult{Success: false, Error: err.Error()}, nil - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() - - metadataJSON, _ := json.Marshal(metadata) - inputJSON, _ := json.Marshal(input) - filePath := input.Path - - script := fmt.Sprintf(` - (function() { - if (typeof extension !== 'undefined') { - if (typeof extension.postProcessV2 === 'function') { - return extension.postProcessV2(%s, %s, %q); - } - if (typeof extension.postProcess === 'function') { - return extension.postProcess(%q, %s, %q); - } - } - return null; - })() - `, string(inputJSON), string(metadataJSON), hookID, filePath, string(metadataJSON), hookID) - - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, PostProcessTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - errMsg := err.Error() - if IsTimeoutError(err) { - errMsg = "postProcess timeout: extension took too long to complete" - } - return &PostProcessResult{ - Success: false, - Error: errMsg, - }, nil - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return &PostProcessResult{ - Success: false, - Error: "postProcess returned null", - }, nil - } - - parseStartedAt := time.Now() - postResult := parseExtensionPostProcessValue(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - perf.setItems(1) - return &postResult, nil -} - func (m *extensionManager) GetSearchProviders() []*extensionProviderWrapper { m.mu.RLock() defer m.mu.RUnlock() @@ -3667,118 +351,6 @@ func (m *extensionManager) RunPostProcessingV2(input PostProcessInput, metadata return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil } -type ExtLyricsResult struct { - Lines []ExtLyricsLine `json:"lines"` - SyncType string `json:"syncType"` - Instrumental bool `json:"instrumental"` - PlainLyrics string `json:"plainLyrics"` - Provider string `json:"provider"` -} - -type ExtLyricsLine struct { - StartTimeMs int64 `json:"startTimeMs"` - Words string `json:"words"` - EndTimeMs int64 `json:"endTimeMs"` -} - -func (p *extensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) { - if !p.extension.Manifest.IsLyricsProvider() { - return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID) - } - - if !p.extension.Enabled { - return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) - } - perf := newExtensionCallPerf(p.extension.ID, "fetchLyrics") - defer perf.finish() - initStartedAt := time.Now() - if err := p.lockReadyVM(); err != nil { - return nil, err - } - perf.recordInit(time.Since(initStartedAt)) - defer p.extension.VMMu.Unlock() - - // Use global variables to avoid JS injection issues with special characters in track/artist names - const trackVar = "__sf_lyrics_track" - const artistVar = "__sf_lyrics_artist" - const albumVar = "__sf_lyrics_album" - const durationVar = "__sf_lyrics_duration" - global := p.vm.GlobalObject() - _ = global.Set(trackVar, trackName) - _ = global.Set(artistVar, artistName) - _ = global.Set(albumVar, albumName) - _ = global.Set(durationVar, durationSec) - defer func() { - global.Delete(trackVar) - global.Delete(artistVar) - global.Delete(albumVar) - global.Delete(durationVar) - }() - - const script = ` - (function() { - if (typeof extension !== 'undefined' && typeof extension.fetchLyrics === 'function') { - return extension.fetchLyrics(__sf_lyrics_track, __sf_lyrics_artist, __sf_lyrics_album, __sf_lyrics_duration); - } - return null; - })() - ` - - jsStartedAt := time.Now() - result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) - perf.recordJS(time.Since(jsStartedAt)) - perf.recordPayload(result) - if err != nil { - if IsTimeoutError(err) { - return nil, fmt.Errorf("fetchLyrics timeout: extension took too long to respond") - } - return nil, fmt.Errorf("fetchLyrics failed: %w", err) - } - - if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { - return nil, fmt.Errorf("fetchLyrics returned null") - } - - parseStartedAt := time.Now() - extResult, err := parseExtensionLyricsValue(p.vm, result) - perf.recordParse(time.Since(parseStartedAt)) - if err != nil { - return nil, fmt.Errorf("failed to parse lyrics result: %w", err) - } - perf.setItems(len(extResult.Lines)) - - response := &LyricsResponse{ - SyncType: extResult.SyncType, - Instrumental: extResult.Instrumental, - PlainLyrics: extResult.PlainLyrics, - Provider: extResult.Provider, - Source: "Extension: " + p.extension.ID, - } - - if response.Provider == "" { - response.Provider = p.extension.Manifest.DisplayName - } - - for _, line := range extResult.Lines { - response.Lines = append(response.Lines, LyricsLine(line)) - } - - if len(response.Lines) == 0 && response.PlainLyrics != "" && !response.Instrumental { - response.SyncType = "UNSYNCED" - for _, line := range strings.Split(response.PlainLyrics, "\n") { - if strings.TrimSpace(line) != "" { - response.Lines = append(response.Lines, LyricsLine{ - StartTimeMs: 0, - Words: line, - EndTimeMs: 0, - }) - } - } - } - - return response, nil -} - func (m *extensionManager) GetLyricsProviders() []*extensionProviderWrapper { m.mu.RLock() defer m.mu.RUnlock()