diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png index fd5e939d..0ae78f0d 100644 Binary files a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..0ae78f0d Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png index 23686cb5..c77ae7b1 100644 Binary files a/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..c77ae7b1 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png index 093d5c26..cc14a761 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..cc14a761 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png index 590c5cda..c932747a 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..c932747a Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png index 1f540975..5634e89d 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..5634e89d Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index c79c58a3..d5063661 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -6,4 +6,9 @@ android:drawable="@drawable/ic_launcher_foreground" android:inset="16%" /> + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index ee6d88cc..04bd66ce 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index a21d95ae..f688ff7a 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 382e5cbb..d298e153 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index de7571e9..52172353 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 6f0aa29e..87ad5274 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/fonts/GoogleSansFlex.ttf b/assets/fonts/GoogleSansFlex.ttf new file mode 100644 index 00000000..6cd6eaf5 Binary files /dev/null and b/assets/fonts/GoogleSansFlex.ttf differ diff --git a/go_backend/exports.go b/go_backend/exports.go index 20c8548e..7ed7eba9 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -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 { diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index 9ff3ecb4..ca8dad5d 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -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) diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 6a07b26e..31974377 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -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) diff --git a/go_backend/extension_providers_test.go b/go_backend/extension_providers_test.go index cff9b1d3..02a36de2 100644 --- a/go_backend/extension_providers_test.go +++ b/go_backend/extension_providers_test.go @@ -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) diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index 288d7ffa..1f8c926b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 1967ef76..111daef5 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index fd15ff97..f667399e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index f5cb5270..d368f165 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index ca280234..0ad7eb72 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index 85b3cd51..802e9d8b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index e34cd643..e6be2977 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index fd15ff97..f667399e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index bd83c86d..803de8ab 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 4eadb79e..ed04d8b2 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png index eba0c613..e604a3de 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png index c0c2baac..7883d2fa 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png index b99ad6d0..760cd72c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png index c4298a3b..32e8b288 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 4eadb79e..ed04d8b2 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index c4363ca4..485e1ac7 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png index b61e41d5..53bc0df1 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png index b656689e..f8a67b96 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index b18d03d1..a18186ac 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index cbf1ca53..c1314869 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index fcc9e1ed..73bf29cf 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 5ed608ab..48d621eb 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -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, diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 92e41ff0..fe185768 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -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', ); } diff --git a/pubspec.yaml b/pubspec.yaml index 04a92b67..348948e1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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