From e9bb3df906f85d67055d1fdbf137983a7229f15c Mon Sep 17 00:00:00 2001 From: unknown <842753636@qq.com> Date: Thu, 16 Jul 2026 18:43:22 +0800 Subject: [PATCH] feat: sync catalog, server, and MusicFree updates --- MusicFree/android/app/build.gradle | 2 +- .../android/app/src/main/AndroidManifest.xml | 168 ++ .../fun/upup/musicfree/utils/UtilsModule.kt | 62 + MusicFree/package.json | 4 +- .../react-native-track-player+4.1.1.patch | 253 +++ MusicFree/release/music_server_latest.js | 1413 +++++++++++++++++ MusicFree/release/version.json | 13 + MusicFree/src/components/base/playAllBar.tsx | 7 +- .../src/components/musicBar/musicInfo.test.ts | 93 ++ .../src/components/musicBar/musicInfo.tsx | 2 +- .../musicSheetPage/components/header.tsx | 5 +- .../components/sheetMusicList.tsx | 16 +- .../src/components/musicSheetPage/index.tsx | 18 +- .../panels/types/addToMusicSheet.test.tsx | 164 ++ .../panels/types/addToMusicSheet.tsx | 68 +- MusicFree/src/core/i18n/languages/en-us.json | 1 + MusicFree/src/core/i18n/languages/zh-cn.json | 1 + MusicFree/src/core/i18n/languages/zh-tw.json | 1 + MusicFree/src/core/pluginManager/plugin.ts | 2 +- MusicFree/src/core/trackPlayer/index.ts | 67 +- .../trackPlayer/playbackFailureLog.test.ts | 24 + .../core/trackPlayer/playbackFailureLog.ts | 13 + .../playbackTransitionDecision.test.ts | 37 + .../trackPlayer/playbackTransitionDecision.ts | 45 + MusicFree/src/entry/bootstrap/bootstrap.ts | 14 +- .../bootstrap/trackPlayerOptions.test.ts | 38 +- .../src/entry/bootstrap/trackPlayerOptions.ts | 51 +- .../trackPlayerRemotePrevious.test.ts | 34 + MusicFree/src/hooks/useCheckUpdate.test.ts | 102 ++ MusicFree/src/hooks/useCheckUpdate.ts | 10 +- MusicFree/src/native/utils/index.ts | 4 + .../albumDetail/hooks/useAlbumMusicList.ts | 116 +- MusicFree/src/pages/albumDetail/index.tsx | 9 +- .../hooks/usePluginSheetMusicList.ts | 118 +- .../src/pages/pluginSheetDetail/index.tsx | 9 +- .../topListDetail/hooks/useTopListDetail.ts | 134 +- MusicFree/src/pages/topListDetail/index.tsx | 3 +- MusicFree/src/service/index.ts | 205 ++- .../service/playbackAnomalyMonitor.test.ts | 180 +++ .../src/service/playbackAnomalyMonitor.ts | 206 +++ .../service/playbackErrorDiagnostics.test.ts | 151 ++ .../src/service/playbackErrorDiagnostics.ts | 87 + MusicFree/src/types/core/i18n/index.d.ts | 1 + MusicFree/src/utils/checkUpdate.test.ts | 59 +- MusicFree/src/utils/checkUpdate.ts | 16 +- MusicFree/src/utils/log.test.ts | 162 ++ MusicFree/src/utils/log.ts | 53 +- .../src/utils/resolvePagedMusicList.test.ts | 31 + MusicFree/src/utils/resolvePagedMusicList.ts | 43 + Music_Server/config/music_server.env.example | 1 + Music_Server/scripts/deploy_to_nas.ps1 | 2 +- Music_Server/scripts/deploy_to_nas.py | 2 +- .../src/music_server/routes/mf_media.py | 53 +- .../music_server/services/media_resolver.py | 19 +- .../music_server/services/stream_tokens.py | 16 +- Music_Server/src/music_server/settings.py | 5 + Music_Server/tests/test_app_update_routes.py | 1 - Music_Server/tests/test_mf_media_routes.py | 201 +++ 58 files changed, 4409 insertions(+), 206 deletions(-) create mode 100644 MusicFree/android/app/src/main/AndroidManifest.xml create mode 100644 MusicFree/patches/react-native-track-player+4.1.1.patch create mode 100644 MusicFree/release/music_server_latest.js create mode 100644 MusicFree/release/version.json create mode 100644 MusicFree/src/components/musicBar/musicInfo.test.ts create mode 100644 MusicFree/src/components/panels/types/addToMusicSheet.test.tsx create mode 100644 MusicFree/src/core/trackPlayer/playbackFailureLog.test.ts create mode 100644 MusicFree/src/core/trackPlayer/playbackFailureLog.ts create mode 100644 MusicFree/src/core/trackPlayer/playbackTransitionDecision.test.ts create mode 100644 MusicFree/src/core/trackPlayer/playbackTransitionDecision.ts create mode 100644 MusicFree/src/entry/bootstrap/trackPlayerRemotePrevious.test.ts create mode 100644 MusicFree/src/hooks/useCheckUpdate.test.ts create mode 100644 MusicFree/src/service/playbackAnomalyMonitor.test.ts create mode 100644 MusicFree/src/service/playbackAnomalyMonitor.ts create mode 100644 MusicFree/src/service/playbackErrorDiagnostics.test.ts create mode 100644 MusicFree/src/service/playbackErrorDiagnostics.ts create mode 100644 MusicFree/src/utils/log.test.ts create mode 100644 MusicFree/src/utils/resolvePagedMusicList.test.ts create mode 100644 MusicFree/src/utils/resolvePagedMusicList.ts diff --git a/MusicFree/android/app/build.gradle b/MusicFree/android/app/build.gradle index e776c05..45f5cb2 100644 --- a/MusicFree/android/app/build.gradle +++ b/MusicFree/android/app/build.gradle @@ -114,7 +114,7 @@ static def getVersion() { // } def appVersion = getVersion() -def appVersionCode = 400012 +def appVersionCode = 400015 android { diff --git a/MusicFree/android/app/src/main/AndroidManifest.xml b/MusicFree/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6a63cb5 --- /dev/null +++ b/MusicFree/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/MusicFree/android/app/src/main/java/fun/upup/musicfree/utils/UtilsModule.kt b/MusicFree/android/app/src/main/java/fun/upup/musicfree/utils/UtilsModule.kt index 03855b0..2b3408e 100644 --- a/MusicFree/android/app/src/main/java/fun/upup/musicfree/utils/UtilsModule.kt +++ b/MusicFree/android/app/src/main/java/fun/upup/musicfree/utils/UtilsModule.kt @@ -11,7 +11,12 @@ import android.provider.Settings import android.util.DisplayMetrics import android.view.WindowInsets import android.view.WindowManager +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import `fun`.upup.musicfree.MainActivity +import `fun`.upup.musicfree.R import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext @@ -23,6 +28,8 @@ import kotlin.system.exitProcess class UtilsModule(context: ReactApplicationContext) : ReactContextBaseJavaModule(context) { private val reactContext: ReactApplicationContext = context; + private val playbackDiagnosticChannelId = "musicfree_playback_diag" + private val playbackDiagnosticNotificationId = 42042 override fun getName() = "NativeUtils" @@ -96,6 +103,61 @@ class UtilsModule(context: ReactApplicationContext) : ReactContextBaseJavaModule } } + @ReactMethod + fun showPlaybackDiagnosticNotification(title: String, message: String, promise: Promise) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val hasPermission = ContextCompat.checkSelfPermission( + reactContext, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + if (!hasPermission) { + promise.resolve(null) + return + } + } + + val manager = NotificationManagerCompat.from(reactContext) + val channel = NotificationChannelCompat.Builder( + playbackDiagnosticChannelId, + NotificationManagerCompat.IMPORTANCE_HIGH, + ) + .setName("Playback Diagnostics") + .setDescription("Playback error and stall diagnostics") + .build() + manager.createNotificationChannel(channel) + + val intent = Intent(reactContext, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + } + val pendingIntentFlags = + android.app.PendingIntent.FLAG_UPDATE_CURRENT or + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) android.app.PendingIntent.FLAG_IMMUTABLE else 0 + val pendingIntent = android.app.PendingIntent.getActivity( + reactContext, + 0, + intent, + pendingIntentFlags, + ) + + val notification = NotificationCompat.Builder(reactContext, playbackDiagnosticChannelId) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(message) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .build() + + manager.notify(playbackDiagnosticNotificationId, notification) + promise.resolve(null) + } catch (e: Exception) { + promise.reject("show_playback_diagnostic_notification_failed", e) + } + } + @ReactMethod(isBlockingSynchronousMethod = true) fun getWindowDimensions(): WritableMap { val windowManager = reactApplicationContext.getSystemService(Context.WINDOW_SERVICE) as WindowManager diff --git a/MusicFree/package.json b/MusicFree/package.json index a3c5c77..fd60b72 100644 --- a/MusicFree/package.json +++ b/MusicFree/package.json @@ -1,6 +1,6 @@ { "name": "MusicFree", - "version": "0.6.3", + "version": "0.6.7", "private": true, "license": "AGPL", "author": { @@ -19,6 +19,7 @@ "connect-mumu": "adb kill-server & adb connect localhost:7555", "build-android": "cd .\\android\\ && .\\gradlew assembleRelease", "generate-assets": "node ./generator/generate-assets.mjs", + "postinstall": "patch-package", "prepare": "husky" }, "dependencies": { @@ -114,6 +115,7 @@ "husky": "^9.1.4", "jest": "^29.6.3", "lint-staged": "^15.2.7", + "patch-package": "^8.0.0", "prettier": "2.8.8", "react-native-svg-transformer": "^1.5.0", "react-test-renderer": "18.3.1", diff --git a/MusicFree/patches/react-native-track-player+4.1.1.patch b/MusicFree/patches/react-native-track-player+4.1.1.patch new file mode 100644 index 0000000..a80465a --- /dev/null +++ b/MusicFree/patches/react-native-track-player+4.1.1.patch @@ -0,0 +1,253 @@ +diff --git a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt +index 9d6d869..8f149f8 100644 +--- a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt ++++ b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt +@@ -4,17 +4,23 @@ import android.app.* + import android.content.Context + import android.content.Intent + import android.content.pm.ServiceInfo ++import android.graphics.Bitmap + import android.net.Uri + import android.os.Binder + import android.os.Build + import android.os.Bundle + import android.os.IBinder + import android.support.v4.media.RatingCompat ++import android.support.v4.media.session.MediaSessionCompat ++import android.support.v4.media.session.PlaybackStateCompat + import androidx.annotation.MainThread + import androidx.core.app.NotificationCompat + import androidx.core.app.NotificationCompat.PRIORITY_LOW ++import androidx.core.app.NotificationManagerCompat ++import androidx.media.session.MediaButtonReceiver + import com.doublesymmetry.kotlinaudio.models.* + import com.doublesymmetry.kotlinaudio.models.NotificationButton.* ++import com.doublesymmetry.kotlinaudio.notification.NotificationManager as KotlinAudioNotificationManager + import com.doublesymmetry.kotlinaudio.players.QueuedAudioPlayer + import com.doublesymmetry.trackplayer.R as TrackPlayerR + import com.doublesymmetry.trackplayer.extensions.NumberExt.Companion.toMilliseconds +@@ -94,11 +100,52 @@ class MusicService : HeadlessJsTaskService() { + private var compactCapabilities: List = emptyList() + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { ++ if (handleNotificationAction(intent)) { ++ return START_STICKY ++ } + startTask(getTaskConfig(intent)) + startAndStopEmptyNotificationToAvoidANR() + return START_STICKY + } + ++ private fun handleNotificationAction(intent: Intent?): Boolean { ++ val action = intent?.action ?: return false ++ if (!this::player.isInitialized) { ++ Timber.w("Ignoring notification action before player initialization: %s", action) ++ return true ++ } ++ when (action) { ++ ACTION_NOTIFICATION_PLAY -> play() ++ ACTION_NOTIFICATION_PAUSE -> pause() ++ ACTION_NOTIFICATION_NEXT -> skipToNext() ++ ACTION_NOTIFICATION_PREVIOUS -> { ++ dispatchPlaybackButtonEvent(MusicEvents.BUTTON_SKIP_PREVIOUS) ++ } ++ else -> return false ++ } ++ Timber.d("Handled explicit notification action: %s", action) ++ return true ++ } ++ ++ private fun dispatchPlaybackButtonEvent(event: String, data: Bundle? = null) { ++ val reactInstanceManager = reactNativeHost.reactInstanceManager ++ if (reactInstanceManager.currentReactContext != null) { ++ emit(event, data) ++ return ++ } ++ startTask(getTaskConfig(null)) ++ scope.launch { ++ repeat(40) { ++ if (reactInstanceManager.currentReactContext != null) { ++ emit(event, data) ++ return@launch ++ } ++ delay(100) ++ } ++ Timber.w("Timed out waiting for React context before dispatching event: %s", event) ++ } ++ } ++ + /** + * Workaround for the "Context.startForegroundService() did not then call Service.startForeground()" + * within 5s" ANR and crash by creating an empty notification and stopping it right after. For more +@@ -236,6 +283,11 @@ class MusicService : HeadlessJsTaskService() { + val notificationConfig = NotificationConfig(buttonsList, accentColor, smallIcon, pendingIntent) + + player.notificationManager.createNotification(notificationConfig) ++ scope.launch { ++ delay(500) ++ player.notificationManager.createNotification(notificationConfig) ++ player.notificationManager.invalidate() ++ } + + // setup progress update events if configured + progressUpdateJob?.cancel() +@@ -279,6 +331,138 @@ class MusicService : HeadlessJsTaskService() { + } + } + ++ private fun hasNotificationCapability(capability: Capability): Boolean { ++ return notificationCapabilities.contains(capability) || capabilities.contains(capability) ++ } ++ ++ private fun getMediaSessionCompat(): MediaSessionCompat? { ++ return try { ++ val field = KotlinAudioNotificationManager::class.java.getDeclaredField("mediaSession") ++ field.isAccessible = true ++ field.get(player.notificationManager) as? MediaSessionCompat ++ } catch (error: Exception) { ++ Timber.w(error, "Failed to access media session from notification manager") ++ null ++ } ++ } ++ ++ private fun buildTransportControlNotification(original: Notification): Notification { ++ val builder = NotificationCompat.Builder(this, getNotificationChannelId(original)) ++ .setContentTitle(original.extras?.getCharSequence(Notification.EXTRA_TITLE)) ++ .setContentText(original.extras?.getCharSequence(Notification.EXTRA_TEXT)) ++ .setSubText(original.extras?.getCharSequence(Notification.EXTRA_SUB_TEXT)) ++ .setContentIntent(original.contentIntent) ++ .setDeleteIntent(original.deleteIntent) ++ .setCategory(original.category ?: Notification.CATEGORY_TRANSPORT) ++ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) ++ .setOnlyAlertOnce(true) ++ .setOngoing(player.playWhenReady) ++ .setShowWhen(original.`when` > 0) ++ .setWhen(original.`when`) ++ .setSmallIcon(ExoPlayerR.drawable.exo_notification_small_icon) ++ val mediaSession = getMediaSessionCompat() ++ val compactActionIndexes = mutableListOf() ++ ++ getNotificationLargeIcon(original)?.let { builder.setLargeIcon(it) } ++ if (original.color != 0) { ++ builder.color = original.color ++ } ++ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { ++ builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE ++ } ++ ++ if (hasNotificationCapability(Capability.SKIP_TO_PREVIOUS)) { ++ val previousIntent = createServiceActionPendingIntent(ACTION_NOTIFICATION_PREVIOUS, 1) ++ if (previousIntent != null) { ++ builder.addAction( ++ ExoPlayerR.drawable.exo_notification_previous, ++ "Previous", ++ previousIntent ++ ) ++ compactActionIndexes += compactActionIndexes.size ++ } ++ } ++ ++ if (hasNotificationCapability(Capability.PLAY) || hasNotificationCapability(Capability.PAUSE)) { ++ val playPauseIntent = if (player.isPlaying) { ++ createServiceActionPendingIntent(ACTION_NOTIFICATION_PAUSE, 2) ++ } else { ++ createServiceActionPendingIntent(ACTION_NOTIFICATION_PLAY, 2) ++ } ++ if (playPauseIntent != null) { ++ builder.addAction( ++ if (player.isPlaying) ExoPlayerR.drawable.exo_notification_pause ++ else ExoPlayerR.drawable.exo_notification_play, ++ if (player.isPlaying) "Pause" else "Play", ++ playPauseIntent ++ ) ++ compactActionIndexes += compactActionIndexes.size ++ } ++ } ++ ++ if (hasNotificationCapability(Capability.SKIP_TO_NEXT)) { ++ val nextIntent = createServiceActionPendingIntent(ACTION_NOTIFICATION_NEXT, 3) ++ if (nextIntent != null) { ++ builder.addAction( ++ ExoPlayerR.drawable.exo_notification_next, ++ "Next", ++ nextIntent ++ ) ++ compactActionIndexes += compactActionIndexes.size ++ } ++ } ++ ++ val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle() ++ mediaSession?.sessionToken?.let { mediaStyle.setMediaSession(it) } ++ if (compactActionIndexes.isNotEmpty()) { ++ mediaStyle.setShowActionsInCompactView(*compactActionIndexes.toIntArray()) ++ } ++ ++ builder.setStyle(mediaStyle) ++ return builder.build() ++ } ++ ++ private fun createServiceActionPendingIntent(action: String, requestCode: Int): PendingIntent? { ++ val intent = Intent(this, MusicService::class.java).apply { ++ this.action = action ++ `package` = packageName ++ } ++ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { ++ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ++ } else { ++ PendingIntent.FLAG_UPDATE_CURRENT ++ } ++ return PendingIntent.getService(this, requestCode, intent, flags) ++ } ++ ++ private fun getNotificationChannelId(original: Notification): String { ++ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { ++ original.channelId ?: getString(TrackPlayerR.string.rntp_temporary_channel_id) ++ } else { ++ getString(TrackPlayerR.string.rntp_temporary_channel_id) ++ } ++ } ++ ++ private fun getNotificationLargeIcon(original: Notification): Bitmap? { ++ return when { ++ Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> { ++ original.getLargeIcon()?.loadDrawable(this)?.let { drawable -> ++ val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: 1 ++ val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: 1 ++ Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { bitmap -> ++ val canvas = android.graphics.Canvas(bitmap) ++ drawable.setBounds(0, 0, canvas.width, canvas.height) ++ drawable.draw(canvas) ++ } ++ } ++ } ++ else -> { ++ @Suppress("DEPRECATION") ++ original.largeIcon ++ } ++ } ++ } ++ + private fun isCompact(capability: Capability): Boolean { + return compactCapabilities.contains(capability) + } +@@ -573,7 +757,8 @@ class MusicService : HeadlessJsTaskService() { + is NotificationState.POSTED -> { + Timber.d("notification posted with id=%s, ongoing=%s", it.notificationId, it.ongoing) + notificationId = it.notificationId; +- notification = it.notification; ++ notification = buildTransportControlNotification(it.notification) ++ NotificationManagerCompat.from(this@MusicService).notify(notificationId!!, notification!!) + if (it.ongoing) { + if (player.playWhenReady) { + startForegroundIfNecessary() +@@ -850,5 +1035,10 @@ class MusicService : HeadlessJsTaskService() { + + const val DEFAULT_JUMP_INTERVAL = 15.0 + const val DEFAULT_STOP_FOREGROUND_GRACE_PERIOD = 5 ++ ++ private const val ACTION_NOTIFICATION_PLAY = "com.doublesymmetry.trackplayer.notification.PLAY" ++ private const val ACTION_NOTIFICATION_PAUSE = "com.doublesymmetry.trackplayer.notification.PAUSE" ++ private const val ACTION_NOTIFICATION_NEXT = "com.doublesymmetry.trackplayer.notification.NEXT" ++ private const val ACTION_NOTIFICATION_PREVIOUS = "com.doublesymmetry.trackplayer.notification.PREVIOUS" + } + } diff --git a/MusicFree/release/music_server_latest.js b/MusicFree/release/music_server_latest.js new file mode 100644 index 0000000..ce19d13 --- /dev/null +++ b/MusicFree/release/music_server_latest.js @@ -0,0 +1,1413 @@ +"use strict"; + +var testHttpClient = null; +var testConfig = null; +var defaultClient = null; +var defaultClientSignature = null; + +var SEARCH_PAGE_SIZE = 20; +var LIST_PAGE_SIZE = 60; +var PLUGIN_SRC_PLACEHOLDER = ["__MUSIC", "_SERVER", "_PLUGIN", "_SRC_URL__"].join(""); +var DEFAULT_PLUGIN_SRC_URL = "__MUSIC_SERVER_PLUGIN_SRC_URL__"; + +function hasOwn(obj, key) { + return Object.prototype.hasOwnProperty.call(obj, key); +} + +function toTrimmedString(value) { + if (value === null || value === undefined) { + return ""; + } + return String(value).trim(); +} + +function normalizeBaseUrl(url) { + var value = toTrimmedString(url); + if (!value) { + return ""; + } + return value.replace(/\/+$/, ""); +} + +function isPluginSrcPlaceholder(value) { + return toTrimmedString(value) === PLUGIN_SRC_PLACEHOLDER; +} + +function normalizeAccessToken(value) { + var token = toTrimmedString(value); + if (!token) { + return ""; + } + return token.replace(/^Bearer\s+/i, "").trim(); +} + +function createFallbackClientId(accessToken) { + var token = normalizeAccessToken(accessToken); + var hash = 0; + var i = 0; + var unsigned = 0; + + if (!token) { + return ""; + } + + for (i = 0; i < token.length; i += 1) { + hash = ((hash << 5) - hash + token.charCodeAt(i)) | 0; + } + unsigned = hash >>> 0; + return "mf-fallback-" + unsigned.toString(16); +} + +function getRuntimeEnv() { + if (typeof env !== "undefined" && env) { + return env; + } + return null; +} + +function readConfigValue(key) { + var runtimeEnv = null; + var variablesFromGetter = null; + var variables = null; + var i = 0; + var item = null; + + try { + if (testConfig && hasOwn(testConfig, key)) { + return testConfig[key]; + } + + runtimeEnv = getRuntimeEnv(); + if (!runtimeEnv || typeof runtimeEnv !== "object") { + return undefined; + } + + if (typeof runtimeEnv.getUserVariables === "function") { + try { + variablesFromGetter = runtimeEnv.getUserVariables(); + if ( + variablesFromGetter && + typeof variablesFromGetter === "object" && + hasOwn(variablesFromGetter, key) + ) { + return variablesFromGetter[key]; + } + if (Array.isArray(variablesFromGetter)) { + for (i = 0; i < variablesFromGetter.length; i += 1) { + item = variablesFromGetter[i]; + if ( + item && + typeof item === "object" && + toTrimmedString(item.key) === key && + hasOwn(item, "value") + ) { + return item.value; + } + } + } + } catch (_error) { + variablesFromGetter = null; + } + } + + try { + variables = runtimeEnv.userVariables; + } catch (_error2) { + variables = null; + } + if (variables && typeof variables === "object" && hasOwn(variables, key)) { + return variables[key]; + } + if (Array.isArray(variables)) { + for (i = 0; i < variables.length; i += 1) { + item = variables[i]; + if ( + item && + typeof item === "object" && + toTrimmedString(item.key) === key && + hasOwn(item, "value") + ) { + return item.value; + } + } + } + } catch (_error3) { + return undefined; + } + + return undefined; +} + +function readFirstConfigValue(keys) { + var i = 0; + var value = undefined; + + for (i = 0; i < keys.length; i += 1) { + value = readConfigValue(keys[i]); + if (value === undefined || value === null) { + continue; + } + if (toTrimmedString(value)) { + return value; + } + } + + return undefined; +} + +function readPluginSrcUrl() { + var explicitSrcUrl = readFirstConfigValue(["srcUrl"]); + if (explicitSrcUrl) { + return explicitSrcUrl; + } + if ( + typeof module !== "undefined" && + module && + module.exports && + typeof module.exports.srcUrl === "string" + ) { + return module.exports.srcUrl; + } + return DEFAULT_PLUGIN_SRC_URL; +} + +function normalizeConfiguredBaseUrl(value) { + var raw = toTrimmedString(value); + var withScheme = ""; + var parsed = null; + var pathname = ""; + var fallbackRaw = ""; + + if (!raw || isPluginSrcPlaceholder(raw)) { + return ""; + } + + if (/^[a-z][a-z\d+\-.]*:\/\//i.test(raw)) { + withScheme = raw; + } else { + withScheme = "http://" + raw; + } + + try { + parsed = new URL(withScheme); + parsed.hash = ""; + pathname = parsed.pathname || ""; + if (/\/plugins\/[^/]+\.js$/i.test(pathname)) { + parsed.pathname = pathname.replace(/\/plugins\/[^/]+\.js$/i, "") || "/"; + parsed.search = ""; + } + return normalizeBaseUrl(parsed.toString()); + } catch (_error) { + fallbackRaw = withScheme.replace(/\/plugins\/[^/?#]+\.js(?:[?#].*)?$/i, ""); + return normalizeBaseUrl(fallbackRaw); + } +} + +function resolveBaseUrl() { + var fromSrcUrl = normalizeConfiguredBaseUrl(readPluginSrcUrl()); + var configured = ""; + + if (fromSrcUrl) { + return fromSrcUrl; + } + + configured = normalizeConfiguredBaseUrl( + readFirstConfigValue(["baseUrl", "baseURL", "serverUrl", "serverURL"]), + ); + if (configured) { + return configured; + } + return ""; +} + +function readRuntimeValue(key) { + var runtimeEnv = null; + var runtimeValues = null; + + try { + if (testConfig && hasOwn(testConfig, key)) { + return testConfig[key]; + } + + runtimeEnv = getRuntimeEnv(); + if (!runtimeEnv) { + return undefined; + } + + if (typeof runtimeEnv.getRuntimeValue === "function") { + try { + return runtimeEnv.getRuntimeValue(key); + } catch (_error) { + return undefined; + } + } + + try { + runtimeValues = runtimeEnv.runtimeValues; + } catch (_error2) { + runtimeValues = null; + } + if (runtimeValues && typeof runtimeValues === "object") { + return runtimeValues[key]; + } + } catch (_error3) { + return undefined; + } + + return undefined; +} + +function buildConfigSnapshot() { + var accessToken = normalizeAccessToken( + readFirstConfigValue([ + "accessToken", + "token", + "authToken", + "authorization", + "Authorization", + ]), + ); + + return { + baseUrl: resolveBaseUrl(), + accessToken: accessToken, + runtimeClientId: String( + readConfigValue("runtimeClientId") || + readRuntimeValue("runtimeClientId") || + createFallbackClientId(accessToken) || + "", + ), + runtimeClientLabel: String( + readConfigValue("runtimeClientLabel") || + readRuntimeValue("runtimeClientLabel") || + "", + ), + }; +} + +function getConfigSignature(snapshot) { + return [ + snapshot.baseUrl, + snapshot.accessToken, + snapshot.runtimeClientId, + snapshot.runtimeClientLabel, + ].join("|"); +} + +function buildClientHeaders(snapshot) { + var currentSnapshot = snapshot || buildConfigSnapshot(); + var headers = {}; + + if (currentSnapshot.accessToken) { + headers.Authorization = "Bearer " + currentSnapshot.accessToken; + } + if (currentSnapshot.runtimeClientId) { + headers["X-Music-Client-Id"] = currentSnapshot.runtimeClientId; + } + if (currentSnapshot.runtimeClientLabel) { + headers["X-Music-Client-Label"] = currentSnapshot.runtimeClientLabel; + } + return headers; +} + +function appendQueryParams(url, params) { + var pairs = []; + var key = ""; + var value = null; + var query = ""; + + if (!params || typeof params !== "object") { + return url; + } + + for (key in params) { + if (!hasOwn(params, key)) { + continue; + } + value = params[key]; + if (value === undefined || value === null) { + continue; + } + pairs.push( + encodeURIComponent(key) + "=" + encodeURIComponent(String(value)), + ); + } + + if (pairs.length === 0) { + return url; + } + + query = pairs.join("&"); + if (!query) { + return url; + } + + if (url.indexOf("?") !== -1) { + return url + "&" + query; + } + return url + "?" + query; +} + +function copyObject(source) { + var target = {}; + var key = ""; + + if (!source || typeof source !== "object") { + return target; + } + + for (key in source) { + if (hasOwn(source, key)) { + target[key] = source[key]; + } + } + return target; +} + +function createFetchClient(snapshot) { + var baseHeaders = buildClientHeaders(snapshot); + + if (typeof fetch !== "function") { + throw new Error("http_client_unavailable"); + } + + async function request(method, path, options) { + var opts = options || {}; + var url = joinWithBaseUrl(path); + var headers = copyObject(baseHeaders); + var body = undefined; + var response = null; + var text = ""; + var payload = {}; + + url = appendQueryParams(url, opts.params); + if (opts.data !== undefined) { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(opts.data); + } + + response = await fetch(url, { + method: method, + headers: headers, + body: body, + }); + text = await response.text(); + if (text) { + try { + payload = JSON.parse(text); + } catch (_error) { + payload = text; + } + } + + if (!response.ok) { + throw new Error("http_" + response.status); + } + + return { data: payload }; + } + + return { + get: function get(path, config) { + return request("GET", path, { params: config && config.params }); + }, + post: function post(path, data) { + return request("POST", path, { data: data }); + }, + }; +} + +function createDefaultClient(snapshot) { + var currentSnapshot = snapshot || buildConfigSnapshot(); + var headers = buildClientHeaders(currentSnapshot); + var axios = null; + + try { + axios = require("axios"); + if (axios && typeof axios.create === "function") { + return axios.create({ + baseURL: currentSnapshot.baseUrl || undefined, + headers: headers, + }); + } + } catch (_error) { + axios = null; + } + + return createFetchClient(currentSnapshot); +} + +function getClient() { + var snapshot = null; + var signature = ""; + + if (testHttpClient) { + return testHttpClient; + } + + snapshot = buildConfigSnapshot(); + signature = getConfigSignature(snapshot); + + if (!defaultClient || defaultClientSignature !== signature) { + defaultClient = createDefaultClient(snapshot); + defaultClientSignature = signature; + } + + return defaultClient; +} + +function __setHttpClientForTests(client) { + testHttpClient = client || null; +} + +function __setConfigForTests(config) { + testConfig = config || null; + defaultClient = null; + defaultClientSignature = null; +} + +function __clearTestState() { + testHttpClient = null; + testConfig = null; + defaultClient = null; + defaultClientSignature = null; +} + +function toPositivePage(page) { + var value = Number(page); + if (!isFinite(value) || value <= 0) { + return 1; + } + return Math.floor(value); +} + +function toNonNegativeInt(value) { + var parsed = Number(value); + if (!isFinite(parsed) || parsed < 0) { + return null; + } + return Math.floor(parsed); +} + +function normalizeDuration(value) { + var parsed = Number(value); + if (!isFinite(parsed) || parsed <= 0) { + return 0; + } + if (parsed > 1000) { + return Math.round(parsed / 1000); + } + return Math.round(parsed); +} + +function pickArrayField(obj, fieldNames) { + var i = 0; + var fieldName = ""; + + if (!obj || typeof obj !== "object") { + return null; + } + + for (i = 0; i < fieldNames.length; i += 1) { + fieldName = fieldNames[i]; + if (Array.isArray(obj[fieldName])) { + return obj[fieldName]; + } + } + return null; +} + +function extractList(payload) { + var list = null; + + if (Array.isArray(payload)) { + return payload; + } + if (!payload || typeof payload !== "object") { + return []; + } + + list = pickArrayField(payload, [ + "data", + "musicList", + "items", + "list", + "rows", + "songs", + "tracks", + "toplists", + ]); + if (list) { + return list; + } + + if (payload.data && typeof payload.data === "object") { + return extractList(payload.data); + } + + return []; +} + +function joinArtists(value) { + var parts = []; + var i = 0; + var item = null; + var part = ""; + + if (Array.isArray(value)) { + for (i = 0; i < value.length; i += 1) { + item = value[i]; + if (typeof item === "string") { + part = toTrimmedString(item); + } else if (item && typeof item === "object") { + part = toTrimmedString(item.name || item.artist || item.title); + } else { + part = ""; + } + if (part) { + parts.push(part); + } + } + return parts.join(", "); + } + + if (value && typeof value === "object") { + return toTrimmedString(value.name || value.artist || value.title); + } + + return toTrimmedString(value); +} + +function parsePublicId(publicId) { + var raw = toTrimmedString(publicId); + var segments = []; + + if (!raw) { + return ""; + } + + segments = raw.split(":"); + if (!segments.length) { + return raw; + } + return segments[segments.length - 1]; +} + +function parsePublicItemRef(publicId) { + var raw = toTrimmedString(publicId); + var segments = []; + + if (!raw) { + return { kind: "", id: "" }; + } + + segments = raw.split(":"); + if (segments.length >= 3 && segments[0] === "catalogsync") { + return { + kind: toTrimmedString(segments[1]), + id: toTrimmedString(segments.slice(2).join(":")), + }; + } + + return { + kind: "", + id: parsePublicId(raw), + }; +} + +function mapMusicItem(item) { + var albumRaw = {}; + var id = ""; + var title = ""; + var album = ""; + var artwork = ""; + var artist = ""; + var duration = 0; + + if (!item || typeof item !== "object") { + return null; + } + + if (item.album && typeof item.album === "object") { + albumRaw = item.album; + } + + id = toTrimmedString(item.id || item.songId || item.song_id || item.musicId); + title = toTrimmedString(item.title || item.name || item.songName); + if (!id && !title) { + return null; + } + + if (typeof item.album === "string") { + album = toTrimmedString(item.album); + } else { + album = toTrimmedString(albumRaw.name || albumRaw.title); + } + artwork = toTrimmedString( + item.artwork || + item.coverImg || + item.cover || + item.picUrl || + albumRaw.artwork || + albumRaw.coverImg || + albumRaw.cover || + albumRaw.picUrl, + ); + artist = joinArtists(item.artist || item.artists || item.ar || item.singers); + duration = normalizeDuration( + item.duration || + item.durationSec || + item.duration_sec || + item.length || + item.dt, + ); + + return { + id: id || title, + title: title, + artist: artist, + album: album, + artwork: artwork, + duration: duration, + }; +} + +function mapSheetItem(item) { + var id = ""; + var result = {}; + var title = ""; + var artist = ""; + var description = ""; + var coverImg = ""; + var worksNumValue = null; + var playableWorksNumRaw = null; + var playableWorksNumValue = null; + var playCountValue = null; + + if (!item || typeof item !== "object") { + return null; + } + + id = toTrimmedString(item.id || item.sheetId || item.playlistId); + if (!id) { + return null; + } + + result.id = id; + + title = toTrimmedString(item.title || item.name); + artist = toTrimmedString( + item.artist || + (item.creator && item.creator.nickname) || + (item.owner && item.owner.nickname) || + item.updateFrequency, + ); + description = toTrimmedString(item.description || item.desc); + coverImg = toTrimmedString( + item.coverImg || + item.cover || + item.artwork || + item.picUrl || + item.coverImgUrl, + ); + worksNumValue = toNonNegativeInt(item.worksNum || item.trackCount || item.musicCount); + if (item.playableWorksNum !== null && item.playableWorksNum !== undefined) { + playableWorksNumRaw = item.playableWorksNum; + } else { + playableWorksNumRaw = item.playableSongCount; + } + playableWorksNumValue = toNonNegativeInt(playableWorksNumRaw); + playCountValue = toNonNegativeInt(item.play_count); + + if (title) { + result.title = title; + } + if (artist) { + result.artist = artist; + } + if (description) { + result.description = description; + } + if (coverImg) { + result.coverImg = coverImg; + } + if (worksNumValue !== null) { + result.worksNum = worksNumValue; + } + if (playableWorksNumValue !== null) { + result.playableWorksNum = playableWorksNumValue; + } + if (playCountValue !== null) { + result.play_count = playCountValue; + } + + return result; +} + +function mapArtistItem(item) { + var id = ""; + var name = ""; + var worksNum = null; + + if (!item || typeof item !== "object") { + return null; + } + + id = toTrimmedString(item.id || item.artistId || item.artist_id); + name = toTrimmedString(item.name || item.title); + if (!id && !name) { + return null; + } + + worksNum = toNonNegativeInt(item.worksNum || item.musicCount || item.playableSongCount); + + return { + id: id || name, + name: name || id, + avatar: toTrimmedString(item.avatar || item.avatarUrl || item.coverImg || item.artwork), + description: toTrimmedString(item.description || item.desc), + worksNum: worksNum !== null ? worksNum : 0, + platform: toTrimmedString(item.platform || "catalogsync"), + supportedArtistTabs: Array.isArray(item.supportedArtistTabs) + ? item.supportedArtistTabs + : ["music"], + }; +} + +function mapTagItem(item) { + var id = ""; + var title = ""; + + if (!item || typeof item !== "object") { + return null; + } + + id = toTrimmedString(item.id || item.value || item.key || item.name); + title = toTrimmedString(item.title || item.name || item.label || id); + if (!id && !title) { + return null; + } + + return { + id: id || title, + title: title || id, + }; +} + +function mapTopListGroup(group, fallbackTitle) { + var rawItems = []; + var items = []; + var i = 0; + var mapped = null; + var title = ""; + + if (!group || typeof group !== "object") { + return null; + } + + rawItems = pickArrayField(group, ["data", "items", "list", "toplists"]) || []; + for (i = 0; i < rawItems.length; i += 1) { + mapped = mapSheetItem(rawItems[i]); + if (mapped) { + items.push(mapped); + } + } + if (!items.length) { + return null; + } + + title = toTrimmedString(group.title || group.name || fallbackTitle); + return { + title: title, + data: items, + }; +} + +function normalizeTopListGroups(payload) { + var source = payload && typeof payload === "object" ? payload : {}; + var baseList = null; + var first = null; + var firstIsGroup = false; + var groups = []; + var i = 0; + var group = null; + var mapped = null; + var data = []; + var row = null; + + if (Array.isArray(payload)) { + baseList = payload; + } else { + baseList = pickArrayField(source, ["data", "groups", "list"]); + } + + if (!Array.isArray(baseList) || !baseList.length) { + return []; + } + + first = baseList[0]; + firstIsGroup = !!( + first && + typeof first === "object" && + pickArrayField(first, ["data", "items", "list", "toplists"]) + ); + + if (firstIsGroup) { + for (i = 0; i < baseList.length; i += 1) { + mapped = mapTopListGroup(baseList[i], ""); + if (mapped) { + groups.push(mapped); + } + } + return groups; + } + + for (i = 0; i < baseList.length; i += 1) { + row = mapSheetItem(baseList[i]); + if (row) { + data.push(row); + } + } + group = { + title: toTrimmedString(source.title || source.name), + data: data, + }; + return [group]; +} + +function resolveIsEnd(payload, page, pageSize, listLength) { + var total = null; + + if (payload && typeof payload === "object") { + if (typeof payload.isEnd === "boolean") { + return payload.isEnd; + } + if (typeof payload.is_end === "boolean") { + return payload.is_end; + } + if (typeof payload.more === "boolean") { + return !payload.more; + } + + total = Number( + payload.total || + payload.count || + payload.totalCount || + payload.total_count, + ); + if (isFinite(total) && total >= 0) { + return page * pageSize >= total; + } + } + + return listLength < pageSize; +} + +async function requestGet(path, params) { + var client = getClient(); + var response = await client.get(path, params ? { params: params } : undefined); + + if ( + response && + typeof response === "object" && + hasOwn(response, "data") + ) { + return response.data || {}; + } + return response || {}; +} + +async function requestPost(path, data) { + var client = getClient(); + var response = await client.post(path, data); + + if ( + response && + typeof response === "object" && + hasOwn(response, "data") + ) { + return response.data || {}; + } + return response || {}; +} + +async function getPluginStatus() { + try { + var payload = await requestGet("/auth/v1/token-status"); + if (payload && typeof payload === "object") { + return payload; + } + return null; + } catch (_error) { + return null; + } +} + +function isAbsoluteUrl(url) { + return /^[a-z][a-z\d+\-.]*:\/\//i.test(toTrimmedString(url)); +} + +function getUrlProtocol(url) { + var matched = toTrimmedString(url).match(/^([a-z][a-z\d+\-.]*:)/i); + return matched ? matched[1] : ""; +} + +function joinWithBaseUrl(path) { + var rawPath = toTrimmedString(path); + var baseUrl = ""; + var protocol = ""; + + if (!rawPath) { + return ""; + } + if (isAbsoluteUrl(rawPath)) { + return rawPath; + } + + baseUrl = resolveBaseUrl(); + if (rawPath.indexOf("//") === 0) { + protocol = getUrlProtocol(baseUrl); + if (!protocol) { + return rawPath; + } + return protocol + rawPath; + } + + if (!baseUrl) { + return rawPath; + } + if (rawPath.charAt(0) === "/") { + return baseUrl + rawPath; + } + return baseUrl + "/" + rawPath; +} + +function normalizeTagValue(tag) { + if (tag && typeof tag === "object") { + if (hasOwn(tag, "id")) { + return toTrimmedString(tag.id); + } + return toTrimmedString(tag.value || tag.title || tag.name); + } + return toTrimmedString(tag); +} + +async function getMediaSource(musicItem, quality) { + var payload = null; + var stream = null; + var streamUrl = ""; + + try { + payload = await requestPost("/mf/v1/media/resolve", { + song_id: musicItem && musicItem.id, + quality: quality, + }); + if (payload && payload.stream && typeof payload.stream === "object") { + stream = payload.stream; + } + streamUrl = joinWithBaseUrl(stream && stream.url); + if (!streamUrl) { + return null; + } + + return { + url: streamUrl, + headers: + stream && stream.headers && typeof stream.headers === "object" + ? stream.headers + : {}, + quality: toTrimmedString( + payload && + payload.selected_source && + payload.selected_source.quality, + ) || quality, + }; + } catch (_error) { + return null; + } +} + +async function search(query, page, type) { + var normalizedPage = 1; + var endpoint = ""; + var payload = null; + var rawList = []; + var data = []; + var i = 0; + var mapped = null; + var mapper = null; + + if (type === "music") { + endpoint = "/mf/v1/search/songs"; + mapper = mapMusicItem; + } else if (type === "artist") { + endpoint = "/mf/v1/search/artists"; + mapper = mapArtistItem; + } else if (type === "sheet") { + endpoint = "/mf/v1/search/sheets"; + mapper = mapSheetItem; + } else { + return { + isEnd: true, + data: [], + }; + } + + try { + normalizedPage = toPositivePage(page); + payload = await requestGet(endpoint, { + q: toTrimmedString(query), + page: normalizedPage, + page_size: SEARCH_PAGE_SIZE, + }); + rawList = extractList(payload); + for (i = 0; i < rawList.length; i += 1) { + mapped = mapper(rawList[i]); + if (mapped) { + data.push(mapped); + } + } + + return { + isEnd: resolveIsEnd(payload, normalizedPage, SEARCH_PAGE_SIZE, rawList.length), + data: data, + }; + } catch (_error) { + return { + isEnd: true, + data: [], + }; + } +} + +async function getRecommendSheetTags() { + var payload = null; + var source = {}; + var pinnedRaw = []; + var groupsRaw = []; + var pinned = []; + var groups = []; + var i = 0; + var j = 0; + var mapped = null; + var group = null; + var groupData = []; + + try { + payload = await requestGet("/mf/v1/recommend/tags"); + source = payload && typeof payload === "object" ? payload : {}; + + if (Array.isArray(source.pinned)) { + pinnedRaw = source.pinned; + } else if (Array.isArray(source.hot)) { + pinnedRaw = source.hot; + } + for (i = 0; i < pinnedRaw.length; i += 1) { + mapped = mapTagItem(pinnedRaw[i]); + if (mapped) { + pinned.push(mapped); + } + } + + if (Array.isArray(source.data)) { + groupsRaw = source.data; + } else if (Array.isArray(source.groups)) { + groupsRaw = source.groups; + } + for (i = 0; i < groupsRaw.length; i += 1) { + group = groupsRaw[i]; + groupData = []; + if (group && Array.isArray(group.data)) { + for (j = 0; j < group.data.length; j += 1) { + mapped = mapTagItem(group.data[j]); + if (mapped) { + groupData.push(mapped); + } + } + } + groups.push({ + title: toTrimmedString(group && (group.title || group.name)), + data: groupData, + }); + } + + return { + pinned: pinned, + data: groups, + }; + } catch (_error) { + return { + pinned: [], + data: [], + }; + } +} + +async function getRecommendSheetsByTag(tag, page) { + var normalizedPage = toPositivePage(page); + var payload = null; + var rawList = []; + var data = []; + var i = 0; + var mapped = null; + + try { + payload = await requestGet("/mf/v1/recommend/sheets", { + tag: normalizeTagValue(tag) || "all", + page: normalizedPage, + page_size: LIST_PAGE_SIZE, + }); + rawList = extractList(payload); + for (i = 0; i < rawList.length; i += 1) { + mapped = mapSheetItem(rawList[i]); + if (mapped) { + data.push(mapped); + } + } + + return { + isEnd: resolveIsEnd(payload, normalizedPage, LIST_PAGE_SIZE, rawList.length), + data: data, + }; + } catch (_error) { + return { + isEnd: true, + data: [], + }; + } +} + +async function getMusicSheetInfo(sheetItem, page) { + var normalizedPage = toPositivePage(page); + var sourceItem = sheetItem && typeof sheetItem === "object" ? sheetItem : {}; + var itemRef = parsePublicItemRef(sourceItem.id); + var mappedSheetItem = mapSheetItem(sourceItem); + var endpointBase = ""; + var detail = null; + var tracksPayload = null; + var rawList = []; + var musicList = []; + var i = 0; + var mapped = null; + var result = null; + + if (!itemRef.id) { + return { + isEnd: true, + sheetItem: normalizedPage === 1 ? mappedSheetItem || sourceItem : sourceItem, + musicList: [], + }; + } + + endpointBase = + itemRef.kind === "toplist" + ? "/mf/v1/toplists/" + itemRef.id + : "/mf/v1/playlists/" + itemRef.id; + + try { + if (normalizedPage === 1) { + try { + detail = await requestGet(endpointBase); + mappedSheetItem = mapSheetItem(detail) || mappedSheetItem; + } catch (_error) { + detail = null; + } + } + + tracksPayload = await requestGet(endpointBase + "/tracks", { + page: normalizedPage, + page_size: LIST_PAGE_SIZE, + }); + rawList = extractList(tracksPayload); + for (i = 0; i < rawList.length; i += 1) { + mapped = mapMusicItem(rawList[i]); + if (mapped) { + musicList.push(mapped); + } + } + + result = { + isEnd: resolveIsEnd( + tracksPayload, + normalizedPage, + LIST_PAGE_SIZE, + rawList.length, + ), + musicList: musicList, + }; + if (normalizedPage === 1) { + result.sheetItem = + mappedSheetItem || + mapSheetItem(sourceItem) || { + id: toTrimmedString(sourceItem.id || itemRef.id), + }; + } + return result; + } catch (_error2) { + result = { + isEnd: true, + musicList: [], + }; + if (normalizedPage === 1) { + result.sheetItem = + mappedSheetItem || + mapSheetItem(sourceItem) || { + id: toTrimmedString(sourceItem.id || itemRef.id), + }; + } + return result; + } +} + +async function getArtistWorks(artistItem, page, type) { + var normalizedPage = toPositivePage(page); + var itemRef = parsePublicItemRef(artistItem && artistItem.id); + var payload = null; + var rawList = []; + var data = []; + var i = 0; + var mapped = null; + + if (type !== "music" || itemRef.kind !== "artist" || !itemRef.id) { + return { isEnd: true, data: [] }; + } + + try { + payload = await requestGet("/mf/v1/artists/" + itemRef.id + "/tracks", { + page: normalizedPage, + page_size: LIST_PAGE_SIZE, + }); + rawList = extractList(payload); + for (i = 0; i < rawList.length; i += 1) { + mapped = mapMusicItem(rawList[i]); + if (mapped) { + data.push(mapped); + } + } + return { + isEnd: resolveIsEnd(payload, normalizedPage, LIST_PAGE_SIZE, rawList.length), + data: data, + }; + } catch (_error) { + return { isEnd: true, data: [] }; + } +} + +async function getTopLists() { + try { + var payload = await requestGet("/mf/v1/toplists"); + return normalizeTopListGroups(payload); + } catch (_error) { + return []; + } +} + +async function getTopListDetail(topListItem, page) { + var normalizedPage = toPositivePage(page); + var sourceItem = topListItem && typeof topListItem === "object" ? topListItem : {}; + var toplistId = parsePublicId(sourceItem.id); + var mappedTopListItem = mapSheetItem(sourceItem); + var detail = null; + var tracksPayload = null; + var rawList = []; + var musicList = []; + var i = 0; + var mapped = null; + var result = null; + + if (!toplistId) { + return { + isEnd: true, + topListItem: normalizedPage === 1 ? mappedTopListItem || sourceItem : sourceItem, + musicList: [], + }; + } + + try { + if (normalizedPage === 1) { + try { + detail = await requestGet("/mf/v1/toplists/" + toplistId); + mappedTopListItem = mapSheetItem(detail) || mappedTopListItem; + } catch (_error) { + detail = null; + } + } + + tracksPayload = await requestGet("/mf/v1/toplists/" + toplistId + "/tracks", { + page: normalizedPage, + page_size: LIST_PAGE_SIZE, + }); + rawList = extractList(tracksPayload); + for (i = 0; i < rawList.length; i += 1) { + mapped = mapMusicItem(rawList[i]); + if (mapped) { + musicList.push(mapped); + } + } + + result = { + isEnd: resolveIsEnd( + tracksPayload, + normalizedPage, + LIST_PAGE_SIZE, + rawList.length, + ), + topListItem: + normalizedPage === 1 + ? mappedTopListItem || + mapSheetItem(sourceItem) || { + id: toTrimmedString(sourceItem.id || toplistId), + } + : sourceItem || { + id: toTrimmedString(sourceItem.id || toplistId), + }, + musicList: musicList, + }; + return result; + } catch (_error2) { + result = { + isEnd: true, + musicList: [], + }; + if (normalizedPage === 1) { + result.topListItem = + mappedTopListItem || + mapSheetItem(sourceItem) || { + id: toTrimmedString(sourceItem.id || toplistId), + }; + } + return result; + } +} + +module.exports = { + platform: "Music_Server", + version: "17010.0.8", + author: "Codex", + srcUrl: "__MUSIC_SERVER_PLUGIN_SRC_URL__", + cacheControl: "no-cache", + primaryKey: ["id"], + description: "Music_Server private plugin for playlists, toplists, search, playback, and token status.", + supportedSearchType: ["music", "artist", "sheet"], + userVariables: [ + { key: "baseUrl", name: "Base URL" }, + { key: "accessToken", name: "Access Token" }, + ], + normalizeBaseUrl: normalizeBaseUrl, + readConfigValue: readConfigValue, + readRuntimeValue: readRuntimeValue, + createDefaultClient: createDefaultClient, + getClient: getClient, + search: search, + getArtistWorks: getArtistWorks, + getMediaSource: getMediaSource, + getRecommendSheetTags: getRecommendSheetTags, + getRecommendSheetsByTag: getRecommendSheetsByTag, + getMusicSheetInfo: getMusicSheetInfo, + getTopLists: getTopLists, + getTopListDetail: getTopListDetail, + getPluginStatus: getPluginStatus, + __setHttpClientForTests: __setHttpClientForTests, + __setConfigForTests: __setConfigForTests, + __clearTestState: __clearTestState, +}; diff --git a/MusicFree/release/version.json b/MusicFree/release/version.json new file mode 100644 index 0000000..013d345 --- /dev/null +++ b/MusicFree/release/version.json @@ -0,0 +1,13 @@ +{ + "version": "0.6.7", + "changeLog": [ + "修复推荐歌单、搜索结果歌单、榜单详情、专辑详情在“添加到歌单”时只保存已懒加载歌曲的问题,现会保存完整歌曲列表", + "增强 Source error 的应用内诊断日志,追加播放状态、进度和轨道上下文", + "修复锁屏、通知栏和悬浮控制中的上一曲在后台 React 未就绪时失效的问题", + "应用内日志新增 RemotePrevious、PlaybackError 决策与切歌目标关键 trace", + "继续保留锁屏卡住、fake-tail 过渡和 stall 的异常通知与日志" + ], + "download": [ + "https://musicserver.daxo.top/app/MusicFree_latest_release_universal.apk" + ] +} diff --git a/MusicFree/src/components/base/playAllBar.tsx b/MusicFree/src/components/base/playAllBar.tsx index e0a797c..6fadce7 100644 --- a/MusicFree/src/components/base/playAllBar.tsx +++ b/MusicFree/src/components/base/playAllBar.tsx @@ -18,9 +18,12 @@ interface IProps { musicList: IMusic.IMusicItem[] | null; canStar?: boolean; musicSheet?: IMusic.IMusicSheetItem | null; + resolveMusicListBeforeAdd?: () => Promise; + addToSheetCount?: number; } export default function (props: IProps) { - const { musicList, canStar, musicSheet } = props; + const { musicList, canStar, musicSheet, resolveMusicListBeforeAdd, addToSheetCount } = + props; const sheetName = musicSheet?.title; const sheetId = musicSheet?.id; @@ -86,6 +89,8 @@ export default function (props: IProps) { showPanel("AddToMusicSheet", { musicItem: musicList ?? [], newSheetDefaultName: sheetName, + resolveMusicItem: resolveMusicListBeforeAdd, + displayCount: addToSheetCount, }); }} /> diff --git a/MusicFree/src/components/musicBar/musicInfo.test.ts b/MusicFree/src/components/musicBar/musicInfo.test.ts new file mode 100644 index 0000000..6083436 --- /dev/null +++ b/MusicFree/src/components/musicBar/musicInfo.test.ts @@ -0,0 +1,93 @@ +import { skipMusicItem } from "./musicInfo"; +import TrackPlayer from "@/core/trackPlayer"; + +jest.mock("react-native-gesture-handler", () => ({ + Gesture: { + Tap: () => ({ + onStart() { + return this; + }, + runOnJS() { + return this; + }, + }), + Pan: () => ({ + minPointers() { + return this; + }, + maxPointers() { + return this; + }, + onUpdate() { + return this; + }, + onEnd() { + return this; + }, + }), + Race: jest.fn(), + }, + GestureDetector: ({ children }: { children: React.ReactNode }) => children, +})); + +jest.mock("react-native-reanimated", () => ({ + __esModule: true, + default: { + View: "AnimatedView", + }, + Easing: { + out: jest.fn((value: unknown) => value), + exp: jest.fn(), + }, + runOnJS: (fn: (...args: any[]) => any) => fn, + useAnimatedStyle: jest.fn(() => ({})), + useSharedValue: jest.fn((value: number) => ({ value })), + withTiming: jest.fn((value: number) => value), +})); + +jest.mock("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ + left: 0, + }), +})); + +jest.mock("@/hooks/useColors", () => () => ({ + musicBarText: "#fff", +})); + +jest.mock("@/core/router", () => ({ + ROUTE_PATH: { + MUSIC_DETAIL: "MUSIC_DETAIL", + }, + useNavigate: () => jest.fn(), +})); + +jest.mock("@/utils/rpx", () => (value: number) => value); + +jest.mock("@/core/trackPlayer", () => ({ + __esModule: true, + default: { + skipToNext: jest.fn(), + skipToPrevious: jest.fn(), + }, +})); + +describe("musicInfo skipMusicItem", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("calls skipToPrevious when swiping right", () => { + skipMusicItem(1); + + expect(TrackPlayer.skipToPrevious).toHaveBeenCalledTimes(1); + expect(TrackPlayer.skipToNext).not.toHaveBeenCalled(); + }); + + it("calls skipToNext when swiping left", () => { + skipMusicItem(-1); + + expect(TrackPlayer.skipToNext).toHaveBeenCalledTimes(1); + expect(TrackPlayer.skipToPrevious).not.toHaveBeenCalled(); + }); +}); diff --git a/MusicFree/src/components/musicBar/musicInfo.tsx b/MusicFree/src/components/musicBar/musicInfo.tsx index 9e81db9..14d17de 100644 --- a/MusicFree/src/components/musicBar/musicInfo.tsx +++ b/MusicFree/src/components/musicBar/musicInfo.tsx @@ -107,7 +107,7 @@ interface IMusicInfoProps { paddingLeft?: number; } -function skipMusicItem(direction: number) { +export function skipMusicItem(direction: number) { if (direction === -1) { TrackPlayer.skipToNext(); } else if (direction === 1) { diff --git a/MusicFree/src/components/musicSheetPage/components/header.tsx b/MusicFree/src/components/musicSheetPage/components/header.tsx index 8c1b03c..0fabe17 100644 --- a/MusicFree/src/components/musicSheetPage/components/header.tsx +++ b/MusicFree/src/components/musicSheetPage/components/header.tsx @@ -12,9 +12,10 @@ interface IHeaderProps { musicSheet: IMusic.IMusicSheetItem | null; musicList: IMusic.IMusicItem[] | null; canStar?: boolean; + resolveMusicListBeforeAdd?: () => Promise; } export default function Header(props: IHeaderProps) { - const { musicSheet, musicList, canStar } = props; + const { musicSheet, musicList, canStar, resolveMusicListBeforeAdd } = props; const colors = useColors(); const [maxLines, setMaxLines] = useState(6); @@ -74,6 +75,8 @@ export default function Header(props: IHeaderProps) { canStar={canStar} musicList={musicList} musicSheet={musicSheet} + resolveMusicListBeforeAdd={resolveMusicListBeforeAdd} + addToSheetCount={musicSheet?.worksNum ?? musicList?.length} /> ); diff --git a/MusicFree/src/components/musicSheetPage/components/sheetMusicList.tsx b/MusicFree/src/components/musicSheetPage/components/sheetMusicList.tsx index d2dbbf6..9559907 100644 --- a/MusicFree/src/components/musicSheetPage/components/sheetMusicList.tsx +++ b/MusicFree/src/components/musicSheetPage/components/sheetMusicList.tsx @@ -13,15 +13,24 @@ import { RequestStateCode } from "@/constants/commonConst"; interface IMusicListProps { sheetInfo: IMusic.IMusicSheetItem | null; musicList?: IMusic.IMusicItem[] | null; - // 是否可收藏 + // 鏄惁鍙敹钘? canStar?: boolean; - // 状态 + // 鐘舵€? state: RequestStateCode; onRetry?: () => void; onLoadMore?: () => void; + resolveMusicListBeforeAdd?: () => Promise; } export default function SheetMusicList(props: IMusicListProps) { - const { sheetInfo, musicList, canStar, state, onRetry, onLoadMore } = props; + const { + sheetInfo, + musicList, + canStar, + state, + onRetry, + onLoadMore, + resolveMusicListBeforeAdd, + } = props; return ( @@ -36,6 +45,7 @@ export default function SheetMusicList(props: IMusicListProps) { canStar={canStar} musicSheet={sheetInfo} musicList={musicList} + resolveMusicListBeforeAdd={resolveMusicListBeforeAdd} /> } onLoadMore={onLoadMore} diff --git a/MusicFree/src/components/musicSheetPage/index.tsx b/MusicFree/src/components/musicSheetPage/index.tsx index cdba75f..fa39be7 100644 --- a/MusicFree/src/components/musicSheetPage/index.tsx +++ b/MusicFree/src/components/musicSheetPage/index.tsx @@ -11,17 +11,26 @@ interface IMusicSheetPageProps { navTitle: string; sheetInfo: ICommon.WithMusicList | null; musicList?: IMusic.IMusicItem[] | null; - // 是否可收藏 + // 鏄惁鍙敹钘? canStar?: boolean; - // 状态 + // 鐘舵€? state: RequestStateCode; onRetry?: () => void; onLoadMore?: () => void; + resolveMusicListBeforeAdd?: () => Promise; } export default function MusicSheetPage(props: IMusicSheetPageProps) { - const { navTitle, sheetInfo, musicList, canStar, onLoadMore, onRetry, state } = - props; + const { + navTitle, + sheetInfo, + musicList, + canStar, + onLoadMore, + onRetry, + state, + resolveMusicListBeforeAdd, + } = props; return ( @@ -37,6 +46,7 @@ export default function MusicSheetPage(props: IMusicSheetPageProps) { state={state} onRetry={onRetry} onLoadMore={onLoadMore} + resolveMusicListBeforeAdd={resolveMusicListBeforeAdd} /> diff --git a/MusicFree/src/components/panels/types/addToMusicSheet.test.tsx b/MusicFree/src/components/panels/types/addToMusicSheet.test.tsx new file mode 100644 index 0000000..643ebc4 --- /dev/null +++ b/MusicFree/src/components/panels/types/addToMusicSheet.test.tsx @@ -0,0 +1,164 @@ +import React from "react"; +import renderer, { act } from "react-test-renderer"; +import AddToMusicSheet from "./addToMusicSheet"; + +const mockShowDialog = jest.fn(); +const mockAddMusic = jest.fn(); +const mockHidePanel = jest.fn(); + +jest.mock("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ + bottom: 0, + }), +})); + +jest.mock("../base/panelBase", () => { + return ({ renderBody }: any) => renderBody(); +}); + +jest.mock("../base/panelHeader", () => { + return () => null; +}); + +jest.mock("@/components/base/listItem", () => { + const React = require("react"); + const { Pressable } = require("react-native"); + + function ListItem(props: any) { + return React.createElement( + Pressable, + { + testID: props.testID ?? "list-item", + onPress: props.onPress, + }, + props.children, + ); + } + + ListItem.ListItemImage = () => null; + ListItem.Content = () => null; + + return ListItem; +}); + +jest.mock("react-native-gesture-handler", () => ({ + FlatList: ({ data, ListHeaderComponent, renderItem }: any) => ( + <> + {ListHeaderComponent} + {data.map((item: any) => renderItem({ item }))} + + ), +})); + +jest.mock("../usePanel", () => ({ + hidePanel: (...args: any[]) => mockHidePanel(...args), + showPanel: jest.fn(), +})); + +jest.mock("@/core/musicSheet", () => ({ + __esModule: true, + default: { + addMusic: (...args: any[]) => mockAddMusic(...args), + }, + useSheetsBase: () => [ + { + id: "sheet-1", + title: "收藏", + worksNum: 0, + }, + ], +})); + +jest.mock("@/components/dialogs/useDialog", () => ({ + showDialog: (...args: any[]) => mockShowDialog(...args), +})); + +jest.mock("@/utils/toast", () => ({ + __esModule: true, + default: { + success: jest.fn(), + warn: jest.fn(), + }, +})); + +jest.mock("@/core/i18n", () => ({ + useI18N: () => ({ + t: (key: string, vars?: Record) => { + if (key === "panel.addToMusicSheet.title") { + return `添加到歌单 (${vars?.count ?? 0}首)`; + } + if (key === "panel.addToMusicSheet.toast.success") { + return "已添加到歌单"; + } + if (key === "panel.addToMusicSheet.toast.fail") { + return "添加到歌单失败"; + } + if (key === "panel.addToMusicSheet.newMusicSheet") { + return "新建歌单"; + } + if (key === "panel.addToMusicSheet.count") { + return `${vars?.count ?? 0}首`; + } + if (key === "common.loading") { + return "加载中"; + } + return key; + }, + }), +})); + +jest.mock("@/constants/assetsConst", () => ({ + ImgAsset: { + add: "add", + albumDefault: "albumDefault", + }, +})); + +jest.mock("@/utils/rpx", () => { + const fn = (value: number) => value; + (fn as any).vmax = (value: number) => value; + return fn; +}); + +describe("AddToMusicSheet", () => { + beforeEach(() => { + mockShowDialog.mockReset(); + mockAddMusic.mockReset(); + mockHidePanel.mockReset(); + }); + + it("resolves the full list before saving to a sheet", async () => { + const partialList = [{ id: "song-1" }] as IMusic.IMusicItem[]; + const fullList = [ + { id: "song-1" }, + { id: "song-2" }, + { id: "song-3" }, + ] as IMusic.IMusicItem[]; + const resolveMusicItem = jest.fn().mockResolvedValue(fullList); + + let tree: renderer.ReactTestRenderer; + await act(async () => { + tree = renderer.create( + , + ); + }); + + const pressables = tree!.root.findAllByType(require("react-native").Pressable); + const targetSheetButton = pressables[1]; + + await act(async () => { + targetSheetButton.props.onPress(); + }); + + expect(mockShowDialog).toHaveBeenCalledTimes(1); + const [, dialogPayload] = mockShowDialog.mock.calls[0]; + await dialogPayload.task(); + + expect(resolveMusicItem).toHaveBeenCalledTimes(1); + expect(mockAddMusic).toHaveBeenCalledWith("sheet-1", fullList); + }); +}); diff --git a/MusicFree/src/components/panels/types/addToMusicSheet.tsx b/MusicFree/src/components/panels/types/addToMusicSheet.tsx index f78f3f7..af019a6 100644 --- a/MusicFree/src/components/panels/types/addToMusicSheet.tsx +++ b/MusicFree/src/components/panels/types/addToMusicSheet.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useMemo } from "react"; import { StyleSheet, View } from "react-native"; import rpx, { vmax } from "@/utils/rpx"; import ListItem from "@/components/base/listItem"; @@ -12,20 +12,57 @@ import { hidePanel, showPanel } from "../usePanel"; import PanelHeader from "../base/panelHeader"; import MusicSheet, { useSheetsBase } from "@/core/musicSheet"; import { useI18N } from "@/core/i18n"; +import { showDialog } from "@/components/dialogs/useDialog"; interface IAddToMusicSheetProps { musicItem: IMusic.IMusicItem | IMusic.IMusicItem[]; - // 如果是新建歌单,可以传入一个默认的名称 + resolveMusicItem?: () => Promise; + displayCount?: number; + // 濡傛灉鏄柊寤烘瓕鍗曪紝鍙互浼犲叆涓€涓粯璁ょ殑鍚嶇О newSheetDefaultName?: string; } export default function AddToMusicSheet(props: IAddToMusicSheetProps) { const sheets = useSheetsBase(); - const { musicItem = [], newSheetDefaultName } = props ?? {}; + const { + musicItem = [], + newSheetDefaultName, + resolveMusicItem, + displayCount, + } = props ?? {}; const safeAreaInsets = useSafeAreaInsets(); const { t } = useI18N(); + const fallbackMusicList = useMemo( + () => (Array.isArray(musicItem) ? musicItem : [musicItem]), + [musicItem], + ); + + const addMusicToSheet = async (sheetId: string) => { + hidePanel(); + showDialog("LoadingDialog", { + title: t("panel.addToMusicSheet.title", { + count: displayCount ?? fallbackMusicList.length, + }), + loadingText: t("common.loading"), + task: async () => { + const resolvedMusicList = + (await resolveMusicItem?.()) ?? fallbackMusicList; + await MusicSheet.addMusic(sheetId, resolvedMusicList); + return resolvedMusicList; + }, + onResolve(_, hideLoadingDialog) { + hideLoadingDialog(); + Toast.success(t("panel.addToMusicSheet.toast.success")); + }, + onReject(_, hideLoadingDialog) { + hideLoadingDialog(); + Toast.warn(t("panel.addToMusicSheet.toast.fail")); + }, + }); + }; + return ( ( @@ -34,7 +71,7 @@ export default function AddToMusicSheet(props: IAddToMusicSheetProps) { hideButtons title={ t("panel.addToMusicSheet.title", { - count: Array.isArray(musicItem) ? musicItem.length : 1, + count: displayCount ?? fallbackMusicList.length, }) } /> @@ -54,13 +91,7 @@ export default function AddToMusicSheet(props: IAddToMusicSheetProps) { defaultName: newSheetDefaultName, async onSheetCreated(sheetId) { try { - await MusicSheet.addMusic( - sheetId, - musicItem, - ); - Toast.success( - t("panel.addToMusicSheet.toast.success"), - ); + await addMusicToSheet(sheetId); } catch { Toast.warn( t("panel.addToMusicSheet.toast.fail"), @@ -69,7 +100,9 @@ export default function AddToMusicSheet(props: IAddToMusicSheetProps) { }, onCancel() { showPanel("AddToMusicSheet", { - musicItem: musicItem, + musicItem, + resolveMusicItem, + displayCount, newSheetDefaultName, }); }, @@ -86,16 +119,7 @@ export default function AddToMusicSheet(props: IAddToMusicSheetProps) { withHorizontalPadding key={`${sheet.id}`} onPress={async () => { - try { - await MusicSheet.addMusic( - sheet.id, - musicItem, - ); - hidePanel(); - Toast.success(t("panel.addToMusicSheet.toast.success")); - } catch { - Toast.warn(t("panel.addToMusicSheet.toast.fail")); - } + await addMusicToSheet(sheet.id); }}> { + const currentTrack = + await ReactNativeTrackPlayer.getActiveTrack().catch(() => null); + const activeTrackIndex = + await ReactNativeTrackPlayer.getActiveTrackIndex().catch(() => null); + const shouldHandle = shouldHandlePlaybackQueueEnded({ + activeTrackIndex, + activeTrackMarker: + (currentTrack as { $?: string } | undefined)?.$, + activeTrackUrl: currentTrack?.url, + eventTrack: evt.track, + fakeTrackMarker: internalFakeSoundKey, + fakeTrackUrl: TrackPlayer.fakeAudioUrl, + }); + + forceTrace("[TrackPlayer] PlaybackQueueEnded", { + eventTrack: evt.track, + position: evt.position, + activeTrackIndex, + activeTrackUrl: currentTrack?.url, + shouldHandle, + }); + + if (shouldHandle) { + forceTrace("[TrackPlayer] fake-tail-queue-ended"); + await this.handlePlayEndTransition(); + } + }, + ); + this.serviceInited = true; } } @@ -723,6 +757,13 @@ class TrackPlayer extends EventEmitter<{ } } else if (message === PlayFailReason.INVALID_SOURCE) { trace("Invalid source, playback failed"); + persistErrorLog( + "[TrackPlayer] invalid-source", + buildInvalidSourceLogPayload( + musicItem, + !this.configService.getConfig("basic.autoStopWhenError"), + ), + ); await this.handlePlayFail(); } else if (message === PlayFailReason.PLAY_LIST_IS_EMPTY) { // 闂冪喎鍨弰顖溾敄閻ㄥ嫸绱濇稉宥呯安鐠囥儱鍤悳鎷岀箹缁夊秵鍎忛敓? @@ -754,11 +795,18 @@ class TrackPlayer extends EventEmitter<{ return; } + const targetMusic = this.getPlayListMusicAt(this.currentIndex + 1); forceTrace("[TrackPlayer] skipToNext", { currentIndex: this.currentIndex, repeatMode: this.repeatMode, + targetMusic: targetMusic + ? { + id: targetMusic.id, + platform: targetMusic.platform, + } + : null, }); - await this.play(this.getPlayListMusicAt(this.currentIndex + 1), true); + await this.play(targetMusic, true); } async skipToPrevious(): Promise { @@ -767,14 +815,20 @@ class TrackPlayer extends EventEmitter<{ return; } + const targetMusic = this.getPlayListMusicAt( + this.currentIndex === -1 ? 0 : this.currentIndex - 1, + ); forceTrace("[TrackPlayer] skipToPrevious", { currentIndex: this.currentIndex, repeatMode: this.repeatMode, + targetMusic: targetMusic + ? { + id: targetMusic.id, + platform: targetMusic.platform, + } + : null, }); - await this.play( - this.getPlayListMusicAt(this.currentIndex === -1 ? 0 : this.currentIndex - 1), - true, - ); + await this.play(targetMusic, true); } async changeQuality(newQuality: IMusic.IQualityKey): Promise { @@ -1335,4 +1389,3 @@ enum PlayFailReason { const trackPlayer = new TrackPlayer(); export default trackPlayer; - diff --git a/MusicFree/src/core/trackPlayer/playbackFailureLog.test.ts b/MusicFree/src/core/trackPlayer/playbackFailureLog.test.ts new file mode 100644 index 0000000..84ab82a --- /dev/null +++ b/MusicFree/src/core/trackPlayer/playbackFailureLog.test.ts @@ -0,0 +1,24 @@ +import { buildInvalidSourceLogPayload } from "./playbackFailureLog"; + +describe("buildInvalidSourceLogPayload", () => { + it("captures track context for invalid source diagnostics", () => { + const payload = buildInvalidSourceLogPayload( + { + artist: "Singer", + id: "song-1", + platform: "TestPlugin", + title: "Song", + } as any, + true, + ); + + expect(payload).toEqual({ + artist: "Singer", + id: "song-1", + platform: "TestPlugin", + title: "Song", + triggerAutoSkip: true, + type: "invalid-source", + }); + }); +}); diff --git a/MusicFree/src/core/trackPlayer/playbackFailureLog.ts b/MusicFree/src/core/trackPlayer/playbackFailureLog.ts new file mode 100644 index 0000000..dcd1670 --- /dev/null +++ b/MusicFree/src/core/trackPlayer/playbackFailureLog.ts @@ -0,0 +1,13 @@ +export function buildInvalidSourceLogPayload( + musicItem?: Partial | null, + triggerAutoSkip = false, +) { + return { + artist: musicItem?.artist, + id: musicItem?.id, + platform: musicItem?.platform, + title: musicItem?.title, + triggerAutoSkip, + type: "invalid-source", + }; +} diff --git a/MusicFree/src/core/trackPlayer/playbackTransitionDecision.test.ts b/MusicFree/src/core/trackPlayer/playbackTransitionDecision.test.ts new file mode 100644 index 0000000..ab5477f --- /dev/null +++ b/MusicFree/src/core/trackPlayer/playbackTransitionDecision.test.ts @@ -0,0 +1,37 @@ +import { shouldHandlePlaybackQueueEnded } from "./playbackTransitionDecision"; + +describe("shouldHandlePlaybackQueueEnded", () => { + it("handles queue-ended when native event says fake tail finished", () => { + expect( + shouldHandlePlaybackQueueEnded({ + eventTrack: 1, + fakeTrackMarker: "__internal_fake__", + fakeTrackUrl: + "file:///data/user/0/fun.upup.musicfree/cache/musicfree-track-player/silent-tail.wav", + }), + ).toBe(true); + }); + + it("handles queue-ended when active track is marked as fake", () => { + expect( + shouldHandlePlaybackQueueEnded({ + activeTrackMarker: "__internal_fake__", + eventTrack: 0, + fakeTrackMarker: "__internal_fake__", + fakeTrackUrl: "musicfree://fake-audio", + }), + ).toBe(true); + }); + + it("ignores queue-ended when active track is already a real song", () => { + expect( + shouldHandlePlaybackQueueEnded({ + activeTrackMarker: undefined, + activeTrackUrl: "https://example.com/real.flac", + eventTrack: 0, + fakeTrackMarker: "__internal_fake__", + fakeTrackUrl: "musicfree://fake-audio", + }), + ).toBe(false); + }); +}); diff --git a/MusicFree/src/core/trackPlayer/playbackTransitionDecision.ts b/MusicFree/src/core/trackPlayer/playbackTransitionDecision.ts new file mode 100644 index 0000000..55428ac --- /dev/null +++ b/MusicFree/src/core/trackPlayer/playbackTransitionDecision.ts @@ -0,0 +1,45 @@ +interface IShouldHandlePlaybackQueueEndedParams { + activeTrackIndex?: number | null; + activeTrackMarker?: string; + activeTrackUrl?: string; + eventTrack?: number | null; + fakeTrackMarker?: string; + fakeTrackUrl?: string; +} + +const defaultFakeTrackUrl = "musicfree://fake-audio"; + +export function shouldHandlePlaybackQueueEnded( + params: IShouldHandlePlaybackQueueEndedParams, +) { + const { + activeTrackIndex, + activeTrackMarker, + activeTrackUrl, + eventTrack, + fakeTrackMarker, + fakeTrackUrl = defaultFakeTrackUrl, + } = params; + + if ( + !!activeTrackMarker && + !!fakeTrackMarker && + activeTrackMarker === fakeTrackMarker + ) { + return true; + } + + if (activeTrackUrl === fakeTrackUrl) { + return true; + } + + if (activeTrackIndex === 1) { + return true; + } + + if ((activeTrackIndex === null || activeTrackIndex === undefined) && eventTrack === 1) { + return true; + } + + return false; +} diff --git a/MusicFree/src/entry/bootstrap/bootstrap.ts b/MusicFree/src/entry/bootstrap/bootstrap.ts index 565943b..c0e65a2 100644 --- a/MusicFree/src/entry/bootstrap/bootstrap.ts +++ b/MusicFree/src/entry/bootstrap/bootstrap.ts @@ -25,7 +25,7 @@ import { PERMISSIONS, check, request } from "react-native-permissions"; import RNTrackPlayer, { AppKilledPlaybackBehavior, Capability } from "react-native-track-player"; import i18n from "@/core/i18n"; import bootstrapAtom from "./bootstrap.atom"; -import { getTrackPlayerOptionPayload } from "./trackPlayerOptions"; +import { applyTrackPlayerOptions } from "./trackPlayerOptions"; import { getDefaultStore } from "jotai"; @@ -164,14 +164,14 @@ export async function initTrackPlayer(logger?: IPerfLogger) { } logger?.mark("加载播放器"); - await RNTrackPlayer.updateOptions({ + await applyTrackPlayerOptions({ + showExitOnNotification: + !!Config.getConfig("basic.showExitOnNotification"), + Capability, + AppKilledPlaybackBehavior, + updateOptions: RNTrackPlayer.updateOptions, icon: ImgAsset.logoTransparent, progressUpdateEventInterval: 1, - ...getTrackPlayerOptionPayload( - !!Config.getConfig("basic.showExitOnNotification"), - Capability, - AppKilledPlaybackBehavior, - ), }); logger?.mark("播放器初始化完成"); trace("播放器初始化完成"); diff --git a/MusicFree/src/entry/bootstrap/trackPlayerOptions.test.ts b/MusicFree/src/entry/bootstrap/trackPlayerOptions.test.ts index aa3e84b..0d8ae3e 100644 --- a/MusicFree/src/entry/bootstrap/trackPlayerOptions.test.ts +++ b/MusicFree/src/entry/bootstrap/trackPlayerOptions.test.ts @@ -1,4 +1,7 @@ -import { getTrackPlayerOptionPayload } from "./trackPlayerOptions"; +import { + applyTrackPlayerOptions, + getTrackPlayerOptionPayload, +} from "./trackPlayerOptions"; describe("getTrackPlayerOptionPayload", () => { const Capability = { @@ -12,6 +15,8 @@ describe("getTrackPlayerOptionPayload", () => { const AppKilledPlaybackBehavior = { ContinuePlayback: "ContinuePlayback", + StopPlaybackAndRemoveNotification: + "StopPlaybackAndRemoveNotification", } as const; it("keeps the playback service foreground across short track transition gaps", () => { @@ -23,7 +28,7 @@ describe("getTrackPlayerOptionPayload", () => { expect(options.android.stopForegroundGracePeriod).toBe(30); expect(options.android.appKilledPlaybackBehavior).toBe( - AppKilledPlaybackBehavior.ContinuePlayback, + AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification, ); expect(options.android.alwaysPauseOnInterruption).toBe(false); }); @@ -44,5 +49,34 @@ describe("getTrackPlayerOptionPayload", () => { expect(withoutStop.capabilities).not.toContain(Capability.Stop); expect(withStop.notificationCapabilities).toContain(Capability.SeekTo); expect(withoutStop.notificationCapabilities).toContain(Capability.SeekTo); + expect(withStop.notificationCapabilities).toContain(Capability.Play); + expect(withStop.notificationCapabilities).not.toContain(Capability.Pause); + expect(withoutStop.notificationCapabilities).not.toContain(Capability.Pause); + }); + + it("reapplies notification control capabilities through updateOptions", async () => { + const updateOptions = jest.fn().mockResolvedValue(undefined); + + const payload = await applyTrackPlayerOptions({ + showExitOnNotification: false, + Capability, + AppKilledPlaybackBehavior, + updateOptions, + icon: 1234, + progressUpdateEventInterval: 1, + }); + + expect(updateOptions).toHaveBeenCalledTimes(1); + expect(updateOptions).toHaveBeenCalledWith({ + icon: 1234, + progressUpdateEventInterval: 1, + ...getTrackPlayerOptionPayload( + false, + Capability, + AppKilledPlaybackBehavior, + ), + }); + expect(payload.notificationCapabilities).toContain(Capability.SeekTo); + expect(payload.capabilities).toContain(Capability.SkipToNext); }); }); diff --git a/MusicFree/src/entry/bootstrap/trackPlayerOptions.ts b/MusicFree/src/entry/bootstrap/trackPlayerOptions.ts index 35c08ee..58b79b1 100644 --- a/MusicFree/src/entry/bootstrap/trackPlayerOptions.ts +++ b/MusicFree/src/entry/bootstrap/trackPlayerOptions.ts @@ -9,8 +9,11 @@ type CapabilityLike = { type AppKilledPlaybackBehaviorLike = { ContinuePlayback: unknown; + StopPlaybackAndRemoveNotification: unknown; }; +type UpdateOptionsFn = (options: Record) => Promise; + export function getTrackPlayerOptionPayload( showExitOnNotification: boolean, Capability: CapabilityLike, @@ -30,16 +33,58 @@ export function getTrackPlayerOptionPayload( Capability.SkipToNext, Capability.SkipToPrevious, ]; + const notificationCapabilities = showExitOnNotification + ? [ + Capability.Play, + Capability.SkipToNext, + Capability.SkipToPrevious, + Capability.Stop, + Capability.SeekTo, + ] + : [ + Capability.Play, + Capability.SkipToNext, + Capability.SkipToPrevious, + Capability.SeekTo, + ]; return { android: { alwaysPauseOnInterruption: false, appKilledPlaybackBehavior: - AppKilledPlaybackBehavior.ContinuePlayback, + AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification, stopForegroundGracePeriod: 30, }, capabilities, - compactCapabilities: capabilities, - notificationCapabilities: [...capabilities, Capability.SeekTo], + compactCapabilities: notificationCapabilities, + notificationCapabilities, }; } + +export async function applyTrackPlayerOptions({ + showExitOnNotification, + Capability, + AppKilledPlaybackBehavior, + updateOptions, + icon, + progressUpdateEventInterval, +}: { + showExitOnNotification: boolean; + Capability: CapabilityLike; + AppKilledPlaybackBehavior: AppKilledPlaybackBehaviorLike; + updateOptions: UpdateOptionsFn; + icon: unknown; + progressUpdateEventInterval: number; +}) { + const payload = { + icon, + progressUpdateEventInterval, + ...getTrackPlayerOptionPayload( + showExitOnNotification, + Capability, + AppKilledPlaybackBehavior, + ), + }; + await updateOptions(payload); + return payload; +} diff --git a/MusicFree/src/entry/bootstrap/trackPlayerRemotePrevious.test.ts b/MusicFree/src/entry/bootstrap/trackPlayerRemotePrevious.test.ts new file mode 100644 index 0000000..82d81f4 --- /dev/null +++ b/MusicFree/src/entry/bootstrap/trackPlayerRemotePrevious.test.ts @@ -0,0 +1,34 @@ +import { getTrackPlayerOptionPayload } from "./trackPlayerOptions"; + +describe("track player remote previous support", () => { + const Capability = { + Play: "Play", + Pause: "Pause", + SkipToNext: "SkipToNext", + SkipToPrevious: "SkipToPrevious", + Stop: "Stop", + SeekTo: "SeekTo", + } as const; + + const AppKilledPlaybackBehavior = { + ContinuePlayback: "ContinuePlayback", + StopPlaybackAndRemoveNotification: + "StopPlaybackAndRemoveNotification", + } as const; + + it("keeps previous control exposed for notification and compact controls", () => { + const payload = getTrackPlayerOptionPayload( + false, + Capability, + AppKilledPlaybackBehavior, + ); + + expect(payload.capabilities).toContain(Capability.SkipToPrevious); + expect(payload.notificationCapabilities).toContain( + Capability.SkipToPrevious, + ); + expect(payload.compactCapabilities).toContain( + Capability.SkipToPrevious, + ); + }); +}); diff --git a/MusicFree/src/hooks/useCheckUpdate.test.ts b/MusicFree/src/hooks/useCheckUpdate.test.ts new file mode 100644 index 0000000..d330ebf --- /dev/null +++ b/MusicFree/src/hooks/useCheckUpdate.test.ts @@ -0,0 +1,102 @@ +jest.mock("@/components/dialogs/useDialog", () => ({ + showDialog: jest.fn(), +})); + +jest.mock("@/utils/persistStatus", () => ({ + __esModule: true, + default: { + get: jest.fn(), + }, +})); + +jest.mock("@/utils/checkUpdate", () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock("@/utils/toast", () => ({ + __esModule: true, + default: { + success: jest.fn(), + warn: jest.fn(), + }, +})); + +jest.mock("@/core/i18n", () => ({ + __esModule: true, + default: { + t: jest.fn((key: string) => key), + }, +})); + +const { showDialog } = require("@/components/dialogs/useDialog"); +const PersistStatus = require("@/utils/persistStatus").default; +const checkUpdate = require("@/utils/checkUpdate").default; +const Toast = require("@/utils/toast").default; +const { + checkUpdateAndShowResult, +} = require("./useCheckUpdate"); + +describe("checkUpdateAndShowResult", () => { + beforeEach(() => { + jest.clearAllMocks(); + PersistStatus.get.mockReturnValue(undefined); + }); + + it("shows latest-version toast only when update check succeeds without new version", async () => { + checkUpdate.mockResolvedValue({ + needUpdate: false, + data: { + version: "0.6.3", + changeLog: [], + download: [], + }, + }); + + checkUpdateAndShowResult(true); + await Promise.resolve(); + + expect(Toast.success).toHaveBeenCalledWith( + "checkUpdate.error.latestVersion", + ); + expect(Toast.warn).not.toHaveBeenCalled(); + expect(showDialog).not.toHaveBeenCalled(); + }); + + it("shows warning toast when update check fails", async () => { + checkUpdate.mockResolvedValue({ + needUpdate: false, + error: true, + }); + + checkUpdateAndShowResult(true); + await Promise.resolve(); + + expect(Toast.warn).toHaveBeenCalledWith( + "checkUpdate.error.checkFailed", + ); + expect(Toast.success).not.toHaveBeenCalled(); + expect(showDialog).not.toHaveBeenCalled(); + }); + + it("shows download dialog when newer version is available", async () => { + checkUpdate.mockResolvedValue({ + needUpdate: true, + data: { + version: "0.6.4", + changeLog: ["diagnostic build"], + download: ["http://example.com/app.apk", "http://backup/app.apk"], + }, + }); + + checkUpdateAndShowResult(true); + await Promise.resolve(); + + expect(showDialog).toHaveBeenCalledWith("DownloadDialog", { + version: "0.6.4", + content: ["diagnostic build"], + fromUrl: "http://example.com/app.apk", + backUrl: "http://backup/app.apk", + }); + }); +}); diff --git a/MusicFree/src/hooks/useCheckUpdate.ts b/MusicFree/src/hooks/useCheckUpdate.ts index f36d2d5..d828f52 100644 --- a/MusicFree/src/hooks/useCheckUpdate.ts +++ b/MusicFree/src/hooks/useCheckUpdate.ts @@ -14,7 +14,6 @@ export const checkUpdateAndShowResult = ( if (updateInfo?.needUpdate) { const { data } = updateInfo; const skipVersion = PersistStatus.get("app.skipVersion"); - console.log(skipVersion, data); if ( checkSkip && skipVersion && @@ -28,8 +27,13 @@ export const checkUpdateAndShowResult = ( fromUrl: data.download[0], backUrl: data.download[1], }); - } else { - if (showToast) { + return; + } + + if (showToast) { + if (updateInfo?.error) { + Toast.warn(i18n.t("checkUpdate.error.checkFailed")); + } else { Toast.success(i18n.t("checkUpdate.error.latestVersion")); } } diff --git a/MusicFree/src/native/utils/index.ts b/MusicFree/src/native/utils/index.ts index d437b9a..c8abb05 100644 --- a/MusicFree/src/native/utils/index.ts +++ b/MusicFree/src/native/utils/index.ts @@ -6,6 +6,10 @@ interface INativeUtils extends NativeModule { requestStoragePermission: () => void; isIgnoringBatteryOptimizations: () => Promise; requestIgnoreBatteryOptimizations: () => Promise; + showPlaybackDiagnosticNotification: ( + title: string, + message: string, + ) => Promise; getWindowDimensions: () => { width: number, height: number }; // Fix bug: https://github.com/facebook/react-native/issues/47080 } diff --git a/MusicFree/src/pages/albumDetail/hooks/useAlbumMusicList.ts b/MusicFree/src/pages/albumDetail/hooks/useAlbumMusicList.ts index 1b8c399..86f291a 100644 --- a/MusicFree/src/pages/albumDetail/hooks/useAlbumMusicList.ts +++ b/MusicFree/src/pages/albumDetail/hooks/useAlbumMusicList.ts @@ -1,13 +1,21 @@ import { RequestStateCode } from "@/constants/commonConst"; import PluginManager from "@/core/pluginManager"; +import { resolvePagedMusicList } from "@/utils/resolvePagedMusicList"; import { useCallback, useEffect, useRef, useState } from "react"; export default function useAlbumDetail( originalAlbumItem: IAlbum.IAlbumItem | null, ) { const currentPageRef = useRef(1); + const musicListRef = useRef( + originalAlbumItem?.musicList ?? [], + ); + const requestStateRef = useRef(RequestStateCode.IDLE); + const pendingLoadRef = useRef | null>(null); - const [requestState, setRequestState] = useState(RequestStateCode.IDLE); + const [requestState, setRequestState] = useState( + RequestStateCode.IDLE, + ); const [albumItem, setAlbumItem] = useState( originalAlbumItem, ); @@ -15,63 +23,99 @@ export default function useAlbumDetail( originalAlbumItem?.musicList ?? [], ); - const getAlbumDetail = useCallback( - async function () { - // 加载中:直接退出 - if (originalAlbumItem === null || - requestState === RequestStateCode.FINISHED || - requestState === RequestStateCode.PENDING_FIRST_PAGE || - requestState === RequestStateCode.PENDING_REST_PAGE) { - return; - } + const getAlbumDetail = useCallback(async function () { + if ( + originalAlbumItem === null || + requestStateRef.current === RequestStateCode.FINISHED || + requestStateRef.current === RequestStateCode.PENDING_FIRST_PAGE || + requestStateRef.current === RequestStateCode.PENDING_REST_PAGE + ) { + return; + } + const task = (async () => { try { - if (currentPageRef.current === 1) { - setRequestState(RequestStateCode.PENDING_FIRST_PAGE); - } else { - setRequestState(RequestStateCode.PENDING_REST_PAGE); - } + const currentPage = currentPageRef.current; + const nextState = + currentPage === 1 + ? RequestStateCode.PENDING_FIRST_PAGE + : RequestStateCode.PENDING_REST_PAGE; + requestStateRef.current = nextState; + setRequestState(nextState); + const result = await PluginManager.getByMedia( originalAlbumItem, - )?.methods?.getAlbumInfo?.( - originalAlbumItem, - currentPageRef.current, - ); + )?.methods?.getAlbumInfo?.(originalAlbumItem, currentPage); if (!result) { throw new Error(); } - if (result?.albumItem) { + if (result.albumItem) { setAlbumItem(prev => ({ ...(prev ?? {}), ...(result.albumItem as IAlbum.IAlbumItemBase), platform: originalAlbumItem.platform, })); } - if (result?.musicList) { + if (result.musicList) { setMusicList(prev => { - if (currentPageRef.current === 1) { - return result?.musicList ?? prev; - } else { - return [...prev, ...(result.musicList ?? [])]; - } + const nextMusicList = + currentPage === 1 + ? result.musicList ?? prev + : [...prev, ...(result.musicList ?? [])]; + musicListRef.current = nextMusicList; + return nextMusicList; }); } - if (result.isEnd) { - setRequestState(RequestStateCode.FINISHED); - } else { - setRequestState(RequestStateCode.PARTLY_DONE); - } + const finished = + result.isEnd === false + ? RequestStateCode.PARTLY_DONE + : RequestStateCode.FINISHED; + requestStateRef.current = finished; + setRequestState(finished); currentPageRef.current += 1; } catch { + requestStateRef.current = RequestStateCode.ERROR; setRequestState(RequestStateCode.ERROR); + } finally { + pendingLoadRef.current = null; } - }, - [requestState], - ); + })(); + + pendingLoadRef.current = task; + await task; + }, [originalAlbumItem]); + + const resolveMusicListBeforeAdd = useCallback(async () => { + if (pendingLoadRef.current) { + await pendingLoadRef.current; + } + + if (originalAlbumItem === null) { + return musicListRef.current; + } + + return resolvePagedMusicList({ + initialMusicList: musicListRef.current, + nextPage: currentPageRef.current, + isEnd: requestStateRef.current === RequestStateCode.FINISHED, + loadPage: async page => { + const result = await PluginManager.getByMedia( + originalAlbumItem, + )?.methods?.getAlbumInfo?.(originalAlbumItem, page); + return result ?? null; + }, + }); + }, [originalAlbumItem]); useEffect(() => { getAlbumDetail(); - }, []); + }, [getAlbumDetail]); - return [requestState, albumItem, musicList, getAlbumDetail] as const; + return [ + requestState, + albumItem, + musicList, + getAlbumDetail, + resolveMusicListBeforeAdd, + ] as const; } diff --git a/MusicFree/src/pages/albumDetail/index.tsx b/MusicFree/src/pages/albumDetail/index.tsx index ffe0ba9..84fe3b4 100644 --- a/MusicFree/src/pages/albumDetail/index.tsx +++ b/MusicFree/src/pages/albumDetail/index.tsx @@ -6,7 +6,13 @@ import { useI18N } from "@/core/i18n"; export default function AlbumDetail() { const { albumItem: originalAlbumItem } = useParams<"album-detail">(); - const [requestStateCode, albumItem, musicList, getAlbumDetail] = + const [ + requestStateCode, + albumItem, + musicList, + getAlbumDetail, + resolveMusicListBeforeAdd, + ] = useAlbumDetail(originalAlbumItem); const { t } = useI18N(); @@ -18,6 +24,7 @@ export default function AlbumDetail() { onRetry={getAlbumDetail} onLoadMore={getAlbumDetail} musicList={musicList} + resolveMusicListBeforeAdd={resolveMusicListBeforeAdd} /> ); } diff --git a/MusicFree/src/pages/pluginSheetDetail/hooks/usePluginSheetMusicList.ts b/MusicFree/src/pages/pluginSheetDetail/hooks/usePluginSheetMusicList.ts index f9591de..70fd519 100644 --- a/MusicFree/src/pages/pluginSheetDetail/hooks/usePluginSheetMusicList.ts +++ b/MusicFree/src/pages/pluginSheetDetail/hooks/usePluginSheetMusicList.ts @@ -1,13 +1,21 @@ import { RequestStateCode } from "@/constants/commonConst"; import PluginManager from "@/core/pluginManager"; +import { resolvePagedMusicList } from "@/utils/resolvePagedMusicList"; import { useCallback, useEffect, useRef, useState } from "react"; export default function usePluginSheetMusicList( originalSheetItem: IMusic.IMusicSheetItem | null, ) { const currentPageRef = useRef(1); + const musicListRef = useRef( + originalSheetItem?.musicList ?? [], + ); + const requestStateRef = useRef(RequestStateCode.IDLE); + const pendingLoadRef = useRef | null>(null); - const [requestState, setRequestState] = useState(RequestStateCode.IDLE); + const [requestState, setRequestState] = useState( + RequestStateCode.IDLE, + ); const [sheetItem, setSheetItem] = useState( originalSheetItem, ); @@ -15,62 +23,100 @@ export default function usePluginSheetMusicList( originalSheetItem?.musicList ?? [], ); - const getSheetDetail = useCallback( - async function () { - // 加载中:直接退出 - if (originalSheetItem === null || - requestState === RequestStateCode.FINISHED || - requestState === RequestStateCode.PENDING_FIRST_PAGE || - requestState === RequestStateCode.PENDING_REST_PAGE) { - return; - } + const getSheetDetail = useCallback(async function () { + if ( + originalSheetItem === null || + requestStateRef.current === RequestStateCode.FINISHED || + requestStateRef.current === RequestStateCode.PENDING_FIRST_PAGE || + requestStateRef.current === RequestStateCode.PENDING_REST_PAGE + ) { + return; + } + + const task = (async () => { try { - if (currentPageRef.current === 1) { - setRequestState(RequestStateCode.PENDING_FIRST_PAGE); - } else { - setRequestState(RequestStateCode.PENDING_REST_PAGE); - } + const currentPage = currentPageRef.current; + const nextState = + currentPage === 1 + ? RequestStateCode.PENDING_FIRST_PAGE + : RequestStateCode.PENDING_REST_PAGE; + requestStateRef.current = nextState; + setRequestState(nextState); + const result = await PluginManager.getByMedia( originalSheetItem as any, - )?.methods?.getMusicSheetInfo?.( - originalSheetItem, - currentPageRef.current, - ); + )?.methods?.getMusicSheetInfo?.(originalSheetItem, currentPage); if (!result) { throw new Error(); } - if (result?.sheetItem) { + if (result.sheetItem) { setSheetItem(prev => ({ ...(prev ?? {}), ...(result.sheetItem as IMusic.IMusicSheetItem), platform: originalSheetItem.platform, })); } - if (result?.musicList) { + if (result.musicList) { setMusicList(prev => { - if (currentPageRef.current === 1) { - return result?.musicList ?? prev; - } else { - return [...prev, ...(result.musicList ?? [])]; - } + const nextMusicList = + currentPage === 1 + ? result.musicList ?? prev + : [...prev, ...(result.musicList ?? [])]; + musicListRef.current = nextMusicList; + return nextMusicList; }); } - if (result.isEnd) { - setRequestState(RequestStateCode.FINISHED); - } else { - setRequestState(RequestStateCode.PARTLY_DONE); - } + const finished = + result.isEnd === false + ? RequestStateCode.PARTLY_DONE + : RequestStateCode.FINISHED; + requestStateRef.current = finished; + setRequestState(finished); currentPageRef.current += 1; } catch { + requestStateRef.current = RequestStateCode.ERROR; setRequestState(RequestStateCode.ERROR); + } finally { + pendingLoadRef.current = null; } - }, - [requestState], - ); + })(); + + pendingLoadRef.current = task; + await task; + }, [originalSheetItem]); + + const resolveMusicListBeforeAdd = useCallback(async () => { + if (pendingLoadRef.current) { + await pendingLoadRef.current; + } + + if ( + originalSheetItem === null || + requestStateRef.current === RequestStateCode.FINISHED + ) { + return musicListRef.current; + } + + return resolvePagedMusicList({ + initialMusicList: musicListRef.current, + nextPage: currentPageRef.current, + isEnd: requestStateRef.current === RequestStateCode.FINISHED, + loadPage: page => + PluginManager.getByMedia( + originalSheetItem as any, + )?.methods?.getMusicSheetInfo?.(originalSheetItem, page), + }); + }, [originalSheetItem]); useEffect(() => { getSheetDetail(); - }, []); + }, [getSheetDetail]); - return [requestState, sheetItem, musicList, getSheetDetail] as const; + return [ + requestState, + sheetItem, + musicList, + getSheetDetail, + resolveMusicListBeforeAdd, + ] as const; } diff --git a/MusicFree/src/pages/pluginSheetDetail/index.tsx b/MusicFree/src/pages/pluginSheetDetail/index.tsx index 2313024..8348aae 100644 --- a/MusicFree/src/pages/pluginSheetDetail/index.tsx +++ b/MusicFree/src/pages/pluginSheetDetail/index.tsx @@ -7,7 +7,13 @@ import i18n from "@/core/i18n"; export default function PluginSheetDetail() { const { sheetInfo } = useParams<"plugin-sheet-detail">(); - const [requestState, sheetItem, musicList, getSheetDetail] = + const [ + requestState, + sheetItem, + musicList, + getSheetDetail, + resolveMusicListBeforeAdd, + ] = usePluginSheetMusicList(sheetInfo as IMusic.IMusicSheetItem); return ( ); } diff --git a/MusicFree/src/pages/topListDetail/hooks/useTopListDetail.ts b/MusicFree/src/pages/topListDetail/hooks/useTopListDetail.ts index e623d39..0010f2c 100644 --- a/MusicFree/src/pages/topListDetail/hooks/useTopListDetail.ts +++ b/MusicFree/src/pages/topListDetail/hooks/useTopListDetail.ts @@ -1,6 +1,7 @@ import { RequestStateCode } from "@/constants/commonConst"; import PluginManager from "@/core/pluginManager"; -import { useEffect, useRef, useState } from "react"; +import { resolvePagedMusicList } from "@/utils/resolvePagedMusicList"; +import { useCallback, useEffect, useRef, useState } from "react"; export default function useTopListDetail( topListItem: IMusic.IMusicSheetItemBase | null, @@ -12,64 +13,107 @@ export default function useTopListDetail( ); const pageRef = useRef(1); + const musicListRef = useRef(topListItem?.musicList ?? []); + const requestStateRef = useRef(RequestStateCode.IDLE); + const pendingLoadRef = useRef | null>(null); const [requestState, setRequestState] = useState(RequestStateCode.IDLE); - async function loadMore() { - if (!topListItem) { + const loadMore = useCallback(async () => { + if ( + !topListItem || + requestStateRef.current === RequestStateCode.PENDING_FIRST_PAGE || + requestStateRef.current === RequestStateCode.PENDING_REST_PAGE || + requestStateRef.current === RequestStateCode.FINISHED + ) { return; } - try { - if ( - requestState === RequestStateCode.PENDING_FIRST_PAGE || - requestState === RequestStateCode.PENDING_REST_PAGE || - requestState === RequestStateCode.FINISHED - ) { - return; - } - if (pageRef.current === 1) { - setRequestState(RequestStateCode.PENDING_FIRST_PAGE); - } else { - setRequestState(RequestStateCode.PENDING_REST_PAGE); - } - const result = await PluginManager.getByHash( - pluginHash, - )?.methods?.getTopListDetail(topListItem, pageRef.current); - if (!result) { - throw new Error(); - } - const currentPage = pageRef.current; - setMergedTopListItem( - prev => - ({ + + const task = (async () => { + try { + const currentPage = pageRef.current; + const nextState = + currentPage === 1 + ? RequestStateCode.PENDING_FIRST_PAGE + : RequestStateCode.PENDING_REST_PAGE; + requestStateRef.current = nextState; + setRequestState(nextState); + + const result = await PluginManager.getByHash( + pluginHash, + )?.methods?.getTopListDetail(topListItem, currentPage); + if (!result) { + throw new Error(); + } + + setMergedTopListItem(prev => { + const nextMusicList = + currentPage === 1 + ? result.musicList ?? [] + : [ + ...(prev?.musicList ?? []), + ...(result.musicList ?? []), + ]; + musicListRef.current = nextMusicList; + return { ...prev, ...result.topListItem, - musicList: - currentPage === 1 - ? result.musicList ?? [] - : [ - ...(prev?.musicList ?? []), - ...(result.musicList ?? []), - ], - } as IMusic.IMusicSheetItem), - ); + musicList: nextMusicList, + } as IMusic.IMusicSheetItem; + }); - if (result.isEnd === false) { - setRequestState(RequestStateCode.PARTLY_DONE); - } else { - setRequestState(RequestStateCode.FINISHED); + const finished = + result.isEnd === false + ? RequestStateCode.PARTLY_DONE + : RequestStateCode.FINISHED; + requestStateRef.current = finished; + setRequestState(finished); + pageRef.current += 1; + } catch { + requestStateRef.current = RequestStateCode.ERROR; + setRequestState(RequestStateCode.ERROR); + } finally { + pendingLoadRef.current = null; } - pageRef.current++; - } catch { - setRequestState(RequestStateCode.ERROR); + })(); + + pendingLoadRef.current = task; + await task; + }, [pluginHash, topListItem]); + + const resolveMusicListBeforeAdd = useCallback(async () => { + if (pendingLoadRef.current) { + await pendingLoadRef.current; } - } + + if (topListItem === null) { + return musicListRef.current; + } + + return resolvePagedMusicList({ + initialMusicList: musicListRef.current, + nextPage: pageRef.current, + isEnd: requestStateRef.current === RequestStateCode.FINISHED, + loadPage: async page => { + const result = await PluginManager.getByHash( + pluginHash, + )?.methods?.getTopListDetail(topListItem, page); + return result ?? null; + }, + }); + }, [pluginHash, topListItem]); useEffect(() => { if (topListItem === null) { return; } loadMore(); - }, []); - return [mergedTopListItem, requestState, loadMore] as const; + }, [loadMore, topListItem]); + + return [ + mergedTopListItem, + requestState, + loadMore, + resolveMusicListBeforeAdd, + ] as const; } diff --git a/MusicFree/src/pages/topListDetail/index.tsx b/MusicFree/src/pages/topListDetail/index.tsx index 98065cd..a8d622c 100644 --- a/MusicFree/src/pages/topListDetail/index.tsx +++ b/MusicFree/src/pages/topListDetail/index.tsx @@ -6,7 +6,7 @@ import useTopListDetail from "./hooks/useTopListDetail"; export default function TopListDetail() { const { pluginHash, topList } = useParams<"top-list-detail">(); - const [topListDetail, state, loadMore] = useTopListDetail( + const [topListDetail, state, loadMore, resolveMusicListBeforeAdd] = useTopListDetail( topList, pluginHash, ); @@ -19,6 +19,7 @@ export default function TopListDetail() { state={state} onLoadMore={loadMore} onRetry={loadMore} + resolveMusicListBeforeAdd={resolveMusicListBeforeAdd} /> ); } diff --git a/MusicFree/src/service/index.ts b/MusicFree/src/service/index.ts index a89d62a..5892488 100644 --- a/MusicFree/src/service/index.ts +++ b/MusicFree/src/service/index.ts @@ -1,13 +1,26 @@ import Config from "@/core/appConfig"; +import { ImgAsset } from "@/constants/assetsConst"; import pathConst from "@/constants/pathConst"; import musicHistory from "@/core/musicHistory"; import PluginManager from "@/core/pluginManager"; -import RNTrackPlayer, { Event, State } from "react-native-track-player"; +import RNTrackPlayer, { + AppKilledPlaybackBehavior, + Capability, + Event, + State, +} from "react-native-track-player"; import TrackPlayer from "@/core/trackPlayer"; +import NativeUtils from "@/native/utils"; +import { + createPlaybackAnomalyMonitor, + PlaybackAnomalyReport, +} from "@/service/playbackAnomalyMonitor"; +import { buildPlaybackErrorDiagnostics } from "@/service/playbackErrorDiagnostics"; import { checkAndCreateDir } from "@/utils/fileUtils"; -import { errorLog, forceTrace, trace } from "@/utils/log"; +import { errorLog, forceTrace, persistErrorLog, trace } from "@/utils/log"; import { musicIsPaused } from "@/utils/trackUtils"; import PersistStatus from "@/utils/persistStatus"; +import { applyTrackPlayerOptions } from "@/entry/bootstrap/trackPlayerOptions"; ( globalThis as typeof globalThis & { @@ -17,6 +30,131 @@ import PersistStatus from "@/utils/persistStatus"; let resumeState: State | null; let serviceReadyPromise: Promise | null = null; +let playbackHealthCheckTimer: ReturnType | null = null; + +function toPlaybackStateValue(state?: State | string | null) { + return (state ?? State.None) as + | "none" + | "ready" + | "playing" + | "paused" + | "stopped" + | "loading" + | "buffering" + | "error" + | "ended"; +} + +function formatTrackLabel(trackTitle?: string | null, artist?: string | null) { + const title = trackTitle?.trim?.() || "\u672A\u77E5\u6B4C\u66F2"; + const singer = artist?.trim?.(); + return singer ? `${title} - ${singer}` : title; +} + +function toMusicRef( + musicItem?: Partial | null, +) { + if (!musicItem) { + return null; + } + + return { + artist: musicItem.artist ?? null, + id: musicItem.id ?? null, + platform: musicItem.platform ?? null, + title: musicItem.title ?? null, + url: musicItem.url ?? null, + }; +} + +async function showPlaybackDiagnosticNotification(report: PlaybackAnomalyReport) { + const trackLabel = formatTrackLabel(report.trackTitle, report.artist); + if (report.type === "error") { + const title = "MusicFree \u64AD\u653E\u51FA\u9519"; + const message = `${trackLabel} \u64AD\u653E\u5931\u8D25\uff0c\u8BF7\u56DE\u5230\u5E94\u7528\u5185\u91CD\u8BD5\u3002`; + await NativeUtils.showPlaybackDiagnosticNotification(title, message); + return; + } + + const title = "MusicFree \u64AD\u653E\u7591\u4F3C\u5361\u4F4F"; + const message = `${trackLabel} \u540E\u53F0\u64AD\u653E\u957F\u65F6\u95F4\u65E0\u8FDB\u5EA6\uff0c\u8BF7\u4EAE\u5C4F\u6216\u56DE\u5230\u5E94\u7528\u5185\u91CD\u8BD5\u3002`; + await NativeUtils.showPlaybackDiagnosticNotification(title, message); +} + +function logPlaybackAnomaly(report: PlaybackAnomalyReport) { + if (report.type === "error") { + const payload = { + activeTrackIndex: report.activeTrackIndex, + artist: report.artist, + code: report.code, + diagnostics: report.diagnostics, + message: report.message, + platform: report.platform, + trackTitle: report.trackTitle, + trackUrl: report.trackUrl, + type: report.type, + }; + persistErrorLog("[PlaybackService] playback-error", payload); + forceTrace("[PlaybackService] playback-error", payload, "error"); + return; + } + + const payload = { + activeTrackIndex: report.activeTrackIndex, + artist: report.artist, + platform: report.platform, + position: report.position, + stalledForMs: report.stalledForMs, + state: report.state, + trackTitle: report.trackTitle, + trackUrl: report.trackUrl, + type: report.type, + }; + persistErrorLog("[PlaybackService] playback-stall", payload); + forceTrace("[PlaybackService] playback-stall", payload, "error"); +} + +const playbackAnomalyMonitor = createPlaybackAnomalyMonitor({ + cooldownMs: 90_000, + report: report => { + logPlaybackAnomaly(report); + showPlaybackDiagnosticNotification(report).catch(error => { + forceTrace( + "[PlaybackService] playback-notification:failed", + error instanceof Error ? error.message : String(error), + "error", + ); + }); + }, + stallThresholdMs: 45_000, +}); + +function startPlaybackHealthCheck() { + if (playbackHealthCheckTimer) { + return; + } + + playbackHealthCheckTimer = setInterval(() => { + runSafely("PlaybackHealthCheck", async () => { + const [playbackState, progress, track, activeTrackIndex] = await Promise.all([ + RNTrackPlayer.getPlaybackState(), + RNTrackPlayer.getProgress(), + RNTrackPlayer.getActiveTrack().catch(() => null), + RNTrackPlayer.getActiveTrackIndex().catch(() => null), + ]); + + playbackAnomalyMonitor.recordSnapshot({ + activeTrackIndex, + artist: (track as { artist?: string | null } | null)?.artist, + platform: (track as { platform?: string | null } | null)?.platform, + position: progress.position ?? 0, + state: toPlaybackStateValue(playbackState.state), + trackTitle: (track as { title?: string | null } | null)?.title, + trackUrl: (track as { url?: string | null } | null)?.url, + }); + }); + }, 15_000); +} async function ensureServiceReady(from: string) { if (serviceReadyPromise) { @@ -40,10 +178,28 @@ async function ensureServiceReady(from: string) { await Config.setup(); await musicHistory.setup(); await PluginManager.setup(); + const appliedOptions = await applyTrackPlayerOptions({ + showExitOnNotification: + !!Config.getConfig("basic.showExitOnNotification"), + Capability, + AppKilledPlaybackBehavior, + updateOptions: RNTrackPlayer.updateOptions, + icon: ImgAsset.logoTransparent, + progressUpdateEventInterval: 1, + }); + forceTrace("[PlaybackService] options:applied", { + showExitOnNotification: + !!Config.getConfig("basic.showExitOnNotification"), + capabilities: appliedOptions.capabilities, + compactCapabilities: appliedOptions.compactCapabilities, + notificationCapabilities: + appliedOptions.notificationCapabilities, + }); await TrackPlayer.setupTrackPlayer(); trace(`[TrackPlayer service] ready from ${from}`); forceTrace("[PlaybackService] ready", { from }); + startPlaybackHealthCheck(); } catch (e: any) { serviceReadyPromise = null; throw e; @@ -97,7 +253,13 @@ module.exports = async function () { runSafely("RemotePause", () => TrackPlayer.pause()), ); RNTrackPlayer.addEventListener(Event.RemotePrevious, () => - runSafely("RemotePrevious", () => TrackPlayer.skipToPrevious()), + runSafely("RemotePrevious", async () => { + forceTrace("[PlaybackService] RemotePrevious", { + currentMusic: toMusicRef(TrackPlayer.currentMusic), + previousMusic: toMusicRef(TrackPlayer.previousMusic), + }); + await TrackPlayer.skipToPrevious(); + }), ); RNTrackPlayer.addEventListener(Event.RemoteNext, () => runSafely("RemoteNext", () => TrackPlayer.skipToNext()), @@ -170,6 +332,43 @@ module.exports = async function () { RNTrackPlayer.addEventListener(Event.PlaybackProgressUpdated, evt => { PersistStatus.set("music.progress", evt.position); + playbackAnomalyMonitor.recordProgress(evt.position); + }); + + RNTrackPlayer.addEventListener(Event.PlaybackError, async evt => { + runSafely("PlaybackError", async () => { + const [track, activeTrackIndex, queueTrack, playbackState, progress] = + await Promise.all([ + RNTrackPlayer.getActiveTrack().catch(() => null), + RNTrackPlayer.getActiveTrackIndex().catch(() => null), + RNTrackPlayer.getTrack(0).catch(() => null), + RNTrackPlayer.getPlaybackState().catch(() => null), + RNTrackPlayer.getProgress().catch(() => null), + ]); + const diagnostics = buildPlaybackErrorDiagnostics({ + activeTrack: track as Record | null, + activeTrackIndex, + currentMusic: TrackPlayer.currentMusic as + | Record + | null, + eventCode: evt.code, + eventMessage: evt.message, + persistedTrack: PersistStatus.get("music.musicItem") as + | Record + | null, + playbackState: toPlaybackStateValue( + playbackState?.state as State | string | null, + ), + progress, + queueTrack: queueTrack as Record | null, + }); + playbackAnomalyMonitor.recordPlaybackError({ + ...diagnostics, + code: evt.code, + diagnostics: diagnostics.diagnostics, + message: evt.message, + }); + }); }); RNTrackPlayer.addEventListener(Event.RemoteStop, async () => { diff --git a/MusicFree/src/service/playbackAnomalyMonitor.test.ts b/MusicFree/src/service/playbackAnomalyMonitor.test.ts new file mode 100644 index 0000000..9ae1cad --- /dev/null +++ b/MusicFree/src/service/playbackAnomalyMonitor.test.ts @@ -0,0 +1,180 @@ +import { createPlaybackAnomalyMonitor } from "./playbackAnomalyMonitor"; + +describe("createPlaybackAnomalyMonitor", () => { + let now = 0; + + beforeEach(() => { + now = 1_000; + }); + + it("reports playback errors only once within cooldown", () => { + const report = jest.fn(); + const monitor = createPlaybackAnomalyMonitor({ + now: () => now, + cooldownMs: 90_000, + stallThresholdMs: 45_000, + report, + }); + + monitor.recordPlaybackError({ + activeTrackIndex: 1, + artist: "Singer", + code: "android-system", + diagnostics: { + playbackState: "playing", + }, + message: "MusicFree error", + platform: "TestPlugin", + trackTitle: "Song", + trackUrl: + "file:///data/user/0/fun.upup.musicfree/cache/musicfree-track-player/silent-tail.wav", + }); + monitor.recordPlaybackError({ + activeTrackIndex: 1, + artist: "Singer", + code: "android-system", + diagnostics: { + playbackState: "playing", + }, + message: "MusicFree error", + platform: "TestPlugin", + trackTitle: "Song", + trackUrl: + "file:///data/user/0/fun.upup.musicfree/cache/musicfree-track-player/silent-tail.wav", + }); + + expect(report).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledWith( + expect.objectContaining({ + activeTrackIndex: 1, + artist: "Singer", + code: "android-system", + diagnostics: { + playbackState: "playing", + }, + platform: "TestPlugin", + trackTitle: "Song", + trackUrl: + "file:///data/user/0/fun.upup.musicfree/cache/musicfree-track-player/silent-tail.wav", + type: "error", + }), + ); + }); + + it("reports stalled playback when progress does not move past threshold", () => { + const report = jest.fn(); + const monitor = createPlaybackAnomalyMonitor({ + now: () => now, + cooldownMs: 90_000, + stallThresholdMs: 45_000, + report, + }); + + monitor.recordSnapshot({ + artist: "Singer", + position: 12, + state: "playing", + trackTitle: "Song", + }); + + now += 46_000; + + monitor.recordSnapshot({ + artist: "Singer", + position: 12, + state: "playing", + trackTitle: "Song", + }); + + expect(report).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledWith( + expect.objectContaining({ + artist: "Singer", + position: 12, + stalledForMs: 46_000, + state: "playing", + trackTitle: "Song", + type: "stall", + }), + ); + }); + + it("does not report stall repeatedly within cooldown", () => { + const report = jest.fn(); + const monitor = createPlaybackAnomalyMonitor({ + now: () => now, + cooldownMs: 90_000, + stallThresholdMs: 45_000, + report, + }); + + monitor.recordSnapshot({ + artist: "Singer", + position: 24, + state: "playing", + trackTitle: "Song", + }); + + now += 46_000; + monitor.recordSnapshot({ + artist: "Singer", + position: 24, + state: "playing", + trackTitle: "Song", + }); + + now += 10_000; + monitor.recordSnapshot({ + artist: "Singer", + position: 24, + state: "playing", + trackTitle: "Song", + }); + + expect(report).toHaveBeenCalledTimes(1); + }); + + it("includes active track details in stall reports", () => { + const report = jest.fn(); + const monitor = createPlaybackAnomalyMonitor({ + now: () => now, + cooldownMs: 90_000, + stallThresholdMs: 45_000, + report, + }); + + monitor.recordSnapshot({ + activeTrackIndex: 1, + artist: "Singer", + platform: "Music_Server", + position: 180, + state: "playing", + trackTitle: "Song", + trackUrl: + "file:///data/user/0/fun.upup.musicfree/cache/musicfree-track-player/silent-tail.wav", + } as any); + + now += 46_000; + + monitor.recordSnapshot({ + activeTrackIndex: 1, + artist: "Singer", + platform: "Music_Server", + position: 180, + state: "playing", + trackTitle: "Song", + trackUrl: + "file:///data/user/0/fun.upup.musicfree/cache/musicfree-track-player/silent-tail.wav", + } as any); + + expect(report).toHaveBeenCalledWith( + expect.objectContaining({ + activeTrackIndex: 1, + platform: "Music_Server", + trackUrl: + "file:///data/user/0/fun.upup.musicfree/cache/musicfree-track-player/silent-tail.wav", + type: "stall", + }), + ); + }); +}); diff --git a/MusicFree/src/service/playbackAnomalyMonitor.ts b/MusicFree/src/service/playbackAnomalyMonitor.ts new file mode 100644 index 0000000..7fe2f71 --- /dev/null +++ b/MusicFree/src/service/playbackAnomalyMonitor.ts @@ -0,0 +1,206 @@ +export type PlaybackAnomalyState = + | "none" + | "ready" + | "playing" + | "paused" + | "stopped" + | "loading" + | "buffering" + | "error" + | "ended"; + +export interface IPlaybackSnapshot { + activeTrackIndex?: number | null; + artist?: string | null; + platform?: string | null; + position: number; + state: PlaybackAnomalyState; + trackTitle?: string | null; + trackUrl?: string | null; +} + +export interface IPlaybackErrorSnapshot { + activeTrackIndex?: number | null; + artist?: string | null; + code?: string | null; + diagnostics?: Record | null; + message?: string | null; + platform?: string | null; + trackTitle?: string | null; + trackUrl?: string | null; +} + +export interface IPlaybackAnomalyReportBase { + activeTrackIndex?: number | null; + artist?: string | null; + platform?: string | null; + trackTitle?: string | null; + trackUrl?: string | null; +} + +export interface IPlaybackErrorReport extends IPlaybackAnomalyReportBase { + activeTrackIndex?: number | null; + code?: string | null; + diagnostics?: Record | null; + message?: string | null; + platform?: string | null; + trackUrl?: string | null; + type: "error"; +} + +export interface IPlaybackStallReport extends IPlaybackAnomalyReportBase { + position: number; + stalledForMs: number; + state: PlaybackAnomalyState; + type: "stall"; +} + +export type PlaybackAnomalyReport = + | IPlaybackErrorReport + | IPlaybackStallReport; + +export interface ICreatePlaybackAnomalyMonitorOptions { + cooldownMs: number; + now?: () => number; + report: (report: PlaybackAnomalyReport) => void | Promise; + stallThresholdMs: number; +} + +const ACTIVE_STALL_STATES = new Set([ + "playing", + "buffering", +]); + +export function createPlaybackAnomalyMonitor( + options: ICreatePlaybackAnomalyMonitorOptions, +) { + const now = options.now ?? (() => Date.now()); + let lastPosition = 0; + let lastPositionChangeAt = now(); + let lastState: PlaybackAnomalyState = "none"; + let lastTrackSignature = ""; + let lastReportedKey = ""; + let lastReportedAt = 0; + + function canReport(key: string) { + const current = now(); + if ( + key === lastReportedKey && + current - lastReportedAt < options.cooldownMs + ) { + return false; + } + lastReportedKey = key; + lastReportedAt = current; + return true; + } + + function normalizeText(value?: string | null) { + const text = value?.trim(); + return text?.length ? text : undefined; + } + + function getTrackSignature(trackTitle?: string | null, artist?: string | null) { + return `${normalizeText(trackTitle) ?? ""}::${normalizeText(artist) ?? ""}`; + } + + function emitReport(report: PlaybackAnomalyReport) { + Promise.resolve(options.report(report)).catch(() => {}); + } + + return { + recordPlaybackError(snapshot: IPlaybackErrorSnapshot) { + const trackTitle = normalizeText(snapshot.trackTitle); + const artist = normalizeText(snapshot.artist); + const code = normalizeText(snapshot.code); + const message = normalizeText(snapshot.message); + const key = `error:${code ?? "unknown"}:${trackTitle ?? ""}:${artist ?? ""}`; + if (!canReport(key)) { + return; + } + + emitReport({ + activeTrackIndex: snapshot.activeTrackIndex ?? null, + artist, + code, + diagnostics: snapshot.diagnostics ?? null, + message, + platform: normalizeText(snapshot.platform), + trackTitle, + trackUrl: normalizeText(snapshot.trackUrl), + type: "error", + }); + }, + + recordProgress(position: number) { + if (position !== lastPosition) { + lastPosition = position; + lastPositionChangeAt = now(); + } + }, + + recordSnapshot(snapshot: IPlaybackSnapshot) { + const current = now(); + const trackTitle = normalizeText(snapshot.trackTitle); + const artist = normalizeText(snapshot.artist); + const trackSignature = getTrackSignature(trackTitle, artist); + + if (trackSignature !== lastTrackSignature) { + lastTrackSignature = trackSignature; + lastPosition = snapshot.position; + lastPositionChangeAt = current; + lastState = snapshot.state; + return; + } + + if (snapshot.position !== lastPosition) { + lastPosition = snapshot.position; + lastPositionChangeAt = current; + lastState = snapshot.state; + return; + } + + if (snapshot.state !== lastState) { + lastState = snapshot.state; + lastPositionChangeAt = current; + return; + } + + if (!ACTIVE_STALL_STATES.has(snapshot.state)) { + lastPositionChangeAt = current; + return; + } + + const stalledForMs = current - lastPositionChangeAt; + if (stalledForMs < options.stallThresholdMs) { + return; + } + + const key = `stall:${trackTitle ?? ""}:${artist ?? ""}:${snapshot.position}`; + if (!canReport(key)) { + return; + } + + emitReport({ + activeTrackIndex: snapshot.activeTrackIndex ?? null, + artist, + platform: normalizeText(snapshot.platform), + position: snapshot.position, + stalledForMs, + state: snapshot.state, + trackTitle, + trackUrl: normalizeText(snapshot.trackUrl), + type: "stall", + }); + }, + + reset() { + lastPosition = 0; + lastPositionChangeAt = now(); + lastState = "none"; + lastTrackSignature = ""; + lastReportedAt = 0; + lastReportedKey = ""; + }, + }; +} diff --git a/MusicFree/src/service/playbackErrorDiagnostics.test.ts b/MusicFree/src/service/playbackErrorDiagnostics.test.ts new file mode 100644 index 0000000..940a05d --- /dev/null +++ b/MusicFree/src/service/playbackErrorDiagnostics.test.ts @@ -0,0 +1,151 @@ +import { buildPlaybackErrorDiagnostics } from "./playbackErrorDiagnostics"; + +describe("buildPlaybackErrorDiagnostics", () => { + it("prefers active track details when available", () => { + const diagnostics = buildPlaybackErrorDiagnostics({ + activeTrack: { + $: "marker-1", + artist: "Singer", + id: "song-1", + isInit: false, + platform: "Music_Server", + title: "Song", + url: "https://example.com/song.flac", + }, + activeTrackIndex: 0, + currentMusic: { + artist: "Current Singer", + id: "song-current", + platform: "Current", + title: "Current Song", + url: "https://example.com/current.mp3", + }, + eventCode: "android-source", + eventMessage: "Source error", + persistedTrack: { + artist: "Persisted Singer", + id: "song-persisted", + platform: "Persisted", + title: "Persisted Song", + url: "https://example.com/persisted.mp3", + }, + playbackState: "playing", + progress: { + buffered: 42, + duration: 180, + position: 12, + }, + queueTrack: { + artist: "Queued Singer", + id: "song-queue", + platform: "Queued", + title: "Queued Song", + url: "https://example.com/queue.mp3", + }, + }); + + expect(diagnostics).toEqual({ + activeTrackIndex: 0, + artist: "Singer", + diagnostics: { + activeTrack: { + artist: "Singer", + id: "song-1", + isInit: false, + marker: "marker-1", + platform: "Music_Server", + title: "Song", + url: "https://example.com/song.flac", + }, + currentMusic: { + artist: "Current Singer", + id: "song-current", + isInit: null, + marker: null, + platform: "Current", + title: "Current Song", + url: "https://example.com/current.mp3", + }, + event: { + code: "android-source", + message: "Source error", + }, + persistedTrack: { + artist: "Persisted Singer", + id: "song-persisted", + isInit: null, + marker: null, + platform: "Persisted", + title: "Persisted Song", + url: "https://example.com/persisted.mp3", + }, + playbackState: "playing", + progress: { + buffered: 42, + duration: 180, + position: 12, + }, + queueTrack: { + artist: "Queued Singer", + id: "song-queue", + isInit: null, + marker: null, + platform: "Queued", + title: "Queued Song", + url: "https://example.com/queue.mp3", + }, + resolvedTrack: { + artist: "Singer", + id: "song-1", + isInit: false, + marker: "marker-1", + platform: "Music_Server", + title: "Song", + url: "https://example.com/song.flac", + }, + }, + platform: "Music_Server", + trackTitle: "Song", + trackUrl: "https://example.com/song.flac", + }); + }); + + it("falls back to queued track details when active track is missing", () => { + const diagnostics = buildPlaybackErrorDiagnostics({ + activeTrack: null, + activeTrackIndex: null, + currentMusic: { + artist: "Current Singer", + id: "song-current", + platform: "Current", + title: "Current Song", + url: "https://example.com/current.mp3", + }, + eventMessage: "Source error", + persistedTrack: { + artist: "Persisted Singer", + id: "song-persisted", + platform: "Persisted", + title: "Persisted Song", + url: "https://example.com/persisted.mp3", + }, + playbackState: "error", + progress: null, + queueTrack: { + artist: "Queued Singer", + id: "song-queue", + platform: "Queued", + title: "Queued Song", + url: "https://example.com/queue.mp3", + }, + }); + + expect(diagnostics.artist).toBe("Queued Singer"); + expect(diagnostics.platform).toBe("Queued"); + expect(diagnostics.trackTitle).toBe("Queued Song"); + expect(diagnostics.trackUrl).toBe("https://example.com/queue.mp3"); + expect(diagnostics.diagnostics.resolvedTrack).toEqual( + diagnostics.diagnostics.queueTrack, + ); + }); +}); diff --git a/MusicFree/src/service/playbackErrorDiagnostics.ts b/MusicFree/src/service/playbackErrorDiagnostics.ts new file mode 100644 index 0000000..4422d19 --- /dev/null +++ b/MusicFree/src/service/playbackErrorDiagnostics.ts @@ -0,0 +1,87 @@ +interface ITrackLike { + $?: unknown; + artist?: unknown; + id?: unknown; + isInit?: unknown; + platform?: unknown; + title?: unknown; + url?: unknown; +} + +interface IProgressLike { + buffered?: number | null; + duration?: number | null; + position?: number | null; +} + +export interface IBuildPlaybackErrorDiagnosticsInput { + activeTrack?: ITrackLike | null; + activeTrackIndex?: number | null; + currentMusic?: ITrackLike | null; + eventCode?: string | null; + eventMessage?: string | null; + persistedTrack?: ITrackLike | null; + playbackState?: string | null; + progress?: IProgressLike | null; + queueTrack?: ITrackLike | null; +} + +function normalizeText(value?: unknown) { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; +} + +function normalizeTrack(track?: ITrackLike | null) { + if (!track) { + return null; + } + + return { + artist: normalizeText(track.artist), + id: track.id ?? null, + isInit: typeof track.isInit === "boolean" ? track.isInit : null, + marker: normalizeText(track.$), + platform: normalizeText(track.platform), + title: normalizeText(track.title), + url: normalizeText(track.url), + }; +} + +export function buildPlaybackErrorDiagnostics( + input: IBuildPlaybackErrorDiagnosticsInput, +) { + const activeTrack = normalizeTrack(input.activeTrack); + const queueTrack = normalizeTrack(input.queueTrack); + const persistedTrack = normalizeTrack(input.persistedTrack); + const currentMusic = normalizeTrack(input.currentMusic); + const resolvedTrack = + activeTrack ?? queueTrack ?? persistedTrack ?? currentMusic; + + return { + activeTrackIndex: input.activeTrackIndex ?? null, + artist: resolvedTrack?.artist ?? null, + diagnostics: { + activeTrack, + currentMusic, + event: { + code: normalizeText(input.eventCode), + message: normalizeText(input.eventMessage), + }, + persistedTrack, + playbackState: normalizeText(input.playbackState), + progress: { + buffered: input.progress?.buffered ?? null, + duration: input.progress?.duration ?? null, + position: input.progress?.position ?? null, + }, + queueTrack, + resolvedTrack, + }, + platform: resolvedTrack?.platform ?? null, + trackTitle: resolvedTrack?.title ?? null, + trackUrl: resolvedTrack?.url ?? null, + }; +} diff --git a/MusicFree/src/types/core/i18n/index.d.ts b/MusicFree/src/types/core/i18n/index.d.ts index 1ccd60b..df03c76 100644 --- a/MusicFree/src/types/core/i18n/index.d.ts +++ b/MusicFree/src/types/core/i18n/index.d.ts @@ -58,6 +58,7 @@ export interface ILanguageData { // 检查更新相关 "checkUpdate.error.latestVersion": string; // 当前已是最新版本 + "checkUpdate.error.checkFailed": string; // 检查更新失败 // 首页相关 "home.recommendSheet": string; // 推荐歌单 diff --git a/MusicFree/src/utils/checkUpdate.test.ts b/MusicFree/src/utils/checkUpdate.test.ts index 74ae86d..c0101e6 100644 --- a/MusicFree/src/utils/checkUpdate.test.ts +++ b/MusicFree/src/utils/checkUpdate.test.ts @@ -83,7 +83,10 @@ describe("checkUpdate", () => { const result = await checkUpdate(); - expect(result).toBeUndefined(); + expect(result).toEqual({ + needUpdate: false, + error: true, + }); expect(axios.get).toHaveBeenCalledTimes(1); expect(axios.get).toHaveBeenCalledWith( "http://10.0.0.2:18080/app/version.json", @@ -99,4 +102,58 @@ describe("checkUpdate", () => { expect(result).toBeUndefined(); expect(axios.get).not.toHaveBeenCalled(); }); + + it("returns latest state when remote version is not newer", async () => { + mockGetByName.mockImplementation((name: string) => { + if (name !== "Music_Server") { + return undefined; + } + return { + name: "Music_Server", + instance: { + srcUrl: "http://192.168.1.10:18080/plugins/music_server.js", + }, + }; + }); + (axios.get as jest.Mock).mockResolvedValue({ + data: { + version: "0.6.0", + changeLog: [], + download: [], + }, + }); + + const result = await checkUpdate(); + + expect(result).toEqual({ + needUpdate: false, + data: { + version: "0.6.0", + changeLog: [], + download: [], + }, + }); + }); + + it("returns failure state when update metadata request fails", async () => { + mockGetByName.mockImplementation((name: string) => { + if (name !== "Music_Server") { + return undefined; + } + return { + name: "Music_Server", + instance: { + srcUrl: "http://192.168.1.10:18080/plugins/music_server.js", + }, + }; + }); + (axios.get as jest.Mock).mockRejectedValue(new Error("timeout")); + + const result = await checkUpdate(); + + expect(result).toEqual({ + needUpdate: false, + error: true, + }); + }); }); diff --git a/MusicFree/src/utils/checkUpdate.ts b/MusicFree/src/utils/checkUpdate.ts index fb0b0dd..6198b50 100644 --- a/MusicFree/src/utils/checkUpdate.ts +++ b/MusicFree/src/utils/checkUpdate.ts @@ -8,6 +8,7 @@ const musicServerAppVersionPath = "/app/version.json"; interface IUpdateInfo { needUpdate: boolean; + error?: boolean; data: { version: string; changeLog: string[]; @@ -74,13 +75,14 @@ export default async function checkUpdate(): Promise { } try { const rawInfo = (await axios.get(updateUrl)).data; - if (compare(rawInfo.version, currentVersion, ">")) { - return { - needUpdate: true, - data: rawInfo, - }; - } + return { + needUpdate: compare(rawInfo.version, currentVersion, ">"), + data: rawInfo, + }; } catch { - return; + return { + needUpdate: false, + error: true, + } as IUpdateInfo; } } diff --git a/MusicFree/src/utils/log.test.ts b/MusicFree/src/utils/log.test.ts new file mode 100644 index 0000000..32022d8 --- /dev/null +++ b/MusicFree/src/utils/log.test.ts @@ -0,0 +1,162 @@ +const mockReadDir = jest.fn(); +const mockReadFile = jest.fn(); + +jest.mock("react-native-fs", () => ({ + __esModule: true, + default: {}, + readDir: (...args: any[]) => mockReadDir(...args), + readFile: (...args: any[]) => mockReadFile(...args), +})); + +jest.mock("@/constants/pathConst", () => ({ + __esModule: true, + default: { + logPath: "/mock/logs", + }, +})); + +jest.mock("../core/appConfig.ts", () => ({ + __esModule: true, + default: { + getConfig: jest.fn(() => false), + }, +})); + +jest.mock("@/lib/react-native-vdebug/src/log", () => ({ + addLog: jest.fn(), +})); + +jest.mock("react-native-logs", () => ({ + fileAsyncTransport: jest.fn(), + logger: { + createLogger: jest.fn(() => ({ + error: jest.fn(), + info: jest.fn(), + })), + }, +})); + +describe("getErrorLogContent", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-06-01T10:00:00+08:00")); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("includes playback diagnostics from trace-log alongside error logs", async () => { + mockReadDir.mockResolvedValue([ + { + isFile: () => true, + path: "/mock/logs/error-log-1-6-2026.log", + }, + { + isFile: () => true, + path: "/mock/logs/trace-log.log", + }, + ]); + mockReadFile.mockImplementation(async (path: string) => { + if (path.endsWith("error-log-1-6-2026.log")) { + return "error-log-line\n"; + } + if (path.endsWith("trace-log.log")) { + return "[PlaybackService] playback-stall\n"; + } + return ""; + }); + + const { getErrorLogContent } = require("./log"); + const content = await getErrorLogContent(); + + expect(content).toContain("error-log-line"); + expect(content).toContain("[PlaybackService] playback-stall"); + }); + + it("includes fake-tail transition diagnostics from trace-log in app log view", async () => { + mockReadDir.mockResolvedValue([ + { + isFile: () => true, + path: "/mock/logs/trace-log.log", + }, + ]); + mockReadFile.mockResolvedValue( + [ + '2026/6/2 15:44:22 | INFO : ', + '{', + ' "desc": "[TrackPlayer] PlaybackActiveTrackChanged",', + ' "message": "{\\"url\\":\\"file:///data/user/0/fun.upup.musicfree/cache/musicfree-track-player/silent-tail.wav\\"}"', + '}', + '2026/6/2 15:44:22 | INFO : ', + '{', + ' "desc": "[TrackPlayer] fake-tail-hit",', + ' "message": ""', + '}', + '2026/6/2 15:44:22 | INFO : ', + '{', + ' "desc": "[TrackPlayer] handlePlayEndTransition:start",', + ' "message": "{\\"currentIndex\\":30}"', + '}', + ].join("\n"), + ); + + const { getErrorLogContent } = require("./log"); + const content = await getErrorLogContent(); + + expect(content).toContain("[TrackPlayer] PlaybackActiveTrackChanged"); + expect(content).toContain("silent-tail.wav"); + expect(content).toContain("[TrackPlayer] fake-tail-hit"); + expect(content).toContain("[TrackPlayer] handlePlayEndTransition:start"); + }); + + it("keeps media source diagnostics written into error logs visible in app", async () => { + mockReadDir.mockResolvedValue([ + { + isFile: () => true, + path: "/mock/logs/error-log-1-6-2026.log", + }, + ]); + mockReadFile.mockResolvedValue( + '[PluginManager] media-source-error {"platform":"TestPlugin","message":"NOT RETRY"}\n', + ); + + const { getErrorLogContent } = require("./log"); + const content = await getErrorLogContent(); + + expect(content).toContain("[PluginManager] media-source-error"); + expect(content).toContain("NOT RETRY"); + }); + + it("includes playback error and remote previous diagnostics from trace-log in app log view", async () => { + mockReadDir.mockResolvedValue([ + { + isFile: () => true, + path: "/mock/logs/trace-log.log", + }, + ]); + mockReadFile.mockResolvedValue( + [ + '2026/6/10 20:52:52 | ERROR : ', + '{', + ' "desc": "[TrackPlayer] PlaybackError",', + ' "message": "{\\"message\\":\\"Source error\\"}"', + '}', + '2026/6/10 20:52:53 | INFO : ', + '{', + ' "desc": "[PlaybackService] RemotePrevious",', + ' "message": "{\\"previousMusic\\":{\\"id\\":\\"song-1\\"}}"', + '}', + ].join("\n"), + ); + + const { getErrorLogContent } = require("./log"); + const content = await getErrorLogContent(); + + expect(content).toContain("[TrackPlayer] PlaybackError"); + expect(content).toContain("Source error"); + expect(content).toContain("[PlaybackService] RemotePrevious"); + expect(content).toContain("song-1"); + }); +}); diff --git a/MusicFree/src/utils/log.ts b/MusicFree/src/utils/log.ts index fc83cc6..2f6c284 100644 --- a/MusicFree/src/utils/log.ts +++ b/MusicFree/src/utils/log.ts @@ -26,6 +26,27 @@ const traceConfig = { const log = logger.createLogger(config); const traceLogger = logger.createLogger(traceConfig); +const appVisibleTraceKeywords = [ + "[PlaybackService] playback-error", + "[PlaybackService] playback-stall", + "[PlaybackService] RemotePrevious", + "[TrackPlayer] PlaybackActiveTrackChanged", + "[TrackPlayer] PlaybackError", + "[TrackPlayer] PlaybackError:decision", + "[TrackPlayer] fake-tail-hit", + "[TrackPlayer] fake-tail-queue-ended", + "[TrackPlayer] handlePlayEndTransition:start", + "[TrackPlayer] handlePlayEndTransition:done", + "[TrackPlayer] PlaybackQueueEnded", + "[TrackPlayer] play:failed", + "[TrackPlayer] play:set-proposed-queue", + "[TrackPlayer] play:source-ready", + "[TrackPlayer] play:start", + "[TrackPlayer] setTrackSource", + "[TrackPlayer] setTrackSource:play-called", + "[TrackPlayer] skipToNext", + "[TrackPlayer] skipToPrevious", +]; export function trace( desc: string, @@ -97,7 +118,6 @@ export async function clearLog() { export async function getErrorLogContent() { try { const files = await readDir(pathConst.logPath); - console.log(files); const today = new Date(); // 两天的错误日志 const yesterday = new Date(); @@ -120,6 +140,9 @@ export async function getErrorLogContent() { }-${yesterday.getFullYear()}.log`, ), ); + const traceLog = files.find( + _ => _.isFile() && _.path.endsWith("trace-log.log"), + ); let logContent = ""; if (todayLog) { logContent += await readFile(todayLog.path, "utf8"); @@ -127,6 +150,23 @@ export async function getErrorLogContent() { if (yesterdayLog) { logContent += await readFile(yesterdayLog.path, "utf8"); } + if (traceLog) { + const traceLogContent = await readFile(traceLog.path, "utf8"); + const playbackDiagnostics = traceLogContent + .split( + /\r?\n(?=(?:\d{1,2}\/\d{1,2}\/\d{4},|\d{4}\/\d{1,2}\/\d{1,2}\s))/, + ) + .filter( + entry => + appVisibleTraceKeywords.some(keyword => + entry.includes(keyword), + ), + ) + .join("\n"); + if (playbackDiagnostics) { + logContent += `${logContent ? "\n" : ""}${playbackDiagnostics}`; + } + } return logContent; } catch { return ""; @@ -143,6 +183,17 @@ export function errorLog(desc: string, message: any) { } } +export function persistErrorLog(desc: string, message: any) { + try { + log.error({ + desc, + message, + }); + } catch {} + + trace(desc, message, "error"); +} + export function devLog( method: "log" | "error" | "warn" | "info", ...args: any[] diff --git a/MusicFree/src/utils/resolvePagedMusicList.test.ts b/MusicFree/src/utils/resolvePagedMusicList.test.ts new file mode 100644 index 0000000..5fc8c14 --- /dev/null +++ b/MusicFree/src/utils/resolvePagedMusicList.test.ts @@ -0,0 +1,31 @@ +import { resolvePagedMusicList } from "./resolvePagedMusicList"; + +describe("resolvePagedMusicList", () => { + it("loads remaining pages and returns the complete music list", async () => { + const initialMusicList = Array.from({ length: 60 }, (_, index) => ({ + id: `song-${index + 1}`, + })) as IMusic.IMusicItem[]; + + const loadPage = jest + .fn() + .mockResolvedValueOnce({ + musicList: Array.from({ length: 40 }, (_, index) => ({ + id: `song-${index + 61}`, + })), + isEnd: true, + }); + + const result = await resolvePagedMusicList({ + initialMusicList, + nextPage: 2, + isEnd: false, + loadPage, + }); + + expect(loadPage).toHaveBeenCalledTimes(1); + expect(loadPage).toHaveBeenCalledWith(2); + expect(result).toHaveLength(100); + expect(result[0]?.id).toBe("song-1"); + expect(result[99]?.id).toBe("song-100"); + }); +}); diff --git a/MusicFree/src/utils/resolvePagedMusicList.ts b/MusicFree/src/utils/resolvePagedMusicList.ts new file mode 100644 index 0000000..6037716 --- /dev/null +++ b/MusicFree/src/utils/resolvePagedMusicList.ts @@ -0,0 +1,43 @@ +interface IResolvePagedMusicListProps { + initialMusicList: IMusic.IMusicItem[]; + nextPage: number; + isEnd: boolean; + loadPage: ( + page: number, + ) => Promise< + | { + musicList?: IMusic.IMusicItem[]; + isEnd?: boolean; + } + | null + | undefined + >; +} + +export async function resolvePagedMusicList( + props: IResolvePagedMusicListProps, +) { + const { initialMusicList, nextPage, isEnd, loadPage } = props; + + if (isEnd) { + return initialMusicList; + } + + const mergedMusicList = [...initialMusicList]; + let currentPage = nextPage; + let reachedEnd: boolean = isEnd; + + while (!reachedEnd) { + const result = await loadPage(currentPage); + if (!result) { + throw new Error("Failed to load complete music list"); + } + if (result.musicList?.length) { + mergedMusicList.push(...result.musicList); + } + reachedEnd = result.isEnd === false ? false : true; + currentPage += 1; + } + + return mergedMusicList; +} diff --git a/Music_Server/config/music_server.env.example b/Music_Server/config/music_server.env.example index 6a55f01..1ef62f8 100644 --- a/Music_Server/config/music_server.env.example +++ b/Music_Server/config/music_server.env.example @@ -8,5 +8,6 @@ MUSIC_SERVER_ADMIN_USERNAME=admin MUSIC_SERVER_ADMIN_PASSWORD_HASH=sha256$replace-with-sha256-hex MUSIC_SERVER_SECRET_ENCRYPTION_KEY=replace-with-a-strong-secret MUSIC_SERVER_CACHE_RECONCILE_INTERVAL_SECONDS=600 +MUSIC_SERVER_STREAM_TOKEN_TTL_SECONDS=3600 MUSICFREE_VERSION_JSON=/app/release/version.json MUSICFREE_APK_PATH=/app/release/MusicFree_latest_release_universal.apk diff --git a/Music_Server/scripts/deploy_to_nas.ps1 b/Music_Server/scripts/deploy_to_nas.ps1 index 02bc259..fce5eaf 100644 --- a/Music_Server/scripts/deploy_to_nas.ps1 +++ b/Music_Server/scripts/deploy_to_nas.ps1 @@ -1,5 +1,5 @@ param( - [string]$HostName = "192.168.5.43", + [string]$HostName = "192.168.5.11", [int]$Port = 222, [string]$User = "xiaoming", [string]$RemoteAppHome = "/volume4/Music_Cloud/Music_Server", diff --git a/Music_Server/scripts/deploy_to_nas.py b/Music_Server/scripts/deploy_to_nas.py index 7dc20a3..8ae675e 100644 --- a/Music_Server/scripts/deploy_to_nas.py +++ b/Music_Server/scripts/deploy_to_nas.py @@ -23,7 +23,7 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Upload Music_Server to NAS staging and trigger deploy_and_restart.sh" ) - parser.add_argument("--host", default="192.168.5.43") + parser.add_argument("--host", default="192.168.5.11") parser.add_argument("--port", type=int, default=222) parser.add_argument("--user", default="xiaoming") parser.add_argument( diff --git a/Music_Server/src/music_server/routes/mf_media.py b/Music_Server/src/music_server/routes/mf_media.py index c1fab9e..26ff0e5 100644 --- a/Music_Server/src/music_server/routes/mf_media.py +++ b/Music_Server/src/music_server/routes/mf_media.py @@ -111,6 +111,39 @@ def _build_stream_url(*, token: str, resolved: dict) -> str: return f"/mf/v1/media/stream/{token}" +def _resolve_stream_source(*, song_id: int, locator: str, quality: str, settings) -> dict: + resolver = MediaResolver(db_path=settings.catalog_db_path) + if locator: + try: + return resolver.resolve_by_locator(song_id=song_id, locator=locator) + except LookupError: + pass + return resolver.resolve(song_id=song_id, quality=quality) + + +def _is_origin_stream_reachable(public_url: str) -> bool: + return _is_cache_url_reachable(public_url) + + +def _pick_viable_origin_source(*, song_id: int, quality: str, settings, preferred_locator: str = "") -> dict: + resolver = MediaResolver(db_path=settings.catalog_db_path) + candidates = resolver.resolve_candidates(song_id=song_id, quality=quality) + if not candidates: + raise LookupError("no playable source found") + + def _sort_key(item: dict) -> tuple[int, int, str]: + locator = str(item.get("locator") or "") + return (0 if preferred_locator and locator == preferred_locator else 1, 0, locator) + + for candidate in sorted(candidates, key=_sort_key): + if candidate.get("backend_type") == "local_fs": + return candidate + public_url = str(candidate.get("public_url") or "") + if public_url and _is_origin_stream_reachable(public_url): + return candidate + return candidates[0] + + @router.post("/media/resolve") def resolve_media(payload: dict) -> dict: settings = get_settings() @@ -146,6 +179,8 @@ def resolve_media(payload: dict) -> dict: secret=settings.access_token, song_id=song_id, locator=token_locator, + quality=quality, + ttl_seconds=settings.stream_token_ttl_seconds, ) selected_source = cached_source or fallback_source or {} selected_size = None @@ -175,6 +210,7 @@ def stream_media(token: str, request: Request, ext: str | None = None): except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc song_id = int(parsed["song_id"]) + quality = str(parsed.get("quality") or "standard") cache_service = _cache_service(settings) cached_source = cache_service.resolve_cached_source(song_id=song_id) @@ -183,9 +219,11 @@ def stream_media(token: str, request: Request, ext: str | None = None): return RedirectResponse(url=str(cached_source["public_url"]), status_code=307) try: - resolved = MediaResolver(db_path=settings.catalog_db_path).resolve_by_locator( + resolved = _resolve_stream_source( song_id=song_id, - locator=str(parsed["locator"]), + locator=str(parsed.get("locator") or ""), + quality=quality, + settings=settings, ) except LookupError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @@ -237,6 +275,17 @@ def stream_media(token: str, request: Request, ext: str | None = None): ) public_url = resolved.get("public_url") + if public_url and not _is_origin_stream_reachable(str(public_url)): + try: + resolved = _pick_viable_origin_source( + song_id=song_id, + quality=quality, + settings=settings, + preferred_locator=str(resolved.get("locator") or ""), + ) + except LookupError: + pass + public_url = resolved.get("public_url") if not public_url: raise HTTPException(status_code=404, detail="public stream url not found") cache_service.record_stream_play(song_id=song_id, stream_token=token) diff --git a/Music_Server/src/music_server/services/media_resolver.py b/Music_Server/src/music_server/services/media_resolver.py index 63d9b60..fe693c9 100644 --- a/Music_Server/src/music_server/services/media_resolver.py +++ b/Music_Server/src/music_server/services/media_resolver.py @@ -7,21 +7,24 @@ class MediaResolver: def __init__(self, db_path: str) -> None: self._db_path = db_path - def resolve(self, song_id: int, quality: str) -> dict: + def resolve_candidates(self, song_id: int, quality: str) -> list[dict]: with closing(connect_sqlite(self._db_path)) as conn: - row = conn.execute( + rows = conn.execute( """ - select song_id, quality_label, ext, file_size_bytes, backend_type, backend_name, locator, public_url + select song_id, quality_label, ext, file_size_bytes, backend_type, backend_name, locator, public_url, is_primary from catalog_track_files where song_id = ? and status = 'active' - order by case when quality_label = ? then 0 else 1 end, is_primary desc - limit 1 + order by case when quality_label = ? then 0 else 1 end, is_primary desc, locator asc """, (song_id, quality), - ).fetchone() - if row is None: + ).fetchall() + return [dict(row) for row in rows] + + def resolve(self, song_id: int, quality: str) -> dict: + rows = self.resolve_candidates(song_id=song_id, quality=quality) + if not rows: raise LookupError("no playable source found") - return dict(row) + return rows[0] def resolve_by_locator(self, song_id: int, locator: str) -> dict: with closing(connect_sqlite(self._db_path)) as conn: diff --git a/Music_Server/src/music_server/services/stream_tokens.py b/Music_Server/src/music_server/services/stream_tokens.py index 713c47f..7d82acd 100644 --- a/Music_Server/src/music_server/services/stream_tokens.py +++ b/Music_Server/src/music_server/services/stream_tokens.py @@ -13,10 +13,18 @@ def _sign_payload(secret: str, payload_json: str) -> str: ).hexdigest() -def create_stream_token(secret: str, song_id: int, locator: str, ttl_seconds: int = 300) -> str: +def create_stream_token( + secret: str, + song_id: int, + locator: str, + *, + quality: str | None = None, + ttl_seconds: int = 3600, +) -> str: payload = { "song_id": int(song_id), "locator": str(locator), + "quality": str(quality or "standard"), "expires_at": int(time.time()) + int(ttl_seconds), } payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) @@ -40,15 +48,15 @@ def parse_stream_token(secret: str, token: str) -> dict: raise ValueError("invalid stream token") song_id = int(payload["song_id"]) - locator = str(payload["locator"]) + locator = str(payload.get("locator") or "") + quality = str(payload.get("quality") or "standard") expires_at = int(payload["expires_at"]) - if not locator: - raise ValueError("invalid stream token") if expires_at < int(time.time()): raise ValueError("stream token expired") return { "song_id": song_id, "locator": locator, + "quality": quality, "expires_at": expires_at, } except ValueError: diff --git a/Music_Server/src/music_server/settings.py b/Music_Server/src/music_server/settings.py index 0c340f8..3d7265f 100644 --- a/Music_Server/src/music_server/settings.py +++ b/Music_Server/src/music_server/settings.py @@ -28,6 +28,7 @@ class Settings: admin_password_hash: str secret_encryption_key: str cache_reconcile_interval_seconds: int + stream_token_ttl_seconds: int musicfree_version_json_path: str musicfree_apk_path: str @@ -78,6 +79,10 @@ def get_settings() -> Settings: "MUSIC_SERVER_CACHE_RECONCILE_INTERVAL_SECONDS", 600, ), + stream_token_ttl_seconds=_env_int( + "MUSIC_SERVER_STREAM_TOKEN_TTL_SECONDS", + 3600, + ), musicfree_version_json_path=os.getenv( "MUSICFREE_VERSION_JSON", str(musicfree_release_dir / "version.json"), diff --git a/Music_Server/tests/test_app_update_routes.py b/Music_Server/tests/test_app_update_routes.py index cddaab6..a47d83c 100644 --- a/Music_Server/tests/test_app_update_routes.py +++ b/Music_Server/tests/test_app_update_routes.py @@ -84,4 +84,3 @@ class AppUpdateRouteTests(unittest.TestCase): if __name__ == "__main__": unittest.main() - diff --git a/Music_Server/tests/test_mf_media_routes.py b/Music_Server/tests/test_mf_media_routes.py index a028d44..04d281b 100644 --- a/Music_Server/tests/test_mf_media_routes.py +++ b/Music_Server/tests/test_mf_media_routes.py @@ -1,5 +1,6 @@ import sqlite3 import tempfile +import time import unittest from pathlib import Path from unittest.mock import patch @@ -156,6 +157,42 @@ class MfMediaRouteTests(unittest.TestCase): self.assertIn("/mf/v1/media/stream/", payload["stream"]["url"]) self.assertTrue(payload["stream"]["url"].endswith(".flac")) + def test_media_resolve_issues_longer_lived_stream_token_for_background_queueing(self): + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "catalog_read.db" + player_db_path = Path(tmpdir) / "player.db" + self._prepare_catalog_db(db_path) + + with patch.dict( + "os.environ", + { + "CATALOG_DB_PATH": str(db_path), + "PLAYER_DB_PATH": str(player_db_path), + "PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token", + }, + clear=False, + ): + client = TestClient(create_app()) + resolve_response = client.post( + "/mf/v1/media/resolve", + headers=auth_headers(player_db_path), + json={"song_id": "catalogsync:song:3476", "quality": "super"}, + ) + + self.assertEqual(200, resolve_response.status_code) + stream_url = resolve_response.json()["stream"]["url"] + token = stream_url.rsplit("/", 1)[-1].split(".", 1)[0] + + from music_server.services.stream_tokens import parse_stream_token + + parsed = parse_stream_token(secret="dev-token", token=token) + remaining = parsed["expires_at"] - int(time.time()) + self.assertGreaterEqual( + remaining, + 1800, + "stream token ttl should be long enough for background playback queueing", + ) + def test_media_stream_redirects_to_public_url(self): with tempfile.TemporaryDirectory() as tmpdir: db_path = Path(tmpdir) / "catalog_read.db" @@ -353,6 +390,170 @@ class MfMediaRouteTests(unittest.TestCase): stream_response.headers.get("location"), ) + def test_media_stream_falls_back_to_current_active_source_when_token_locator_is_stale(self): + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "catalog_read.db" + player_db_path = Path(tmpdir) / "player.db" + conn = sqlite3.connect(db_path) + conn.execute( + """ + create table catalog_track_files ( + song_id integer not null, + quality_label text not null, + ext text not null, + file_size_bytes integer not null, + backend_type text not null, + backend_name text not null, + locator text not null, + public_url text, + status text not null, + is_primary integer not null + ) + """ + ) + conn.execute( + """ + insert into catalog_track_files ( + song_id, quality_label, ext, file_size_bytes, backend_type, backend_name, + locator, public_url, status, is_primary + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + 3476, + "super", + "flac", + 42345678, + "object_storage", + "main-s3", + "music/netease/new.flac", + "https://cdn.example/new.flac", + "active", + 1, + ), + ) + conn.commit() + conn.close() + + with patch.dict( + "os.environ", + { + "CATALOG_DB_PATH": str(db_path), + "PLAYER_DB_PATH": str(player_db_path), + "PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token", + }, + clear=False, + ): + client = TestClient(create_app()) + from music_server.services.stream_tokens import create_stream_token + + stale_token = create_stream_token( + secret="dev-token", + song_id=3476, + locator="music/netease/old.flac", + ttl_seconds=3600, + ) + + stream_response = client.get( + f"/mf/v1/media/stream/{stale_token}.flac", + follow_redirects=False, + ) + + self.assertEqual(307, stream_response.status_code) + self.assertEqual( + "https://cdn.example/new.flac", + stream_response.headers.get("location"), + ) + + def test_media_stream_falls_back_to_next_origin_source_when_selected_public_url_is_unreachable(self): + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "catalog_read.db" + player_db_path = Path(tmpdir) / "player.db" + conn = sqlite3.connect(db_path) + conn.execute( + """ + create table catalog_track_files ( + song_id integer not null, + quality_label text not null, + ext text not null, + file_size_bytes integer not null, + backend_type text not null, + backend_name text not null, + locator text not null, + public_url text, + status text not null, + is_primary integer not null + ) + """ + ) + conn.executemany( + """ + insert into catalog_track_files ( + song_id, quality_label, ext, file_size_bytes, backend_type, backend_name, + locator, public_url, status, is_primary + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + [ + ( + 3476, + "super", + "flac", + 42345678, + "object_storage", + "main-s3", + "music/netease/first.flac", + "https://cdn.example/first.flac", + "active", + 1, + ), + ( + 3476, + "super", + "flac", + 42345678, + "object_storage", + "backup-s3", + "music/netease/second.flac", + "https://cdn.example/second.flac", + "active", + 0, + ), + ], + ) + conn.commit() + conn.close() + + with patch.dict( + "os.environ", + { + "CATALOG_DB_PATH": str(db_path), + "PLAYER_DB_PATH": str(player_db_path), + "PUBLIC_MUSIC_ACCESS_TOKEN": "dev-token", + }, + clear=False, + ): + client = TestClient(create_app()) + resolve_response = client.post( + "/mf/v1/media/resolve", + headers=auth_headers(player_db_path), + json={"song_id": "catalogsync:song:3476", "quality": "super"}, + ) + self.assertEqual(200, resolve_response.status_code) + + with patch( + "music_server.routes.mf_media._is_origin_stream_reachable", + side_effect=lambda url: url.endswith("second.flac"), + ): + stream_response = client.get( + resolve_response.json()["stream"]["url"], + follow_redirects=False, + ) + + self.assertEqual(307, stream_response.status_code) + self.assertEqual( + "https://cdn.example/second.flac", + stream_response.headers.get("location"), + ) + def test_media_stream_falls_back_when_cached_public_url_is_unreachable(self): with tempfile.TemporaryDirectory() as tmpdir: db_path = Path(tmpdir) / "catalog_read.db"