From 34894faabf862c5fbd821af010c9a5b041ae3c78 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 3 May 2026 14:12:53 +0700 Subject: [PATCH] perf: reduce bridge and UI churn --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 112 ++- go_backend/cancel.go | 82 ++ go_backend/exports.go | 56 +- go_backend/extension_perf.go | 119 +++ go_backend/extension_providers.go | 870 ++++++++++++++---- go_backend/extension_providers_test.go | 242 +++++ go_backend/extension_runtime.go | 28 +- go_backend/extension_runtime_utils.go | 8 + go_backend/extension_test.go | 44 + go_backend/extension_timeout.go | 28 +- go_backend/progress.go | 139 ++- go_backend/progress_test.go | 52 +- ios/Runner/AppDelegate.swift | 56 +- lib/providers/explore_provider.dart | 17 +- lib/providers/extension_provider.dart | 13 +- lib/providers/track_provider.dart | 1 + lib/screens/artist_screen.dart | 56 +- lib/screens/home_tab.dart | 63 +- lib/screens/home_tab_widgets.dart | 50 +- lib/screens/playlist_screen.dart | 19 +- lib/screens/queue_tab.dart | 64 +- lib/screens/search_screen.dart | 151 +-- lib/services/platform_bridge.dart | 621 ++++++++++++- lib/widgets/cached_cover_image.dart | 54 +- 24 files changed, 2451 insertions(+), 494 deletions(-) create mode 100644 go_backend/extension_perf.go diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 6ec1cc7d..cfe22a39 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -43,6 +43,8 @@ class MainActivity: FlutterFragmentActivity() { "com.zarz.spotiflac/library_scan_progress_stream" private val DOWNLOAD_PROGRESS_STREAM_POLLING_INTERVAL_MS = 1200L private val LIBRARY_SCAN_PROGRESS_STREAM_POLLING_INTERVAL_MS = 200L + private val LARGE_JSON_RESULT_FILE_KEY = "__json_file" + private val LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024 private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var pendingSafTreeResult: MethodChannel.Result? = null private val safScanLock = Any() @@ -51,6 +53,7 @@ class MainActivity: FlutterFragmentActivity() { private var downloadProgressStreamJob: Job? = null private var downloadProgressEventSink: EventChannel.EventSink? = null private var lastDownloadProgressPayload: String? = null + private var lastDownloadProgressSeq = 0L private var libraryScanProgressStreamJob: Job? = null private var libraryScanProgressEventSink: EventChannel.EventSink? = null private var lastLibraryScanProgressPayload: String? = null @@ -504,17 +507,46 @@ class MainActivity: FlutterFragmentActivity() { } } + private fun bridgeJsonResult(payload: String): Any { + if (payload.toByteArray(Charsets.UTF_8).size < LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES) { + return payload + } + + return try { + val file = File(cacheDir, "bridge_json_${System.nanoTime()}.json") + file.writeText(payload, Charsets.UTF_8) + mapOf(LARGE_JSON_RESULT_FILE_KEY to file.absolutePath) + } catch (e: Exception) { + android.util.Log.w( + "SpotiFLAC", + "Failed to spill large bridge JSON result to file: ${e.message}", + ) + payload + } + } + + private fun updateDownloadProgressSeq(payload: String) { + try { + val seq = JSONObject(payload).optLong("seq", lastDownloadProgressSeq) + if (seq > lastDownloadProgressSeq) { + lastDownloadProgressSeq = seq + } + } catch (_: Exception) {} + } + private fun startDownloadProgressStream(sink: EventChannel.EventSink) { stopDownloadProgressStream() downloadProgressEventSink = sink lastDownloadProgressPayload = null + lastDownloadProgressSeq = 0L downloadProgressStreamJob = scope.launch { while (isActive && downloadProgressEventSink === sink) { try { val payload = withContext(Dispatchers.IO) { - Gobackend.getAllDownloadProgress() + Gobackend.getAllDownloadProgressDelta(lastDownloadProgressSeq) } - if (payload != lastDownloadProgressPayload) { + if (payload.isNotEmpty() && payload != lastDownloadProgressPayload) { + updateDownloadProgressSeq(payload) lastDownloadProgressPayload = payload sink.success(parseJsonPayload(payload)) } @@ -534,6 +566,7 @@ class MainActivity: FlutterFragmentActivity() { downloadProgressStreamJob = null downloadProgressEventSink = null lastDownloadProgressPayload = null + lastDownloadProgressSeq = 0L } private fun startLibraryScanProgressStream(sink: EventChannel.EventSink) { @@ -580,17 +613,17 @@ class MainActivity: FlutterFragmentActivity() { lastLibraryScanProgressPayload = null } - private fun loadExistingFilesJsonFromSnapshot(snapshotPath: String): String { + private fun loadExistingFilesFromSnapshot(snapshotPath: String): MutableMap { + val result = mutableMapOf() if (snapshotPath.isBlank()) { - return "{}" + return result } val snapshotFile = File(snapshotPath) if (!snapshotFile.exists()) { - return "{}" + return result } - val result = JSONObject() snapshotFile.forEachLine { line -> if (line.isBlank()) return@forEachLine val separatorIndex = line.indexOf('\t') @@ -600,10 +633,10 @@ class MainActivity: FlutterFragmentActivity() { val modTime = line.substring(0, separatorIndex).toLongOrNull() ?: 0L val filePath = line.substring(separatorIndex + 1) if (filePath.isNotEmpty()) { - result.put(filePath, modTime) + result[filePath] = modTime } } - return result.toString() + return result } private fun resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String { @@ -1452,6 +1485,22 @@ class MainActivity: FlutterFragmentActivity() { * @return JSON object with new/changed files and removed URIs */ private fun scanSafTreeIncremental(treeUriStr: String, existingFilesJson: String): String { + val existingFiles = mutableMapOf() + try { + val obj = JSONObject(existingFilesJson) + val keys = obj.keys() + while (keys.hasNext()) { + val key = keys.next() + existingFiles[key] = obj.optLong(key, 0) + } + } catch (_: Exception) {} + return scanSafTreeIncremental(treeUriStr, existingFiles) + } + + private fun scanSafTreeIncremental( + treeUriStr: String, + existingFiles: Map, + ): String { if (treeUriStr.isBlank()) { val result = JSONObject() result.put("files", JSONArray()) @@ -1471,16 +1520,6 @@ class MainActivity: FlutterFragmentActivity() { return result.toString() } - val existingFiles = mutableMapOf() - try { - val obj = JSONObject(existingFilesJson) - val keys = obj.keys() - while (keys.hasNext()) { - val key = keys.next() - existingFiles[key] = obj.optLong(key, 0) - } - } catch (_: Exception) {} - resetSafScanProgress() safScanCancel = false safScanActive = true @@ -3222,11 +3261,19 @@ class MainActivity: FlutterFragmentActivity() { val extensionId = call.argument("extension_id") ?: "" val query = call.argument("query") ?: "" val optionsJson = call.argument("options") ?: "" + val requestId = call.argument("request_id") ?: "" val response = withContext(Dispatchers.IO) { - Gobackend.customSearchWithExtensionJSON(extensionId, query, optionsJson) + Gobackend.customSearchWithExtensionJSONWithRequestID(extensionId, query, optionsJson, requestId) } result.success(response) } + "cancelExtensionRequest" -> { + val requestId = call.argument("request_id") ?: "" + withContext(Dispatchers.IO) { + Gobackend.cancelExtensionRequestJSON(requestId) + } + result.success(null) + } "getSearchProviders" -> { val response = withContext(Dispatchers.IO) { Gobackend.getSearchProvidersJSON() @@ -3359,8 +3406,9 @@ class MainActivity: FlutterFragmentActivity() { } "getExtensionHomeFeed" -> { val extensionId = call.argument("extension_id") ?: "" + val requestId = call.argument("request_id") ?: "" val response = withContext(Dispatchers.IO) { - Gobackend.getExtensionHomeFeedJSON(extensionId) + Gobackend.getExtensionHomeFeedJSONWithRequestID(extensionId, requestId) } result.success(response) } @@ -3382,7 +3430,7 @@ class MainActivity: FlutterFragmentActivity() { val folderPath = call.argument("folder_path") ?: "" val response = withContext(Dispatchers.IO) { safScanActive = false - Gobackend.scanLibraryFolderJSON(folderPath) + bridgeJsonResult(Gobackend.scanLibraryFolderJSON(folderPath)) } result.success(response) } @@ -3391,7 +3439,9 @@ class MainActivity: FlutterFragmentActivity() { val existingFiles = call.argument("existing_files") ?: "{}" val response = withContext(Dispatchers.IO) { safScanActive = false - Gobackend.scanLibraryFolderIncrementalJSON(folderPath, existingFiles) + bridgeJsonResult( + Gobackend.scanLibraryFolderIncrementalJSON(folderPath, existingFiles) + ) } result.success(response) } @@ -3400,9 +3450,11 @@ class MainActivity: FlutterFragmentActivity() { val snapshotPath = call.argument("snapshot_path") ?: "" val response = withContext(Dispatchers.IO) { safScanActive = false - Gobackend.scanLibraryFolderIncrementalFromSnapshotJSON( - folderPath, - snapshotPath, + bridgeJsonResult( + Gobackend.scanLibraryFolderIncrementalFromSnapshotJSON( + folderPath, + snapshotPath, + ) ) } result.success(response) @@ -3410,7 +3462,7 @@ class MainActivity: FlutterFragmentActivity() { "scanSafTree" -> { val treeUri = call.argument("tree_uri") ?: "" val response = withContext(Dispatchers.IO) { - scanSafTree(treeUri) + bridgeJsonResult(scanSafTree(treeUri)) } result.success(response) } @@ -3418,7 +3470,7 @@ class MainActivity: FlutterFragmentActivity() { val treeUri = call.argument("tree_uri") ?: "" val existingFiles = call.argument("existing_files") ?: "{}" val response = withContext(Dispatchers.IO) { - scanSafTreeIncremental(treeUri, existingFiles) + bridgeJsonResult(scanSafTreeIncremental(treeUri, existingFiles)) } result.success(response) } @@ -3426,9 +3478,9 @@ class MainActivity: FlutterFragmentActivity() { val treeUri = call.argument("tree_uri") ?: "" val snapshotPath = call.argument("snapshot_path") ?: "" val response = withContext(Dispatchers.IO) { - val existingFilesJson = - loadExistingFilesJsonFromSnapshot(snapshotPath) - scanSafTreeIncremental(treeUri, existingFilesJson) + val existingFiles = + loadExistingFilesFromSnapshot(snapshotPath) + bridgeJsonResult(scanSafTreeIncremental(treeUri, existingFiles)) } result.success(response) } diff --git a/go_backend/cancel.go b/go_backend/cancel.go index 5369aae9..10b7e2e0 100644 --- a/go_backend/cancel.go +++ b/go_backend/cancel.go @@ -9,6 +9,10 @@ import ( // ErrDownloadCancelled is returned when a download is cancelled by the user. var ErrDownloadCancelled = errors.New("download cancelled") +// ErrExtensionRequestCancelled is returned when a UI-driven extension request +// is superseded by a newer home/search request. +var ErrExtensionRequestCancelled = errors.New("extension request cancelled") + type cancelEntry struct { ctx context.Context cancel context.CancelFunc @@ -19,6 +23,9 @@ type cancelEntry struct { var ( cancelMu sync.Mutex cancelMap = make(map[string]*cancelEntry) + + extensionRequestCancelMu sync.Mutex + extensionRequestCancelMap = make(map[string]*cancelEntry) ) func initDownloadCancel(itemID string) context.Context { @@ -98,3 +105,78 @@ func clearDownloadCancel(itemID string) { } cancelMu.Unlock() } + +func initExtensionRequestCancel(requestID string) context.Context { + if requestID == "" { + return context.Background() + } + + extensionRequestCancelMu.Lock() + defer extensionRequestCancelMu.Unlock() + + if entry, ok := extensionRequestCancelMap[requestID]; ok { + if entry.ctx == nil { + ctx, cancel := context.WithCancel(context.Background()) + entry.ctx = ctx + entry.cancel = cancel + if entry.canceled && entry.cancel != nil { + entry.cancel() + } + } + entry.refs++ + return entry.ctx + } + + ctx, cancel := context.WithCancel(context.Background()) + extensionRequestCancelMap[requestID] = &cancelEntry{ + ctx: ctx, + cancel: cancel, + canceled: false, + refs: 1, + } + return ctx +} + +func cancelExtensionRequest(requestID string) { + if requestID == "" { + return + } + + extensionRequestCancelMu.Lock() + if entry, ok := extensionRequestCancelMap[requestID]; ok { + entry.canceled = true + if entry.cancel != nil { + entry.cancel() + } + } else { + extensionRequestCancelMap[requestID] = &cancelEntry{canceled: true} + } + extensionRequestCancelMu.Unlock() +} + +func isExtensionRequestCancelled(requestID string) bool { + if requestID == "" { + return false + } + + extensionRequestCancelMu.Lock() + entry, ok := extensionRequestCancelMap[requestID] + canceled := ok && entry.canceled + extensionRequestCancelMu.Unlock() + return canceled +} + +func clearExtensionRequestCancel(requestID string) { + if requestID == "" { + return + } + + extensionRequestCancelMu.Lock() + if entry, ok := extensionRequestCancelMap[requestID]; ok { + entry.refs-- + if entry.refs <= 0 { + delete(extensionRequestCancelMap, requestID) + } + } + extensionRequestCancelMu.Unlock() +} diff --git a/go_backend/exports.go b/go_backend/exports.go index 7ed7eba9..7e7f7501 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -3,6 +3,7 @@ package gobackend import ( "context" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -1059,6 +1060,10 @@ func GetAllDownloadProgress() string { return GetMultiProgress() } +func GetAllDownloadProgressDelta(sinceSeq int64) string { + return GetMultiProgressDelta(sinceSeq) +} + func InitItemProgress(itemID string) { StartItemProgress(itemID) } @@ -3217,6 +3222,10 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) } func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) { + return CustomSearchWithExtensionJSONWithRequestID(extensionID, query, optionsJSON, "") +} + +func CustomSearchWithExtensionJSONWithRequestID(extensionID, query string, optionsJSON string, requestID string) (string, error) { manager := getExtensionManager() ext, err := manager.GetExtension(extensionID) if err != nil { @@ -3235,7 +3244,7 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string } provider := newExtensionProviderWrapper(ext) - tracks, err := provider.CustomSearch(query, options) + tracks, err := provider.CustomSearchForRequestID(query, options, requestID) if err != nil { return "", err } @@ -3714,6 +3723,10 @@ func ClearStoreCacheJSON() error { } func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Duration) (string, error) { + return callExtensionFunctionJSONWithRequestID(extensionID, functionName, timeout, "") +} + +func callExtensionFunctionJSONWithRequestID(extensionID, functionName string, timeout time.Duration, requestID string) (string, error) { manager := getExtensionManager() ext, err := manager.GetExtension(extensionID) if err != nil { @@ -3723,11 +3736,27 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du if !ext.Enabled { return "", fmt.Errorf("extension '%s' is disabled", extensionID) } + perf := newExtensionCallPerf(extensionID, functionName) + defer perf.finish() + initStartedAt := time.Now() vm, err := ext.lockReadyVM() if err != nil { return "", err } + perf.recordInit(time.Since(initStartedAt)) defer ext.VMMu.Unlock() + requestCtx := context.Background() + if requestID != "" { + if ext.runtime != nil { + ext.runtime.setActiveRequestID(requestID) + defer ext.runtime.clearActiveRequestID() + } + requestCtx = initExtensionRequestCancel(requestID) + defer clearExtensionRequestCancel(requestID) + if isExtensionRequestCancelled(requestID) { + return "", ErrExtensionRequestCancelled + } + } // Goja runtime is not thread-safe; guard direct extension.*() calls with VMMu // to avoid races with other provider calls (e.g. getAlbum/getPlaylist). @@ -3740,20 +3769,31 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du })() `, functionName, functionName) - result, err := RunWithTimeoutAndRecover(vm, script, timeout) + jsStartedAt := time.Now() + result, err := RunWithTimeoutContextAndRecover(requestCtx, vm, script, timeout) + perf.recordJS(time.Since(jsStartedAt)) if err != nil { + if isExtensionRequestCancelled(requestID) || errors.Is(err, ErrExtensionRequestCancelled) { + return "", ErrExtensionRequestCancelled + } return "", fmt.Errorf("%s failed: %w", functionName, err) } + if isExtensionRequestCancelled(requestID) { + return "", ErrExtensionRequestCancelled + } if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { return "", fmt.Errorf("%s returned null", functionName) } - exported := result.Export() - jsonBytes, err := json.Marshal(exported) + parseStartedAt := time.Now() + jsonBytes, err := json.Marshal(result) + perf.recordParse(time.Since(parseStartedAt)) if err != nil { return "", fmt.Errorf("failed to marshal result: %w", err) } + perf.setPayloadBytes(len(jsonBytes)) + perf.setItems(countExtensionTopLevelItems(vm, result)) return string(jsonBytes), nil } @@ -3762,10 +3802,18 @@ func GetExtensionHomeFeedJSON(extensionID string) (string, error) { return callExtensionFunctionJSON(extensionID, "getHomeFeed", 60*time.Second) } +func GetExtensionHomeFeedJSONWithRequestID(extensionID, requestID string) (string, error) { + return callExtensionFunctionJSONWithRequestID(extensionID, "getHomeFeed", 60*time.Second, requestID) +} + func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) { return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second) } +func CancelExtensionRequestJSON(requestID string) { + cancelExtensionRequest(requestID) +} + func SetLibraryCoverCacheDirJSON(cacheDir string) { SetLibraryCoverCacheDir(cacheDir) } diff --git a/go_backend/extension_perf.go b/go_backend/extension_perf.go new file mode 100644 index 00000000..b7f13f90 --- /dev/null +++ b/go_backend/extension_perf.go @@ -0,0 +1,119 @@ +package gobackend + +import ( + "encoding/json" + "time" + + "github.com/dop251/goja" +) + +type extensionCallPerf struct { + extensionID string + operation string + startedAt time.Time + initMs float64 + jsMs float64 + parseMs float64 + items int + payloadBytes int +} + +func newExtensionCallPerf(extensionID, operation string) *extensionCallPerf { + if !GetLogBuffer().IsLoggingEnabled() { + return nil + } + return &extensionCallPerf{ + extensionID: extensionID, + operation: operation, + startedAt: time.Now(), + } +} + +func extensionDurationMs(duration time.Duration) float64 { + return float64(duration.Microseconds()) / 1000.0 +} + +func (p *extensionCallPerf) recordInit(duration time.Duration) { + if p == nil { + return + } + p.initMs += extensionDurationMs(duration) +} + +func (p *extensionCallPerf) recordJS(duration time.Duration) { + if p == nil { + return + } + p.jsMs += extensionDurationMs(duration) +} + +func (p *extensionCallPerf) recordParse(duration time.Duration) { + if p == nil { + return + } + p.parseMs += extensionDurationMs(duration) +} + +func (p *extensionCallPerf) recordPayload(value goja.Value) { + if p == nil || gojaValueIsEmpty(value) { + return + } + if payload, err := json.Marshal(value); err == nil { + p.payloadBytes = len(payload) + } +} + +func (p *extensionCallPerf) setPayloadBytes(payloadBytes int) { + if p == nil { + return + } + p.payloadBytes = payloadBytes +} + +func (p *extensionCallPerf) setItems(items int) { + if p == nil { + return + } + p.items = items +} + +func (p *extensionCallPerf) finish() { + if p == nil { + return + } + LogDebug( + "ExtensionPerf", + "extension=%s op=%s totalMs=%.1f initMs=%.1f jsMs=%.1f parseMs=%.1f items=%d payloadBytes=%d", + p.extensionID, + p.operation, + extensionDurationMs(time.Since(p.startedAt)), + p.initMs, + p.jsMs, + p.parseMs, + p.items, + p.payloadBytes, + ) +} + +func countExtensionTopLevelItems(vm *goja.Runtime, value goja.Value) int { + if gojaValueIsEmpty(value) { + return 0 + } + + if length, err := gojaArrayLength(value, vm); err == nil && length > 0 { + return length + } + + obj := value.ToObject(vm) + for _, key := range []string{"items", "tracks", "sections", "albums", "artists", "playlists", "results"} { + child := obj.Get(key) + if gojaValueIsEmpty(child) { + continue + } + if length, err := gojaArrayLength(child, vm); err == nil && length > 0 { + return length + } + } + + return 1 +} diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 74709e75..bc2a4741 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -1,12 +1,14 @@ package gobackend import ( + "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" + "strconv" "strings" "sync" "time" @@ -562,6 +564,494 @@ func (p *extensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe return p.SearchTracksForItemID(query, limit, "") } +func gojaValueIsEmpty(value goja.Value) bool { + return value == nil || goja.IsUndefined(value) || goja.IsNull(value) +} + +func gojaObjectString(obj *goja.Object, keys ...string) string { + for _, key := range keys { + value := obj.Get(key) + if gojaValueIsEmpty(value) { + continue + } + if str, ok := value.Export().(string); ok { + return str + } + } + return "" +} + +func gojaObjectValue(obj *goja.Object, keys ...string) goja.Value { + for _, key := range keys { + value := obj.Get(key) + if !gojaValueIsEmpty(value) { + return value + } + } + return nil +} + +func gojaObjectInt(obj *goja.Object, keys ...string) int { + for _, key := range keys { + value := obj.Get(key) + if gojaValueIsEmpty(value) { + continue + } + return int(value.ToInteger()) + } + return 0 +} + +func gojaObjectInt64(obj *goja.Object, keys ...string) int64 { + for _, key := range keys { + value := obj.Get(key) + if gojaValueIsEmpty(value) { + continue + } + return value.ToInteger() + } + return 0 +} + +func gojaObjectFloat(obj *goja.Object, keys ...string) float64 { + for _, key := range keys { + value := obj.Get(key) + if gojaValueIsEmpty(value) { + continue + } + return value.ToFloat() + } + return 0 +} + +func gojaObjectBool(obj *goja.Object, keys ...string) bool { + for _, key := range keys { + value := obj.Get(key) + if gojaValueIsEmpty(value) { + continue + } + return value.ToBoolean() + } + return false +} + +func gojaObjectInterfaceMap(obj *goja.Object, keys ...string) map[string]interface{} { + value := gojaObjectValue(obj, keys...) + if gojaValueIsEmpty(value) { + return nil + } + + exported, ok := value.Export().(map[string]interface{}) + if !ok || len(exported) == 0 { + return nil + } + return exported +} + +func gojaObjectStringMap(vm *goja.Runtime, obj *goja.Object, keys ...string) map[string]string { + value := gojaObjectValue(obj, keys...) + if gojaValueIsEmpty(value) { + return nil + } + + valueObj := value.ToObject(vm) + objectKeys := valueObj.Keys() + if len(objectKeys) == 0 { + return nil + } + + result := make(map[string]string, len(objectKeys)) + for _, childKey := range objectKeys { + childValue := valueObj.Get(childKey) + if gojaValueIsEmpty(childValue) { + continue + } + result[childKey] = childValue.String() + } + if len(result) == 0 { + return nil + } + return result +} + +func gojaArrayLength(value goja.Value, vm *goja.Runtime) (int, error) { + if gojaValueIsEmpty(value) { + return 0, nil + } + lengthValue := value.ToObject(vm).Get("length") + if gojaValueIsEmpty(lengthValue) { + return 0, fmt.Errorf("value is not an array") + } + length := lengthValue.ToInteger() + if length <= 0 { + return 0, nil + } + return int(length), nil +} + +func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetadata { + obj := value.ToObject(vm) + return ExtTrackMetadata{ + ID: gojaObjectString(obj, "id"), + Name: gojaObjectString(obj, "name"), + Artists: gojaObjectString(obj, "artists"), + AlbumName: gojaObjectString(obj, "album_name", "albumName"), + AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"), + DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"), + CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"), + Images: gojaObjectString(obj, "images"), + ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"), + TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"), + TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"), + DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"), + TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"), + ISRC: gojaObjectString(obj, "isrc"), + ProviderID: gojaObjectString(obj, "provider_id", "providerId"), + ItemType: gojaObjectString(obj, "item_type", "itemType"), + AlbumType: gojaObjectString(obj, "album_type", "albumType"), + TidalID: gojaObjectString(obj, "tidal_id", "tidalId"), + QobuzID: gojaObjectString(obj, "qobuz_id", "qobuzId"), + DeezerID: gojaObjectString(obj, "deezer_id", "deezerId"), + SpotifyID: gojaObjectString(obj, "spotify_id", "spotifyId"), + ExternalLinks: gojaObjectStringMap(vm, obj, "external_links", "externalLinks"), + Label: gojaObjectString(obj, "label"), + Copyright: gojaObjectString(obj, "copyright"), + Genre: gojaObjectString(obj, "genre"), + Composer: gojaObjectString(obj, "composer"), + AudioQuality: gojaObjectString(obj, "audio_quality", "audioQuality"), + AudioModes: gojaObjectString(obj, "audio_modes", "audioModes"), + } +} + +func parseExtensionTrackArray(vm *goja.Runtime, value goja.Value) ([]ExtTrackMetadata, error) { + length, err := gojaArrayLength(value, vm) + if err != nil { + return nil, err + } + if length == 0 { + return []ExtTrackMetadata{}, nil + } + + arrayObj := value.ToObject(vm) + tracks := make([]ExtTrackMetadata, 0, length) + for i := 0; i < length; i++ { + trackValue := arrayObj.Get(strconv.Itoa(i)) + if gojaValueIsEmpty(trackValue) { + continue + } + tracks = append(tracks, parseExtensionTrackValue(vm, trackValue)) + } + return tracks, nil +} + +func parseExtensionAlbumValue(vm *goja.Runtime, value goja.Value) (ExtAlbumMetadata, error) { + if gojaValueIsEmpty(value) { + return ExtAlbumMetadata{}, nil + } + + obj := value.ToObject(vm) + tracks := []ExtTrackMetadata{} + if tracksValue := gojaObjectValue(obj, "tracks"); !gojaValueIsEmpty(tracksValue) { + parsedTracks, err := parseExtensionTrackArray(vm, tracksValue) + if err != nil { + return ExtAlbumMetadata{}, err + } + tracks = parsedTracks + } + + return ExtAlbumMetadata{ + ID: gojaObjectString(obj, "id"), + Name: gojaObjectString(obj, "name"), + Artists: gojaObjectString(obj, "artists"), + ArtistID: gojaObjectString(obj, "artist_id", "artistId"), + CoverURL: gojaObjectString(obj, "cover_url", "coverUrl", "images"), + ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"), + TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"), + AlbumType: gojaObjectString(obj, "album_type", "albumType"), + Tracks: tracks, + ProviderID: gojaObjectString(obj, "provider_id", "providerId"), + }, nil +} + +func parseExtensionAlbumArray(vm *goja.Runtime, value goja.Value) ([]ExtAlbumMetadata, error) { + length, err := gojaArrayLength(value, vm) + if err != nil { + return nil, err + } + if length == 0 { + return []ExtAlbumMetadata{}, nil + } + + arrayObj := value.ToObject(vm) + albums := make([]ExtAlbumMetadata, 0, length) + for i := 0; i < length; i++ { + albumValue := arrayObj.Get(strconv.Itoa(i)) + if gojaValueIsEmpty(albumValue) { + continue + } + album, err := parseExtensionAlbumValue(vm, albumValue) + if err != nil { + return nil, err + } + albums = append(albums, album) + } + return albums, nil +} + +func parseExtensionArtistValue(vm *goja.Runtime, value goja.Value) (ExtArtistMetadata, error) { + if gojaValueIsEmpty(value) { + return ExtArtistMetadata{}, nil + } + + obj := value.ToObject(vm) + albums := []ExtAlbumMetadata{} + if albumsValue := gojaObjectValue(obj, "albums"); !gojaValueIsEmpty(albumsValue) { + parsedAlbums, err := parseExtensionAlbumArray(vm, albumsValue) + if err != nil { + return ExtArtistMetadata{}, err + } + albums = parsedAlbums + } + + releases := []ExtAlbumMetadata{} + if releasesValue := gojaObjectValue(obj, "releases"); !gojaValueIsEmpty(releasesValue) { + parsedReleases, err := parseExtensionAlbumArray(vm, releasesValue) + if err != nil { + return ExtArtistMetadata{}, err + } + releases = parsedReleases + } + + topTracks := []ExtTrackMetadata{} + if topTracksValue := gojaObjectValue(obj, "top_tracks", "topTracks"); !gojaValueIsEmpty(topTracksValue) { + parsedTopTracks, err := parseExtensionTrackArray(vm, topTracksValue) + if err != nil { + return ExtArtistMetadata{}, err + } + topTracks = parsedTopTracks + } + + return ExtArtistMetadata{ + ID: gojaObjectString(obj, "id"), + Name: gojaObjectString(obj, "name"), + ImageURL: gojaObjectString(obj, "image_url", "imageUrl"), + HeaderImage: gojaObjectString(obj, "header_image", "headerImage"), + Listeners: gojaObjectInt(obj, "listeners"), + Albums: albums, + Releases: releases, + TopTracks: topTracks, + ProviderID: gojaObjectString(obj, "provider_id", "providerId"), + }, nil +} + +func parseExtensionAvailabilityValue(vm *goja.Runtime, value goja.Value) ExtAvailabilityResult { + obj := value.ToObject(vm) + return ExtAvailabilityResult{ + Available: gojaObjectBool(obj, "available"), + Reason: gojaObjectString(obj, "reason"), + TrackID: gojaObjectString(obj, "track_id", "trackId"), + SkipFallback: gojaObjectBool(obj, "skip_fallback", "skipFallback"), + } +} + +func parseExtensionDownloadURLValue(vm *goja.Runtime, value goja.Value) ExtDownloadURLResult { + obj := value.ToObject(vm) + return ExtDownloadURLResult{ + URL: gojaObjectString(obj, "url"), + Format: gojaObjectString(obj, "format"), + BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"), + SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"), + } +} + +func parseExtensionDownloadDecryptionValue(vm *goja.Runtime, value goja.Value) *DownloadDecryptionInfo { + if gojaValueIsEmpty(value) { + return nil + } + + obj := value.ToObject(vm) + info := &DownloadDecryptionInfo{ + Strategy: gojaObjectString(obj, "strategy"), + Key: gojaObjectString(obj, "key"), + IV: gojaObjectString(obj, "iv"), + InputFormat: gojaObjectString(obj, "input_format", "inputFormat"), + OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"), + Options: gojaObjectInterfaceMap(obj, "options"), + } + if info.Strategy == "" && info.Key == "" && info.IV == "" && info.InputFormat == "" && info.OutputExtension == "" && len(info.Options) == 0 { + return nil + } + return info +} + +func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDownloadResult { + obj := value.ToObject(vm) + return ExtDownloadResult{ + Success: gojaObjectBool(obj, "success"), + FilePath: gojaObjectString(obj, "file_path", "filePath", "path"), + AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"), + BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"), + SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"), + ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"), + ErrorType: gojaObjectString(obj, "error_type", "errorType"), + Title: gojaObjectString(obj, "title"), + Artist: gojaObjectString(obj, "artist"), + Album: gojaObjectString(obj, "album"), + AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"), + TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"), + DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"), + TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"), + TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"), + ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"), + CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"), + ISRC: gojaObjectString(obj, "isrc"), + Genre: gojaObjectString(obj, "genre"), + Label: gojaObjectString(obj, "label"), + Copyright: gojaObjectString(obj, "copyright"), + Composer: gojaObjectString(obj, "composer"), + LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"), + DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"), + Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")), + } +} + +func parseExtensionURLHandleValue(vm *goja.Runtime, value goja.Value) (ExtURLHandleResult, error) { + obj := value.ToObject(vm) + handleResult := ExtURLHandleResult{ + Type: gojaObjectString(obj, "type"), + Name: gojaObjectString(obj, "name"), + CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"), + } + + if trackValue := gojaObjectValue(obj, "track"); !gojaValueIsEmpty(trackValue) { + track := parseExtensionTrackValue(vm, trackValue) + handleResult.Track = &track + } + if tracksValue := gojaObjectValue(obj, "tracks"); !gojaValueIsEmpty(tracksValue) { + tracks, err := parseExtensionTrackArray(vm, tracksValue) + if err != nil { + return ExtURLHandleResult{}, err + } + handleResult.Tracks = tracks + } + if albumValue := gojaObjectValue(obj, "album"); !gojaValueIsEmpty(albumValue) { + album, err := parseExtensionAlbumValue(vm, albumValue) + if err != nil { + return ExtURLHandleResult{}, err + } + handleResult.Album = &album + } + if artistValue := gojaObjectValue(obj, "artist"); !gojaValueIsEmpty(artistValue) { + artist, err := parseExtensionArtistValue(vm, artistValue) + if err != nil { + return ExtURLHandleResult{}, err + } + handleResult.Artist = &artist + } + + return handleResult, nil +} + +func parseExtensionMatchTrackValue(vm *goja.Runtime, value goja.Value) MatchTrackResult { + obj := value.ToObject(vm) + return MatchTrackResult{ + Matched: gojaObjectBool(obj, "matched"), + TrackID: gojaObjectString(obj, "track_id", "trackId"), + Confidence: gojaObjectFloat(obj, "confidence"), + Reason: gojaObjectString(obj, "reason"), + } +} + +func parseExtensionPostProcessValue(vm *goja.Runtime, value goja.Value) PostProcessResult { + obj := value.ToObject(vm) + return PostProcessResult{ + Success: gojaObjectBool(obj, "success"), + NewFilePath: gojaObjectString(obj, "new_file_path", "newFilePath"), + NewFileURI: gojaObjectString(obj, "new_file_uri", "newFileUri"), + Error: gojaObjectString(obj, "error"), + BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"), + SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"), + } +} + +func parseExtensionLyricsLineArray(vm *goja.Runtime, value goja.Value) ([]ExtLyricsLine, error) { + length, err := gojaArrayLength(value, vm) + if err != nil { + return nil, err + } + if length == 0 { + return []ExtLyricsLine{}, nil + } + + arrayObj := value.ToObject(vm) + lines := make([]ExtLyricsLine, 0, length) + for i := 0; i < length; i++ { + lineValue := arrayObj.Get(strconv.Itoa(i)) + if gojaValueIsEmpty(lineValue) { + continue + } + lineObj := lineValue.ToObject(vm) + lines = append(lines, ExtLyricsLine{ + StartTimeMs: gojaObjectInt64(lineObj, "startTimeMs", "start_time_ms"), + Words: gojaObjectString(lineObj, "words"), + EndTimeMs: gojaObjectInt64(lineObj, "endTimeMs", "end_time_ms"), + }) + } + return lines, nil +} + +func parseExtensionLyricsValue(vm *goja.Runtime, value goja.Value) (ExtLyricsResult, error) { + obj := value.ToObject(vm) + lines := []ExtLyricsLine{} + if linesValue := gojaObjectValue(obj, "lines"); !gojaValueIsEmpty(linesValue) { + parsedLines, err := parseExtensionLyricsLineArray(vm, linesValue) + if err != nil { + return ExtLyricsResult{}, err + } + lines = parsedLines + } + + return ExtLyricsResult{ + Lines: lines, + SyncType: gojaObjectString(obj, "syncType", "sync_type"), + Instrumental: gojaObjectBool(obj, "instrumental"), + PlainLyrics: gojaObjectString(obj, "plainLyrics", "plain_lyrics"), + Provider: gojaObjectString(obj, "provider"), + }, nil +} + +func parseExtensionSearchResult(vm *goja.Runtime, value goja.Value) (ExtSearchResult, error) { + if gojaValueIsEmpty(value) { + return ExtSearchResult{}, nil + } + + resultObj := value.ToObject(vm) + tracksValue := resultObj.Get("tracks") + if gojaValueIsEmpty(tracksValue) { + tracks, err := parseExtensionTrackArray(vm, value) + if err != nil { + return ExtSearchResult{}, err + } + return ExtSearchResult{ + Tracks: tracks, + Total: len(tracks), + }, nil + } + + tracks, err := parseExtensionTrackArray(vm, tracksValue) + if err != nil { + return ExtSearchResult{}, err + } + total := gojaObjectInt(resultObj, "total") + if total == 0 { + total = len(tracks) + } + return ExtSearchResult{ + Tracks: tracks, + Total: total, + }, nil +} + func (p *extensionProviderWrapper) SearchTracksForItemID(query string, limit int, itemID string) (*ExtSearchResult, error) { if !p.extension.Manifest.IsMetadataProvider() { return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) @@ -570,9 +1060,13 @@ func (p *extensionProviderWrapper) SearchTracksForItemID(query string, limit int if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + perf := newExtensionCallPerf(p.extension.ID, "searchTracks") + defer perf.finish() + initStartedAt := time.Now() if err := p.lockReadyVM(); err != nil { return nil, err } + perf.recordInit(time.Since(initStartedAt)) defer p.extension.VMMu.Unlock() if itemID != "" { if p.extension.runtime != nil { @@ -595,7 +1089,10 @@ func (p *extensionProviderWrapper) SearchTracksForItemID(query string, limit int })() `, query, limit) + jsStartedAt := time.Now() result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) if err != nil { if isDownloadCancelled(itemID) { return nil, ErrDownloadCancelled @@ -613,24 +1110,13 @@ func (p *extensionProviderWrapper) SearchTracksForItemID(query string, limit int return nil, fmt.Errorf("searchTracks returned null") } - exported := result.Export() - jsonBytes, err := json.Marshal(exported) + parseStartedAt := time.Now() + searchResult, err := parseExtensionSearchResult(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) if err != nil { - return nil, fmt.Errorf("failed to marshal result: %w", err) - } - - var searchResult ExtSearchResult - - if err := json.Unmarshal(jsonBytes, &searchResult); err != nil { - var tracks []ExtTrackMetadata - if arrErr := json.Unmarshal(jsonBytes, &tracks); arrErr != nil { - return nil, fmt.Errorf("failed to parse search result: %w (also tried array: %v)", err, arrErr) - } - searchResult = ExtSearchResult{ - Tracks: tracks, - Total: len(tracks), - } + return nil, fmt.Errorf("failed to parse search result: %w", err) } + perf.setItems(len(searchResult.Tracks)) for i := range searchResult.Tracks { searchResult.Tracks[i].ProviderID = p.extension.ID @@ -647,9 +1133,13 @@ func (p *extensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + perf := newExtensionCallPerf(p.extension.ID, "getTrack") + defer perf.finish() + initStartedAt := time.Now() if err := p.lockReadyVM(); err != nil { return nil, err } + perf.recordInit(time.Since(initStartedAt)) defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` @@ -661,7 +1151,10 @@ func (p *extensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, })() `, trackID) + jsStartedAt := time.Now() result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) if err != nil { if IsTimeoutError(err) { return nil, fmt.Errorf("getTrack timeout: extension took too long to respond") @@ -673,17 +1166,10 @@ func (p *extensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, return nil, fmt.Errorf("getTrack returned null") } - exported := result.Export() - jsonBytes, err := json.Marshal(exported) - if err != nil { - return nil, fmt.Errorf("failed to marshal result: %w", err) - } - - var track ExtTrackMetadata - if err := json.Unmarshal(jsonBytes, &track); err != nil { - return nil, fmt.Errorf("failed to parse track: %w", err) - } - + parseStartedAt := time.Now() + track := parseExtensionTrackValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + perf.setItems(1) track.ProviderID = p.extension.ID return &track, nil } @@ -696,9 +1182,13 @@ func (p *extensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + perf := newExtensionCallPerf(p.extension.ID, "getAlbum") + defer perf.finish() + initStartedAt := time.Now() if err := p.lockReadyVM(); err != nil { return nil, err } + perf.recordInit(time.Since(initStartedAt)) defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` @@ -710,7 +1200,10 @@ func (p *extensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, })() `, albumID) + jsStartedAt := time.Now() result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) if err != nil { if IsTimeoutError(err) { return nil, fmt.Errorf("getAlbum timeout: extension took too long to respond") @@ -722,16 +1215,13 @@ func (p *extensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, return nil, fmt.Errorf("getAlbum returned null") } - exported := result.Export() - jsonBytes, err := json.Marshal(exported) + parseStartedAt := time.Now() + album, err := parseExtensionAlbumValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) if err != nil { - return nil, fmt.Errorf("failed to marshal result: %w", err) - } - - var album ExtAlbumMetadata - if err := json.Unmarshal(jsonBytes, &album); err != nil { return nil, fmt.Errorf("failed to parse album: %w", err) } + perf.setItems(len(album.Tracks)) album.ProviderID = p.extension.ID for i := range album.Tracks { @@ -748,9 +1238,13 @@ func (p *extensionProviderWrapper) GetPlaylist(playlistID string) (*ExtAlbumMeta if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + perf := newExtensionCallPerf(p.extension.ID, "getPlaylist") + defer perf.finish() + initStartedAt := time.Now() if err := p.lockReadyVM(); err != nil { return nil, err } + perf.recordInit(time.Since(initStartedAt)) defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` @@ -765,7 +1259,10 @@ func (p *extensionProviderWrapper) GetPlaylist(playlistID string) (*ExtAlbumMeta })() `, playlistID, playlistID) + jsStartedAt := time.Now() result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) if err != nil { if IsTimeoutError(err) { return nil, fmt.Errorf("getPlaylist timeout: extension took too long to respond") @@ -777,16 +1274,13 @@ func (p *extensionProviderWrapper) GetPlaylist(playlistID string) (*ExtAlbumMeta return nil, fmt.Errorf("getPlaylist returned null") } - exported := result.Export() - jsonBytes, err := json.Marshal(exported) + parseStartedAt := time.Now() + playlist, err := parseExtensionAlbumValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) if err != nil { - return nil, fmt.Errorf("failed to marshal result: %w", err) - } - - var playlist ExtAlbumMetadata - if err := json.Unmarshal(jsonBytes, &playlist); err != nil { return nil, fmt.Errorf("failed to parse playlist: %w", err) } + perf.setItems(len(playlist.Tracks)) playlist.ProviderID = p.extension.ID for i := range playlist.Tracks { @@ -803,9 +1297,13 @@ func (p *extensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + perf := newExtensionCallPerf(p.extension.ID, "getArtist") + defer perf.finish() + initStartedAt := time.Now() if err := p.lockReadyVM(); err != nil { return nil, err } + perf.recordInit(time.Since(initStartedAt)) defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` @@ -817,7 +1315,10 @@ func (p *extensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat })() `, artistID) + jsStartedAt := time.Now() result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) if err != nil { if IsTimeoutError(err) { return nil, fmt.Errorf("getArtist timeout: extension took too long to respond") @@ -829,16 +1330,13 @@ func (p *extensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat return nil, fmt.Errorf("getArtist returned null") } - exported := result.Export() - jsonBytes, err := json.Marshal(exported) + parseStartedAt := time.Now() + artist, err := parseExtensionArtistValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) if err != nil { - return nil, fmt.Errorf("failed to marshal result: %w", err) - } - - var artist ExtArtistMetadata - if err := json.Unmarshal(jsonBytes, &artist); err != nil { return nil, fmt.Errorf("failed to parse artist: %w", err) } + perf.setItems(len(artist.Albums) + len(artist.Releases) + len(artist.TopTracks)) artist.ProviderID = p.extension.ID for i := range artist.Releases { @@ -862,10 +1360,14 @@ func (p *extensionProviderWrapper) EnrichTrackForItemID(track *ExtTrackMetadata, if !p.extension.Enabled { return track, nil } + perf := newExtensionCallPerf(p.extension.ID, "enrichTrack") + defer perf.finish() + initStartedAt := time.Now() if err := p.lockReadyVM(); err != nil { GoLog("[Extension] EnrichTrack init error for %s: %v\n", p.extension.ID, err) return track, nil } + perf.recordInit(time.Since(initStartedAt)) defer p.extension.VMMu.Unlock() if itemID != "" { if p.extension.runtime != nil { @@ -895,7 +1397,10 @@ func (p *extensionProviderWrapper) EnrichTrackForItemID(track *ExtTrackMetadata, })() `, string(trackJSON)) + jsStartedAt := time.Now() result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) if err != nil { if isDownloadCancelled(itemID) { return track, ErrDownloadCancelled @@ -915,19 +1420,10 @@ func (p *extensionProviderWrapper) EnrichTrackForItemID(track *ExtTrackMetadata, return track, nil } - exported := result.Export() - jsonBytes, err := json.Marshal(exported) - if err != nil { - GoLog("[Extension] EnrichTrack: failed to marshal result: %v\n", err) - return track, nil - } - - var enrichedTrack ExtTrackMetadata - if err := json.Unmarshal(jsonBytes, &enrichedTrack); err != nil { - GoLog("[Extension] EnrichTrack: failed to parse enriched track: %v\n", err) - return track, nil - } - + parseStartedAt := time.Now() + enrichedTrack := parseExtensionTrackValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + perf.setItems(1) enrichedTrack.ProviderID = track.ProviderID return &enrichedTrack, nil @@ -945,9 +1441,13 @@ func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, a if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + perf := newExtensionCallPerf(p.extension.ID, "checkAvailability") + defer perf.finish() + initStartedAt := time.Now() if err := p.lockReadyVM(); err != nil { return nil, err } + perf.recordInit(time.Since(initStartedAt)) defer p.extension.VMMu.Unlock() if itemID != "" { if p.extension.runtime != nil { @@ -976,7 +1476,10 @@ func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, a })() `, isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID, durationMS) + jsStartedAt := time.Now() result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) if err != nil { if isDownloadCancelled(itemID) { return nil, ErrDownloadCancelled @@ -994,17 +1497,10 @@ func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, a return &ExtAvailabilityResult{Available: false, Reason: "not implemented"}, nil } - exported := result.Export() - jsonBytes, err := json.Marshal(exported) - if err != nil { - return nil, fmt.Errorf("failed to marshal result: %w", err) - } - - var availability ExtAvailabilityResult - if err := json.Unmarshal(jsonBytes, &availability); err != nil { - return nil, fmt.Errorf("failed to parse availability: %w", err) - } - + parseStartedAt := time.Now() + availability := parseExtensionAvailabilityValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + perf.setItems(1) return &availability, nil } @@ -1016,9 +1512,13 @@ func (p *extensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + perf := newExtensionCallPerf(p.extension.ID, "getDownloadUrl") + defer perf.finish() + initStartedAt := time.Now() if err := p.lockReadyVM(); err != nil { return nil, err } + perf.recordInit(time.Since(initStartedAt)) defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` @@ -1030,7 +1530,10 @@ func (p *extensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext })() `, trackID, quality) + jsStartedAt := time.Now() result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) if err != nil { if IsTimeoutError(err) { return nil, fmt.Errorf("getDownloadUrl timeout: extension took too long to respond") @@ -1042,17 +1545,10 @@ func (p *extensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext return nil, fmt.Errorf("getDownloadUrl returned null") } - exported := result.Export() - jsonBytes, err := json.Marshal(exported) - if err != nil { - return nil, fmt.Errorf("failed to marshal result: %w", err) - } - - var urlResult ExtDownloadURLResult - if err := json.Unmarshal(jsonBytes, &urlResult); err != nil { - return nil, fmt.Errorf("failed to parse download URL: %w", err) - } - + parseStartedAt := time.Now() + urlResult := parseExtensionDownloadURLValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + perf.setItems(1) return &urlResult, nil } @@ -1066,9 +1562,13 @@ func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + perf := newExtensionCallPerf(p.extension.ID, "download") + defer perf.finish() + initStartedAt := time.Now() p.extension.VMMu.Lock() vm, runtime, err := newIsolatedExtensionRuntime(p.extension) p.extension.VMMu.Unlock() + perf.recordInit(time.Since(initStartedAt)) if err != nil { return &ExtDownloadResult{ Success: false, @@ -1122,7 +1622,10 @@ func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID })() `, trackID, quality, outputPath) + jsStartedAt := time.Now() result, err := RunWithTimeoutAndRecover(vm, script, ExtDownloadTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) if err != nil { errMsg := err.Error() errType := "script_error" @@ -1145,24 +1648,10 @@ func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID }, nil } - exported := result.Export() - jsonBytes, err := json.Marshal(exported) - if err != nil { - return &ExtDownloadResult{ - Success: false, - ErrorMessage: fmt.Sprintf("failed to marshal result: %v", err), - ErrorType: "internal_error", - }, nil - } - - var downloadResult ExtDownloadResult - if err := json.Unmarshal(jsonBytes, &downloadResult); err != nil { - return &ExtDownloadResult{ - Success: false, - ErrorMessage: fmt.Sprintf("failed to parse result: %v", err), - ErrorType: "internal_error", - }, nil - } + parseStartedAt := time.Now() + downloadResult := parseExtensionDownloadResultValue(vm, result) + perf.recordParse(time.Since(parseStartedAt)) + perf.setItems(1) downloadResult.Decryption = normalizeDownloadDecryptionInfo( downloadResult.Decryption, downloadResult.DecryptionKey, @@ -2276,10 +2765,18 @@ func canEmbedGenreLabel(filePath string) bool { } func (p *extensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) { - return p.CustomSearchForItemID(query, options, "") + return p.customSearch(query, options, "", "") +} + +func (p *extensionProviderWrapper) CustomSearchForRequestID(query string, options map[string]interface{}, requestID string) ([]ExtTrackMetadata, error) { + return p.customSearch(query, options, "", requestID) } func (p *extensionProviderWrapper) CustomSearchForItemID(query string, options map[string]interface{}, itemID string) ([]ExtTrackMetadata, error) { + return p.customSearch(query, options, itemID, "") +} + +func (p *extensionProviderWrapper) customSearch(query string, options map[string]interface{}, itemID, requestID string) ([]ExtTrackMetadata, error) { if !p.extension.Manifest.HasCustomSearch() { return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID) } @@ -2287,9 +2784,13 @@ func (p *extensionProviderWrapper) CustomSearchForItemID(query string, options m if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + perf := newExtensionCallPerf(p.extension.ID, "customSearch") + defer perf.finish() + initStartedAt := time.Now() if err := p.lockReadyVM(); err != nil { return nil, err } + perf.recordInit(time.Since(initStartedAt)) defer p.extension.VMMu.Unlock() if itemID != "" { if p.extension.runtime != nil { @@ -2302,6 +2803,18 @@ func (p *extensionProviderWrapper) CustomSearchForItemID(query string, options m return nil, ErrDownloadCancelled } } + requestCtx := context.Background() + if requestID != "" { + if p.extension.runtime != nil { + p.extension.runtime.setActiveRequestID(requestID) + defer p.extension.runtime.clearActiveRequestID() + } + requestCtx = initExtensionRequestCancel(requestID) + defer clearExtensionRequestCancel(requestID) + if isExtensionRequestCancelled(requestID) { + return nil, ErrExtensionRequestCancelled + } + } if options == nil { options = map[string]interface{}{} @@ -2328,11 +2841,20 @@ func (p *extensionProviderWrapper) CustomSearchForItemID(query string, options m })() ` - result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + jsStartedAt := time.Now() + result, err := RunWithTimeoutContextAndRecover(requestCtx, p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) if err != nil { + if isExtensionRequestCancelled(requestID) { + return nil, ErrExtensionRequestCancelled + } if isDownloadCancelled(itemID) { return nil, ErrDownloadCancelled } + if errors.Is(err, ErrExtensionRequestCancelled) { + return nil, ErrExtensionRequestCancelled + } if IsTimeoutError(err) { return nil, fmt.Errorf("customSearch timeout: extension took too long to respond") } @@ -2341,25 +2863,21 @@ func (p *extensionProviderWrapper) CustomSearchForItemID(query string, options m if isDownloadCancelled(itemID) { return nil, ErrDownloadCancelled } + if isExtensionRequestCancelled(requestID) { + return nil, ErrExtensionRequestCancelled + } if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { return []ExtTrackMetadata{}, nil } - exported := result.Export() - jsonBytes, err := json.Marshal(exported) + parseStartedAt := time.Now() + tracks, err := parseExtensionTrackArray(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) if err != nil { - return nil, fmt.Errorf("failed to marshal result: %w", err) - } - - var tracks []ExtTrackMetadata - if err := json.Unmarshal(jsonBytes, &tracks); err != nil { return nil, fmt.Errorf("failed to parse search result: %w", err) } - - if tracks == nil { - tracks = []ExtTrackMetadata{} - } + perf.setItems(len(tracks)) for i := range tracks { tracks[i].ProviderID = p.extension.ID @@ -2386,9 +2904,13 @@ func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + perf := newExtensionCallPerf(p.extension.ID, "handleUrl") + defer perf.finish() + initStartedAt := time.Now() if err := p.lockReadyVM(); err != nil { return nil, err } + perf.recordInit(time.Since(initStartedAt)) defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` @@ -2400,7 +2922,10 @@ func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e })() `, url) + jsStartedAt := time.Now() result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) if err != nil { if IsTimeoutError(err) { return nil, fmt.Errorf("handleUrl timeout: extension took too long to respond") @@ -2412,16 +2937,23 @@ func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e return nil, fmt.Errorf("handleUrl returned null - URL not recognized") } - exported := result.Export() - jsonBytes, err := json.Marshal(exported) + parseStartedAt := time.Now() + handleResult, err := parseExtensionURLHandleValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) if err != nil { - return nil, fmt.Errorf("failed to marshal result: %w", err) - } - - var handleResult ExtURLHandleResult - if err := json.Unmarshal(jsonBytes, &handleResult); err != nil { return nil, fmt.Errorf("failed to parse URL handle result: %w", err) } + urlItems := len(handleResult.Tracks) + if handleResult.Track != nil { + urlItems++ + } + if handleResult.Album != nil { + urlItems += 1 + len(handleResult.Album.Tracks) + } + if handleResult.Artist != nil { + urlItems += 1 + len(handleResult.Artist.Albums) + len(handleResult.Artist.Releases) + len(handleResult.Artist.TopTracks) + } + perf.setItems(urlItems) if handleResult.Track != nil { handleResult.Track.ProviderID = p.extension.ID @@ -2472,9 +3004,13 @@ func (p *extensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{} if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + perf := newExtensionCallPerf(p.extension.ID, "matchTrack") + defer perf.finish() + initStartedAt := time.Now() if err := p.lockReadyVM(); err != nil { return nil, err } + perf.recordInit(time.Since(initStartedAt)) defer p.extension.VMMu.Unlock() sourceJSON, _ := json.Marshal(sourceTrack) @@ -2489,7 +3025,10 @@ func (p *extensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{} })() `, string(sourceJSON), string(candidatesJSON)) + jsStartedAt := time.Now() result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) if err != nil { if IsTimeoutError(err) { return nil, fmt.Errorf("matchTrack timeout: extension took too long to respond") @@ -2501,17 +3040,10 @@ func (p *extensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{} return &MatchTrackResult{Matched: false, Reason: "not implemented"}, nil } - exported := result.Export() - jsonBytes, err := json.Marshal(exported) - if err != nil { - return nil, fmt.Errorf("failed to marshal result: %w", err) - } - - var matchResult MatchTrackResult - if err := json.Unmarshal(jsonBytes, &matchResult); err != nil { - return nil, fmt.Errorf("failed to parse match result: %w", err) - } - + parseStartedAt := time.Now() + matchResult := parseExtensionMatchTrackValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + perf.setItems(1) return &matchResult, nil } @@ -2543,9 +3075,13 @@ func (p *extensionProviderWrapper) PostProcess(filePath string, metadata map[str if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + perf := newExtensionCallPerf(p.extension.ID, "postProcess") + defer perf.finish() + initStartedAt := time.Now() if err := p.lockReadyVM(); err != nil { return &PostProcessResult{Success: false, Error: err.Error()}, nil } + perf.recordInit(time.Since(initStartedAt)) defer p.extension.VMMu.Unlock() metadataJSON, _ := json.Marshal(metadata) @@ -2559,7 +3095,10 @@ func (p *extensionProviderWrapper) PostProcess(filePath string, metadata map[str })() `, filePath, string(metadataJSON), hookID) + jsStartedAt := time.Now() result, err := RunWithTimeoutAndRecover(p.vm, script, PostProcessTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) if err != nil { errMsg := err.Error() if IsTimeoutError(err) { @@ -2578,23 +3117,10 @@ func (p *extensionProviderWrapper) PostProcess(filePath string, metadata map[str }, nil } - exported := result.Export() - jsonBytes, err := json.Marshal(exported) - if err != nil { - return &PostProcessResult{ - Success: false, - Error: fmt.Sprintf("failed to marshal result: %v", err), - }, nil - } - - var postResult PostProcessResult - if err := json.Unmarshal(jsonBytes, &postResult); err != nil { - return &PostProcessResult{ - Success: false, - Error: fmt.Sprintf("failed to parse result: %v", err), - }, nil - } - + parseStartedAt := time.Now() + postResult := parseExtensionPostProcessValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + perf.setItems(1) return &postResult, nil } @@ -2606,9 +3132,13 @@ func (p *extensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + perf := newExtensionCallPerf(p.extension.ID, "postProcessV2") + defer perf.finish() + initStartedAt := time.Now() if err := p.lockReadyVM(); err != nil { return &PostProcessResult{Success: false, Error: err.Error()}, nil } + perf.recordInit(time.Since(initStartedAt)) defer p.extension.VMMu.Unlock() metadataJSON, _ := json.Marshal(metadata) @@ -2629,7 +3159,10 @@ func (p *extensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat })() `, string(inputJSON), string(metadataJSON), hookID, filePath, string(metadataJSON), hookID) + jsStartedAt := time.Now() result, err := RunWithTimeoutAndRecover(p.vm, script, PostProcessTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) if err != nil { errMsg := err.Error() if IsTimeoutError(err) { @@ -2648,23 +3181,10 @@ func (p *extensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat }, nil } - exported := result.Export() - jsonBytes, err := json.Marshal(exported) - if err != nil { - return &PostProcessResult{ - Success: false, - Error: fmt.Sprintf("failed to marshal result: %v", err), - }, nil - } - - var postResult PostProcessResult - if err := json.Unmarshal(jsonBytes, &postResult); err != nil { - return &PostProcessResult{ - Success: false, - Error: fmt.Sprintf("failed to parse result: %v", err), - }, nil - } - + parseStartedAt := time.Now() + postResult := parseExtensionPostProcessValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) + perf.setItems(1) return &postResult, nil } @@ -2865,9 +3385,13 @@ func (p *extensionProviderWrapper) FetchLyrics(trackName, artistName, albumName if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } + perf := newExtensionCallPerf(p.extension.ID, "fetchLyrics") + defer perf.finish() + initStartedAt := time.Now() if err := p.lockReadyVM(); err != nil { return nil, err } + perf.recordInit(time.Since(initStartedAt)) defer p.extension.VMMu.Unlock() // Use global variables to avoid JS injection issues with special characters in track/artist names @@ -2896,7 +3420,10 @@ func (p *extensionProviderWrapper) FetchLyrics(trackName, artistName, albumName })() ` + jsStartedAt := time.Now() result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) + perf.recordJS(time.Since(jsStartedAt)) + perf.recordPayload(result) if err != nil { if IsTimeoutError(err) { return nil, fmt.Errorf("fetchLyrics timeout: extension took too long to respond") @@ -2908,16 +3435,13 @@ func (p *extensionProviderWrapper) FetchLyrics(trackName, artistName, albumName return nil, fmt.Errorf("fetchLyrics returned null") } - exported := result.Export() - jsonBytes, err := json.Marshal(exported) + parseStartedAt := time.Now() + extResult, err := parseExtensionLyricsValue(p.vm, result) + perf.recordParse(time.Since(parseStartedAt)) if err != nil { - return nil, fmt.Errorf("failed to marshal lyrics result: %w", err) - } - - var extResult ExtLyricsResult - if err := json.Unmarshal(jsonBytes, &extResult); err != nil { return nil, fmt.Errorf("failed to parse lyrics result: %w", err) } + perf.setItems(len(extResult.Lines)) response := &LyricsResponse{ SyncType: extResult.SyncType, diff --git a/go_backend/extension_providers_test.go b/go_backend/extension_providers_test.go index 02a36de2..6efb6e41 100644 --- a/go_backend/extension_providers_test.go +++ b/go_backend/extension_providers_test.go @@ -13,6 +13,8 @@ import ( "sync" "testing" "time" + + "github.com/dop251/goja" ) func TestSetMetadataProviderPriorityStripsRetiredBuiltIns(t *testing.T) { @@ -396,3 +398,243 @@ func TestSearchTracksWithMetadataProvidersIgnoresRetiredBuiltIns(t *testing.T) { t.Fatalf("expected retired built-in provider not to be queried, got %v", calls) } } + +func TestParseExtensionSearchResultAcceptsObjectAndArrayShapes(t *testing.T) { + vm := goja.New() + value, err := vm.RunString(`({ + tracks: [{ + id: "track-1", + name: "Song", + artists: "Artist", + album_name: "Album", + duration_ms: 123000, + cover_url: "https://img.test/cover.jpg", + external_links: { spotify: "spotify:track:1" }, + audio_quality: "LOSSLESS" + }], + total: 9 + })`) + if err != nil { + t.Fatalf("build object search result: %v", err) + } + + result, err := parseExtensionSearchResult(vm, value) + if err != nil { + t.Fatalf("parse object search result: %v", err) + } + if result.Total != 9 || len(result.Tracks) != 1 { + t.Fatalf("unexpected object result: %+v", result) + } + track := result.Tracks[0] + if track.ID != "track-1" || + track.AlbumName != "Album" || + track.DurationMS != 123000 || + track.CoverURL != "https://img.test/cover.jpg" || + track.ExternalLinks["spotify"] != "spotify:track:1" || + track.AudioQuality != "LOSSLESS" { + t.Fatalf("unexpected parsed track: %+v", track) + } + + arrayValue, err := vm.RunString(`[ + {id: "track-2", name: "Other Song", artists: "Other Artist", albumName: "Other Album", durationMs: 456000} + ]`) + if err != nil { + t.Fatalf("build array search result: %v", err) + } + + arrayResult, err := parseExtensionSearchResult(vm, arrayValue) + if err != nil { + t.Fatalf("parse array search result: %v", err) + } + if arrayResult.Total != 1 || + len(arrayResult.Tracks) != 1 || + arrayResult.Tracks[0].AlbumName != "Other Album" || + arrayResult.Tracks[0].DurationMS != 456000 { + t.Fatalf("unexpected array result: %+v", arrayResult) + } +} + +func TestParseExtensionMetadataAndDownloadResults(t *testing.T) { + vm := goja.New() + value, err := vm.RunString(`({ + id: "album-1", + name: "Album", + artists: "Artist", + artistId: "artist-1", + coverUrl: "https://img.test/album.jpg", + releaseDate: "2024-02-03", + totalTracks: 2, + albumType: "album", + tracks: [ + {id: "track-1", name: "Song 1", artists: "Artist", durationMs: 180000}, + {id: "track-2", name: "Song 2", artists: "Artist", duration_ms: 181000} + ] + })`) + if err != nil { + t.Fatalf("build album value: %v", err) + } + + album, err := parseExtensionAlbumValue(vm, value) + if err != nil { + t.Fatalf("parse album: %v", err) + } + if album.ID != "album-1" || + album.ArtistID != "artist-1" || + album.CoverURL != "https://img.test/album.jpg" || + album.TotalTracks != 2 || + len(album.Tracks) != 2 || + album.Tracks[0].DurationMS != 180000 || + album.Tracks[1].DurationMS != 181000 { + t.Fatalf("unexpected album: %+v", album) + } + + artistValue, err := vm.RunString(`({ + id: "artist-1", + name: "Artist", + imageUrl: "https://img.test/artist.jpg", + headerImage: "https://img.test/header.jpg", + listeners: 1234, + albums: [{id: "album-1", name: "Album", tracks: [{id: "track-1", name: "Song"}]}], + releases: [{id: "single-1", name: "Single"}], + topTracks: [{id: "top-1", name: "Top Song"}] + })`) + if err != nil { + t.Fatalf("build artist value: %v", err) + } + + artist, err := parseExtensionArtistValue(vm, artistValue) + if err != nil { + t.Fatalf("parse artist: %v", err) + } + if artist.ID != "artist-1" || + artist.ImageURL != "https://img.test/artist.jpg" || + artist.HeaderImage != "https://img.test/header.jpg" || + artist.Listeners != 1234 || + len(artist.Albums) != 1 || + len(artist.Albums[0].Tracks) != 1 || + len(artist.Releases) != 1 || + len(artist.TopTracks) != 1 { + t.Fatalf("unexpected artist: %+v", artist) + } + + downloadValue, err := vm.RunString(`({ + success: true, + filePath: "/tmp/song.flac", + alreadyExists: true, + bitDepth: 24, + sampleRate: 96000, + title: "Song", + albumArtist: "Album Artist", + lyricsLrc: "[00:00.00]Line", + decryptionKey: "001122", + decryption: { + strategy: "mp4_decryption_key", + key: "001122", + inputFormat: "m4a", + options: { map: "0:a" } + } + })`) + if err != nil { + t.Fatalf("build download value: %v", err) + } + + download := parseExtensionDownloadResultValue(vm, downloadValue) + if !download.Success || + download.FilePath != "/tmp/song.flac" || + !download.AlreadyExists || + download.BitDepth != 24 || + download.SampleRate != 96000 || + download.AlbumArtist != "Album Artist" || + download.LyricsLRC != "[00:00.00]Line" || + download.Decryption == nil || + download.Decryption.InputFormat != "m4a" || + download.Decryption.Options["map"] != "0:a" { + t.Fatalf("unexpected download result: %+v", download) + } + + availabilityValue, err := vm.RunString(`({ available: true, trackId: "track-1", skipFallback: true, reason: "direct" })`) + if err != nil { + t.Fatalf("build availability value: %v", err) + } + availability := parseExtensionAvailabilityValue(vm, availabilityValue) + if !availability.Available || availability.TrackID != "track-1" || !availability.SkipFallback || availability.Reason != "direct" { + t.Fatalf("unexpected availability: %+v", availability) + } +} + +func TestParseExtensionURLHandleResult(t *testing.T) { + vm := goja.New() + value, err := vm.RunString(`({ + type: "album", + name: "Shared Album", + coverUrl: "https://img.test/shared.jpg", + track: { id: "track-1", name: "Song" }, + tracks: [{ id: "track-2", name: "Song 2" }], + album: { id: "album-1", name: "Album", tracks: [{ id: "track-3", name: "Song 3" }] }, + artist: { id: "artist-1", name: "Artist", topTracks: [{ id: "track-4", name: "Song 4" }] } + })`) + if err != nil { + t.Fatalf("build URL handle value: %v", err) + } + + result, err := parseExtensionURLHandleValue(vm, value) + if err != nil { + t.Fatalf("parse URL handle: %v", err) + } + if result.Type != "album" || + result.CoverURL != "https://img.test/shared.jpg" || + result.Track == nil || + result.Track.ID != "track-1" || + len(result.Tracks) != 1 || + result.Album == nil || + len(result.Album.Tracks) != 1 || + result.Artist == nil || + len(result.Artist.TopTracks) != 1 { + t.Fatalf("unexpected URL handle result: %+v", result) + } +} + +func TestParseExtensionAuxiliaryResults(t *testing.T) { + vm := goja.New() + + matchValue, err := vm.RunString(`({ matched: true, trackId: "track-1", confidence: 0.92, reason: "isrc" })`) + if err != nil { + t.Fatalf("build match value: %v", err) + } + match := parseExtensionMatchTrackValue(vm, matchValue) + if !match.Matched || match.TrackID != "track-1" || match.Confidence != 0.92 || match.Reason != "isrc" { + t.Fatalf("unexpected match result: %+v", match) + } + + postValue, err := vm.RunString(`({ success: true, newFilePath: "/tmp/new.flac", newFileUri: "content://new", bitDepth: 24, sampleRate: 96000 })`) + if err != nil { + t.Fatalf("build post-process value: %v", err) + } + post := parseExtensionPostProcessValue(vm, postValue) + if !post.Success || post.NewFilePath != "/tmp/new.flac" || post.NewFileURI != "content://new" || post.BitDepth != 24 || post.SampleRate != 96000 { + t.Fatalf("unexpected post-process result: %+v", post) + } + + lyricsValue, err := vm.RunString(`({ + syncType: "LINE_SYNCED", + instrumental: false, + plainLyrics: "Line", + provider: "Lyrics Provider", + lines: [{ startTimeMs: 1000, words: "Line", endTimeMs: 2000 }] + })`) + if err != nil { + t.Fatalf("build lyrics value: %v", err) + } + lyrics, err := parseExtensionLyricsValue(vm, lyricsValue) + if err != nil { + t.Fatalf("parse lyrics: %v", err) + } + if lyrics.SyncType != "LINE_SYNCED" || + lyrics.PlainLyrics != "Line" || + lyrics.Provider != "Lyrics Provider" || + len(lyrics.Lines) != 1 || + lyrics.Lines[0].StartTimeMs != 1000 || + lyrics.Lines[0].EndTimeMs != 2000 { + t.Fatalf("unexpected lyrics result: %+v", lyrics) + } +} diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index ce4fe367..97f0fcf7 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -94,6 +94,9 @@ type extensionRuntime struct { activeDownloadMu sync.RWMutex activeDownloadItemID string + activeRequestMu sync.RWMutex + activeRequestID string + storageMu sync.RWMutex storageCache map[string]interface{} storageLoaded bool @@ -209,6 +212,24 @@ func (r *extensionRuntime) getActiveDownloadItemID() string { return r.activeDownloadItemID } +func (r *extensionRuntime) setActiveRequestID(requestID string) { + r.activeRequestMu.Lock() + defer r.activeRequestMu.Unlock() + r.activeRequestID = strings.TrimSpace(requestID) +} + +func (r *extensionRuntime) clearActiveRequestID() { + r.activeRequestMu.Lock() + defer r.activeRequestMu.Unlock() + r.activeRequestID = "" +} + +func (r *extensionRuntime) getActiveRequestID() string { + r.activeRequestMu.RLock() + defer r.activeRequestMu.RUnlock() + return r.activeRequestID +} + func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Request { if req == nil { return nil @@ -216,7 +237,11 @@ func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Re itemID := r.getActiveDownloadItemID() if itemID == "" { - return req + requestID := r.getActiveRequestID() + if requestID == "" { + return req + } + return req.WithContext(initExtensionRequestCancel(requestID)) } return req.WithContext(initDownloadCancel(itemID)) @@ -479,6 +504,7 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) { utilsObj.Set("appUserAgent", r.appUserAgent) utilsObj.Set("sleep", r.sleep) utilsObj.Set("isDownloadCancelled", r.isDownloadCancelled) + utilsObj.Set("isRequestCancelled", r.isRequestCancelled) utilsObj.Set("setDownloadStatus", r.setDownloadStatus) vm.Set("utils", utilsObj) diff --git a/go_backend/extension_runtime_utils.go b/go_backend/extension_runtime_utils.go index 5bfb7250..e06541e6 100644 --- a/go_backend/extension_runtime_utils.go +++ b/go_backend/extension_runtime_utils.go @@ -312,6 +312,14 @@ func (r *extensionRuntime) isDownloadCancelled(call goja.FunctionCall) goja.Valu return r.vm.ToValue(isDownloadCancelled(itemID)) } +func (r *extensionRuntime) isRequestCancelled(call goja.FunctionCall) goja.Value { + requestID := r.getActiveRequestID() + if requestID == "" { + return r.vm.ToValue(false) + } + return r.vm.ToValue(isExtensionRequestCancelled(requestID)) +} + func (r *extensionRuntime) setDownloadStatus(call goja.FunctionCall) goja.Value { itemID := r.getActiveDownloadItemID() if itemID == "" || len(call.Arguments) < 1 { diff --git a/go_backend/extension_test.go b/go_backend/extension_test.go index 10bdca60..50fad881 100644 --- a/go_backend/extension_test.go +++ b/go_backend/extension_test.go @@ -1,6 +1,8 @@ package gobackend import ( + "context" + "errors" "net/http" "path/filepath" "testing" @@ -379,6 +381,48 @@ func TestExtensionRuntime_BindDownloadCancelContextPreservesPreCancelledState(t } } +func TestRunWithTimeoutContextCancelsExecution(t *testing.T) { + vm := goja.New() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := RunWithTimeoutContextAndRecover(ctx, vm, `while (true) {}`, 5*time.Second) + if !errors.Is(err, ErrExtensionRequestCancelled) { + t.Fatalf("expected extension request cancellation, got %v", err) + } +} + +func TestExtensionRuntime_BindExtensionRequestCancelContext(t *testing.T) { + ext := &loadedExtension{ + ID: "test-ext", + Manifest: &ExtensionManifest{ + Name: "test-ext", + }, + DataDir: t.TempDir(), + } + runtime := newExtensionRuntime(ext) + + const requestID = "test-extension-request" + clearExtensionRequestCancel(requestID) + defer clearExtensionRequestCancel(requestID) + + runtime.setActiveRequestID(requestID) + defer runtime.clearActiveRequestID() + + req, err := http.NewRequest(http.MethodGet, "https://example.com", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + req = runtime.bindDownloadCancelContext(req) + + cancelExtensionRequest(requestID) + select { + case <-req.Context().Done(): + case <-time.After(time.Second): + t.Fatal("expected request context to be cancelled") + } +} + func TestExtensionRuntime_SSRFProtection(t *testing.T) { // Create extension with limited network permissions ext := &loadedExtension{ diff --git a/go_backend/extension_timeout.go b/go_backend/extension_timeout.go index d3fbf876..c5163320 100644 --- a/go_backend/extension_timeout.go +++ b/go_backend/extension_timeout.go @@ -20,6 +20,10 @@ func (e *JSExecutionError) Error() string { } func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) { + return RunWithTimeoutContext(context.Background(), vm, script, timeout) +} + +func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) { if vm == nil { return nil, fmt.Errorf("extension runtime unavailable") } @@ -28,7 +32,10 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj timeout = DefaultJSTimeout } - ctx, cancel := context.WithTimeout(context.Background(), timeout) + if ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() type result struct { @@ -67,11 +74,16 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj case res := <-resultCh: return res.value, res.err case <-ctx.Done(): + cancelled := ctx.Err() == context.Canceled interruptMu.Lock() interrupted = true interruptMu.Unlock() - vm.Interrupt("execution timeout") + if cancelled { + vm.Interrupt("extension request cancelled") + } else { + vm.Interrupt("execution timeout") + } // MUST wait for the goroutine to finish before returning. // The Goja VM is NOT thread-safe — if we return while the goroutine @@ -80,6 +92,9 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj // pointer dereference. select { case res := <-resultCh: + if cancelled { + return nil, ErrExtensionRequestCancelled + } if res.err != nil { return nil, res.err } @@ -91,6 +106,9 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj // Goroutine is truly stuck (e.g. HTTP read with no timeout). // Log a warning — the VM should NOT be reused after this. GoLog("[extensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n") + if cancelled { + return nil, ErrExtensionRequestCancelled + } return nil, &JSExecutionError{ Message: "execution timeout exceeded (force)", IsTimeout: true, @@ -102,7 +120,11 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj // RunWithTimeoutAndRecover runs JS with timeout and clears interrupt state after // This should be used when you want to continue using the VM after a timeout func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) { - result, err := RunWithTimeout(vm, script, timeout) + return RunWithTimeoutContextAndRecover(context.Background(), vm, script, timeout) +} + +func RunWithTimeoutContextAndRecover(ctx context.Context, vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) { + result, err := RunWithTimeoutContext(ctx, vm, script, timeout) if vm != nil { vm.ClearInterrupt() diff --git a/go_backend/progress.go b/go_backend/progress.go index 428d0fa1..42f4562d 100644 --- a/go_backend/progress.go +++ b/go_backend/progress.go @@ -2,6 +2,7 @@ package gobackend import ( "encoding/json" + "math" "sync" "time" ) @@ -24,6 +25,7 @@ type ItemProgress struct { SpeedMBps float64 `json:"speed_mbps"` IsDownloading bool `json:"is_downloading"` Status string `json:"status"` + revision int64 } const ( @@ -37,6 +39,22 @@ type MultiProgress struct { Items map[string]*ItemProgress `json:"items"` } +type MultiProgressDelta struct { + Seq int64 `json:"seq"` + Reset bool `json:"reset,omitempty"` + Items map[string]*ItemProgress `json:"items,omitempty"` + Removed []string `json:"removed,omitempty"` +} + +type progressBridgeState struct { + bytesBucket int64 + bytesTotal int64 + progressPct int64 + speedDeciMBps int64 + downloading bool + status string +} + var ( downloadDir string downloadDirMu sync.RWMutex @@ -45,12 +63,50 @@ var ( multiMu sync.RWMutex multiProgressDirty = true cachedMultiProgress = "{\"items\":{}}" + multiProgressSeq int64 + multiProgressReset int64 + removedProgressSeq = make(map[string]int64) ) func markMultiProgressDirtyLocked() { multiProgressDirty = true } +func nextMultiProgressSeqLocked() int64 { + multiProgressSeq++ + return multiProgressSeq +} + +func itemProgressBridgeState(item *ItemProgress) progressBridgeState { + progress := item.Progress + if math.IsNaN(progress) || progress <= 0 { + progress = 0 + } else if progress >= 1 { + progress = 1 + } + + speed := item.SpeedMBps + if math.IsNaN(speed) || speed <= 0 { + speed = 0 + } + + return progressBridgeState{ + bytesBucket: item.BytesReceived / progressUpdateThreshold, + bytesTotal: item.BytesTotal, + progressPct: int64(math.Round(progress * 100)), + speedDeciMBps: int64(math.Round(speed * 10)), + downloading: item.IsDownloading, + status: item.Status, + } +} + +func markMultiProgressDirtyIfChangedLocked(item *ItemProgress, before progressBridgeState) { + if itemProgressBridgeState(item) != before { + item.revision = nextMultiProgressSeqLocked() + markMultiProgressDirtyLocked() + } +} + func getProgress() DownloadProgress { multiMu.RLock() defer multiMu.RUnlock() @@ -92,6 +148,54 @@ func GetMultiProgress() string { return cachedMultiProgress } +func GetMultiProgressDelta(sinceSeq int64) string { + multiMu.RLock() + currentSeq := multiProgressSeq + if sinceSeq >= currentSeq { + multiMu.RUnlock() + return "" + } + + reset := sinceSeq <= 0 || sinceSeq < multiProgressReset + delta := MultiProgressDelta{ + Seq: currentSeq, + Reset: reset, + } + if reset { + if len(multiProgress.Items) > 0 { + delta.Items = make(map[string]*ItemProgress, len(multiProgress.Items)) + for id, item := range multiProgress.Items { + copy := *item + copy.revision = 0 + delta.Items[id] = © + } + } + } else { + for id, item := range multiProgress.Items { + if item.revision > sinceSeq { + if delta.Items == nil { + delta.Items = make(map[string]*ItemProgress) + } + copy := *item + copy.revision = 0 + delta.Items[id] = © + } + } + for id, revision := range removedProgressSeq { + if revision > sinceSeq { + delta.Removed = append(delta.Removed, id) + } + } + } + multiMu.RUnlock() + + jsonBytes, err := json.Marshal(delta) + if err != nil { + return "" + } + return string(jsonBytes) +} + func GetItemProgress(itemID string) string { multiMu.RLock() defer multiMu.RUnlock() @@ -114,7 +218,9 @@ func StartItemProgress(itemID string) { Progress: 0, IsDownloading: true, Status: itemProgressStatusDownloading, + revision: nextMultiProgressSeqLocked(), } + delete(removedProgressSeq, itemID) markMultiProgressDirtyLocked() } @@ -123,13 +229,14 @@ func SetItemPreparing(itemID string) { defer multiMu.Unlock() if item, ok := multiProgress.Items[itemID]; ok { + before := itemProgressBridgeState(item) item.Progress = 0 item.BytesReceived = 0 item.BytesTotal = 0 item.SpeedMBps = 0 item.IsDownloading = true item.Status = itemProgressStatusPreparing - markMultiProgressDirtyLocked() + markMultiProgressDirtyIfChangedLocked(item, before) } } @@ -138,9 +245,10 @@ func SetItemDownloading(itemID string) { defer multiMu.Unlock() if item, ok := multiProgress.Items[itemID]; ok { + before := itemProgressBridgeState(item) item.IsDownloading = true item.Status = itemProgressStatusDownloading - markMultiProgressDirtyLocked() + markMultiProgressDirtyIfChangedLocked(item, before) } } @@ -149,8 +257,9 @@ func SetItemBytesTotal(itemID string, total int64) { defer multiMu.Unlock() if item, ok := multiProgress.Items[itemID]; ok { + before := itemProgressBridgeState(item) item.BytesTotal = total - markMultiProgressDirtyLocked() + markMultiProgressDirtyIfChangedLocked(item, before) } } @@ -159,11 +268,12 @@ func SetItemBytesReceived(itemID string, received int64) { defer multiMu.Unlock() if item, ok := multiProgress.Items[itemID]; ok { + before := itemProgressBridgeState(item) item.BytesReceived = received if item.BytesTotal > 0 { item.Progress = float64(received) / float64(item.BytesTotal) } - markMultiProgressDirtyLocked() + markMultiProgressDirtyIfChangedLocked(item, before) } } @@ -172,12 +282,13 @@ func SetItemBytesReceivedWithSpeed(itemID string, received int64, speedMBps floa defer multiMu.Unlock() if item, ok := multiProgress.Items[itemID]; ok { + before := itemProgressBridgeState(item) item.BytesReceived = received item.SpeedMBps = speedMBps if item.BytesTotal > 0 { item.Progress = float64(received) / float64(item.BytesTotal) } - markMultiProgressDirtyLocked() + markMultiProgressDirtyIfChangedLocked(item, before) } } @@ -186,10 +297,11 @@ func CompleteItemProgress(itemID string) { defer multiMu.Unlock() if item, ok := multiProgress.Items[itemID]; ok { + before := itemProgressBridgeState(item) item.Progress = 1.0 item.IsDownloading = false item.Status = itemProgressStatusCompleted - markMultiProgressDirtyLocked() + markMultiProgressDirtyIfChangedLocked(item, before) } } @@ -198,6 +310,7 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal defer multiMu.Unlock() if item, ok := multiProgress.Items[itemID]; ok { + before := itemProgressBridgeState(item) item.Progress = progress if bytesReceived > 0 { item.BytesReceived = bytesReceived @@ -205,7 +318,7 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal if bytesTotal > 0 { item.BytesTotal = bytesTotal } - markMultiProgressDirtyLocked() + markMultiProgressDirtyIfChangedLocked(item, before) } } @@ -214,9 +327,10 @@ func SetItemFinalizing(itemID string) { defer multiMu.Unlock() if item, ok := multiProgress.Items[itemID]; ok { + before := itemProgressBridgeState(item) item.Progress = 1.0 item.Status = itemProgressStatusFinalizing - markMultiProgressDirtyLocked() + markMultiProgressDirtyIfChangedLocked(item, before) } } @@ -224,7 +338,10 @@ func RemoveItemProgress(itemID string) { multiMu.Lock() defer multiMu.Unlock() - delete(multiProgress.Items, itemID) + if _, ok := multiProgress.Items[itemID]; ok { + delete(multiProgress.Items, itemID) + removedProgressSeq[itemID] = nextMultiProgressSeqLocked() + } markMultiProgressDirtyLocked() } @@ -233,6 +350,8 @@ func ClearAllItemProgress() { defer multiMu.Unlock() multiProgress.Items = make(map[string]*ItemProgress) + removedProgressSeq = make(map[string]int64) + multiProgressReset = nextMultiProgressSeqLocked() markMultiProgressDirtyLocked() } @@ -253,7 +372,7 @@ type ItemProgressWriter struct { lastBytes int64 } -const progressUpdateThreshold = 64 * 1024 +const progressUpdateThreshold = 128 * 1024 func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter { now := time.Now() diff --git a/go_backend/progress_test.go b/go_backend/progress_test.go index e9d62d3b..16efc98d 100644 --- a/go_backend/progress_test.go +++ b/go_backend/progress_test.go @@ -1,6 +1,9 @@ package gobackend -import "testing" +import ( + "encoding/json" + "testing" +) func TestItemProgressPreparingAndDownloadingStatuses(t *testing.T) { const itemID = "progress-phase-item" @@ -57,3 +60,50 @@ func TestItemProgressFinalizingAndCompletedStatuses(t *testing.T) { t.Fatalf("status = %q, want %q", item.Status, itemProgressStatusCompleted) } } + +func TestMultiProgressDeltaResetChangedAndRemoved(t *testing.T) { + ClearAllItemProgress() + defer ClearAllItemProgress() + + StartItemProgress("item-a") + SetItemBytesTotal("item-a", 1000) + + var initial MultiProgressDelta + if err := json.Unmarshal([]byte(GetMultiProgressDelta(0)), &initial); err != nil { + t.Fatalf("initial delta parse failed: %v", err) + } + if !initial.Reset { + t.Fatal("initial delta should reset") + } + if initial.Seq <= 0 { + t.Fatalf("initial seq = %d, want > 0", initial.Seq) + } + if _, ok := initial.Items["item-a"]; !ok { + t.Fatal("initial delta missing item-a") + } + + if delta := GetMultiProgressDelta(initial.Seq); delta != "" { + t.Fatalf("delta after same seq = %q, want empty", delta) + } + + SetItemBytesReceivedWithSpeed("item-a", 256*1024, 2.5) + var changed MultiProgressDelta + if err := json.Unmarshal([]byte(GetMultiProgressDelta(initial.Seq)), &changed); err != nil { + t.Fatalf("changed delta parse failed: %v", err) + } + if changed.Reset { + t.Fatal("changed delta should not reset") + } + if _, ok := changed.Items["item-a"]; !ok { + t.Fatal("changed delta missing item-a") + } + + RemoveItemProgress("item-a") + var removed MultiProgressDelta + if err := json.Unmarshal([]byte(GetMultiProgressDelta(changed.Seq)), &removed); err != nil { + t.Fatalf("removed delta parse failed: %v", err) + } + if len(removed.Removed) != 1 || removed.Removed[0] != "item-a" { + t.Fatalf("removed = %#v, want item-a", removed.Removed) + } +} diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index df0ef0c6..468eeff5 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -7,10 +7,13 @@ import Gobackend // Import Go framework private let CHANNEL = "com.zarz.spotiflac/backend" private let DOWNLOAD_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/download_progress_stream" private let LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL = "com.zarz.spotiflac/library_scan_progress_stream" + private let LARGE_JSON_RESULT_FILE_KEY = "__json_file" + private let LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024 private let streamQueue = DispatchQueue(label: "com.zarz.spotiflac.progress_stream", qos: .utility) private var downloadProgressTimer: DispatchSourceTimer? private var downloadProgressEventSink: FlutterEventSink? private var lastDownloadProgressPayload: String? + private var lastDownloadProgressSeq: Int64 = 0 private var libraryScanProgressTimer: DispatchSourceTimer? private var libraryScanProgressEventSink: FlutterEventSink? private var lastLibraryScanProgressPayload: String? @@ -131,15 +134,17 @@ import Gobackend // Import Go framework stopDownloadProgressStream() downloadProgressEventSink = eventSink lastDownloadProgressPayload = nil + lastDownloadProgressSeq = 0 let timer = DispatchSource.makeTimerSource(queue: streamQueue) timer.schedule(deadline: .now(), repeating: .milliseconds(800)) timer.setEventHandler { [weak self] in guard let self else { return } - let payload = GobackendGetAllDownloadProgress() as String? ?? "{}" - if payload == self.lastDownloadProgressPayload { + let payload = GobackendGetAllDownloadProgressDelta(self.lastDownloadProgressSeq) as String? ?? "" + if payload.isEmpty || payload == self.lastDownloadProgressPayload { return } + self.updateDownloadProgressSeq(payload) self.lastDownloadProgressPayload = payload DispatchQueue.main.async { [weak self] in self?.downloadProgressEventSink?(self?.parseJsonPayload(payload)) @@ -155,6 +160,7 @@ import Gobackend // Import Go framework downloadProgressTimer = nil downloadProgressEventSink = nil lastDownloadProgressPayload = nil + lastDownloadProgressSeq = 0 } private func startLibraryScanProgressStream(_ eventSink: @escaping FlutterEventSink) { @@ -197,6 +203,34 @@ import Gobackend // Import Go framework return payload } } + + private func updateDownloadProgressSeq(_ payload: String) { + guard let data = payload.data(using: .utf8) else { return } + do { + if let obj = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) as? [String: Any], + let seq = obj["seq"] as? NSNumber, + seq.int64Value > lastDownloadProgressSeq { + lastDownloadProgressSeq = seq.int64Value + } + } catch { + } + } + + private func bridgeJsonResult(_ payload: String) -> Any { + if payload.utf8.count < LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES { + return payload + } + + do { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("bridge_json_\(UUID().uuidString).json") + try payload.write(to: url, atomically: true, encoding: .utf8) + return [LARGE_JSON_RESULT_FILE_KEY: url.path] + } catch { + NSLog("SpotiFLAC: failed to spill large bridge JSON result to file: \(error.localizedDescription)") + return payload + } + } private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) { DispatchQueue.global(qos: .userInitiated).async { @@ -784,10 +818,17 @@ import Gobackend // Import Go framework let extensionId = args["extension_id"] as! String let query = args["query"] as! String let optionsJson = args["options"] as? String ?? "" - let response = GobackendCustomSearchWithExtensionJSON(extensionId, query, optionsJson, &error) + let requestId = args["request_id"] as? String ?? "" + let response = GobackendCustomSearchWithExtensionJSONWithRequestID(extensionId, query, optionsJson, requestId, &error) if let error = error { throw error } return response - + + case "cancelExtensionRequest": + let args = call.arguments as! [String: Any] + let requestId = args["request_id"] as? String ?? "" + GobackendCancelExtensionRequestJSON(requestId) + return nil + case "getSearchProviders": let response = GobackendGetSearchProvidersJSON(&error) if let error = error { throw error } @@ -896,7 +937,8 @@ import Gobackend // Import Go framework case "getExtensionHomeFeed": let args = call.arguments as! [String: Any] let extensionId = args["extension_id"] as! String - let response = GobackendGetExtensionHomeFeedJSON(extensionId, &error) + let requestId = args["request_id"] as? String ?? "" + let response = GobackendGetExtensionHomeFeedJSONWithRequestID(extensionId, requestId, &error) if let error = error { throw error } return response @@ -919,7 +961,7 @@ import Gobackend // Import Go framework let folderPath = args["folder_path"] as! String let response = GobackendScanLibraryFolderJSON(folderPath, &error) if let error = error { throw error } - return response + return bridgeJsonResult(response as String? ?? "[]") case "scanLibraryFolderIncremental": let args = call.arguments as! [String: Any] @@ -927,7 +969,7 @@ import Gobackend // Import Go framework let existingFiles = args["existing_files"] as? String ?? "{}" let response = GobackendScanLibraryFolderIncrementalJSON(folderPath, existingFiles, &error) if let error = error { throw error } - return response + return bridgeJsonResult(response as String? ?? "{}") case "getLibraryScanProgress": let response = GobackendGetLibraryScanProgressJSON() diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart index 903786be..01997f3c 100644 --- a/lib/providers/explore_provider.dart +++ b/lib/providers/explore_provider.dart @@ -213,6 +213,7 @@ List _buildExploreSectionsFromNormalizedPayload( class ExploreNotifier extends Notifier { static const _cacheKey = 'explore_home_feed_cache'; static const _cacheTsKey = 'explore_home_feed_ts'; + int _homeFeedRequestId = 0; @override ExploreState build() { @@ -281,6 +282,8 @@ class ExploreNotifier extends Notifier { if (ref.read(settingsProvider).homeFeedProvider == AppSettings.homeFeedProviderOff) { _log.d('Home feed disabled by user setting'); + _homeFeedRequestId++; + PlatformBridge.cancelExtensionHomeFeedRequests(); state = const ExploreState(); return; } @@ -293,11 +296,12 @@ class ExploreNotifier extends Notifier { return; } - if (state.isLoading) { + if (state.isLoading && !forceRefresh) { _log.d('Home feed fetch already in progress'); return; } + final requestId = ++_homeFeedRequestId; final showLoading = !state.hasContent; state = state.copyWith(isLoading: showLoading, error: null); @@ -330,6 +334,7 @@ class ExploreNotifier extends Notifier { if (targetExt == null) { _log.w('No extension with homeFeed capability found'); + if (requestId != _homeFeedRequestId) return; state = state.copyWith( isLoading: false, error: 'No extension with home feed support enabled', @@ -338,7 +343,11 @@ class ExploreNotifier extends Notifier { } _log.i('Fetching home feed from ${targetExt.id}...'); - final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id); + final result = await PlatformBridge.getExtensionHomeFeed( + targetExt.id, + cancelPrevious: forceRefresh, + ); + if (requestId != _homeFeedRequestId) return; if (result == null) { state = state.copyWith( @@ -362,6 +371,7 @@ class ExploreNotifier extends Notifier { _normalizeExploreSectionsPayload, sectionsData, ); + if (requestId != _homeFeedRequestId) return; final sections = _buildExploreSectionsFromNormalizedPayload( normalizedSections, ); @@ -388,11 +398,14 @@ class ExploreNotifier extends Notifier { _saveToCache(normalizedSections); } catch (e, stack) { _log.e('Error fetching home feed: $e', e, stack); + if (requestId != _homeFeedRequestId) return; state = state.copyWith(isLoading: false, error: e.toString()); } } void clear() { + _homeFeedRequestId++; + PlatformBridge.cancelExtensionHomeFeedRequests(); state = const ExploreState(); } diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 6ea261e9..04627e41 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -15,6 +15,15 @@ const _metadataProviderPriorityKey = 'metadata_provider_priority'; const _providerPriorityKey = 'provider_priority'; const _spotifyWebExtensionId = 'spotify-web'; +bool _stringListEquals(List a, List b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} + class BuiltInProviderSpec { final String id; final String displayName; @@ -1033,7 +1042,7 @@ class ExtensionNotifier extends Notifier { } final sanitized = _sanitizeDownloadProviderPriority(state.providerPriority); - if (jsonEncode(sanitized) == jsonEncode(state.providerPriority)) { + if (_stringListEquals(sanitized, state.providerPriority)) { return; } @@ -1053,7 +1062,7 @@ class ExtensionNotifier extends Notifier { state.metadataProviderPriority, ); final sanitized = _sanitizeMetadataProviderPriority(replaced); - if (jsonEncode(sanitized) == jsonEncode(state.metadataProviderPriority)) { + if (_stringListEquals(sanitized, state.metadataProviderPriority)) { return; } diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 321975a0..114c6a78 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -778,6 +778,7 @@ class TrackNotifier extends Notifier { extensionId, query, options: options, + cancelPrevious: true, ); if (!_isRequestValid(requestId)) { diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index b534e8d2..d1ee99fe 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -1,9 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:intl/intl.dart'; -import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; @@ -24,6 +22,7 @@ import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; +import 'package:spotiflac_android/widgets/cached_cover_image.dart'; class _ArtistCache { static final Map _cache = {}; @@ -1164,12 +1163,11 @@ class _ArtistScreenState extends ConsumerState { fit: StackFit.expand, children: [ if (hasValidImage) - CachedNetworkImage( + CachedCoverImage( imageUrl: imageUrl, fit: BoxFit.cover, alignment: Alignment.topCenter, memCacheWidth: 800, - cacheManager: CoverCacheManager.instance, placeholder: (context, url) => Container(color: colorScheme.surfaceContainerHighest), errorWidget: (context, url, error) => Container( @@ -1477,33 +1475,18 @@ class _ArtistScreenState extends ConsumerState { ), ), const SizedBox(width: 12), - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: track.coverUrl != null - ? CachedNetworkImage( - imageUrl: track.coverUrl!, + track.coverUrl != null + ? CachedCoverImage( + imageUrl: track.coverUrl!, + width: 48, + height: 48, + borderRadius: BorderRadius.circular(4), + placeholder: (context, url) => Container( width: 48, height: 48, - fit: BoxFit.cover, - memCacheWidth: 96, - cacheManager: CoverCacheManager.instance, - placeholder: (context, url) => Container( - width: 48, - height: 48, - color: colorScheme.surfaceContainerHighest, - ), - errorWidget: (context, url, error) => Container( - width: 48, - height: 48, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - size: 24, - ), - ), - ) - : Container( + color: colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( width: 48, height: 48, color: colorScheme.surfaceContainerHighest, @@ -1513,7 +1496,17 @@ class _ArtistScreenState extends ConsumerState { size: 24, ), ), - ), + ) + : Container( + width: 48, + height: 48, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + size: 24, + ), + ), const SizedBox(width: 12), Expanded( child: Column( @@ -1801,13 +1794,12 @@ class _ArtistScreenState extends ConsumerState { ClipRRect( borderRadius: BorderRadius.circular(8), child: album.coverUrl != null - ? CachedNetworkImage( + ? CachedCoverImage( imageUrl: album.coverUrl!, width: tileSize, height: tileSize, fit: BoxFit.cover, memCacheWidth: (tileSize * 2).round(), - cacheManager: CoverCacheManager.instance, placeholder: (context, url) => Container( width: tileSize, height: tileSize, diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 18c16d55..db340e7b 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -4,8 +4,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/track.dart'; @@ -33,6 +31,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/provider_ui_utils.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; +import 'package:spotiflac_android/widgets/cached_cover_image.dart'; part 'home_tab_helpers.dart'; part 'home_tab_widgets.dart'; @@ -1182,7 +1181,9 @@ class _HomeTabState extends ConsumerState final mediaQuery = MediaQuery.of(context); final screenHeight = mediaQuery.size.height; final topPadding = normalizedHeaderTopPadding(context); - final historyItems = ref.watch(_homeHistoryPreviewProvider); + final hasHistoryItems = ref.watch( + _homeHistoryPreviewProvider.select((items) => items.isNotEmpty), + ); final recentModeRequested = isShowingRecentAccess || isSearchFocused; final showRecentAccess = @@ -1212,7 +1213,7 @@ class _HomeTabState extends ConsumerState !hasHomeFeedExtension && !hasExploreContent && !hasResults && - historyItems.isEmpty; + !hasHistoryItems; ref.listen(settingsProvider.select((s) => s.defaultSearchTab), ( previous, @@ -1393,18 +1394,25 @@ class _HomeTabState extends ConsumerState ), ), ), - if (historyItems.isNotEmpty) - Padding( - padding: const EdgeInsets.fromLTRB( - 24, - 32, - 24, - 24, - ), - child: _buildRecentDownloads( - historyItems, - colorScheme, - ), + if (hasHistoryItems) + Consumer( + builder: (context, ref, _) { + final historyItems = ref.watch( + _homeHistoryPreviewProvider, + ); + return Padding( + padding: const EdgeInsets.fromLTRB( + 24, + 32, + 24, + 24, + ), + child: _buildRecentDownloads( + historyItems, + colorScheme, + ), + ); + }, ), ], ), @@ -1717,7 +1725,11 @@ class _HomeTabState extends ConsumerState final sectionIndex = index - sectionOffset; if (sectionIndex < sections.length) { - return _buildExploreSection(sections[sectionIndex], colorScheme); + final section = sections[sectionIndex]; + return KeyedSubtree( + key: ValueKey('explore-section-${section.uri}-${section.title}'), + child: _buildExploreSection(section, colorScheme), + ); } return const SizedBox(height: 24); @@ -1753,6 +1765,9 @@ class _HomeTabState extends ConsumerState itemBuilder: (context, index) { final item = section.items[index]; return StaggeredListItem( + key: ValueKey( + 'explore-item-${item.type}-${item.id}-${item.uri}', + ), index: index, staggerDelay: const Duration(milliseconds: 50), child: _buildExploreItem(item, colorScheme), @@ -1806,14 +1821,11 @@ class _HomeTabState extends ConsumerState isArtist ? cardSize / 2 : 10, ), child: item.coverUrl != null && item.coverUrl!.isNotEmpty - ? CachedNetworkImage( + ? CachedCoverImage( imageUrl: item.coverUrl!, width: cardSize, height: cardSize, fit: BoxFit.cover, - memCacheWidth: (cardSize * 2).round(), - memCacheHeight: (cardSize * 2).round(), - cacheManager: CoverCacheManager.instance, errorWidget: (context, url, error) => Container( width: cardSize, height: cardSize, @@ -1968,13 +1980,11 @@ class _HomeTabState extends ConsumerState ClipRRect( borderRadius: BorderRadius.circular(8), child: item.coverUrl != null && item.coverUrl!.isNotEmpty - ? CachedNetworkImage( + ? CachedCoverImage( imageUrl: item.coverUrl!, width: 64, height: 64, fit: BoxFit.cover, - memCacheWidth: 128, - cacheManager: CoverCacheManager.instance, ) : Container( width: 64, @@ -2474,10 +2484,7 @@ class _HomeTabState extends ConsumerState final targetSize = (360 * dpr).round().clamp(512, 1024).toInt(); precacheImage( ResizeImage( - CachedNetworkImageProvider( - url, - cacheManager: CoverCacheManager.instance, - ), + cachedCoverImageProvider(url), width: targetSize, height: targetSize, ), diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index f4ae8222..1da20943 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -25,8 +25,12 @@ class _SearchProviderDropdown extends ConsumerWidget { final rawCurrentProvider = ref.watch( settingsProvider.select((s) => s.searchProvider), ); - final extensionState = ref.watch(extensionProvider); - final extensions = extensionState.extensions; + final extensions = ref.watch(extensionProvider.select((s) => s.extensions)); + final providerReadiness = ref.watch( + extensionProvider.select( + (s) => (isInitialized: s.isInitialized, error: s.error), + ), + ); final colorScheme = Theme.of(context).colorScheme; final searchProviders = extensions @@ -36,7 +40,7 @@ class _SearchProviderDropdown extends ConsumerWidget { final hasAnyProvider = searchProviders.isNotEmpty || builtInProviders.isNotEmpty; final isProviderLoading = - !extensionState.isInitialized && extensionState.error == null; + !providerReadiness.isInitialized && providerReadiness.error == null; if (!hasAnyProvider) { return Padding( @@ -324,14 +328,11 @@ class _TrackItemWithStatus extends ConsumerWidget { ClipRRect( borderRadius: BorderRadius.circular(10), child: track.coverUrl != null - ? CachedNetworkImage( + ? CachedCoverImage( imageUrl: track.coverUrl!, width: thumbWidth, height: thumbHeight, fit: BoxFit.cover, - memCacheWidth: (thumbWidth * 2).toInt(), - memCacheHeight: (thumbHeight * 2).toInt(), - cacheManager: CoverCacheManager.instance, ) : Container( width: thumbWidth, @@ -518,14 +519,11 @@ class _CollectionItemWidget extends StatelessWidget { ClipRRect( borderRadius: BorderRadius.circular(isArtist ? 28 : 10), child: item.coverUrl != null && item.coverUrl!.isNotEmpty - ? CachedNetworkImage( + ? CachedCoverImage( imageUrl: item.coverUrl!, width: 56, height: 56, fit: BoxFit.cover, - memCacheWidth: 112, - memCacheHeight: 112, - cacheManager: CoverCacheManager.instance, ) : Container( width: 56, @@ -623,14 +621,11 @@ class _SearchArtistItemWidget extends StatelessWidget { ClipRRect( borderRadius: BorderRadius.circular(28), child: hasValidImage - ? CachedNetworkImage( + ? CachedCoverImage( imageUrl: artist.imageUrl!, width: 56, height: 56, fit: BoxFit.cover, - memCacheWidth: 112, - memCacheHeight: 112, - cacheManager: CoverCacheManager.instance, ) : Container( width: 56, @@ -724,14 +719,11 @@ class _SearchAlbumItemWidget extends StatelessWidget { ClipRRect( borderRadius: BorderRadius.circular(10), child: hasValidImage - ? CachedNetworkImage( + ? CachedCoverImage( imageUrl: album.imageUrl!, width: 56, height: 56, fit: BoxFit.cover, - memCacheWidth: 112, - memCacheHeight: 112, - cacheManager: CoverCacheManager.instance, ) : Container( width: 56, @@ -828,14 +820,11 @@ class _SearchPlaylistItemWidget extends StatelessWidget { ClipRRect( borderRadius: BorderRadius.circular(10), child: hasValidImage - ? CachedNetworkImage( + ? CachedCoverImage( imageUrl: playlist.imageUrl!, width: 56, height: 56, fit: BoxFit.cover, - memCacheWidth: 112, - memCacheHeight: 112, - cacheManager: CoverCacheManager.instance, ) : Container( width: 56, @@ -996,14 +985,13 @@ class _DownloadedOrRemoteCoverState extends State<_DownloadedOrRemoteCover> { errorBuilder: (_, _, _) => _fallback(), ); } else if (widget.imageUrl != null && widget.imageUrl!.isNotEmpty) { - child = CachedNetworkImage( + child = CachedCoverImage( imageUrl: widget.imageUrl!, width: widget.width, height: widget.height, fit: BoxFit.cover, memCacheWidth: cacheWidth, memCacheHeight: cacheHeight, - cacheManager: CoverCacheManager.instance, errorWidget: (_, _, _) => _fallback(), ); } else { @@ -1617,7 +1605,12 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> { return Column( children: List.generate(pageItemCount, (index) { final item = widget.section.items[startIndex + index]; - return _buildQuickPickItem(item); + return KeyedSubtree( + key: ValueKey( + 'quick-pick-${item.type}-${item.id}-${item.uri}', + ), + child: _buildQuickPickItem(item), + ); }), ); }, @@ -1661,14 +1654,11 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> { ClipRRect( borderRadius: BorderRadius.circular(4), child: item.coverUrl != null && item.coverUrl!.isNotEmpty - ? CachedNetworkImage( + ? CachedCoverImage( imageUrl: item.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, - memCacheWidth: 96, - memCacheHeight: 96, - cacheManager: CoverCacheManager.instance, errorWidget: (context, url, error) => Container( width: 48, height: 48, diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 426fb512..fcf3ec02 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; @@ -19,6 +17,7 @@ import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; +import 'package:spotiflac_android/widgets/cached_cover_image.dart'; class PlaylistScreen extends ConsumerStatefulWidget { final String playlistName; @@ -297,11 +296,10 @@ class _PlaylistScreenState extends ConsumerState { fit: StackFit.expand, children: [ if (_coverUrl != null) - CachedNetworkImage( + CachedCoverImage( imageUrl: _highResCoverUrl(_coverUrl) ?? _coverUrl!, fit: BoxFit.cover, memCacheWidth: cacheWidth, - cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), errorWidget: (_, _, _) => @@ -838,16 +836,11 @@ class _PlaylistTrackItem extends ConsumerWidget { borderRadius: BorderRadius.circular(12), ), leading: track.coverUrl != null - ? ClipRRect( + ? CachedCoverImage( + imageUrl: track.coverUrl!, + width: 48, + height: 48, borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - imageUrl: track.coverUrl!, - width: 48, - height: 48, - fit: BoxFit.cover, - memCacheWidth: 96, - cacheManager: CoverCacheManager.instance, - ), ) : Container( width: 48, diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 65d2935c..d12345bd 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -4,10 +4,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:share_plus/share_plus.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; @@ -31,6 +29,7 @@ import 'package:spotiflac_android/screens/favorite_artists_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart'; import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; +import 'package:spotiflac_android/widgets/cached_cover_image.dart'; import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; @@ -1841,10 +1840,7 @@ class _QueueTabState extends ConsumerState { final targetSize = (360 * dpr).round().clamp(512, 1024).toInt(); precacheImage( ResizeImage( - CachedNetworkImageProvider( - url, - cacheManager: CoverCacheManager.instance, - ), + cachedCoverImageProvider(url), width: targetSize, height: targetSize, ), @@ -2166,18 +2162,14 @@ class _QueueTabState extends ConsumerState { ), ); } - return ClipRRect( + return CachedCoverImage( + imageUrl: firstCoverUrl, + width: size, + height: size, + memCacheWidth: cacheExtent, borderRadius: borderRadius, - child: CachedNetworkImage( - imageUrl: firstCoverUrl, - width: size, - height: size, - fit: BoxFit.cover, - memCacheWidth: cacheExtent, - cacheManager: CoverCacheManager.instance, - placeholder: (_, _) => placeholder, - errorWidget: (_, _, _) => placeholder, - ), + placeholder: (_, _) => placeholder, + errorWidget: (_, _, _) => placeholder, ); } @@ -3799,14 +3791,13 @@ class _QueueTabState extends ConsumerState { _albumPlaceholder(colorScheme), ) : album.coverUrl != null - ? CachedNetworkImage( + ? CachedCoverImage( imageUrl: album.coverUrl!, fit: BoxFit.cover, width: double.infinity, height: double.infinity, memCacheWidth: 300, memCacheHeight: 300, - cacheManager: CoverCacheManager.instance, ) : null, badgeColor: colorScheme.primaryContainer, @@ -5383,20 +5374,13 @@ class _QueueTabState extends ConsumerState { Widget _buildCoverArt(DownloadItem item, ColorScheme colorScheme) { final coverSize = _queueCoverSize(); - final memCacheSize = (coverSize * 2).round(); return item.track.coverUrl != null - ? ClipRRect( + ? CachedCoverImage( + imageUrl: item.track.coverUrl!, + width: coverSize, + height: coverSize, borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - imageUrl: item.track.coverUrl!, - width: coverSize, - height: coverSize, - fit: BoxFit.cover, - memCacheWidth: memCacheSize, - memCacheHeight: memCacheSize, - cacheManager: CoverCacheManager.instance, - ), ) : Container( width: coverSize, @@ -5651,19 +5635,15 @@ class _QueueTabState extends ConsumerState { } if (item.coverUrl != null) { - return ClipRRect( + return CachedCoverImage( + imageUrl: item.coverUrl!, + width: size, + height: size, + memCacheWidth: cacheSize, + memCacheHeight: cacheSize, borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - imageUrl: item.coverUrl!, - width: size, - height: size, - fit: BoxFit.cover, - memCacheWidth: cacheSize, - memCacheHeight: cacheSize, - cacheManager: CoverCacheManager.instance, - placeholder: (context, url) => buildPlaceholder(), - errorWidget: (context, url, error) => buildPlaceholder(), - ), + placeholder: (context, url) => buildPlaceholder(), + errorWidget: (context, url, error) => buildPlaceholder(), ); } diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index b894f0c1..5977eb17 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; -import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; @@ -12,6 +10,7 @@ import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; +import 'package:spotiflac_android/widgets/cached_cover_image.dart'; class SearchScreen extends ConsumerStatefulWidget { final String query; @@ -49,30 +48,8 @@ class _SearchScreenState extends ConsumerState { } } - void _downloadTrack(Track track) { - final settings = ref.read(settingsProvider); - final extensionState = ref.read(extensionProvider); - final service = resolveEffectiveDownloadService( - settings.defaultService, - extensionState, - ); - if (service.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), - ); - return; - } - ref.read(downloadQueueProvider.notifier).addToQueue(track, service); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), - ); - } - @override Widget build(BuildContext context) { - final tracks = ref.watch(trackProvider.select((s) => s.tracks)); - final isLoading = ref.watch(trackProvider.select((s) => s.isLoading)); - final error = ref.watch(trackProvider.select((s) => s.error)); final colorScheme = Theme.of(context).colorScheme; return Scaffold( @@ -98,36 +75,61 @@ class _SearchScreenState extends ConsumerState { ), ], ), - body: Column( - children: [ - if (isLoading) LinearProgressIndicator(color: colorScheme.primary), - if (error != null) - Padding( - padding: const EdgeInsets.all(16.0), - child: Text(error, style: TextStyle(color: colorScheme.error)), - ), - Expanded( - child: AnimatedStateSwitcher( - child: isLoading && tracks.isEmpty - ? const TrackListSkeleton(key: ValueKey('loading')) - : tracks.isEmpty - ? _buildEmptyState(colorScheme) - : ListView.builder( - key: const ValueKey('results'), - itemCount: tracks.length, - itemBuilder: (context, index) => StaggeredListItem( - index: index, - child: _buildTrackTile(tracks[index], colorScheme), - ), - ), - ), - ), - ], - ), + body: const _SearchResultsBody(), ); } +} - Widget _buildEmptyState(ColorScheme colorScheme) { +class _SearchResultsBody extends ConsumerWidget { + const _SearchResultsBody(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tracks = ref.watch(trackProvider.select((s) => s.tracks)); + final isLoading = ref.watch(trackProvider.select((s) => s.isLoading)); + final error = ref.watch(trackProvider.select((s) => s.error)); + final colorScheme = Theme.of(context).colorScheme; + + return Column( + children: [ + if (isLoading) LinearProgressIndicator(color: colorScheme.primary), + if (error != null) + Padding( + padding: const EdgeInsets.all(16.0), + child: Text(error, style: TextStyle(color: colorScheme.error)), + ), + Expanded( + child: AnimatedStateSwitcher( + child: isLoading && tracks.isEmpty + ? const TrackListSkeleton(key: ValueKey('loading')) + : tracks.isEmpty + ? _SearchEmptyState( + key: const ValueKey('empty'), + colorScheme: colorScheme, + ) + : ListView.builder( + key: const ValueKey('results'), + itemCount: tracks.length, + itemBuilder: (context, index) => StaggeredListItem( + key: ValueKey('search-track-${tracks[index].id}-$index'), + index: index, + child: _SearchTrackTile(track: tracks[index]), + ), + ), + ), + ), + ], + ); + } +} + +class _SearchEmptyState extends StatelessWidget { + final ColorScheme colorScheme; + + const _SearchEmptyState({super.key, required this.colorScheme}); + + @override + Widget build(BuildContext context) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -144,20 +146,41 @@ class _SearchScreenState extends ConsumerState { ), ); } +} - Widget _buildTrackTile(Track track, ColorScheme colorScheme) { +class _SearchTrackTile extends ConsumerWidget { + final Track track; + + const _SearchTrackTile({required this.track}); + + void _downloadTrack(BuildContext context, WidgetRef ref) { + final settings = ref.read(settingsProvider); + final extensionState = ref.read(extensionProvider); + final service = resolveEffectiveDownloadService( + settings.defaultService, + extensionState, + ); + if (service.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.extensionsNoDownloadProvider)), + ); + return; + } + ref.read(downloadQueueProvider.notifier).addToQueue(track, service); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; final coverWidget = track.coverUrl != null - ? ClipRRect( + ? CachedCoverImage( + imageUrl: track.coverUrl!, + width: 48, + height: 48, borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - imageUrl: track.coverUrl!, - width: 48, - height: 48, - fit: BoxFit.cover, - memCacheWidth: 144, - memCacheHeight: 144, - cacheManager: CoverCacheManager.instance, - ), ) : Container( width: 48, @@ -218,11 +241,11 @@ class _SearchScreenState extends ConsumerState { IconButton( icon: const Icon(Icons.download_rounded), tooltip: context.l10n.dialogDownload, - onPressed: () => _downloadTrack(track), + onPressed: () => _downloadTrack(context, ref), ), ], ), - onTap: () => _downloadTrack(track), + onTap: () => _downloadTrack(context, ref), ); } } diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 3efb1568..878f4515 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1,19 +1,61 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/services/download_request_payload.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('PlatformBridge'); +class _BridgeCacheEntry { + final Map value; + final DateTime expiresAt; + + const _BridgeCacheEntry({required this.value, required this.expiresAt}); + + bool get isExpired => DateTime.now().isAfter(expiresAt); +} + +class _BridgeInFlight { + final String requestId; + final String scopeKey; + final Future future; + + const _BridgeInFlight({ + required this.requestId, + required this.scopeKey, + required this.future, + }); +} + class PlatformBridge { static const _channel = MethodChannel('com.zarz.spotiflac/backend'); + static const _jsonResultFileKey = '__json_file'; + static const _metadataCacheTtl = Duration(minutes: 20); + static const _availabilityCacheTtl = Duration(minutes: 15); + static const _bridgeCacheMaxEntries = 256; + static const _metadataPersistentCacheKey = 'bridge_metadata_lookup_cache_v1'; + static const _availabilityPersistentCacheKey = + 'bridge_availability_lookup_cache_v1'; static const _downloadProgressEvents = EventChannel( 'com.zarz.spotiflac/download_progress_stream', ); static const _libraryScanProgressEvents = EventChannel( 'com.zarz.spotiflac/library_scan_progress_stream', ); + static final Map _metadataCache = {}; + static final Map _availabilityCache = {}; + static final Map>> _metadataInFlight = {}; + static final Map>> _availabilityInFlight = + {}; + static final Map>>> + _customSearchInFlight = {}; + static final Map?>> + _homeFeedInFlight = {}; + static Future? _persistentLookupCacheLoadFuture; + static int _lookupCacheGeneration = 0; + static int _extensionRequestSequence = 0; static bool get supportsCoreBackend => Platform.isAndroid || Platform.isIOS; @@ -24,12 +66,324 @@ class PlatformBridge { String spotifyId, String isrc, ) async { - _log.d('checkAvailability: $spotifyId (ISRC: $isrc)'); - final result = await _channel.invokeMethod('checkAvailability', { - 'spotify_id': spotifyId, - 'isrc': isrc, + final cacheKey = _availabilityCacheKey(spotifyId, isrc); + if (cacheKey.isEmpty) { + _log.d('checkAvailability: $spotifyId (ISRC: $isrc)'); + final result = await _channel.invokeMethod('checkAvailability', { + 'spotify_id': spotifyId, + 'isrc': isrc, + }); + return _decodeRequiredMapResult(result, 'checkAvailability'); + } + await _ensurePersistentLookupCachesLoaded(); + final cached = _getCachedMap(_availabilityCache, cacheKey); + if (cached != null) return cached; + + final inFlight = _availabilityInFlight[cacheKey]; + if (inFlight != null) return _copyStringMap(await inFlight); + + final generation = _lookupCacheGeneration; + final future = _invokeCachedMap( + cacheKey, + _availabilityCache, + () async { + _log.d('checkAvailability: $spotifyId (ISRC: $isrc)'); + final result = await _channel.invokeMethod('checkAvailability', { + 'spotify_id': spotifyId, + 'isrc': isrc, + }); + return _decodeRequiredMapResult(result, 'checkAvailability'); + }, + _availabilityCacheTtl, + generation, + _availabilityPersistentCacheKey, + ); + _availabilityInFlight[cacheKey] = future; + try { + return _copyStringMap(await future); + } finally { + _availabilityInFlight.remove(cacheKey); + } + } + + static Future> _invokeCachedMap( + String key, + Map cache, + Future> Function() loader, + Duration ttl, + int generation, + String persistentCacheKey, + ) async { + final value = await loader(); + if (generation == _lookupCacheGeneration) { + _putCachedMap(cache, key, value, ttl, persistentCacheKey); + } + return _copyStringMap(value); + } + + static String _availabilityCacheKey(String spotifyId, String isrc) { + final normalizedIsrc = isrc.trim().toUpperCase(); + if (normalizedIsrc.isNotEmpty) { + return 'isrc:$normalizedIsrc'; + } + final normalizedSpotifyId = spotifyId.trim(); + if (normalizedSpotifyId.isEmpty) return ''; + return 'spotify:$normalizedSpotifyId'; + } + + static String _providerMetadataCacheKey( + String providerId, + String resourceType, + String resourceId, + ) { + return [ + providerId.trim().toLowerCase(), + resourceType.trim().toLowerCase(), + resourceId.trim(), + ].join(':'); + } + + static Map? _getCachedMap( + Map cache, + String key, + ) { + _pruneExpiredBridgeCache(cache); + final entry = cache[key]; + if (entry == null) return null; + if (entry.isExpired) { + cache.remove(key); + return null; + } + return _copyStringMap(entry.value); + } + + static void _putCachedMap( + Map cache, + String key, + Map value, + Duration ttl, + String persistentCacheKey, + ) { + _pruneExpiredBridgeCache(cache); + while (cache.length >= _bridgeCacheMaxEntries && cache.isNotEmpty) { + cache.remove(cache.keys.first); + } + cache[key] = _BridgeCacheEntry( + value: _copyStringMap(value), + expiresAt: DateTime.now().add(ttl), + ); + unawaited( + _persistLookupCache(cache, persistentCacheKey, _lookupCacheGeneration), + ); + } + + static void _pruneExpiredBridgeCache(Map cache) { + if (cache.isEmpty) return; + final now = DateTime.now(); + cache.removeWhere((_, entry) => now.isAfter(entry.expiresAt)); + } + + static dynamic _copyJsonLike(dynamic value) { + if (value is Map) { + return { + for (final entry in value.entries) + entry.key.toString(): _copyJsonLike(entry.value), + }; + } + if (value is List) { + return value.map(_copyJsonLike).toList(growable: false); + } + return value; + } + + static Map _copyStringMap(Map value) { + return { + for (final entry in value.entries) entry.key: _copyJsonLike(entry.value), + }; + } + + static Map? _copyNullableStringMap( + Map? value, + ) { + if (value == null) return null; + return _copyStringMap(value); + } + + static List> _copyMapList( + List> value, + ) { + return value.map(_copyStringMap).toList(growable: false); + } + + static dynamic _canonicalizeJsonLike(dynamic value) { + if (value is Map) { + final entries = value.entries.toList() + ..sort((a, b) => a.key.toString().compareTo(b.key.toString())); + return { + for (final entry in entries) + entry.key.toString(): _canonicalizeJsonLike(entry.value), + }; + } + if (value is List) { + return value.map(_canonicalizeJsonLike).toList(growable: false); + } + return value; + } + + static Future _ensurePersistentLookupCachesLoaded() { + return _persistentLookupCacheLoadFuture ??= _loadPersistentLookupCaches( + _lookupCacheGeneration, + ); + } + + static Future _loadPersistentLookupCaches(int generation) async { + try { + final prefs = await SharedPreferences.getInstance(); + if (generation != _lookupCacheGeneration) return; + _restorePersistentCache( + prefs, + _metadataPersistentCacheKey, + _metadataCache, + ); + _restorePersistentCache( + prefs, + _availabilityPersistentCacheKey, + _availabilityCache, + ); + } catch (e) { + _log.w('Failed to load bridge lookup cache: $e'); + } + } + + static void _restorePersistentCache( + SharedPreferences prefs, + String prefsKey, + Map target, + ) { + final raw = prefs.getString(prefsKey); + if (raw == null || raw.isEmpty) return; + + final decoded = jsonDecode(raw); + if (decoded is! Map) return; + + final now = DateTime.now(); + for (final entry in decoded.entries) { + if (target.length >= _bridgeCacheMaxEntries) break; + final key = entry.key.toString(); + final rawEntry = entry.value; + if (key.isEmpty || rawEntry is! Map) continue; + + final expiresAtMs = rawEntry['expires_at']; + final value = rawEntry['value']; + if (expiresAtMs is! int || value is! Map) continue; + + final expiresAt = DateTime.fromMillisecondsSinceEpoch(expiresAtMs); + if (!expiresAt.isAfter(now)) continue; + + target[key] = _BridgeCacheEntry( + value: _copyStringMap(Map.from(value)), + expiresAt: expiresAt, + ); + } + } + + static Future _persistLookupCache( + Map cache, + String prefsKey, + int generation, + ) async { + try { + _pruneExpiredBridgeCache(cache); + final data = { + for (final entry in cache.entries) + entry.key: { + 'expires_at': entry.value.expiresAt.millisecondsSinceEpoch, + 'value': entry.value.value, + }, + }; + final prefs = await SharedPreferences.getInstance(); + if (generation != _lookupCacheGeneration) return; + await prefs.setString(prefsKey, jsonEncode(data)); + } catch (e) { + _log.w('Failed to persist bridge lookup cache: $e'); + } + } + + static Future _clearPersistentLookupCaches() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_metadataPersistentCacheKey); + await prefs.remove(_availabilityPersistentCacheKey); + } catch (e) { + _log.w('Failed to clear bridge lookup cache: $e'); + } + } + + static Future _clearLookupCaches() async { + _lookupCacheGeneration++; + _persistentLookupCacheLoadFuture = null; + _metadataCache.clear(); + _availabilityCache.clear(); + _metadataInFlight.clear(); + _availabilityInFlight.clear(); + for (final inFlight in _customSearchInFlight.values) { + _cancelExtensionRequestUnawaited(inFlight.requestId); + } + for (final inFlight in _homeFeedInFlight.values) { + _cancelExtensionRequestUnawaited(inFlight.requestId); + } + _customSearchInFlight.clear(); + _homeFeedInFlight.clear(); + await _clearPersistentLookupCaches(); + } + + static String _nextExtensionRequestId(String kind, String extensionId) { + _extensionRequestSequence++; + return [ + kind, + DateTime.now().microsecondsSinceEpoch, + _extensionRequestSequence, + extensionId.trim(), + ].join(':'); + } + + static void _cancelExtensionRequestUnawaited(String requestId) { + if (requestId.isEmpty) return; + unawaited( + cancelExtensionRequest(requestId).catchError((Object e) { + _log.w('Failed to cancel extension request $requestId: $e'); + }), + ); + } + + static Future cancelExtensionRequest(String requestId) async { + if (requestId.isEmpty) return; + await _channel.invokeMethod('cancelExtensionRequest', { + 'request_id': requestId, }); - return _decodeRequiredMapResult(result, 'checkAvailability'); + } + + static void _cancelCustomSearchInFlightForScope( + String scopeKey, { + String? exceptKey, + }) { + for (final entry in _customSearchInFlight.entries.toList()) { + if (entry.key == exceptKey || entry.value.scopeKey != scopeKey) continue; + _cancelExtensionRequestUnawaited(entry.value.requestId); + } + } + + static void cancelExtensionHomeFeedRequests() { + for (final inFlight in _homeFeedInFlight.values) { + _cancelExtensionRequestUnawaited(inFlight.requestId); + } + _homeFeedInFlight.clear(); + } + + static int _lookupCacheSize() { + _pruneExpiredBridgeCache(_metadataCache); + _pruneExpiredBridgeCache(_availabilityCache); + return _metadataCache.length + _availabilityCache.length; } static Future> _invokeDownloadMethod( @@ -485,11 +839,13 @@ class PlatformBridge { } static Future getTrackCacheSize() async { + await _ensurePersistentLookupCachesLoaded(); final result = await _channel.invokeMethod('getTrackCacheSize'); - return result as int; + return (result as int) + _lookupCacheSize(); } static Future clearTrackCache() async { + await _clearLookupCaches(); await _channel.invokeMethod('clearTrackCache'); } @@ -533,17 +889,45 @@ class PlatformBridge { String resourceType, String resourceId, ) async { - final result = await _channel.invokeMethod('getProviderMetadata', { - 'provider_id': providerId, - 'resource_type': resourceType, - 'resource_id': resourceId, - }); - if (result == null) { - throw Exception( - 'getProviderMetadata returned null for $providerId:$resourceType:$resourceId', - ); + final cacheKey = _providerMetadataCacheKey( + providerId, + resourceType, + resourceId, + ); + await _ensurePersistentLookupCachesLoaded(); + final cached = _getCachedMap(_metadataCache, cacheKey); + if (cached != null) return cached; + + final inFlight = _metadataInFlight[cacheKey]; + if (inFlight != null) return _copyStringMap(await inFlight); + + final generation = _lookupCacheGeneration; + final future = _invokeCachedMap( + cacheKey, + _metadataCache, + () async { + final result = await _channel.invokeMethod('getProviderMetadata', { + 'provider_id': providerId, + 'resource_type': resourceType, + 'resource_id': resourceId, + }); + if (result == null) { + throw Exception( + 'getProviderMetadata returned null for $providerId:$resourceType:$resourceId', + ); + } + return _decodeRequiredMapResult(result, 'getProviderMetadata'); + }, + _metadataCacheTtl, + generation, + _metadataPersistentCacheKey, + ); + _metadataInFlight[cacheKey] = future; + try { + return _copyStringMap(await future); + } finally { + _metadataInFlight.remove(cacheKey); } - return _decodeRequiredMapResult(result, 'getProviderMetadata'); } static Future> searchDeezerByISRC( @@ -584,11 +968,39 @@ class PlatformBridge { String resourceType, String spotifyId, ) async { - final result = await _channel.invokeMethod('convertSpotifyToDeezer', { - 'resource_type': resourceType, - 'spotify_id': spotifyId, - }); - return _decodeRequiredMapResult(result, 'convertSpotifyToDeezer'); + final cacheKey = _providerMetadataCacheKey( + 'spotify-to-deezer', + resourceType, + spotifyId, + ); + await _ensurePersistentLookupCachesLoaded(); + final cached = _getCachedMap(_metadataCache, cacheKey); + if (cached != null) return cached; + + final inFlight = _metadataInFlight[cacheKey]; + if (inFlight != null) return _copyStringMap(await inFlight); + + final generation = _lookupCacheGeneration; + final future = _invokeCachedMap( + cacheKey, + _metadataCache, + () async { + final result = await _channel.invokeMethod('convertSpotifyToDeezer', { + 'resource_type': resourceType, + 'spotify_id': spotifyId, + }); + return _decodeRequiredMapResult(result, 'convertSpotifyToDeezer'); + }, + _metadataCacheTtl, + generation, + _metadataPersistentCacheKey, + ); + _metadataInFlight[cacheKey] = future; + try { + return _copyStringMap(await future); + } finally { + _metadataInFlight.remove(cacheKey); + } } static Future>> getGoLogs() async { @@ -641,6 +1053,7 @@ class PlatformBridge { String filePath, ) async { _log.d('loadExtensionFromPath: $filePath'); + await _clearLookupCaches(); final result = await _channel.invokeMethod('loadExtensionFromPath', { 'file_path': filePath, }); @@ -649,6 +1062,7 @@ class PlatformBridge { static Future unloadExtension(String extensionId) async { _log.d('unloadExtension: $extensionId'); + await _clearLookupCaches(); await _channel.invokeMethod('unloadExtension', { 'extension_id': extensionId, }); @@ -656,6 +1070,7 @@ class PlatformBridge { static Future removeExtension(String extensionId) async { _log.d('removeExtension: $extensionId'); + await _clearLookupCaches(); await _channel.invokeMethod('removeExtension', { 'extension_id': extensionId, }); @@ -663,6 +1078,7 @@ class PlatformBridge { static Future> upgradeExtension(String filePath) async { _log.d('upgradeExtension: $filePath'); + await _clearLookupCaches(); final result = await _channel.invokeMethod('upgradeExtension', { 'file_path': filePath, }); @@ -689,6 +1105,7 @@ class PlatformBridge { bool enabled, ) async { _log.d('setExtensionEnabled: $extensionId = $enabled'); + await _clearLookupCaches(); await _channel.invokeMethod('setExtensionEnabled', { 'extension_id': extensionId, 'enabled': enabled, @@ -697,6 +1114,7 @@ class PlatformBridge { static Future setProviderPriority(List providerIds) async { _log.d('setProviderPriority: $providerIds'); + await _clearLookupCaches(); await _channel.invokeMethod('setProviderPriority', { 'priority': jsonEncode(providerIds), }); @@ -711,6 +1129,7 @@ class PlatformBridge { List? extensionIds, ) async { _log.d('setDownloadFallbackExtensionIds: $extensionIds'); + await _clearLookupCaches(); await _channel.invokeMethod('setDownloadFallbackExtensionIds', { 'extension_ids': extensionIds == null ? '' : jsonEncode(extensionIds), }); @@ -720,6 +1139,7 @@ class PlatformBridge { List providerIds, ) async { _log.d('setMetadataProviderPriority: $providerIds'); + await _clearLookupCaches(); await _channel.invokeMethod('setMetadataProviderPriority', { 'priority': jsonEncode(providerIds), }); @@ -744,6 +1164,7 @@ class PlatformBridge { Map settings, ) async { _log.d('setExtensionSettings: $extensionId'); + await _clearLookupCaches(); await _channel.invokeMethod('setExtensionSettings', { 'extension_id': extensionId, 'settings': jsonEncode(settings), @@ -883,13 +1304,45 @@ class PlatformBridge { String extensionId, String query, { Map? options, + bool cancelPrevious = false, }) async { - final result = await _channel.invokeMethod('customSearchWithExtension', { - 'extension_id': extensionId, - 'query': query, - 'options': options != null ? jsonEncode(options) : '', - }); - return _decodeMapListResult(result, 'customSearchWithExtension'); + final optionsJson = options != null ? jsonEncode(options) : ''; + final scopeKey = 'customSearch:${extensionId.trim()}'; + final cacheKey = [ + scopeKey, + query, + jsonEncode(_canonicalizeJsonLike(options ?? const {})), + ].join('\n'); + final inFlight = _customSearchInFlight[cacheKey]; + if (inFlight != null) return _copyMapList(await inFlight.future); + if (cancelPrevious) { + _cancelCustomSearchInFlightForScope(scopeKey, exceptKey: cacheKey); + } + + final requestId = _nextExtensionRequestId('customSearch', extensionId); + final future = (() async { + final result = await _channel.invokeMethod('customSearchWithExtension', { + 'extension_id': extensionId, + 'query': query, + 'options': optionsJson, + 'request_id': requestId, + }); + return _decodeMapListResult(result, 'customSearchWithExtension'); + })(); + + final entry = _BridgeInFlight>>( + requestId: requestId, + scopeKey: scopeKey, + future: future, + ); + _customSearchInFlight[cacheKey] = entry; + try { + return _copyMapList(await future); + } finally { + if (identical(_customSearchInFlight[cacheKey], entry)) { + _customSearchInFlight.remove(cacheKey); + } + } } static Future>> getSearchProviders() async { @@ -927,16 +1380,44 @@ class PlatformBridge { } static Future?> getExtensionHomeFeed( - String extensionId, - ) async { + String extensionId, { + bool cancelPrevious = false, + }) async { + final cacheKey = 'homeFeed:${extensionId.trim()}'; + final inFlight = _homeFeedInFlight[cacheKey]; + if (inFlight != null) { + if (!cancelPrevious) { + return _copyNullableStringMap(await inFlight.future); + } + _cancelExtensionRequestUnawaited(inFlight.requestId); + _homeFeedInFlight.remove(cacheKey); + } + + final requestId = _nextExtensionRequestId('homeFeed', extensionId); + final future = (() async { + try { + final result = await _channel.invokeMethod('getExtensionHomeFeed', { + 'extension_id': extensionId, + 'request_id': requestId, + }); + return _decodeNullableMapResult(result, 'getExtensionHomeFeed'); + } catch (e) { + _log.e('getExtensionHomeFeed failed: $e'); + return null; + } + })(); + final entry = _BridgeInFlight?>( + requestId: requestId, + scopeKey: cacheKey, + future: future, + ); + _homeFeedInFlight[cacheKey] = entry; try { - final result = await _channel.invokeMethod('getExtensionHomeFeed', { - 'extension_id': extensionId, - }); - return _decodeNullableMapResult(result, 'getExtensionHomeFeed'); - } catch (e) { - _log.e('getExtensionHomeFeed failed: $e'); - return null; + return _copyNullableStringMap(await future); + } finally { + if (identical(_homeFeedInFlight[cacheKey], entry)) { + _homeFeedInFlight.remove(cacheKey); + } } } @@ -969,7 +1450,7 @@ class PlatformBridge { final result = await _channel.invokeMethod('scanLibraryFolder', { 'folder_path': folderPath, }); - return _decodeMapListResult(result, 'scanLibraryFolder'); + return _decodeMapListResultAsync(result, 'scanLibraryFolder'); } static Future> scanLibraryFolderIncremental( @@ -983,7 +1464,10 @@ class PlatformBridge { 'folder_path': folderPath, 'existing_files': jsonEncode(existingFiles), }); - return _decodeRequiredMapResult(result, 'scanLibraryFolderIncremental'); + return _decodeRequiredMapResultAsync( + result, + 'scanLibraryFolderIncremental', + ); } static Future> scanLibraryFolderIncrementalFromSnapshot( @@ -994,7 +1478,7 @@ class PlatformBridge { 'scanLibraryFolderIncrementalFromSnapshot', {'folder_path': folderPath, 'snapshot_path': snapshotPath}, ); - return _decodeRequiredMapResult( + return _decodeRequiredMapResultAsync( result, 'scanLibraryFolderIncrementalFromSnapshot', ); @@ -1005,7 +1489,7 @@ class PlatformBridge { final result = await _channel.invokeMethod('scanSafTree', { 'tree_uri': treeUri, }); - return _decodeMapListResult(result, 'scanSafTree'); + return _decodeMapListResultAsync(result, 'scanSafTree'); } static Future> scanSafTreeIncremental( @@ -1019,7 +1503,7 @@ class PlatformBridge { 'tree_uri': treeUri, 'existing_files': jsonEncode(existingFiles), }); - return _decodeRequiredMapResult(result, 'scanSafTreeIncremental'); + return _decodeRequiredMapResultAsync(result, 'scanSafTreeIncremental'); } static Future> scanSafTreeIncrementalFromSnapshot( @@ -1030,7 +1514,7 @@ class PlatformBridge { 'scanSafTreeIncrementalFromSnapshot', {'tree_uri': treeUri, 'snapshot_path': snapshotPath}, ); - return _decodeRequiredMapResult( + return _decodeRequiredMapResultAsync( result, 'scanSafTreeIncrementalFromSnapshot', ); @@ -1067,6 +1551,22 @@ class PlatformBridge { return result; } + static Future _decodeJsonResultAsync(dynamic result) async { + if (result is Map && result[_jsonResultFileKey] is String) { + final file = File(result[_jsonResultFileKey] as String); + try { + final contents = await file.readAsString(); + if (contents.isEmpty) return null; + return jsonDecode(contents); + } finally { + try { + await file.delete(); + } catch (_) {} + } + } + return _decodeJsonResult(result); + } + static Map _decodeRequiredMapResult( dynamic result, String method, @@ -1094,6 +1594,19 @@ class PlatformBridge { ); } + static Future> _decodeRequiredMapResultAsync( + dynamic result, + String method, + ) async { + final decoded = await _decodeJsonResultAsync(result); + if (decoded is Map) { + return decoded.cast(); + } + throw FormatException( + 'Expected map result from $method, got ${decoded.runtimeType}', + ); + } + static List _decodeRequiredListResult( dynamic result, String method, @@ -1105,6 +1618,17 @@ class PlatformBridge { ); } + static Future> _decodeRequiredListResultAsync( + dynamic result, + String method, + ) async { + final decoded = await _decodeJsonResultAsync(result); + if (decoded is List) return decoded; + throw FormatException( + 'Expected list result from $method, got ${decoded.runtimeType}', + ); + } + static List> _decodeMapListResult( dynamic result, String method, @@ -1117,6 +1641,19 @@ class PlatformBridge { }).toList(); } + static Future>> _decodeMapListResultAsync( + dynamic result, + String method, + ) async { + final decoded = await _decodeRequiredListResultAsync(result, method); + return decoded.map((entry) { + if (entry is Map) return entry.cast(); + throw FormatException( + 'Expected map entry from $method, got ${entry.runtimeType}', + ); + }).toList(); + } + static List _decodeStringListResult(dynamic result, String method) { return _decodeRequiredListResult(result, method).map((entry) { if (entry is String) return entry; diff --git a/lib/widgets/cached_cover_image.dart b/lib/widgets/cached_cover_image.dart index a983d818..aff11cfa 100644 --- a/lib/widgets/cached_cover_image.dart +++ b/lib/widgets/cached_cover_image.dart @@ -3,10 +3,14 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; class CachedCoverImage extends StatelessWidget { + static const int _defaultMinCacheExtent = 64; + static const int _defaultMaxCacheExtent = 512; + final String imageUrl; final double? width; final double? height; final BoxFit fit; + final Alignment alignment; final int? memCacheWidth; final int? memCacheHeight; final Widget Function(BuildContext, String, Object)? errorWidget; @@ -19,6 +23,7 @@ class CachedCoverImage extends StatelessWidget { this.width, this.height, this.fit = BoxFit.cover, + this.alignment = Alignment.center, this.memCacheWidth, this.memCacheHeight, this.errorWidget, @@ -28,36 +33,65 @@ class CachedCoverImage extends StatelessWidget { @override Widget build(BuildContext context) { + final autoMemCacheWidth = + memCacheWidth ?? _cacheExtentForLogicalSize(context, width); + final autoMemCacheHeight = + memCacheHeight ?? _cacheExtentForLogicalSize(context, height); final image = CachedNetworkImage( imageUrl: imageUrl, width: width, height: height, fit: fit, - memCacheWidth: memCacheWidth, - memCacheHeight: memCacheHeight, - cacheManager: CoverCacheManager.isInitialized - ? CoverCacheManager.instance + alignment: alignment, + memCacheWidth: autoMemCacheWidth, + memCacheHeight: autoMemCacheHeight, + maxWidthDiskCache: autoMemCacheWidth, + maxHeightDiskCache: autoMemCacheHeight, + cacheManager: CoverCacheManager.isInitialized + ? CoverCacheManager.instance : null, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + useOldImageOnUrlChange: true, + filterQuality: FilterQuality.low, errorWidget: errorWidget, placeholder: placeholder, ); if (borderRadius != null) { - return ClipRRect( - borderRadius: borderRadius!, - child: image, - ); + return ClipRRect(borderRadius: borderRadius!, child: image); } return image; } + + static int? _cacheExtentForLogicalSize(BuildContext context, double? size) { + if (size == null || !size.isFinite || size <= 0) return null; + final dpr = MediaQuery.devicePixelRatioOf( + context, + ).clamp(1.0, 3.0).toDouble(); + return (size * dpr) + .round() + .clamp(_defaultMinCacheExtent, _defaultMaxCacheExtent) + .toInt(); + } } CachedNetworkImageProvider cachedCoverImageProvider(String url) { return CachedNetworkImageProvider( url, - cacheManager: CoverCacheManager.isInitialized - ? CoverCacheManager.instance + cacheManager: CoverCacheManager.isInitialized + ? CoverCacheManager.instance : null, ); } + +int coverImageCacheExtent( + BuildContext context, + double logicalSize, { + int min = 64, + int max = 512, +}) { + final dpr = MediaQuery.devicePixelRatioOf(context).clamp(1.0, 3.0).toDouble(); + return (logicalSize * dpr).round().clamp(min, max).toInt(); +}