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"` DurationMS int `json:"duration_ms"` CoverURL string `json:"cover_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"` 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"` ReleaseDate string `json:"release_date,omitempty"` TotalTracks int `json:"total_tracks"` AlbumType string `json:"album_type,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"` 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, 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 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) return &DownloadResponse{ Success: false, Error: fmt.Sprintf("Fallback stopped by %s: %s", providerID, reason), ErrorType: "extension_error", Service: providerID, } } func shouldAbortCancelledFallback(itemID string, err error) bool { if errors.Is(err, ErrDownloadCancelled) { return true } return itemID != "" && isDownloadCancelled(itemID) } 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"` ErrorMessage string `json:"error_message,omitempty"` ErrorType string `json:"error_type,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 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"), DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"), CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"), 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"), 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"), ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"), TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"), AlbumType: gojaObjectString(obj, "album_type", "albumType"), Tracks: tracks, ProviderID: gojaObjectString(obj, "provider_id", "providerId"), }, nil } 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"), 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"), ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"), ErrorType: gojaObjectString(obj, "error_type", "errorType"), 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"), } 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() var providers []*extensionProviderWrapper for _, ext := range m.extensions { if ext.Enabled && ext.Manifest.IsMetadataProvider() && ext.Error == "" { providers = append(providers, newExtensionProviderWrapper(ext)) } } return providers } func (m *extensionManager) GetDownloadProviders() []*extensionProviderWrapper { m.mu.RLock() defer m.mu.RUnlock() var providers []*extensionProviderWrapper for _, ext := range m.extensions { if ext.Enabled && ext.Manifest.IsDownloadProvider() && ext.Error == "" { providers = append(providers, newExtensionProviderWrapper(ext)) } } return providers } func (m *extensionManager) SearchTracksWithExtensions(query string, limit int) ([]ExtTrackMetadata, error) { providers := m.GetMetadataProviders() if len(providers) == 0 { return nil, nil } providerByID := make(map[string]*extensionProviderWrapper, len(providers)) orderedProviders := make([]*extensionProviderWrapper, 0, len(providers)) for _, provider := range providers { providerByID[provider.extension.ID] = provider } for _, providerID := range GetMetadataProviderPriority() { if provider := providerByID[providerID]; provider != nil { orderedProviders = append(orderedProviders, provider) delete(providerByID, providerID) } } if len(providerByID) > 0 { remainingIDs := make([]string, 0, len(providerByID)) for providerID := range providerByID { remainingIDs = append(remainingIDs, providerID) } sort.Strings(remainingIDs) for _, providerID := range remainingIDs { orderedProviders = append(orderedProviders, providerByID[providerID]) } } var allTracks []ExtTrackMetadata for _, provider := range orderedProviders { result, err := provider.SearchTracks(query, limit) if err != nil { GoLog("[Extension] Search error from %s: %v\n", provider.extension.ID, err) continue } if result != nil { allTracks = append(allTracks, result.Tracks...) } } 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 true 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 true default: 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) } if spotifyID := strings.TrimSpace(track.SpotifyID); spotifyID != "" { return "spotify:" + spotifyID } if providerID := strings.TrimSpace(track.ProviderID); providerID != "" && strings.TrimSpace(track.ID) != "" { return providerID + ":" + strings.TrimSpace(track.ID) } return strings.TrimSpace(track.Name) + "|" + strings.TrimSpace(track.Artists) } func (m *extensionManager) SearchTracksWithMetadataProviders(query string, limit int, includeExtensions bool) ([]ExtTrackMetadata, error) { return m.SearchTracksWithMetadataProvidersForItemID(query, limit, includeExtensions, "") } func (m *extensionManager) SearchTracksWithMetadataProvidersForItemID(query string, limit int, includeExtensions bool, itemID string) ([]ExtTrackMetadata, error) { priority := GetMetadataProviderPriority() if limit <= 0 { limit = 20 } extensionProviders := make(map[string]*extensionProviderWrapper) if includeExtensions { for _, provider := range m.GetMetadataProviders() { extensionProviders[provider.extension.ID] = provider } } orderedProviderIDs := make([]string, 0, len(priority)+len(extensionProviders)) seenProviderIDs := make(map[string]struct{}, len(priority)+len(extensionProviders)) for _, providerID := range priority { providerID = strings.TrimSpace(providerID) if providerID == "" { continue } orderedProviderIDs = append(orderedProviderIDs, providerID) seenProviderIDs[providerID] = struct{}{} } if includeExtensions { remainingIDs := make([]string, 0, len(extensionProviders)) for providerID := range extensionProviders { if _, exists := seenProviderIDs[providerID]; exists { continue } remainingIDs = append(remainingIDs, providerID) } sort.Strings(remainingIDs) orderedProviderIDs = append(orderedProviderIDs, remainingIDs...) } tracks := make([]ExtTrackMetadata, 0, limit) seenTracks := make(map[string]struct{}) for _, providerID := range orderedProviderIDs { if isDownloadCancelled(itemID) { return nil, ErrDownloadCancelled } if !includeExtensions { continue } provider := extensionProviders[providerID] if provider == nil { continue } result, err := provider.SearchTracksForItemID(query, limit, itemID) providerTracks := []ExtTrackMetadata(nil) if result != nil { providerTracks = result.Tracks } if err != nil { if errors.Is(err, ErrDownloadCancelled) { return nil, ErrDownloadCancelled } GoLog("[MetadataSearch] Search error from %s: %v\n", providerID, err) continue } for _, track := range providerTracks { key := metadataTrackDedupKey(track) if key == "" { continue } if _, exists := seenTracks[key]; exists { continue } seenTracks[key] = struct{}{} tracks = append(tracks, track) if len(tracks) >= limit { return tracks, nil } } } 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 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 } if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(normalizedResult.FilePath) { if err := EmbedGenreLabel(normalizedResult.FilePath, req.Genre, req.Label); err != nil { GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err) } else { GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label) } } else if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") { GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", normalizedResult.FilePath) } 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 } else if result.ErrorMessage != "" { lastErr = fmt.Errorf("%s", result.ErrorMessage) } GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr) 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: "extension_error", Service: req.Source, }, nil } } else { GoLog("[DownloadWithExtensionFallback] Source extension %s not available or not a download provider\n", req.Source) } } for _, providerID := range priority { if isDownloadCancelled(req.ItemID) { return nil, ErrDownloadCancelled } providerID = strings.TrimSpace(providerID) if providerID == "" { continue } if providerID == req.Source { continue } if !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 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) } result, err := provider.Download(availability.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 " + 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) if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(normalizedResult.FilePath) { if err := EmbedGenreLabel(normalizedResult.FilePath, req.Genre, req.Label); err != nil { GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err) } else { GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label) } } else if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") { GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", normalizedResult.FilePath) } 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 } else if result.ErrorMessage != "" { lastErr = fmt.Errorf("%s", result.ErrorMessage) } GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, lastErr) if terminalAvailability { GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after download failure\n", providerID) return buildExtensionFallbackStoppedResponse(providerID, availability, lastErr), nil } } } if lastErr != nil { return &DownloadResponse{ Success: false, Error: "All providers failed. Last error: " + lastErr.Error(), ErrorType: "not_found", }, 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, "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 filename == "" { filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)) } 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, "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 filename == "" { filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)) } 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 (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"` } 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() var providers []*extensionProviderWrapper for _, ext := range m.extensions { if ext.Enabled && ext.Manifest.HasCustomSearch() && ext.Error == "" { providers = append(providers, newExtensionProviderWrapper(ext)) } } return providers } func (m *extensionManager) GetURLHandlers() []*extensionProviderWrapper { m.mu.RLock() defer m.mu.RUnlock() var providers []*extensionProviderWrapper for _, ext := range m.extensions { if ext.Enabled && ext.Manifest.HasURLHandler() && ext.Error == "" { providers = append(providers, newExtensionProviderWrapper(ext)) } } return providers } func (m *extensionManager) FindURLHandler(url string) *extensionProviderWrapper { m.mu.RLock() defer m.mu.RUnlock() for _, ext := range m.extensions { if ext.Enabled && ext.Manifest.MatchesURL(url) && ext.Error == "" { return newExtensionProviderWrapper(ext) } } return nil } type ExtURLHandleResultWithExtID struct { Result *ExtURLHandleResult ExtensionID string } func (m *extensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResultWithExtID, error) { handler := m.FindURLHandler(url) if handler == nil { return nil, fmt.Errorf("no extension found to handle URL: %s", url) } result, err := handler.HandleURL(url) if err != nil { return &ExtURLHandleResultWithExtID{ Result: nil, ExtensionID: handler.extension.ID, }, err } return &ExtURLHandleResultWithExtID{ Result: result, ExtensionID: handler.extension.ID, }, nil } func (m *extensionManager) GetPostProcessingProviders() []*extensionProviderWrapper { m.mu.RLock() defer m.mu.RUnlock() var providers []*extensionProviderWrapper for _, ext := range m.extensions { if ext.Enabled && ext.Manifest.HasPostProcessing() && ext.Error == "" { providers = append(providers, newExtensionProviderWrapper(ext)) } } return providers } func (m *extensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) { providers := m.GetPostProcessingProviders() if len(providers) == 0 { return &PostProcessResult{Success: true, NewFilePath: filePath}, nil } currentPath := filePath for _, provider := range providers { hooks := provider.extension.Manifest.GetPostProcessingHooks() for _, hook := range hooks { if !hook.DefaultEnabled { continue } ext := strings.ToLower(filepath.Ext(currentPath)) if len(hook.SupportedFormats) > 0 { supported := false for _, format := range hook.SupportedFormats { if "."+format == ext || format == ext[1:] { supported = true break } } if !supported { continue } } GoLog("[PostProcess] Running hook %s from %s on %s\n", hook.ID, provider.extension.ID, currentPath) result, err := provider.PostProcess(currentPath, metadata, hook.ID) if err != nil { GoLog("[PostProcess] Hook %s failed: %v\n", hook.ID, err) continue } if result.Success && result.NewFilePath != "" { currentPath = result.NewFilePath } } } return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil } func (m *extensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) { providers := m.GetPostProcessingProviders() if len(providers) == 0 { return &PostProcessResult{Success: true, NewFilePath: input.Path, NewFileURI: input.URI}, nil } currentInput := input for _, provider := range providers { hooks := provider.extension.Manifest.GetPostProcessingHooks() for _, hook := range hooks { if !hook.DefaultEnabled { continue } ext := strings.ToLower(filepath.Ext(currentInput.Path)) if ext == "" && currentInput.Name != "" { ext = strings.ToLower(filepath.Ext(currentInput.Name)) } if len(hook.SupportedFormats) > 0 && ext != "" { supported := false for _, format := range hook.SupportedFormats { if "."+format == ext || format == ext[1:] { supported = true break } } if !supported { continue } } GoLog("[PostProcessV2] Running hook %s from %s on %s\n", hook.ID, provider.extension.ID, currentInput.Path) result, err := provider.PostProcessV2(currentInput, metadata, hook.ID) if err != nil { GoLog("[PostProcessV2] Hook %s failed: %v\n", hook.ID, err) continue } if result.Success && result.NewFilePath != "" { currentInput.Path = result.NewFilePath if currentInput.Name == "" { currentInput.Name = filepath.Base(result.NewFilePath) } } if result.Success && result.NewFileURI != "" { currentInput.URI = result.NewFileURI } } } 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() var providers []*extensionProviderWrapper for _, ext := range m.extensions { if ext.Enabled && ext.Manifest.IsLyricsProvider() && ext.Error == "" { providers = append(providers, newExtensionProviderWrapper(ext)) } } sort.Slice(providers, func(i, j int) bool { return providers[i].extension.ID < providers[j].extension.ID }) return providers }