feat: retire built-in download providers, add isolated extension runtimes, Google Sans Flex font, and monochrome icon support
- Remove all built-in download provider code paths (DownloadTrack, DownloadWithFallback, tryBuiltInProvider, isBuiltInDownloadProvider, normalizeQualityForBuiltIn) - Simplify DownloadByStrategy to route exclusively through extension providers - Add newIsolatedExtensionRuntime() for concurrent per-download Goja VMs - Extract reusable initializeExtensionRuntimeWithSettings() and runCleanupOnVM() - Add TestExtensionDownloadUsesIsolatedRuntimeForConcurrentCalls - Add Google Sans Flex font family to app themes - Add Android adaptive icon monochrome support - Regenerate iOS and Android app icons
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -6,4 +6,9 @@
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
<monochrome>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_monochrome"
|
||||
android:inset="16%" />
|
||||
</monochrome>
|
||||
</adaptive-icon>
|
||||
|
||||
|
Before Width: | Height: | Size: 954 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 647 B After Width: | Height: | Size: 828 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.1 KiB |
@@ -3,7 +3,6 @@ package gobackend
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -1020,105 +1019,22 @@ func applySongLinkRegionFromRequest(req *DownloadRequest) {
|
||||
}
|
||||
|
||||
func DownloadTrack(requestJSON string) (string, error) {
|
||||
var req DownloadRequest
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
applySongLinkRegionFromRequest(&req)
|
||||
defer closeOwnedOutputFD(req.OutputFD)
|
||||
if req.ItemID != "" {
|
||||
initDownloadCancel(req.ItemID)
|
||||
defer clearDownloadCancel(req.ItemID)
|
||||
if isDownloadCancelled(req.ItemID) {
|
||||
return errorResponse("Download cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
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 errorResponse("Download cancelled")
|
||||
}
|
||||
|
||||
if !isBuiltInDownloadProvider(req.Service) {
|
||||
return errorResponse("Unknown service: " + req.Service)
|
||||
}
|
||||
|
||||
result, err := downloadWithBuiltInProvider(req.Service, req)
|
||||
|
||||
if err != nil {
|
||||
return errorResponse(err.Error())
|
||||
}
|
||||
|
||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||
actualPath := result.FilePath[7:]
|
||||
result.FilePath = actualPath
|
||||
enrichResultQualityFromFile(&result)
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
req.Service,
|
||||
"File already exists",
|
||||
actualPath,
|
||||
true,
|
||||
)
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
enrichResultQualityFromFile(&result)
|
||||
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
req.Service,
|
||||
"Download complete",
|
||||
result.FilePath,
|
||||
false,
|
||||
)
|
||||
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
return errorResponse("Built-in download providers have been retired. Use downloadByStrategy with extension providers.")
|
||||
}
|
||||
|
||||
// DownloadByStrategy routes download requests with priority: YouTube > extension fallback > built-in fallback > direct service.
|
||||
// DownloadByStrategy routes all download requests through extension providers.
|
||||
func DownloadByStrategy(requestJSON string) (string, error) {
|
||||
var req DownloadRequest
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
|
||||
serviceRaw := strings.TrimSpace(req.Service)
|
||||
serviceNormalized := strings.ToLower(serviceRaw)
|
||||
|
||||
normalizedReq := req
|
||||
if isBuiltInDownloadProvider(serviceNormalized) {
|
||||
normalizedReq.Service = serviceNormalized
|
||||
}
|
||||
|
||||
normalizedBytes, err := json.Marshal(normalizedReq)
|
||||
normalizedBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
normalizedJSON := string(normalizedBytes)
|
||||
|
||||
if req.UseExtensions {
|
||||
// Respect strict mode when auto fallback is disabled:
|
||||
// for built-in providers, route directly to selected service only.
|
||||
if !req.UseFallback && isBuiltInDownloadProvider(serviceNormalized) {
|
||||
return DownloadTrack(normalizedJSON)
|
||||
}
|
||||
resp, err := DownloadWithExtensionsJSON(normalizedJSON)
|
||||
if err != nil {
|
||||
return errorResponse(err.Error())
|
||||
@@ -1126,120 +1042,11 @@ func DownloadByStrategy(requestJSON string) (string, error) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if req.UseFallback {
|
||||
return DownloadWithFallback(normalizedJSON)
|
||||
}
|
||||
|
||||
return DownloadTrack(normalizedJSON)
|
||||
return errorResponse("Extension providers are disabled; built-in download providers have been retired")
|
||||
}
|
||||
|
||||
func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
var req DownloadRequest
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
applySongLinkRegionFromRequest(&req)
|
||||
defer closeOwnedOutputFD(req.OutputFD)
|
||||
if req.ItemID != "" {
|
||||
initDownloadCancel(req.ItemID)
|
||||
defer clearDownloadCancel(req.ItemID)
|
||||
if isDownloadCancelled(req.ItemID) {
|
||||
return errorResponse("Download cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
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 errorResponse("Download cancelled")
|
||||
}
|
||||
|
||||
allServices := make([]string, 0, len(getBuiltInProviderSpecs()))
|
||||
for _, spec := range getBuiltInProviderSpecs() {
|
||||
if spec.SupportsDownload {
|
||||
allServices = append(allServices, spec.ID)
|
||||
}
|
||||
}
|
||||
if len(allServices) == 0 {
|
||||
return errorResponse("No built-in download providers available")
|
||||
}
|
||||
preferredService := req.Service
|
||||
if !isBuiltInDownloadProvider(preferredService) {
|
||||
preferredService = allServices[0]
|
||||
}
|
||||
|
||||
GoLog("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
|
||||
|
||||
services := []string{preferredService}
|
||||
for _, s := range allServices {
|
||||
if s != preferredService {
|
||||
services = append(services, s)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[DownloadWithFallback] Service order: %v\n", services)
|
||||
|
||||
var lastErr error
|
||||
|
||||
for _, service := range services {
|
||||
GoLog("[DownloadWithFallback] Trying service: %s\n", service)
|
||||
req.Service = service
|
||||
|
||||
result, err := downloadWithBuiltInProvider(service, req)
|
||||
if err != nil && !errors.Is(err, ErrDownloadCancelled) {
|
||||
GoLog("[DownloadWithFallback] %s error: %v\n", service, err)
|
||||
}
|
||||
|
||||
if err != nil && errors.Is(err, ErrDownloadCancelled) {
|
||||
return errorResponse("Download cancelled")
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||
actualPath := result.FilePath[7:]
|
||||
result.FilePath = actualPath
|
||||
enrichResultQualityFromFile(&result)
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
service,
|
||||
"File already exists",
|
||||
actualPath,
|
||||
true,
|
||||
)
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
enrichResultQualityFromFile(&result)
|
||||
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
service,
|
||||
"Downloaded from "+service,
|
||||
result.FilePath,
|
||||
false,
|
||||
)
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
return errorResponse("All services failed. Last error: " + lastErr.Error())
|
||||
return errorResponse("Built-in fallback has been retired. Use extension fallback through downloadByStrategy.")
|
||||
}
|
||||
|
||||
func GetDownloadProgress() string {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
@@ -342,20 +343,87 @@ func initializeVMLocked(ext *loadedExtension) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
|
||||
vm := goja.New()
|
||||
|
||||
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
||||
jsCode, err := os.ReadFile(indexPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
|
||||
}
|
||||
|
||||
runtime := &extensionRuntime{
|
||||
extensionID: ext.ID,
|
||||
manifest: ext.Manifest,
|
||||
settings: make(map[string]interface{}),
|
||||
cookieJar: nil,
|
||||
dataDir: ext.DataDir,
|
||||
vm: vm,
|
||||
storageFlushDelay: defaultStorageFlushDelay,
|
||||
}
|
||||
if ext.runtime != nil && ext.runtime.cookieJar != nil {
|
||||
runtime.cookieJar = ext.runtime.cookieJar
|
||||
} else {
|
||||
jar, _ := newSimpleCookieJar()
|
||||
runtime.cookieJar = jar
|
||||
}
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second))
|
||||
runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout)
|
||||
runtime.RegisterAPIs(vm)
|
||||
runtime.RegisterGoBackendAPIs(vm)
|
||||
|
||||
console := vm.NewObject()
|
||||
console.Set("log", func(call goja.FunctionCall) goja.Value {
|
||||
args := make([]interface{}, len(call.Arguments))
|
||||
for i, arg := range call.Arguments {
|
||||
args[i] = arg.Export()
|
||||
}
|
||||
GoLog("[Extension:%s] %v\n", ext.ID, args)
|
||||
return goja.Undefined()
|
||||
})
|
||||
vm.Set("console", console)
|
||||
|
||||
var registeredExtension goja.Value
|
||||
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) > 0 {
|
||||
registeredExtension = call.Arguments[0]
|
||||
vm.Set("extension", call.Arguments[0])
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
if _, err := vm.RunString(string(jsCode)); err != nil {
|
||||
runtime.closeStorageFlusher()
|
||||
return nil, nil, fmt.Errorf("failed to execute extension code: %w", err)
|
||||
}
|
||||
|
||||
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
|
||||
runtime.closeStorageFlusher()
|
||||
return nil, nil, fmt.Errorf("extension did not call registerExtension()")
|
||||
}
|
||||
|
||||
settings := getExtensionInitSettings(ext.ID)
|
||||
if len(settings) > 0 {
|
||||
if err := initializeExtensionRuntimeWithSettings(vm, ext.ID, settings); err != nil {
|
||||
runtime.closeStorageFlusher()
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return vm, runtime, nil
|
||||
}
|
||||
|
||||
func (m *extensionManager) initializeVM(ext *loadedExtension) error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
return initializeVMLocked(ext)
|
||||
}
|
||||
|
||||
func initializeExtensionWithSettingsLocked(
|
||||
ext *loadedExtension,
|
||||
func initializeExtensionRuntimeWithSettings(
|
||||
vm *goja.Runtime,
|
||||
extensionID string,
|
||||
settings map[string]interface{},
|
||||
) error {
|
||||
if ext.VM == nil {
|
||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||
}
|
||||
|
||||
settingsJSON, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to save settings")
|
||||
@@ -376,11 +444,9 @@ func initializeExtensionWithSettingsLocked(
|
||||
})()
|
||||
`, string(settingsJSON))
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
result, err := vm.RunString(script)
|
||||
if err != nil {
|
||||
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize error for %s: %v\n", ext.ID, err)
|
||||
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -392,14 +458,29 @@ func initializeExtensionWithSettingsLocked(
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
ext.Error = errMsg
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize failed for %s: %s\n", ext.ID, errMsg)
|
||||
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
|
||||
return fmt.Errorf("initialize failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initializeExtensionWithSettingsLocked(
|
||||
ext *loadedExtension,
|
||||
settings map[string]interface{},
|
||||
) error {
|
||||
if ext.VM == nil {
|
||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||
}
|
||||
|
||||
if err := initializeExtensionRuntimeWithSettings(ext.VM, ext.ID, settings); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
return err
|
||||
}
|
||||
|
||||
ext.initialized = true
|
||||
GoLog("[Extension] Initialized %s\n", ext.ID)
|
||||
return nil
|
||||
@@ -407,45 +488,56 @@ func initializeExtensionWithSettingsLocked(
|
||||
|
||||
func runCleanupLocked(ext *loadedExtension) error {
|
||||
if ext.VM != nil {
|
||||
script := `
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||
try {
|
||||
extension.cleanup();
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no cleanup function' };
|
||||
})()
|
||||
`
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
if err != nil {
|
||||
if err := runCleanupOnVM(ext.VM); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
return fmt.Errorf("cleanup failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) {
|
||||
if ext.VM.Get("extension") != nil {
|
||||
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCleanupOnVM(vm *goja.Runtime) error {
|
||||
if vm == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
script := `
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||
try {
|
||||
extension.cleanup();
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no cleanup function' };
|
||||
})()
|
||||
`
|
||||
|
||||
result, err := vm.RunString(script)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
return fmt.Errorf("cleanup failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func teardownVMLocked(ext *loadedExtension) {
|
||||
if err := runCleanupLocked(ext); err != nil {
|
||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
||||
|
||||
@@ -103,12 +103,10 @@ type builtInProviderSpec struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
SupportsMetadata bool `json:"supports_metadata"`
|
||||
SupportsDownload bool `json:"supports_download"`
|
||||
SupportsSearch bool `json:"supports_search"`
|
||||
GetMetadata func(resourceType, resourceID string) (string, error) `json:"-"`
|
||||
SearchAll func(query string, trackLimit, artistLimit int, filter string) (string, error) `json:"-"`
|
||||
SearchTracks func(query string, limit int) ([]ExtTrackMetadata, error) `json:"-"`
|
||||
Download func(req DownloadRequest) (DownloadResult, error) `json:"-"`
|
||||
}
|
||||
|
||||
var builtInProviderRegistry = []builtInProviderSpec{}
|
||||
@@ -153,14 +151,6 @@ func searchBuiltInProviderTracks(providerID, query string, limit int) ([]ExtTrac
|
||||
return spec.SearchTracks(query, limit)
|
||||
}
|
||||
|
||||
func downloadWithBuiltInProvider(providerID string, req DownloadRequest) (DownloadResult, error) {
|
||||
spec, ok := getBuiltInProviderSpec(providerID)
|
||||
if !ok || !spec.SupportsDownload || spec.Download == nil {
|
||||
return DownloadResult{}, fmt.Errorf("unknown built-in provider: %s", providerID)
|
||||
}
|
||||
return spec.Download(req)
|
||||
}
|
||||
|
||||
func manifestCapabilityStringList(manifest *ExtensionManifest, key string) []string {
|
||||
if manifest == nil || manifest.Capabilities == nil {
|
||||
return nil
|
||||
@@ -1076,17 +1066,30 @@ func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
p.extension.VMMu.Lock()
|
||||
vm, runtime, err := newIsolatedExtensionRuntime(p.extension)
|
||||
p.extension.VMMu.Unlock()
|
||||
if err != nil {
|
||||
return &ExtDownloadResult{
|
||||
Success: false,
|
||||
ErrorMessage: err.Error(),
|
||||
ErrorType: "init_error",
|
||||
}, nil
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
if p.extension.runtime != nil {
|
||||
p.extension.runtime.setActiveDownloadItemID(itemID)
|
||||
defer p.extension.runtime.clearActiveDownloadItemID()
|
||||
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)
|
||||
@@ -1094,7 +1097,7 @@ func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID
|
||||
SetItemPreparing(itemID)
|
||||
}
|
||||
|
||||
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
|
||||
vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) > 0 {
|
||||
percent := int(call.Arguments[0].ToInteger())
|
||||
if percent < 0 {
|
||||
@@ -1119,7 +1122,7 @@ func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID
|
||||
})()
|
||||
`, trackID, quality, outputPath)
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(p.vm, script, ExtDownloadTimeout)
|
||||
result, err := RunWithTimeoutAndRecover(vm, script, ExtDownloadTimeout)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
errType := "script_error"
|
||||
@@ -1314,13 +1317,9 @@ func sanitizeDownloadProviderPriority(providerIDs []string) []string {
|
||||
continue
|
||||
}
|
||||
|
||||
normalizedBuiltIn := strings.ToLower(providerID)
|
||||
if isRetiredBuiltInDownloadProvider(normalizedBuiltIn) {
|
||||
if isRetiredBuiltInDownloadProvider(providerID) {
|
||||
continue
|
||||
}
|
||||
if isBuiltInDownloadProvider(normalizedBuiltIn) {
|
||||
providerID = normalizedBuiltIn
|
||||
}
|
||||
|
||||
seenKey := strings.ToLower(providerID)
|
||||
if _, exists := seen[seenKey]; exists {
|
||||
@@ -1338,9 +1337,6 @@ func isRetiredBuiltInDownloadProvider(providerID string) bool {
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
if isBuiltInDownloadProvider(normalized) {
|
||||
return false
|
||||
}
|
||||
switch normalized {
|
||||
case "deezer", "qobuz", "tidal":
|
||||
return true
|
||||
@@ -1379,7 +1375,7 @@ func SetExtensionFallbackProviderIDs(providerIDs []string) {
|
||||
seen := map[string]struct{}{}
|
||||
for _, providerID := range providerIDs {
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
if providerID == "" || isBuiltInDownloadProvider(strings.ToLower(providerID)) {
|
||||
if providerID == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[providerID]; exists {
|
||||
@@ -1407,10 +1403,6 @@ func GetExtensionFallbackProviderIDs() []string {
|
||||
}
|
||||
|
||||
func isExtensionFallbackAllowed(providerID string) bool {
|
||||
if isBuiltInDownloadProvider(strings.ToLower(providerID)) {
|
||||
return true
|
||||
}
|
||||
|
||||
allowed := GetExtensionFallbackProviderIDs()
|
||||
if allowed == nil {
|
||||
return true
|
||||
@@ -1473,24 +1465,6 @@ func isBuiltInSearchProvider(providerID string) bool {
|
||||
return ok && spec.SupportsSearch
|
||||
}
|
||||
|
||||
func isBuiltInDownloadProvider(providerID string) bool {
|
||||
spec, ok := getBuiltInProviderSpec(providerID)
|
||||
return ok && spec.SupportsDownload
|
||||
}
|
||||
|
||||
func normalizeQualityForBuiltIn(quality string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(quality)) {
|
||||
case "alac", "hi_res_lossless", "lossless":
|
||||
return "HI_RES_LOSSLESS"
|
||||
case "atmos", "ac3", "dolby_atmos":
|
||||
return "LOSSLESS"
|
||||
case "aac", "aac-legacy":
|
||||
return "LOSSLESS"
|
||||
default:
|
||||
return quality
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeBuiltInMetadataTrack(track TrackMetadata, providerID string) ExtTrackMetadata {
|
||||
deezerID := ""
|
||||
tidalID := ""
|
||||
@@ -1666,17 +1640,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
if !strictMode && req.Service != "" && isBuiltInDownloadProvider(strings.ToLower(req.Service)) {
|
||||
GoLog("[DownloadWithExtensionFallback] User selected service: %s, prioritizing it first\n", req.Service)
|
||||
newPriority := []string{req.Service}
|
||||
for _, p := range priority {
|
||||
if p != req.Service {
|
||||
newPriority = append(newPriority, p)
|
||||
}
|
||||
}
|
||||
priority = newPriority
|
||||
GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority)
|
||||
} else if !strictMode && req.Service != "" && !isBuiltInDownloadProvider(strings.ToLower(req.Service)) {
|
||||
if !strictMode && req.Service != "" {
|
||||
found := false
|
||||
for _, p := range priority {
|
||||
if strings.EqualFold(p, req.Service) {
|
||||
@@ -2050,67 +2014,18 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
if providerID == "" {
|
||||
continue
|
||||
}
|
||||
providerIDNormalized := strings.ToLower(providerID)
|
||||
if providerID == req.Source {
|
||||
continue
|
||||
}
|
||||
|
||||
if skipBuiltIn && isBuiltInDownloadProvider(providerIDNormalized) {
|
||||
GoLog("[DownloadWithExtensionFallback] Skipping built-in provider %s (skipBuiltInFallback)\n", providerID)
|
||||
continue
|
||||
}
|
||||
|
||||
if !isBuiltInDownloadProvider(providerIDNormalized) && !isExtensionFallbackAllowed(providerID) {
|
||||
if !isExtensionFallbackAllowed(providerID) {
|
||||
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (not enabled for fallback)\n", providerID)
|
||||
continue
|
||||
}
|
||||
|
||||
GoLog("[DownloadWithExtensionFallback] Trying provider: %s\n", providerID)
|
||||
|
||||
if isBuiltInDownloadProvider(providerIDNormalized) {
|
||||
req.OutputExt = ""
|
||||
if (req.Genre == "" || req.Label == "" || req.Copyright == "") &&
|
||||
req.ISRC != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Enriching extra metadata from ISRC: %s\n", req.ISRC)
|
||||
enrichExtraMetadataByISRC("DownloadWithExtensionFallback", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
|
||||
if isDownloadCancelled(req.ItemID) {
|
||||
return nil, ErrDownloadCancelled
|
||||
}
|
||||
}
|
||||
|
||||
origQuality := req.Quality
|
||||
req.Quality = normalizeQualityForBuiltIn(req.Quality)
|
||||
result, err := tryBuiltInProvider(providerIDNormalized, req)
|
||||
req.Quality = origQuality
|
||||
if err == nil && result.Success {
|
||||
result.Service = providerIDNormalized
|
||||
if req.Label != "" {
|
||||
result.Label = req.Label
|
||||
}
|
||||
if req.Copyright != "" {
|
||||
result.Copyright = req.Copyright
|
||||
}
|
||||
if req.Genre != "" {
|
||||
result.Genre = req.Genre
|
||||
}
|
||||
if req.ReleaseDate != "" && result.ReleaseDate == "" {
|
||||
result.ReleaseDate = req.ReleaseDate
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Download cancelled",
|
||||
ErrorType: "cancelled",
|
||||
Service: providerIDNormalized,
|
||||
}, nil
|
||||
}
|
||||
lastErr = err
|
||||
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerIDNormalized, err)
|
||||
}
|
||||
} else {
|
||||
{
|
||||
ext, err := extManager.GetExtension(providerID)
|
||||
if err != nil || !ext.Enabled || ext.Error != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Extension %s not available\n", providerID)
|
||||
@@ -2244,42 +2159,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "No providers available",
|
||||
Error: "No extension download providers available",
|
||||
ErrorType: "not_found",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadResponse, error) {
|
||||
req.Service = providerID
|
||||
|
||||
result, err := downloadWithBuiltInProvider(providerID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DownloadResponse{
|
||||
Success: true,
|
||||
Message: "Download complete",
|
||||
FilePath: result.FilePath,
|
||||
ActualBitDepth: result.BitDepth,
|
||||
ActualSampleRate: result.SampleRate,
|
||||
Title: result.Title,
|
||||
Artist: result.Artist,
|
||||
Album: result.Album,
|
||||
ReleaseDate: result.ReleaseDate,
|
||||
TrackNumber: result.TrackNumber,
|
||||
DiscNumber: result.DiscNumber,
|
||||
ISRC: result.ISRC,
|
||||
CoverURL: result.CoverURL,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
LyricsLRC: result.LyricsLRC,
|
||||
DecryptionKey: result.DecryptionKey,
|
||||
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildOutputPath(req DownloadRequest) string {
|
||||
if strings.TrimSpace(req.OutputPath) != "" {
|
||||
return strings.TrimSpace(req.OutputPath)
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSetMetadataProviderPriorityStripsRetiredBuiltIns(t *testing.T) {
|
||||
@@ -115,6 +123,110 @@ func TestNormalizeDownloadDecryptionInfoCanonicalizesMovAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionDownloadUsesIsolatedRuntimeForConcurrentCalls(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
defer server.Close()
|
||||
setPrivateIPCache("download.test", false, time.Minute)
|
||||
|
||||
originalTransport := sharedTransport
|
||||
testTransport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, server.Listener.Addr().String())
|
||||
},
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
sharedTransport = testTransport
|
||||
defer func() {
|
||||
testTransport.CloseIdleConnections()
|
||||
sharedTransport = originalTransport
|
||||
}()
|
||||
|
||||
extDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(extDir, "index.js"), []byte(`
|
||||
registerExtension({
|
||||
download: function(trackID, quality, outputPath, onProgress) {
|
||||
var result = file.download('https://download.test/' + trackID, outputPath, {
|
||||
onProgress: function(written, total) {
|
||||
if (onProgress) onProgress(50);
|
||||
}
|
||||
});
|
||||
if (!result || !result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error_message: result && result.error ? result.error : 'download failed',
|
||||
error_type: 'download_error'
|
||||
};
|
||||
}
|
||||
if (onProgress) onProgress(100);
|
||||
return { success: true, file_path: result.path };
|
||||
}
|
||||
});
|
||||
`), 0600); err != nil {
|
||||
t.Fatalf("write extension index: %v", err)
|
||||
}
|
||||
|
||||
outputDir := t.TempDir()
|
||||
SetAllowedDownloadDirs([]string{outputDir})
|
||||
defer SetAllowedDownloadDirs(nil)
|
||||
|
||||
ext := &loadedExtension{
|
||||
ID: "concurrent-download",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "concurrent-download",
|
||||
Description: "Concurrent download test",
|
||||
Version: "1.0.0",
|
||||
Types: []ExtensionType{ExtensionTypeDownloadProvider},
|
||||
Permissions: ExtensionPermissions{
|
||||
Network: []string{"download.test"},
|
||||
File: true,
|
||||
},
|
||||
},
|
||||
Enabled: true,
|
||||
SourceDir: extDir,
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
provider := newExtensionProviderWrapper(ext)
|
||||
|
||||
start := time.Now()
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
i := i
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result, err := provider.Download(
|
||||
fmt.Sprintf("track-%d", i),
|
||||
"LOSSLESS",
|
||||
filepath.Join(outputDir, fmt.Sprintf("track-%d.flac", i)),
|
||||
"",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if result == nil || !result.Success {
|
||||
errs <- fmt.Errorf("download failed: %#v", result)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if elapsed := time.Since(start); elapsed >= 850*time.Millisecond {
|
||||
t.Fatalf("expected same-extension downloads to overlap, elapsed %s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathAddsExplicitOutputDirToAllowedDirs(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 429 B After Width: | Height: | Size: 428 B |
|
Before Width: | Height: | Size: 905 B After Width: | Height: | Size: 872 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 624 B After Width: | Height: | Size: 648 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 905 B After Width: | Height: | Size: 872 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -15,8 +15,6 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final extensionState = ref.watch(extensionProvider);
|
||||
final hasExtensions = extensionState.extensions.isNotEmpty;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
@@ -93,18 +91,6 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
onChanged: (v) =>
|
||||
ref.read(settingsProvider.notifier).setAutoFallback(v),
|
||||
),
|
||||
if (hasExtensions)
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.extension,
|
||||
title: context.l10n.optionsUseExtensionProviders,
|
||||
subtitle: settings.useExtensionProviders
|
||||
? context.l10n.optionsUseExtensionProvidersOn
|
||||
: context.l10n.optionsUseExtensionProvidersOff,
|
||||
value: settings.useExtensionProviders,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setUseExtensionProviders(v),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.sell_outlined,
|
||||
title: context.l10n.optionsEmbedMetadata,
|
||||
|
||||
@@ -32,6 +32,7 @@ class AppTheme {
|
||||
switchTheme: _switchTheme(scheme),
|
||||
chipTheme: _chipTheme(scheme),
|
||||
dividerTheme: _dividerTheme(scheme),
|
||||
fontFamily: 'Google Sans Flex',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,6 +68,7 @@ class AppTheme {
|
||||
switchTheme: _switchTheme(scheme),
|
||||
chipTheme: _chipTheme(scheme),
|
||||
dividerTheme: _dividerTheme(scheme),
|
||||
fontFamily: 'Google Sans Flex',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ flutter_launcher_icons:
|
||||
adaptive_icon_background: "#000000"
|
||||
adaptive_icon_foreground: "icon_foreground_android.png"
|
||||
adaptive_icon_foreground_inset: 16
|
||||
adaptive_icon_monochrome: "icon_foreground_android.png"
|
||||
ios_content_mode: scaleAspectFill
|
||||
remove_alpha_ios: true
|
||||
background_color_ios: "#000000"
|
||||
@@ -92,3 +93,8 @@ flutter:
|
||||
|
||||
assets:
|
||||
- assets/images/
|
||||
|
||||
fonts:
|
||||
- family: Google Sans Flex
|
||||
fonts:
|
||||
- asset: assets/fonts/GoogleSansFlex.ttf
|
||||
|
||||