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"