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
This commit is contained in:
zarzet
2026-05-01 02:34:31 +07:00
parent 611abdc6ae
commit bdd3f4aef5
45 changed files with 292 additions and 398 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 954 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 647 B

After

Width:  |  Height:  |  Size: 828 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.
+5 -198
View File
@@ -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 {
+136 -44
View File
@@ -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)
+26 -142
View File
@@ -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)
+112
View File
@@ -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)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 B

After

Width:  |  Height:  |  Size: 428 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 905 B

After

Width:  |  Height:  |  Size: 872 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 624 B

After

Width:  |  Height:  |  Size: 648 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 905 B

After

Width:  |  Height:  |  Size: 872 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

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,
+2
View File
@@ -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',
);
}
+6
View File
@@ -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