mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 15:28:48 +02:00
marshalJSONString collapses 62 identical marshal-and-return blocks in the gomobile exports; jsError/jsSuccess collapse 135 goja extension-response maps; the two MusicBrainz fetchers share one request+retry helper; the extension-HTTP default User-Agent block and fileExists get single homes. No exported signature changes.
1125 lines
31 KiB
Go
1125 lines
31 KiB
Go
package gobackend
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dop251/goja"
|
|
)
|
|
|
|
func normalizeExtensionTrackMetadataMap(
|
|
track ExtTrackMetadata,
|
|
fallbackCover string,
|
|
fallbackTrackNumber int,
|
|
) map[string]any {
|
|
coverURL := track.ResolvedCoverURL()
|
|
if coverURL == "" {
|
|
coverURL = fallbackCover
|
|
}
|
|
|
|
trackNum := track.TrackNumber
|
|
if trackNum == 0 && fallbackTrackNumber > 0 {
|
|
trackNum = fallbackTrackNumber
|
|
}
|
|
|
|
return map[string]any{
|
|
"id": track.ID,
|
|
"name": track.Name,
|
|
"artists": track.Artists,
|
|
"album_name": track.AlbumName,
|
|
"album_artist": track.AlbumArtist,
|
|
"album_id": track.AlbumID,
|
|
"album_url": track.AlbumURL,
|
|
"artist_id": track.ArtistID,
|
|
"artist_url": track.ArtistURL,
|
|
"external_urls": track.ExternalURL,
|
|
"duration_ms": track.DurationMS,
|
|
"images": coverURL,
|
|
"cover_url": coverURL,
|
|
"preview_url": track.PreviewURL,
|
|
"release_date": track.ReleaseDate,
|
|
"track_number": trackNum,
|
|
"total_tracks": track.TotalTracks,
|
|
"disc_number": track.DiscNumber,
|
|
"total_discs": track.TotalDiscs,
|
|
"isrc": track.ISRC,
|
|
"provider_id": track.ProviderID,
|
|
"item_type": track.ItemType,
|
|
"album_type": track.AlbumType,
|
|
"spotify_id": track.SpotifyID,
|
|
"composer": track.Composer,
|
|
"audio_quality": track.AudioQuality,
|
|
"audio_modes": track.AudioModes,
|
|
}
|
|
}
|
|
|
|
func normalizeExtensionAlbumInfoMap(album *ExtAlbumMetadata) map[string]any {
|
|
if album == nil {
|
|
return map[string]any{}
|
|
}
|
|
|
|
return map[string]any{
|
|
"id": album.ID,
|
|
"name": album.Name,
|
|
"artists": album.Artists,
|
|
"artist_id": album.ArtistID,
|
|
"images": album.CoverURL,
|
|
"cover_url": album.CoverURL,
|
|
"header_image": album.HeaderImage,
|
|
"header_video": album.HeaderVideo,
|
|
"release_date": album.ReleaseDate,
|
|
"total_tracks": album.TotalTracks,
|
|
"album_type": album.AlbumType,
|
|
"audio_traits": album.AudioTraits,
|
|
"provider_id": album.ProviderID,
|
|
}
|
|
}
|
|
|
|
func normalizeExtensionArtistAlbumMap(album ExtAlbumMetadata) map[string]any {
|
|
return map[string]any{
|
|
"id": album.ID,
|
|
"name": album.Name,
|
|
"artists": album.Artists,
|
|
"images": album.CoverURL,
|
|
"cover_url": album.CoverURL,
|
|
"release_date": album.ReleaseDate,
|
|
"total_tracks": album.TotalTracks,
|
|
"album_type": album.AlbumType,
|
|
"provider_id": album.ProviderID,
|
|
}
|
|
}
|
|
|
|
func getExtensionProviderMetadataResponse(
|
|
providerID,
|
|
resourceType,
|
|
resourceID string,
|
|
) (map[string]any, error) {
|
|
manager := getExtensionManager()
|
|
ext, err := manager.GetExtension(providerID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !ext.Manifest.IsMetadataProvider() {
|
|
return nil, fmt.Errorf("extension '%s' is not a metadata provider", providerID)
|
|
}
|
|
if !ext.Enabled {
|
|
return nil, fmt.Errorf("extension '%s' is disabled", providerID)
|
|
}
|
|
|
|
provider := newExtensionProviderWrapper(ext)
|
|
|
|
switch resourceType {
|
|
case "track":
|
|
track, err := provider.GetTrack(resourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if track == nil {
|
|
return nil, fmt.Errorf("track not found")
|
|
}
|
|
return map[string]any{
|
|
"track": normalizeExtensionTrackMetadataMap(*track, "", 0),
|
|
}, nil
|
|
case "album":
|
|
album, err := provider.GetAlbum(resourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if album == nil {
|
|
return nil, fmt.Errorf("album not found")
|
|
}
|
|
|
|
tracks := make([]map[string]any, len(album.Tracks))
|
|
for i, track := range album.Tracks {
|
|
tracks[i] = normalizeExtensionTrackMetadataMap(track, album.CoverURL, i+1)
|
|
}
|
|
|
|
return map[string]any{
|
|
"album_info": normalizeExtensionAlbumInfoMap(album),
|
|
"track_list": tracks,
|
|
}, nil
|
|
case "playlist":
|
|
playlist, err := provider.GetPlaylist(resourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if playlist == nil {
|
|
return nil, fmt.Errorf("playlist not found")
|
|
}
|
|
|
|
tracks := make([]map[string]any, len(playlist.Tracks))
|
|
for i, track := range playlist.Tracks {
|
|
tracks[i] = normalizeExtensionTrackMetadataMap(track, playlist.CoverURL, i+1)
|
|
}
|
|
|
|
return map[string]any{
|
|
"playlist_info": map[string]any{
|
|
"id": playlist.ID,
|
|
"name": playlist.Name,
|
|
"images": playlist.CoverURL,
|
|
"cover_url": playlist.CoverURL,
|
|
"header_image": playlist.HeaderImage,
|
|
"header_video": playlist.HeaderVideo,
|
|
"provider_id": playlist.ProviderID,
|
|
"owner": map[string]any{
|
|
"name": playlist.Artists,
|
|
"images": playlist.CoverURL,
|
|
},
|
|
},
|
|
"track_list": tracks,
|
|
}, nil
|
|
case "artist":
|
|
artist, err := provider.GetArtist(resourceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if artist == nil {
|
|
return nil, fmt.Errorf("artist not found")
|
|
}
|
|
|
|
albums := make([]map[string]any, len(artist.Albums))
|
|
for i, album := range artist.Albums {
|
|
albums[i] = normalizeExtensionArtistAlbumMap(album)
|
|
}
|
|
|
|
response := map[string]any{
|
|
"artist_info": map[string]any{
|
|
"id": artist.ID,
|
|
"name": artist.Name,
|
|
"images": firstNonEmptyTrimmed(artist.HeaderImage, artist.ImageURL),
|
|
"cover_url": artist.ImageURL,
|
|
"header_image": artist.HeaderImage,
|
|
"header_video": artist.HeaderVideo,
|
|
"provider_id": artist.ProviderID,
|
|
},
|
|
"albums": albums,
|
|
}
|
|
|
|
if len(artist.Releases) > 0 {
|
|
releases := make([]map[string]any, len(artist.Releases))
|
|
for i, release := range artist.Releases {
|
|
releases[i] = normalizeExtensionArtistAlbumMap(release)
|
|
}
|
|
response["releases"] = releases
|
|
}
|
|
|
|
if artist.Listeners > 0 {
|
|
artistInfo := response["artist_info"].(map[string]any)
|
|
artistInfo["listeners"] = artist.Listeners
|
|
}
|
|
|
|
if len(artist.TopTracks) > 0 {
|
|
topTracks := make([]map[string]any, len(artist.TopTracks))
|
|
for i, track := range artist.TopTracks {
|
|
topTracks[i] = normalizeExtensionTrackMetadataMap(track, artist.ImageURL, i+1)
|
|
}
|
|
response["top_tracks"] = topTracks
|
|
}
|
|
|
|
return response, nil
|
|
default:
|
|
return nil, fmt.Errorf("unsupported provider resource type: %s", resourceType)
|
|
}
|
|
}
|
|
|
|
func firstNonEmptyTrimmed(values ...string) string {
|
|
for _, value := range values {
|
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func GetProviderMetadataJSON(providerID, resourceType, resourceID string) (string, error) {
|
|
trimmedProviderID := strings.TrimSpace(providerID)
|
|
if trimmedProviderID == "" {
|
|
return "", fmt.Errorf("empty provider ID")
|
|
}
|
|
|
|
switch strings.ToLower(trimmedProviderID) {
|
|
case "deezer":
|
|
if response, ok, err := getEnabledExtensionProviderMetadataResponse(trimmedProviderID, resourceType, resourceID); ok || err != nil {
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return marshalJSONString(response)
|
|
}
|
|
return GetDeezerMetadata(resourceType, resourceID)
|
|
default:
|
|
response, err := getExtensionProviderMetadataResponse(trimmedProviderID, resourceType, resourceID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return marshalJSONString(response)
|
|
}
|
|
}
|
|
|
|
func getEnabledExtensionProviderMetadataResponse(providerID, resourceType, resourceID string) (map[string]any, bool, error) {
|
|
manager := getExtensionManager()
|
|
ext, err := manager.GetExtension(providerID)
|
|
if err != nil || ext == nil || !ext.Enabled || !ext.Manifest.IsMetadataProvider() {
|
|
return nil, false, nil
|
|
}
|
|
response, err := getExtensionProviderMetadataResponse(providerID, resourceType, resourceID)
|
|
if err != nil {
|
|
return nil, true, err
|
|
}
|
|
return response, true, nil
|
|
}
|
|
|
|
func InitExtensionSystem(extensionsDir, dataDir string) error {
|
|
manager := getExtensionManager()
|
|
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
|
|
return err
|
|
}
|
|
|
|
settingsStore := GetExtensionSettingsStore()
|
|
if err := settingsStore.SetDataDir(dataDir); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func LoadExtensionsFromDir(dirPath string) (string, error) {
|
|
manager := getExtensionManager()
|
|
loaded, errors := manager.LoadExtensionsFromDirectory(dirPath)
|
|
|
|
result := map[string]any{
|
|
"loaded": loaded,
|
|
"errors": make([]string, len(errors)),
|
|
}
|
|
|
|
for i, err := range errors {
|
|
result["errors"].([]string)[i] = err.Error()
|
|
}
|
|
|
|
return marshalJSONString(result)
|
|
}
|
|
|
|
func LoadExtensionFromPath(filePath string) (string, error) {
|
|
manager := getExtensionManager()
|
|
ext, err := manager.LoadExtensionFromFile(filePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
result := map[string]any{
|
|
"id": ext.ID,
|
|
"name": ext.Manifest.Name,
|
|
"display_name": ext.Manifest.DisplayName,
|
|
"version": ext.Manifest.Version,
|
|
"enabled": ext.Enabled,
|
|
}
|
|
|
|
return marshalJSONString(result)
|
|
}
|
|
|
|
func UnloadExtensionByID(extensionID string) error {
|
|
manager := getExtensionManager()
|
|
return manager.UnloadExtension(extensionID)
|
|
}
|
|
|
|
func RemoveExtensionByID(extensionID string) error {
|
|
manager := getExtensionManager()
|
|
return manager.RemoveExtension(extensionID)
|
|
}
|
|
|
|
func UpgradeExtensionFromPath(filePath string) (string, error) {
|
|
manager := getExtensionManager()
|
|
ext, err := manager.UpgradeExtension(filePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
result := map[string]any{
|
|
"id": ext.ID,
|
|
"display_name": ext.Manifest.DisplayName,
|
|
"version": ext.Manifest.Version,
|
|
"enabled": ext.Enabled,
|
|
}
|
|
|
|
return marshalJSONString(result)
|
|
}
|
|
|
|
func CheckExtensionUpgradeFromPath(filePath string) (string, error) {
|
|
manager := getExtensionManager()
|
|
return manager.CheckExtensionUpgradeJSON(filePath)
|
|
}
|
|
|
|
func GetInstalledExtensions() (string, error) {
|
|
manager := getExtensionManager()
|
|
return manager.GetInstalledExtensionsJSON()
|
|
}
|
|
|
|
func SetExtensionEnabledByID(extensionID string, enabled bool) error {
|
|
manager := getExtensionManager()
|
|
return manager.SetExtensionEnabled(extensionID, enabled)
|
|
}
|
|
|
|
func SetProviderPriorityJSON(priorityJSON string) error {
|
|
var priority []string
|
|
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
|
|
return err
|
|
}
|
|
|
|
SetProviderPriority(priority)
|
|
return nil
|
|
}
|
|
|
|
func GetProviderPriorityJSON() (string, error) {
|
|
priority := GetProviderPriority()
|
|
return marshalJSONString(priority)
|
|
}
|
|
|
|
func SetExtensionFallbackProviderIDsJSON(providerIDsJSON string) error {
|
|
if strings.TrimSpace(providerIDsJSON) == "" {
|
|
SetExtensionFallbackProviderIDs(nil)
|
|
return nil
|
|
}
|
|
|
|
var providerIDs []string
|
|
if err := json.Unmarshal([]byte(providerIDsJSON), &providerIDs); err != nil {
|
|
return err
|
|
}
|
|
|
|
SetExtensionFallbackProviderIDs(providerIDs)
|
|
return nil
|
|
}
|
|
|
|
func GetExtensionFallbackProviderIDsJSON() (string, error) {
|
|
providerIDs := GetExtensionFallbackProviderIDs()
|
|
return marshalJSONString(providerIDs)
|
|
}
|
|
|
|
func SetMetadataProviderPriorityJSON(priorityJSON string) error {
|
|
var priority []string
|
|
if err := json.Unmarshal([]byte(priorityJSON), &priority); err != nil {
|
|
return err
|
|
}
|
|
|
|
SetMetadataProviderPriority(priority)
|
|
return nil
|
|
}
|
|
|
|
func GetMetadataProviderPriorityJSON() (string, error) {
|
|
priority := GetMetadataProviderPriority()
|
|
return marshalJSONString(priority)
|
|
}
|
|
|
|
func GetExtensionSettingsJSON(extensionID string) (string, error) {
|
|
store := GetExtensionSettingsStore()
|
|
settings := store.GetAll(extensionID)
|
|
|
|
return marshalJSONString(settings)
|
|
}
|
|
|
|
func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
|
|
var settings map[string]any
|
|
if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil {
|
|
return err
|
|
}
|
|
|
|
store := GetExtensionSettingsStore()
|
|
if err := store.SetAll(extensionID, settings); err != nil {
|
|
return err
|
|
}
|
|
|
|
manager := getExtensionManager()
|
|
return manager.InitializeExtension(extensionID, settings)
|
|
}
|
|
|
|
func SearchTracksWithExtensionsJSON(query string, limit int) (string, error) {
|
|
manager := getExtensionManager()
|
|
tracks, err := manager.SearchTracksWithExtensions(query, limit)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return marshalJSONString(tracks)
|
|
}
|
|
|
|
func SearchTracksWithMetadataProvidersJSON(query string, limit int, includeExtensions bool) (string, error) {
|
|
manager := getExtensionManager()
|
|
tracks, err := manager.SearchTracksWithMetadataProviders(query, limit, includeExtensions)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return marshalJSONString(tracks)
|
|
}
|
|
|
|
func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
|
|
var req DownloadRequest
|
|
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
|
return "", fmt.Errorf("invalid request: %w", err)
|
|
}
|
|
applySongLinkRegionFromRequest(&req)
|
|
defer closeOwnedOutputFD(req.OutputFD)
|
|
if req.ItemID != "" {
|
|
initDownloadCancel(req.ItemID)
|
|
defer clearDownloadCancel(req.ItemID)
|
|
if isDownloadCancelled(req.ItemID) {
|
|
return "", ErrDownloadCancelled
|
|
}
|
|
}
|
|
|
|
req.TrackName = strings.TrimSpace(req.TrackName)
|
|
req.ArtistName = strings.TrimSpace(req.ArtistName)
|
|
req.AlbumName = strings.TrimSpace(req.AlbumName)
|
|
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
|
|
req.OutputDir = strings.TrimSpace(req.OutputDir)
|
|
req.OutputPath = strings.TrimSpace(req.OutputPath)
|
|
req.OutputExt = strings.TrimSpace(req.OutputExt)
|
|
if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" {
|
|
AddAllowedDownloadDir(req.OutputDir)
|
|
}
|
|
|
|
enrichRequestExtendedMetadata(&req)
|
|
if isDownloadCancelled(req.ItemID) {
|
|
return "", ErrDownloadCancelled
|
|
}
|
|
|
|
result, err := DownloadWithExtensionFallback(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return marshalJSONString(result)
|
|
}
|
|
|
|
func CleanupExtensions() {
|
|
manager := getExtensionManager()
|
|
manager.UnloadAllExtensions()
|
|
}
|
|
|
|
func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
|
|
manager := getExtensionManager()
|
|
result, err := manager.InvokeAction(extensionID, actionName)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return marshalJSONString(result)
|
|
}
|
|
|
|
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
|
req := ensureExtensionPendingAuthRequest(extensionID)
|
|
if req == nil {
|
|
return "", nil
|
|
}
|
|
|
|
result := map[string]any{
|
|
"extension_id": req.ExtensionID,
|
|
"auth_url": req.AuthURL,
|
|
"callback_url": req.CallbackURL,
|
|
}
|
|
|
|
return marshalJSONString(result)
|
|
}
|
|
|
|
func ensureExtensionPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
|
extensionID = strings.TrimSpace(extensionID)
|
|
if extensionID == "" {
|
|
return nil
|
|
}
|
|
|
|
if req := GetPendingAuthRequest(extensionID); req != nil {
|
|
if time.Since(req.CreatedAt) < pendingAuthRequestTTL {
|
|
return req
|
|
}
|
|
// The cached challenge is stale (e.g. verification was requested
|
|
// while the app was backgrounded and never completed); serving it
|
|
// would send the user to an expired page. Start a fresh one.
|
|
ClearPendingAuthRequest(extensionID)
|
|
}
|
|
|
|
manager := getExtensionManager()
|
|
ext, err := manager.GetExtension(extensionID)
|
|
if err != nil || ext == nil || !ext.Enabled || ext.Manifest == nil || ext.Manifest.SignedSession == nil {
|
|
return nil
|
|
}
|
|
|
|
if err := ext.ensureRuntimeReady(); err != nil || ext.runtime == nil {
|
|
return nil
|
|
}
|
|
|
|
config := signedSessionConfigWithDefaults(ext.Manifest.SignedSession)
|
|
if config.Namespace == "" || config.BaseURL == "" {
|
|
return nil
|
|
}
|
|
if record, err := ext.runtime.loadSignedSession(config); err == nil {
|
|
record.SessionID = ""
|
|
record.SessionSecret = ""
|
|
record.ExpiresAt = ""
|
|
_ = ext.runtime.saveSignedSession(config, record)
|
|
}
|
|
ext.runtime.startSignedSessionVerification(config, "pending-auth-request")
|
|
return GetPendingAuthRequest(extensionID)
|
|
}
|
|
|
|
func SetExtensionAuthCodeByID(extensionID, authCode string) {
|
|
SetExtensionAuthCode(extensionID, authCode)
|
|
}
|
|
|
|
func SetExtensionSessionGrantByID(extensionID, grant string) {
|
|
setPendingSignedSessionGrant(extensionID, grant)
|
|
}
|
|
|
|
func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) {
|
|
var expiresAt time.Time
|
|
if expiresIn > 0 {
|
|
expiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
|
|
}
|
|
SetExtensionTokens(extensionID, accessToken, refreshToken, expiresAt)
|
|
}
|
|
|
|
func ClearExtensionPendingAuthByID(extensionID string) {
|
|
ClearPendingAuthRequest(extensionID)
|
|
}
|
|
|
|
func IsExtensionAuthenticatedByID(extensionID string) bool {
|
|
extensionAuthStateMu.RLock()
|
|
defer extensionAuthStateMu.RUnlock()
|
|
|
|
state, exists := extensionAuthState[extensionID]
|
|
if !exists {
|
|
return false
|
|
}
|
|
|
|
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
|
|
return false
|
|
}
|
|
|
|
return state.IsAuthenticated
|
|
}
|
|
|
|
func GetAllPendingAuthRequestsJSON() (string, error) {
|
|
pendingAuthRequestsMu.RLock()
|
|
defer pendingAuthRequestsMu.RUnlock()
|
|
|
|
requests := make([]map[string]any, 0, len(pendingAuthRequests))
|
|
for _, req := range pendingAuthRequests {
|
|
requests = append(requests, map[string]any{
|
|
"extension_id": req.ExtensionID,
|
|
"auth_url": req.AuthURL,
|
|
"callback_url": req.CallbackURL,
|
|
})
|
|
}
|
|
|
|
return marshalJSONString(requests)
|
|
}
|
|
|
|
func GetPendingFFmpegCommandJSON(commandID string) (string, error) {
|
|
cmd := GetPendingFFmpegCommand(commandID)
|
|
if cmd == nil {
|
|
return "", nil
|
|
}
|
|
|
|
result := map[string]any{
|
|
"command_id": commandID,
|
|
"extension_id": cmd.ExtensionID,
|
|
"command": cmd.Command,
|
|
"input_path": cmd.InputPath,
|
|
"output_path": cmd.OutputPath,
|
|
}
|
|
|
|
return marshalJSONString(result)
|
|
}
|
|
|
|
func SetFFmpegCommandResultByID(commandID string, success bool, output, errorMsg string) {
|
|
SetFFmpegCommandResult(commandID, success, output, errorMsg)
|
|
}
|
|
|
|
func GetAllPendingFFmpegCommandsJSON() (string, error) {
|
|
ffmpegCommandsMu.RLock()
|
|
defer ffmpegCommandsMu.RUnlock()
|
|
|
|
commands := make([]map[string]any, 0)
|
|
for cmdID, cmd := range ffmpegCommands {
|
|
if !cmd.Completed {
|
|
commands = append(commands, map[string]any{
|
|
"command_id": cmdID,
|
|
"extension_id": cmd.ExtensionID,
|
|
"command": cmd.Command,
|
|
})
|
|
}
|
|
}
|
|
|
|
return marshalJSONString(commands)
|
|
}
|
|
|
|
func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) {
|
|
manager := getExtensionManager()
|
|
ext, err := manager.GetExtension(extensionID)
|
|
if err != nil {
|
|
return trackJSON, nil
|
|
}
|
|
|
|
if !ext.Manifest.IsMetadataProvider() {
|
|
return trackJSON, nil
|
|
}
|
|
|
|
var track ExtTrackMetadata
|
|
if err := json.Unmarshal([]byte(trackJSON), &track); err != nil {
|
|
return trackJSON, fmt.Errorf("failed to parse track: %w", err)
|
|
}
|
|
|
|
provider := newExtensionProviderWrapper(ext)
|
|
enrichedTrack, err := provider.EnrichTrack(&track)
|
|
if err != nil {
|
|
return trackJSON, nil
|
|
}
|
|
|
|
jsonBytes, err := json.Marshal(enrichedTrack)
|
|
if err != nil {
|
|
return trackJSON, nil
|
|
}
|
|
|
|
return string(jsonBytes), nil
|
|
}
|
|
|
|
func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) {
|
|
return CustomSearchWithExtensionJSONWithRequestID(extensionID, query, optionsJSON, "")
|
|
}
|
|
|
|
func CustomSearchWithExtensionJSONWithRequestID(extensionID, query string, optionsJSON string, requestID string) (string, error) {
|
|
manager := getExtensionManager()
|
|
ext, err := manager.GetExtension(extensionID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if !ext.Manifest.HasCustomSearch() {
|
|
return "", fmt.Errorf("extension '%s' does not support custom search", extensionID)
|
|
}
|
|
|
|
var options map[string]any
|
|
if optionsJSON != "" {
|
|
if err := json.Unmarshal([]byte(optionsJSON), &options); err != nil {
|
|
options = make(map[string]any)
|
|
}
|
|
}
|
|
|
|
provider := newExtensionProviderWrapper(ext)
|
|
tracks, err := provider.CustomSearchForRequestID(query, options, requestID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
result := make([]map[string]any, len(tracks))
|
|
for i, track := range tracks {
|
|
result[i] = map[string]any{
|
|
"id": track.ID,
|
|
"name": track.Name,
|
|
"artists": track.Artists,
|
|
"album_name": track.AlbumName,
|
|
"album_artist": track.AlbumArtist,
|
|
"duration_ms": track.DurationMS,
|
|
"images": track.ResolvedCoverURL(),
|
|
"preview_url": track.PreviewURL,
|
|
"release_date": track.ReleaseDate,
|
|
"track_number": track.TrackNumber,
|
|
"total_tracks": track.TotalTracks,
|
|
"disc_number": track.DiscNumber,
|
|
"total_discs": track.TotalDiscs,
|
|
"isrc": track.ISRC,
|
|
"provider_id": track.ProviderID,
|
|
"item_type": track.ItemType,
|
|
"album_type": track.AlbumType,
|
|
"composer": track.Composer,
|
|
"audio_quality": track.AudioQuality,
|
|
"audio_modes": track.AudioModes,
|
|
}
|
|
}
|
|
|
|
return marshalJSONString(result)
|
|
}
|
|
|
|
func GetSearchProvidersJSON() (string, error) {
|
|
manager := getExtensionManager()
|
|
providers := manager.GetSearchProviders()
|
|
|
|
result := make([]map[string]any, 0, len(providers))
|
|
for _, p := range providers {
|
|
result = append(result, map[string]any{
|
|
"id": p.extension.ID,
|
|
"display_name": p.extension.Manifest.DisplayName,
|
|
"placeholder": p.extension.Manifest.SearchBehavior.Placeholder,
|
|
"primary": p.extension.Manifest.SearchBehavior.Primary,
|
|
"icon": p.extension.Manifest.SearchBehavior.Icon,
|
|
})
|
|
}
|
|
|
|
return marshalJSONString(result)
|
|
}
|
|
|
|
func HandleURLWithExtensionJSON(url string) (string, error) {
|
|
manager := getExtensionManager()
|
|
resultWithID, err := manager.HandleURLWithExtension(url)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
result := resultWithID.Result
|
|
extensionID := resultWithID.ExtensionID
|
|
|
|
if result == nil {
|
|
return "", fmt.Errorf("extension %s failed to handle URL", extensionID)
|
|
}
|
|
|
|
response := map[string]any{
|
|
"type": result.Type,
|
|
"extension_id": extensionID,
|
|
"name": result.Name,
|
|
"cover_url": result.CoverURL,
|
|
"header_image": result.HeaderImage,
|
|
"header_video": result.HeaderVideo,
|
|
}
|
|
|
|
if result.Track != nil {
|
|
response["track"] = map[string]any{
|
|
"id": result.Track.ID,
|
|
"name": result.Track.Name,
|
|
"artists": result.Track.Artists,
|
|
"album_name": result.Track.AlbumName,
|
|
"album_artist": result.Track.AlbumArtist,
|
|
"duration_ms": result.Track.DurationMS,
|
|
"images": result.Track.ResolvedCoverURL(),
|
|
"preview_url": result.Track.PreviewURL,
|
|
"release_date": result.Track.ReleaseDate,
|
|
"track_number": result.Track.TrackNumber,
|
|
"total_tracks": result.Track.TotalTracks,
|
|
"disc_number": result.Track.DiscNumber,
|
|
"total_discs": result.Track.TotalDiscs,
|
|
"isrc": result.Track.ISRC,
|
|
"provider_id": result.Track.ProviderID,
|
|
"composer": result.Track.Composer,
|
|
}
|
|
}
|
|
|
|
if len(result.Tracks) > 0 {
|
|
tracks := make([]map[string]any, len(result.Tracks))
|
|
for i, track := range result.Tracks {
|
|
tracks[i] = map[string]any{
|
|
"id": track.ID,
|
|
"name": track.Name,
|
|
"artists": track.Artists,
|
|
"album_name": track.AlbumName,
|
|
"album_artist": track.AlbumArtist,
|
|
"duration_ms": track.DurationMS,
|
|
"images": track.ResolvedCoverURL(),
|
|
"preview_url": track.PreviewURL,
|
|
"release_date": track.ReleaseDate,
|
|
"track_number": track.TrackNumber,
|
|
"total_tracks": track.TotalTracks,
|
|
"disc_number": track.DiscNumber,
|
|
"total_discs": track.TotalDiscs,
|
|
"isrc": track.ISRC,
|
|
"provider_id": track.ProviderID,
|
|
"item_type": track.ItemType,
|
|
"album_type": track.AlbumType,
|
|
"composer": track.Composer,
|
|
}
|
|
}
|
|
response["tracks"] = tracks
|
|
}
|
|
|
|
if result.Album != nil {
|
|
response["album"] = map[string]any{
|
|
"id": result.Album.ID,
|
|
"name": result.Album.Name,
|
|
"artists": result.Album.Artists,
|
|
"cover_url": result.Album.CoverURL,
|
|
"header_image": result.Album.HeaderImage,
|
|
"header_video": result.Album.HeaderVideo,
|
|
"audio_traits": result.Album.AudioTraits,
|
|
"release_date": result.Album.ReleaseDate,
|
|
"total_tracks": result.Album.TotalTracks,
|
|
"album_type": result.Album.AlbumType,
|
|
"provider_id": result.Album.ProviderID,
|
|
}
|
|
}
|
|
|
|
if result.Artist != nil {
|
|
artistResponse := map[string]any{
|
|
"id": result.Artist.ID,
|
|
"name": result.Artist.Name,
|
|
"image_url": result.Artist.ImageURL,
|
|
"header_image": result.Artist.HeaderImage,
|
|
"header_video": result.Artist.HeaderVideo,
|
|
"listeners": result.Artist.Listeners,
|
|
"provider_id": result.Artist.ProviderID,
|
|
}
|
|
|
|
if len(result.Artist.Albums) > 0 {
|
|
albums := make([]map[string]any, len(result.Artist.Albums))
|
|
for i, album := range result.Artist.Albums {
|
|
albumType := album.AlbumType
|
|
if albumType == "" {
|
|
albumType = "album"
|
|
}
|
|
albums[i] = map[string]any{
|
|
"id": album.ID,
|
|
"name": album.Name,
|
|
"artists": album.Artists,
|
|
"images": album.CoverURL,
|
|
"cover_url": album.CoverURL,
|
|
"release_date": album.ReleaseDate,
|
|
"total_tracks": album.TotalTracks,
|
|
"album_type": albumType,
|
|
"provider_id": album.ProviderID,
|
|
}
|
|
}
|
|
artistResponse["albums"] = albums
|
|
}
|
|
|
|
if len(result.Artist.Releases) > 0 {
|
|
releases := make([]map[string]any, len(result.Artist.Releases))
|
|
for i, release := range result.Artist.Releases {
|
|
releaseType := release.AlbumType
|
|
if releaseType == "" {
|
|
releaseType = "album"
|
|
}
|
|
releases[i] = map[string]any{
|
|
"id": release.ID,
|
|
"name": release.Name,
|
|
"artists": release.Artists,
|
|
"images": release.CoverURL,
|
|
"cover_url": release.CoverURL,
|
|
"release_date": release.ReleaseDate,
|
|
"total_tracks": release.TotalTracks,
|
|
"album_type": releaseType,
|
|
"provider_id": release.ProviderID,
|
|
}
|
|
}
|
|
artistResponse["releases"] = releases
|
|
}
|
|
|
|
if len(result.Artist.TopTracks) > 0 {
|
|
topTracks := make([]map[string]any, len(result.Artist.TopTracks))
|
|
for i, track := range result.Artist.TopTracks {
|
|
topTracks[i] = map[string]any{
|
|
"id": track.ID,
|
|
"name": track.Name,
|
|
"artists": track.Artists,
|
|
"album_name": track.AlbumName,
|
|
"album_artist": track.AlbumArtist,
|
|
"duration_ms": track.DurationMS,
|
|
"images": track.ResolvedCoverURL(),
|
|
"preview_url": track.PreviewURL,
|
|
"release_date": track.ReleaseDate,
|
|
"track_number": track.TrackNumber,
|
|
"total_tracks": track.TotalTracks,
|
|
"disc_number": track.DiscNumber,
|
|
"total_discs": track.TotalDiscs,
|
|
"isrc": track.ISRC,
|
|
"provider_id": track.ProviderID,
|
|
"spotify_id": track.SpotifyID,
|
|
"composer": track.Composer,
|
|
}
|
|
}
|
|
artistResponse["top_tracks"] = topTracks
|
|
}
|
|
|
|
response["artist"] = artistResponse
|
|
}
|
|
|
|
return marshalJSONString(response)
|
|
}
|
|
|
|
func FindURLHandlerJSON(url string) string {
|
|
manager := getExtensionManager()
|
|
handler := manager.FindURLHandler(url)
|
|
if handler == nil {
|
|
return ""
|
|
}
|
|
return handler.extension.ID
|
|
}
|
|
|
|
func GetURLHandlersJSON() (string, error) {
|
|
manager := getExtensionManager()
|
|
handlers := manager.GetURLHandlers()
|
|
|
|
result := make([]map[string]any, 0, len(handlers))
|
|
for _, h := range handlers {
|
|
result = append(result, map[string]any{
|
|
"id": h.extension.ID,
|
|
"display_name": h.extension.Manifest.DisplayName,
|
|
"patterns": h.extension.Manifest.URLHandler.Patterns,
|
|
})
|
|
}
|
|
|
|
return marshalJSONString(result)
|
|
}
|
|
|
|
func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) {
|
|
var metadata map[string]any
|
|
if metadataJSON != "" {
|
|
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
|
metadata = make(map[string]any)
|
|
}
|
|
}
|
|
|
|
manager := getExtensionManager()
|
|
result, err := manager.RunPostProcessing(filePath, metadata)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return marshalJSONString(result)
|
|
}
|
|
|
|
func RunPostProcessingV2JSON(inputJSON, metadataJSON string) (string, error) {
|
|
var metadata map[string]any
|
|
if metadataJSON != "" {
|
|
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
|
|
metadata = make(map[string]any)
|
|
}
|
|
}
|
|
|
|
var input PostProcessInput
|
|
if inputJSON != "" {
|
|
if err := json.Unmarshal([]byte(inputJSON), &input); err != nil {
|
|
input = PostProcessInput{}
|
|
}
|
|
}
|
|
|
|
manager := getExtensionManager()
|
|
result, err := manager.RunPostProcessingV2(input, metadata)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return marshalJSONString(result)
|
|
}
|
|
|
|
func GetPostProcessingProvidersJSON() (string, error) {
|
|
manager := getExtensionManager()
|
|
providers := manager.GetPostProcessingProviders()
|
|
|
|
result := make([]map[string]any, 0, len(providers))
|
|
for _, p := range providers {
|
|
hooks := make([]map[string]any, 0)
|
|
for _, h := range p.extension.Manifest.GetPostProcessingHooks() {
|
|
hooks = append(hooks, map[string]any{
|
|
"id": h.ID,
|
|
"name": h.Name,
|
|
"description": h.Description,
|
|
"default_enabled": h.DefaultEnabled,
|
|
"supported_formats": h.SupportedFormats,
|
|
})
|
|
}
|
|
|
|
result = append(result, map[string]any{
|
|
"id": p.extension.ID,
|
|
"display_name": p.extension.Manifest.DisplayName,
|
|
"hooks": hooks,
|
|
})
|
|
}
|
|
|
|
return marshalJSONString(result)
|
|
}
|
|
|
|
func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Duration) (string, error) {
|
|
return callExtensionFunctionJSONWithRequestID(extensionID, functionName, timeout, "")
|
|
}
|
|
|
|
func callExtensionFunctionJSONWithRequestID(extensionID, functionName string, timeout time.Duration, requestID string) (string, error) {
|
|
manager := getExtensionManager()
|
|
ext, err := manager.GetExtension(extensionID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if !ext.Enabled {
|
|
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
|
|
}
|
|
perf := newExtensionCallPerf(extensionID, functionName)
|
|
defer perf.finish()
|
|
initStartedAt := time.Now()
|
|
vm, err := ext.lockReadyVM()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
perf.recordInit(time.Since(initStartedAt))
|
|
defer ext.VMMu.Unlock()
|
|
requestCtx := context.Background()
|
|
if requestID != "" {
|
|
if ext.runtime != nil {
|
|
ext.runtime.setActiveRequestID(requestID)
|
|
defer ext.runtime.clearActiveRequestID()
|
|
}
|
|
requestCtx = initExtensionRequestCancel(requestID)
|
|
defer clearExtensionRequestCancel(requestID)
|
|
if isExtensionRequestCancelled(requestID) {
|
|
return "", ErrExtensionRequestCancelled
|
|
}
|
|
}
|
|
|
|
// Goja runtime is not thread-safe; guard direct extension.*() calls with VMMu
|
|
// to avoid races with other provider calls (e.g. getAlbum/getPlaylist).
|
|
script := fmt.Sprintf(`
|
|
(function() {
|
|
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
|
return extension.%s();
|
|
}
|
|
if (typeof %s === 'function') {
|
|
return %s();
|
|
}
|
|
return null;
|
|
})()
|
|
`, functionName, functionName, functionName, functionName)
|
|
|
|
jsStartedAt := time.Now()
|
|
result, err := RunWithTimeoutContextAndRecover(requestCtx, vm, script, timeout)
|
|
perf.recordJS(time.Since(jsStartedAt))
|
|
if err != nil {
|
|
if isExtensionRequestCancelled(requestID) || errors.Is(err, ErrExtensionRequestCancelled) {
|
|
return "", ErrExtensionRequestCancelled
|
|
}
|
|
return "", fmt.Errorf("%s failed: %w", functionName, err)
|
|
}
|
|
if isExtensionRequestCancelled(requestID) {
|
|
return "", ErrExtensionRequestCancelled
|
|
}
|
|
|
|
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
|
return "", fmt.Errorf("%s returned null", functionName)
|
|
}
|
|
|
|
parseStartedAt := time.Now()
|
|
jsonBytes, err := json.Marshal(result)
|
|
perf.recordParse(time.Since(parseStartedAt))
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal result: %w", err)
|
|
}
|
|
perf.setPayloadBytes(len(jsonBytes))
|
|
perf.setItems(countExtensionTopLevelItems(vm, result))
|
|
|
|
return string(jsonBytes), nil
|
|
}
|
|
|
|
func GetExtensionHomeFeedJSON(extensionID string) (string, error) {
|
|
return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second)
|
|
}
|
|
|
|
func GetExtensionHomeFeedJSONWithRequestID(extensionID, requestID string) (string, error) {
|
|
return callExtensionFunctionJSONWithRequestID(extensionID, "getHomeFeed", 60*time.Second, requestID)
|
|
}
|
|
|
|
func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) {
|
|
return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second)
|
|
}
|
|
|
|
func CancelExtensionRequestJSON(requestID string) {
|
|
cancelExtensionRequest(requestID)
|
|
}
|