1 Commits

Author SHA1 Message Date
xiaoming e9bb3df906 feat: sync catalog, server, and MusicFree updates 2026-07-16 18:43:22 +08:00
62 changed files with 4412 additions and 389 deletions
+1 -1
View File
@@ -114,7 +114,7 @@ static def getVersion() {
// } // }
def appVersion = getVersion() def appVersion = getVersion()
def appVersionCode = 400012 def appVersionCode = 400015
android { android {
@@ -0,0 +1,168 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="fun.upup.musicfree">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application
android:name=".MainApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false"
android:theme="@style/AppTheme"
android:supportsRtl="true"
android:requestLegacyExternalStorage="true"
android:usesCleartextTraffic="true"
android:extractNativeLibs="true"
>
<activity
android:name=".MainActivity"
android:theme="@style/Theme.App.SplashScreen"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="musicfree" android:host="app"/>
<data android:scheme="musicfree" android:host="install"/>
</intent-filter>
<!-- 处理音频文件 -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" />
<data android:mimeType="audio/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:mimeType="audio/*" />
</intent-filter>
<!-- 处理特定音频格式 -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.mp3" />
<data android:pathPattern=".*\\.MP3" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.flac" />
<data android:pathPattern=".*\\.FLAC" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.m4a" />
<data android:pathPattern=".*\\.M4A" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.wav" />
<data android:pathPattern=".*\\.WAV" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.mp3" />
<data android:pathPattern=".*\\.MP3" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.flac" />
<data android:pathPattern=".*\\.FLAC" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.m4a" />
<data android:pathPattern=".*\\.M4A" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.wav" />
<data android:pathPattern=".*\\.WAV" />
</intent-filter>
<!-- 处理JavaScript文件 -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" />
<data android:mimeType="text/javascript" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:mimeType="text/javascript" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.js" />
<data android:pathPattern=".*\\.JS" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" />
<data android:mimeType="*/*" />
<data android:pathPattern=".*\\.js" />
<data android:pathPattern=".*\\.JS" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -11,7 +11,12 @@ import android.provider.Settings
import android.util.DisplayMetrics import android.util.DisplayMetrics
import android.view.WindowInsets import android.view.WindowInsets
import android.view.WindowManager 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 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.Arguments
import com.facebook.react.bridge.Promise import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactApplicationContext
@@ -23,6 +28,8 @@ import kotlin.system.exitProcess
class UtilsModule(context: ReactApplicationContext) : ReactContextBaseJavaModule(context) { class UtilsModule(context: ReactApplicationContext) : ReactContextBaseJavaModule(context) {
private val reactContext: ReactApplicationContext = context; private val reactContext: ReactApplicationContext = context;
private val playbackDiagnosticChannelId = "musicfree_playback_diag"
private val playbackDiagnosticNotificationId = 42042
override fun getName() = "NativeUtils" 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) @ReactMethod(isBlockingSynchronousMethod = true)
fun getWindowDimensions(): WritableMap { fun getWindowDimensions(): WritableMap {
val windowManager = reactApplicationContext.getSystemService(Context.WINDOW_SERVICE) as WindowManager val windowManager = reactApplicationContext.getSystemService(Context.WINDOW_SERVICE) as WindowManager
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "MusicFree", "name": "MusicFree",
"version": "0.6.3", "version": "0.6.7",
"private": true, "private": true,
"license": "AGPL", "license": "AGPL",
"author": { "author": {
@@ -19,6 +19,7 @@
"connect-mumu": "adb kill-server & adb connect localhost:7555", "connect-mumu": "adb kill-server & adb connect localhost:7555",
"build-android": "cd .\\android\\ && .\\gradlew assembleRelease", "build-android": "cd .\\android\\ && .\\gradlew assembleRelease",
"generate-assets": "node ./generator/generate-assets.mjs", "generate-assets": "node ./generator/generate-assets.mjs",
"postinstall": "patch-package",
"prepare": "husky" "prepare": "husky"
}, },
"dependencies": { "dependencies": {
@@ -114,6 +115,7 @@
"husky": "^9.1.4", "husky": "^9.1.4",
"jest": "^29.6.3", "jest": "^29.6.3",
"lint-staged": "^15.2.7", "lint-staged": "^15.2.7",
"patch-package": "^8.0.0",
"prettier": "2.8.8", "prettier": "2.8.8",
"react-native-svg-transformer": "^1.5.0", "react-native-svg-transformer": "^1.5.0",
"react-test-renderer": "18.3.1", "react-test-renderer": "18.3.1",
@@ -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<Capability> = 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<Int>()
+
+ 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"
}
}
File diff suppressed because it is too large Load Diff
+13
View File
@@ -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"
]
}
+6 -1
View File
@@ -18,9 +18,12 @@ interface IProps {
musicList: IMusic.IMusicItem[] | null; musicList: IMusic.IMusicItem[] | null;
canStar?: boolean; canStar?: boolean;
musicSheet?: IMusic.IMusicSheetItem | null; musicSheet?: IMusic.IMusicSheetItem | null;
resolveMusicListBeforeAdd?: () => Promise<IMusic.IMusicItem[]>;
addToSheetCount?: number;
} }
export default function (props: IProps) { export default function (props: IProps) {
const { musicList, canStar, musicSheet } = props; const { musicList, canStar, musicSheet, resolveMusicListBeforeAdd, addToSheetCount } =
props;
const sheetName = musicSheet?.title; const sheetName = musicSheet?.title;
const sheetId = musicSheet?.id; const sheetId = musicSheet?.id;
@@ -86,6 +89,8 @@ export default function (props: IProps) {
showPanel("AddToMusicSheet", { showPanel("AddToMusicSheet", {
musicItem: musicList ?? [], musicItem: musicList ?? [],
newSheetDefaultName: sheetName, newSheetDefaultName: sheetName,
resolveMusicItem: resolveMusicListBeforeAdd,
displayCount: addToSheetCount,
}); });
}} }}
/> />
@@ -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();
});
});
@@ -107,7 +107,7 @@ interface IMusicInfoProps {
paddingLeft?: number; paddingLeft?: number;
} }
function skipMusicItem(direction: number) { export function skipMusicItem(direction: number) {
if (direction === -1) { if (direction === -1) {
TrackPlayer.skipToNext(); TrackPlayer.skipToNext();
} else if (direction === 1) { } else if (direction === 1) {
@@ -12,9 +12,10 @@ interface IHeaderProps {
musicSheet: IMusic.IMusicSheetItem | null; musicSheet: IMusic.IMusicSheetItem | null;
musicList: IMusic.IMusicItem[] | null; musicList: IMusic.IMusicItem[] | null;
canStar?: boolean; canStar?: boolean;
resolveMusicListBeforeAdd?: () => Promise<IMusic.IMusicItem[]>;
} }
export default function Header(props: IHeaderProps) { export default function Header(props: IHeaderProps) {
const { musicSheet, musicList, canStar } = props; const { musicSheet, musicList, canStar, resolveMusicListBeforeAdd } = props;
const colors = useColors(); const colors = useColors();
const [maxLines, setMaxLines] = useState<number | undefined>(6); const [maxLines, setMaxLines] = useState<number | undefined>(6);
@@ -74,6 +75,8 @@ export default function Header(props: IHeaderProps) {
canStar={canStar} canStar={canStar}
musicList={musicList} musicList={musicList}
musicSheet={musicSheet} musicSheet={musicSheet}
resolveMusicListBeforeAdd={resolveMusicListBeforeAdd}
addToSheetCount={musicSheet?.worksNum ?? musicList?.length}
/> />
</View> </View>
); );
@@ -13,15 +13,24 @@ import { RequestStateCode } from "@/constants/commonConst";
interface IMusicListProps { interface IMusicListProps {
sheetInfo: IMusic.IMusicSheetItem | null; sheetInfo: IMusic.IMusicSheetItem | null;
musicList?: IMusic.IMusicItem[] | null; musicList?: IMusic.IMusicItem[] | null;
// 是否可收藏 // 鏄惁鍙敹钘?
canStar?: boolean; canStar?: boolean;
// 状态 // 鐘舵€?
state: RequestStateCode; state: RequestStateCode;
onRetry?: () => void; onRetry?: () => void;
onLoadMore?: () => void; onLoadMore?: () => void;
resolveMusicListBeforeAdd?: () => Promise<IMusic.IMusicItem[]>;
} }
export default function SheetMusicList(props: IMusicListProps) { export default function SheetMusicList(props: IMusicListProps) {
const { sheetInfo, musicList, canStar, state, onRetry, onLoadMore } = props; const {
sheetInfo,
musicList,
canStar,
state,
onRetry,
onLoadMore,
resolveMusicListBeforeAdd,
} = props;
return ( return (
<View style={globalStyle.fwflex1}> <View style={globalStyle.fwflex1}>
@@ -36,6 +45,7 @@ export default function SheetMusicList(props: IMusicListProps) {
canStar={canStar} canStar={canStar}
musicSheet={sheetInfo} musicSheet={sheetInfo}
musicList={musicList} musicList={musicList}
resolveMusicListBeforeAdd={resolveMusicListBeforeAdd}
/> />
} }
onLoadMore={onLoadMore} onLoadMore={onLoadMore}
@@ -11,17 +11,26 @@ interface IMusicSheetPageProps {
navTitle: string; navTitle: string;
sheetInfo: ICommon.WithMusicList<IMusic.IMusicSheetItemBase> | null; sheetInfo: ICommon.WithMusicList<IMusic.IMusicSheetItemBase> | null;
musicList?: IMusic.IMusicItem[] | null; musicList?: IMusic.IMusicItem[] | null;
// 是否可收藏 // 鏄惁鍙敹钘?
canStar?: boolean; canStar?: boolean;
// 状态 // 鐘舵€?
state: RequestStateCode; state: RequestStateCode;
onRetry?: () => void; onRetry?: () => void;
onLoadMore?: () => void; onLoadMore?: () => void;
resolveMusicListBeforeAdd?: () => Promise<IMusic.IMusicItem[]>;
} }
export default function MusicSheetPage(props: IMusicSheetPageProps) { export default function MusicSheetPage(props: IMusicSheetPageProps) {
const { navTitle, sheetInfo, musicList, canStar, onLoadMore, onRetry, state } = const {
props; navTitle,
sheetInfo,
musicList,
canStar,
onLoadMore,
onRetry,
state,
resolveMusicListBeforeAdd,
} = props;
return ( return (
<VerticalSafeAreaView style={globalStyle.fwflex1}> <VerticalSafeAreaView style={globalStyle.fwflex1}>
@@ -37,6 +46,7 @@ export default function MusicSheetPage(props: IMusicSheetPageProps) {
state={state} state={state}
onRetry={onRetry} onRetry={onRetry}
onLoadMore={onLoadMore} onLoadMore={onLoadMore}
resolveMusicListBeforeAdd={resolveMusicListBeforeAdd}
/> />
<MusicBar /> <MusicBar />
</VerticalSafeAreaView> </VerticalSafeAreaView>
@@ -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<string, any>) => {
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(
<AddToMusicSheet
musicItem={partialList}
resolveMusicItem={resolveMusicItem}
displayCount={3}
/>,
);
});
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);
});
});
@@ -1,4 +1,4 @@
import React from "react"; import React, { useMemo } from "react";
import { StyleSheet, View } from "react-native"; import { StyleSheet, View } from "react-native";
import rpx, { vmax } from "@/utils/rpx"; import rpx, { vmax } from "@/utils/rpx";
import ListItem from "@/components/base/listItem"; import ListItem from "@/components/base/listItem";
@@ -12,20 +12,57 @@ import { hidePanel, showPanel } from "../usePanel";
import PanelHeader from "../base/panelHeader"; import PanelHeader from "../base/panelHeader";
import MusicSheet, { useSheetsBase } from "@/core/musicSheet"; import MusicSheet, { useSheetsBase } from "@/core/musicSheet";
import { useI18N } from "@/core/i18n"; import { useI18N } from "@/core/i18n";
import { showDialog } from "@/components/dialogs/useDialog";
interface IAddToMusicSheetProps { interface IAddToMusicSheetProps {
musicItem: IMusic.IMusicItem | IMusic.IMusicItem[]; musicItem: IMusic.IMusicItem | IMusic.IMusicItem[];
// 如果是新建歌单,可以传入一个默认的名称 resolveMusicItem?: () => Promise<IMusic.IMusicItem[]>;
displayCount?: number;
// 濡傛灉鏄柊寤烘瓕鍗曪紝鍙互浼犲叆涓€涓粯璁ょ殑鍚嶇О
newSheetDefaultName?: string; newSheetDefaultName?: string;
} }
export default function AddToMusicSheet(props: IAddToMusicSheetProps) { export default function AddToMusicSheet(props: IAddToMusicSheetProps) {
const sheets = useSheetsBase(); const sheets = useSheetsBase();
const { musicItem = [], newSheetDefaultName } = props ?? {}; const {
musicItem = [],
newSheetDefaultName,
resolveMusicItem,
displayCount,
} = props ?? {};
const safeAreaInsets = useSafeAreaInsets(); const safeAreaInsets = useSafeAreaInsets();
const { t } = useI18N(); 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 ( return (
<PanelBase <PanelBase
renderBody={() => ( renderBody={() => (
@@ -34,7 +71,7 @@ export default function AddToMusicSheet(props: IAddToMusicSheetProps) {
hideButtons hideButtons
title={ title={
t("panel.addToMusicSheet.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, defaultName: newSheetDefaultName,
async onSheetCreated(sheetId) { async onSheetCreated(sheetId) {
try { try {
await MusicSheet.addMusic( await addMusicToSheet(sheetId);
sheetId,
musicItem,
);
Toast.success(
t("panel.addToMusicSheet.toast.success"),
);
} catch { } catch {
Toast.warn( Toast.warn(
t("panel.addToMusicSheet.toast.fail"), t("panel.addToMusicSheet.toast.fail"),
@@ -69,7 +100,9 @@ export default function AddToMusicSheet(props: IAddToMusicSheetProps) {
}, },
onCancel() { onCancel() {
showPanel("AddToMusicSheet", { showPanel("AddToMusicSheet", {
musicItem: musicItem, musicItem,
resolveMusicItem,
displayCount,
newSheetDefaultName, newSheetDefaultName,
}); });
}, },
@@ -86,16 +119,7 @@ export default function AddToMusicSheet(props: IAddToMusicSheetProps) {
withHorizontalPadding withHorizontalPadding
key={`${sheet.id}`} key={`${sheet.id}`}
onPress={async () => { onPress={async () => {
try { await addMusicToSheet(sheet.id);
await MusicSheet.addMusic(
sheet.id,
musicItem,
);
hidePanel();
Toast.success(t("panel.addToMusicSheet.toast.success"));
} catch {
Toast.warn(t("panel.addToMusicSheet.toast.fail"));
}
}}> }}>
<ListItem.ListItemImage <ListItem.ListItemImage
uri={sheet.coverImg} uri={sheet.coverImg}
@@ -46,6 +46,7 @@
"sidebar.languageSettings": "Language Settings", "sidebar.languageSettings": "Language Settings",
"sidebar.downloadManager": "Download Manager", "sidebar.downloadManager": "Download Manager",
"checkUpdate.error.latestVersion": "Already the latest version", "checkUpdate.error.latestVersion": "Already the latest version",
"checkUpdate.error.checkFailed": "Failed to check for updates. Verify the update source and try again.",
"home.recommendSheet": "Recommended Playlists", "home.recommendSheet": "Recommended Playlists",
"home.topList": "Charts", "home.topList": "Charts",
"home.playHistory": "Play History", "home.playHistory": "Play History",
@@ -46,6 +46,7 @@
"sidebar.languageSettings": "语言设置", "sidebar.languageSettings": "语言设置",
"sidebar.downloadManager": "下载管理", "sidebar.downloadManager": "下载管理",
"checkUpdate.error.latestVersion": "当前已是最新版本", "checkUpdate.error.latestVersion": "当前已是最新版本",
"checkUpdate.error.checkFailed": "检查更新失败,请确认更新源可访问后重试",
"home.recommendSheet": "推荐歌单", "home.recommendSheet": "推荐歌单",
"home.topList": "榜单", "home.topList": "榜单",
"home.playHistory": "播放历史", "home.playHistory": "播放历史",
@@ -46,6 +46,7 @@
"sidebar.languageSettings": "語言設定", "sidebar.languageSettings": "語言設定",
"sidebar.downloadManager": "下載管理", "sidebar.downloadManager": "下載管理",
"checkUpdate.error.latestVersion": "當前已是最新版本", "checkUpdate.error.latestVersion": "當前已是最新版本",
"checkUpdate.error.checkFailed": "檢查更新失敗,請確認更新來源可存取後重試",
"home.recommendSheet": "推薦歌單", "home.recommendSheet": "推薦歌單",
"home.topList": "榜單", "home.topList": "榜單",
"home.playHistory": "播放歷史", "home.playHistory": "播放歷史",
+1 -1
View File
@@ -26,7 +26,7 @@ import { default as DeviceInfo, default as deviceInfoModule } from "react-native
import RNFS, { exists, readFile, stat, writeFile } from "react-native-fs"; import RNFS, { exists, readFile, stat, writeFile } from "react-native-fs";
import { URL } from "react-native-url-polyfill"; import { URL } from "react-native-url-polyfill";
import * as webdav from "webdav"; import * as webdav from "webdav";
import { devLog, errorLog, trace } from "../../utils/log"; import { devLog, errorLog, persistErrorLog, trace } from "../../utils/log";
import Network from "../../utils/network"; import Network from "../../utils/network";
import LocalMusicSheet from "../localMusicSheet"; import LocalMusicSheet from "../localMusicSheet";
import MediaCache from "../mediaCache"; import MediaCache from "../mediaCache";
+60 -7
View File
@@ -8,7 +8,7 @@ import pathConst from "@/constants/pathConst";
import { MusicRepeatMode } from "@/constants/repeatModeConst"; import { MusicRepeatMode } from "@/constants/repeatModeConst";
import delay from "@/utils/delay"; import delay from "@/utils/delay";
import getUrlExt from "@/utils/getUrlExt"; import getUrlExt from "@/utils/getUrlExt";
import { errorLog, forceTrace, trace } from "@/utils/log"; import { errorLog, forceTrace, persistErrorLog, trace } from "@/utils/log";
import { createMediaIndexMap } from "@/utils/mediaIndexMap"; import { createMediaIndexMap } from "@/utils/mediaIndexMap";
import { import {
getLocalPath, getLocalPath,
@@ -36,11 +36,13 @@ import {
PlaybackErrorAction, PlaybackErrorAction,
decidePlaybackErrorAction, decidePlaybackErrorAction,
} from "./playbackErrorDecision"; } from "./playbackErrorDecision";
import { shouldHandlePlaybackQueueEnded } from "./playbackTransitionDecision";
import { import {
FAKE_TAIL_SILENT_WAV_BASE64, FAKE_TAIL_SILENT_WAV_BASE64,
resolveFakeTailAudioPath, resolveFakeTailAudioPath,
shouldRewriteFakeTailAudio, shouldRewriteFakeTailAudio,
} from "./fakeTailAudio"; } from "./fakeTailAudio";
import { buildInvalidSourceLogPayload } from "./playbackFailureLog";
import { TrackPlayerEvents } from "@/core.defination/trackPlayer"; import { TrackPlayerEvents } from "@/core.defination/trackPlayer";
import type { IAppConfig } from "@/types/core/config"; import type { IAppConfig } from "@/types/core/config";
@@ -319,6 +321,38 @@ class TrackPlayer extends EventEmitter<{
}, },
); );
ReactNativeTrackPlayer.addEventListener(
Event.PlaybackQueueEnded,
async evt => {
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; this.serviceInited = true;
} }
} }
@@ -723,6 +757,13 @@ class TrackPlayer extends EventEmitter<{
} }
} else if (message === PlayFailReason.INVALID_SOURCE) { } else if (message === PlayFailReason.INVALID_SOURCE) {
trace("Invalid source, playback failed"); trace("Invalid source, playback failed");
persistErrorLog(
"[TrackPlayer] invalid-source",
buildInvalidSourceLogPayload(
musicItem,
!this.configService.getConfig("basic.autoStopWhenError"),
),
);
await this.handlePlayFail(); await this.handlePlayFail();
} else if (message === PlayFailReason.PLAY_LIST_IS_EMPTY) { } else if (message === PlayFailReason.PLAY_LIST_IS_EMPTY) {
// 闂冪喎鍨弰顖溾敄閻ㄥ嫸绱濇稉宥呯安鐠囥儱鍤悳鎷岀箹缁夊秵鍎忛敓? // 闂冪喎鍨弰顖溾敄閻ㄥ嫸绱濇稉宥呯安鐠囥儱鍤悳鎷岀箹缁夊秵鍎忛敓?
@@ -754,11 +795,18 @@ class TrackPlayer extends EventEmitter<{
return; return;
} }
const targetMusic = this.getPlayListMusicAt(this.currentIndex + 1);
forceTrace("[TrackPlayer] skipToNext", { forceTrace("[TrackPlayer] skipToNext", {
currentIndex: this.currentIndex, currentIndex: this.currentIndex,
repeatMode: this.repeatMode, 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<void> { async skipToPrevious(): Promise<void> {
@@ -767,14 +815,20 @@ class TrackPlayer extends EventEmitter<{
return; return;
} }
const targetMusic = this.getPlayListMusicAt(
this.currentIndex === -1 ? 0 : this.currentIndex - 1,
);
forceTrace("[TrackPlayer] skipToPrevious", { forceTrace("[TrackPlayer] skipToPrevious", {
currentIndex: this.currentIndex, currentIndex: this.currentIndex,
repeatMode: this.repeatMode, repeatMode: this.repeatMode,
targetMusic: targetMusic
? {
id: targetMusic.id,
platform: targetMusic.platform,
}
: null,
}); });
await this.play( await this.play(targetMusic, true);
this.getPlayListMusicAt(this.currentIndex === -1 ? 0 : this.currentIndex - 1),
true,
);
} }
async changeQuality(newQuality: IMusic.IQualityKey): Promise<boolean> { async changeQuality(newQuality: IMusic.IQualityKey): Promise<boolean> {
@@ -1335,4 +1389,3 @@ enum PlayFailReason {
const trackPlayer = new TrackPlayer(); const trackPlayer = new TrackPlayer();
export default trackPlayer; export default trackPlayer;
@@ -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",
});
});
});
@@ -0,0 +1,13 @@
export function buildInvalidSourceLogPayload(
musicItem?: Partial<IMusic.IMusicItem> | null,
triggerAutoSkip = false,
) {
return {
artist: musicItem?.artist,
id: musicItem?.id,
platform: musicItem?.platform,
title: musicItem?.title,
triggerAutoSkip,
type: "invalid-source",
};
}
@@ -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);
});
});
@@ -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;
}
+7 -7
View File
@@ -25,7 +25,7 @@ import { PERMISSIONS, check, request } from "react-native-permissions";
import RNTrackPlayer, { AppKilledPlaybackBehavior, Capability } from "react-native-track-player"; import RNTrackPlayer, { AppKilledPlaybackBehavior, Capability } from "react-native-track-player";
import i18n from "@/core/i18n"; import i18n from "@/core/i18n";
import bootstrapAtom from "./bootstrap.atom"; import bootstrapAtom from "./bootstrap.atom";
import { getTrackPlayerOptionPayload } from "./trackPlayerOptions"; import { applyTrackPlayerOptions } from "./trackPlayerOptions";
import { getDefaultStore } from "jotai"; import { getDefaultStore } from "jotai";
@@ -164,14 +164,14 @@ export async function initTrackPlayer(logger?: IPerfLogger) {
} }
logger?.mark("加载播放器"); logger?.mark("加载播放器");
await RNTrackPlayer.updateOptions({ await applyTrackPlayerOptions({
showExitOnNotification:
!!Config.getConfig("basic.showExitOnNotification"),
Capability,
AppKilledPlaybackBehavior,
updateOptions: RNTrackPlayer.updateOptions,
icon: ImgAsset.logoTransparent, icon: ImgAsset.logoTransparent,
progressUpdateEventInterval: 1, progressUpdateEventInterval: 1,
...getTrackPlayerOptionPayload(
!!Config.getConfig("basic.showExitOnNotification"),
Capability,
AppKilledPlaybackBehavior,
),
}); });
logger?.mark("播放器初始化完成"); logger?.mark("播放器初始化完成");
trace("播放器初始化完成"); trace("播放器初始化完成");
@@ -1,4 +1,7 @@
import { getTrackPlayerOptionPayload } from "./trackPlayerOptions"; import {
applyTrackPlayerOptions,
getTrackPlayerOptionPayload,
} from "./trackPlayerOptions";
describe("getTrackPlayerOptionPayload", () => { describe("getTrackPlayerOptionPayload", () => {
const Capability = { const Capability = {
@@ -12,6 +15,8 @@ describe("getTrackPlayerOptionPayload", () => {
const AppKilledPlaybackBehavior = { const AppKilledPlaybackBehavior = {
ContinuePlayback: "ContinuePlayback", ContinuePlayback: "ContinuePlayback",
StopPlaybackAndRemoveNotification:
"StopPlaybackAndRemoveNotification",
} as const; } as const;
it("keeps the playback service foreground across short track transition gaps", () => { 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.stopForegroundGracePeriod).toBe(30);
expect(options.android.appKilledPlaybackBehavior).toBe( expect(options.android.appKilledPlaybackBehavior).toBe(
AppKilledPlaybackBehavior.ContinuePlayback, AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification,
); );
expect(options.android.alwaysPauseOnInterruption).toBe(false); expect(options.android.alwaysPauseOnInterruption).toBe(false);
}); });
@@ -44,5 +49,34 @@ describe("getTrackPlayerOptionPayload", () => {
expect(withoutStop.capabilities).not.toContain(Capability.Stop); expect(withoutStop.capabilities).not.toContain(Capability.Stop);
expect(withStop.notificationCapabilities).toContain(Capability.SeekTo); expect(withStop.notificationCapabilities).toContain(Capability.SeekTo);
expect(withoutStop.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);
}); });
}); });
@@ -9,8 +9,11 @@ type CapabilityLike = {
type AppKilledPlaybackBehaviorLike = { type AppKilledPlaybackBehaviorLike = {
ContinuePlayback: unknown; ContinuePlayback: unknown;
StopPlaybackAndRemoveNotification: unknown;
}; };
type UpdateOptionsFn = (options: Record<string, unknown>) => Promise<unknown>;
export function getTrackPlayerOptionPayload( export function getTrackPlayerOptionPayload(
showExitOnNotification: boolean, showExitOnNotification: boolean,
Capability: CapabilityLike, Capability: CapabilityLike,
@@ -30,16 +33,58 @@ export function getTrackPlayerOptionPayload(
Capability.SkipToNext, Capability.SkipToNext,
Capability.SkipToPrevious, Capability.SkipToPrevious,
]; ];
const notificationCapabilities = showExitOnNotification
? [
Capability.Play,
Capability.SkipToNext,
Capability.SkipToPrevious,
Capability.Stop,
Capability.SeekTo,
]
: [
Capability.Play,
Capability.SkipToNext,
Capability.SkipToPrevious,
Capability.SeekTo,
];
return { return {
android: { android: {
alwaysPauseOnInterruption: false, alwaysPauseOnInterruption: false,
appKilledPlaybackBehavior: appKilledPlaybackBehavior:
AppKilledPlaybackBehavior.ContinuePlayback, AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification,
stopForegroundGracePeriod: 30, stopForegroundGracePeriod: 30,
}, },
capabilities, capabilities,
compactCapabilities: capabilities, compactCapabilities: notificationCapabilities,
notificationCapabilities: [...capabilities, Capability.SeekTo], 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;
}
@@ -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,
);
});
});
+102
View File
@@ -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",
});
});
});
+7 -3
View File
@@ -14,7 +14,6 @@ export const checkUpdateAndShowResult = (
if (updateInfo?.needUpdate) { if (updateInfo?.needUpdate) {
const { data } = updateInfo; const { data } = updateInfo;
const skipVersion = PersistStatus.get("app.skipVersion"); const skipVersion = PersistStatus.get("app.skipVersion");
console.log(skipVersion, data);
if ( if (
checkSkip && checkSkip &&
skipVersion && skipVersion &&
@@ -28,8 +27,13 @@ export const checkUpdateAndShowResult = (
fromUrl: data.download[0], fromUrl: data.download[0],
backUrl: data.download[1], backUrl: data.download[1],
}); });
} else { return;
if (showToast) { }
if (showToast) {
if (updateInfo?.error) {
Toast.warn(i18n.t("checkUpdate.error.checkFailed"));
} else {
Toast.success(i18n.t("checkUpdate.error.latestVersion")); Toast.success(i18n.t("checkUpdate.error.latestVersion"));
} }
} }
+4
View File
@@ -6,6 +6,10 @@ interface INativeUtils extends NativeModule {
requestStoragePermission: () => void; requestStoragePermission: () => void;
isIgnoringBatteryOptimizations: () => Promise<boolean>; isIgnoringBatteryOptimizations: () => Promise<boolean>;
requestIgnoreBatteryOptimizations: () => Promise<boolean>; requestIgnoreBatteryOptimizations: () => Promise<boolean>;
showPlaybackDiagnosticNotification: (
title: string,
message: string,
) => Promise<void>;
getWindowDimensions: () => { width: number, height: number }; // Fix bug: https://github.com/facebook/react-native/issues/47080 getWindowDimensions: () => { width: number, height: number }; // Fix bug: https://github.com/facebook/react-native/issues/47080
} }
@@ -1,13 +1,21 @@
import { RequestStateCode } from "@/constants/commonConst"; import { RequestStateCode } from "@/constants/commonConst";
import PluginManager from "@/core/pluginManager"; import PluginManager from "@/core/pluginManager";
import { resolvePagedMusicList } from "@/utils/resolvePagedMusicList";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
export default function useAlbumDetail( export default function useAlbumDetail(
originalAlbumItem: IAlbum.IAlbumItem | null, originalAlbumItem: IAlbum.IAlbumItem | null,
) { ) {
const currentPageRef = useRef(1); const currentPageRef = useRef(1);
const musicListRef = useRef<IMusic.IMusicItem[]>(
originalAlbumItem?.musicList ?? [],
);
const requestStateRef = useRef<RequestStateCode>(RequestStateCode.IDLE);
const pendingLoadRef = useRef<Promise<void> | null>(null);
const [requestState, setRequestState] = useState<RequestStateCode>(RequestStateCode.IDLE); const [requestState, setRequestState] = useState<RequestStateCode>(
RequestStateCode.IDLE,
);
const [albumItem, setAlbumItem] = useState<IAlbum.IAlbumItemBase | null>( const [albumItem, setAlbumItem] = useState<IAlbum.IAlbumItemBase | null>(
originalAlbumItem, originalAlbumItem,
); );
@@ -15,63 +23,99 @@ export default function useAlbumDetail(
originalAlbumItem?.musicList ?? [], originalAlbumItem?.musicList ?? [],
); );
const getAlbumDetail = useCallback( const getAlbumDetail = useCallback(async function () {
async function () { if (
// 加载中:直接退出 originalAlbumItem === null ||
if (originalAlbumItem === null || requestStateRef.current === RequestStateCode.FINISHED ||
requestState === RequestStateCode.FINISHED || requestStateRef.current === RequestStateCode.PENDING_FIRST_PAGE ||
requestState === RequestStateCode.PENDING_FIRST_PAGE || requestStateRef.current === RequestStateCode.PENDING_REST_PAGE
requestState === RequestStateCode.PENDING_REST_PAGE) { ) {
return; return;
} }
const task = (async () => {
try { try {
if (currentPageRef.current === 1) { const currentPage = currentPageRef.current;
setRequestState(RequestStateCode.PENDING_FIRST_PAGE); const nextState =
} else { currentPage === 1
setRequestState(RequestStateCode.PENDING_REST_PAGE); ? RequestStateCode.PENDING_FIRST_PAGE
} : RequestStateCode.PENDING_REST_PAGE;
requestStateRef.current = nextState;
setRequestState(nextState);
const result = await PluginManager.getByMedia( const result = await PluginManager.getByMedia(
originalAlbumItem, originalAlbumItem,
)?.methods?.getAlbumInfo?.( )?.methods?.getAlbumInfo?.(originalAlbumItem, currentPage);
originalAlbumItem,
currentPageRef.current,
);
if (!result) { if (!result) {
throw new Error(); throw new Error();
} }
if (result?.albumItem) { if (result.albumItem) {
setAlbumItem(prev => ({ setAlbumItem(prev => ({
...(prev ?? {}), ...(prev ?? {}),
...(result.albumItem as IAlbum.IAlbumItemBase), ...(result.albumItem as IAlbum.IAlbumItemBase),
platform: originalAlbumItem.platform, platform: originalAlbumItem.platform,
})); }));
} }
if (result?.musicList) { if (result.musicList) {
setMusicList(prev => { setMusicList(prev => {
if (currentPageRef.current === 1) { const nextMusicList =
return result?.musicList ?? prev; currentPage === 1
} else { ? result.musicList ?? prev
return [...prev, ...(result.musicList ?? [])]; : [...prev, ...(result.musicList ?? [])];
} musicListRef.current = nextMusicList;
return nextMusicList;
}); });
} }
if (result.isEnd) { const finished =
setRequestState(RequestStateCode.FINISHED); result.isEnd === false
} else { ? RequestStateCode.PARTLY_DONE
setRequestState(RequestStateCode.PARTLY_DONE); : RequestStateCode.FINISHED;
} requestStateRef.current = finished;
setRequestState(finished);
currentPageRef.current += 1; currentPageRef.current += 1;
} catch { } catch {
requestStateRef.current = RequestStateCode.ERROR;
setRequestState(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(() => { useEffect(() => {
getAlbumDetail(); getAlbumDetail();
}, []); }, [getAlbumDetail]);
return [requestState, albumItem, musicList, getAlbumDetail] as const; return [
requestState,
albumItem,
musicList,
getAlbumDetail,
resolveMusicListBeforeAdd,
] as const;
} }
+8 -1
View File
@@ -6,7 +6,13 @@ import { useI18N } from "@/core/i18n";
export default function AlbumDetail() { export default function AlbumDetail() {
const { albumItem: originalAlbumItem } = useParams<"album-detail">(); const { albumItem: originalAlbumItem } = useParams<"album-detail">();
const [requestStateCode, albumItem, musicList, getAlbumDetail] = const [
requestStateCode,
albumItem,
musicList,
getAlbumDetail,
resolveMusicListBeforeAdd,
] =
useAlbumDetail(originalAlbumItem); useAlbumDetail(originalAlbumItem);
const { t } = useI18N(); const { t } = useI18N();
@@ -18,6 +24,7 @@ export default function AlbumDetail() {
onRetry={getAlbumDetail} onRetry={getAlbumDetail}
onLoadMore={getAlbumDetail} onLoadMore={getAlbumDetail}
musicList={musicList} musicList={musicList}
resolveMusicListBeforeAdd={resolveMusicListBeforeAdd}
/> />
); );
} }
@@ -1,13 +1,21 @@
import { RequestStateCode } from "@/constants/commonConst"; import { RequestStateCode } from "@/constants/commonConst";
import PluginManager from "@/core/pluginManager"; import PluginManager from "@/core/pluginManager";
import { resolvePagedMusicList } from "@/utils/resolvePagedMusicList";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
export default function usePluginSheetMusicList( export default function usePluginSheetMusicList(
originalSheetItem: IMusic.IMusicSheetItem | null, originalSheetItem: IMusic.IMusicSheetItem | null,
) { ) {
const currentPageRef = useRef(1); const currentPageRef = useRef(1);
const musicListRef = useRef<IMusic.IMusicItem[]>(
originalSheetItem?.musicList ?? [],
);
const requestStateRef = useRef<RequestStateCode>(RequestStateCode.IDLE);
const pendingLoadRef = useRef<Promise<void> | null>(null);
const [requestState, setRequestState] = useState<RequestStateCode>(RequestStateCode.IDLE); const [requestState, setRequestState] = useState<RequestStateCode>(
RequestStateCode.IDLE,
);
const [sheetItem, setSheetItem] = useState<IMusic.IMusicSheetItem | null>( const [sheetItem, setSheetItem] = useState<IMusic.IMusicSheetItem | null>(
originalSheetItem, originalSheetItem,
); );
@@ -15,62 +23,100 @@ export default function usePluginSheetMusicList(
originalSheetItem?.musicList ?? [], originalSheetItem?.musicList ?? [],
); );
const getSheetDetail = useCallback( const getSheetDetail = useCallback(async function () {
async function () { if (
// 加载中:直接退出 originalSheetItem === null ||
if (originalSheetItem === null || requestStateRef.current === RequestStateCode.FINISHED ||
requestState === RequestStateCode.FINISHED || requestStateRef.current === RequestStateCode.PENDING_FIRST_PAGE ||
requestState === RequestStateCode.PENDING_FIRST_PAGE || requestStateRef.current === RequestStateCode.PENDING_REST_PAGE
requestState === RequestStateCode.PENDING_REST_PAGE) { ) {
return; return;
} }
const task = (async () => {
try { try {
if (currentPageRef.current === 1) { const currentPage = currentPageRef.current;
setRequestState(RequestStateCode.PENDING_FIRST_PAGE); const nextState =
} else { currentPage === 1
setRequestState(RequestStateCode.PENDING_REST_PAGE); ? RequestStateCode.PENDING_FIRST_PAGE
} : RequestStateCode.PENDING_REST_PAGE;
requestStateRef.current = nextState;
setRequestState(nextState);
const result = await PluginManager.getByMedia( const result = await PluginManager.getByMedia(
originalSheetItem as any, originalSheetItem as any,
)?.methods?.getMusicSheetInfo?.( )?.methods?.getMusicSheetInfo?.(originalSheetItem, currentPage);
originalSheetItem,
currentPageRef.current,
);
if (!result) { if (!result) {
throw new Error(); throw new Error();
} }
if (result?.sheetItem) { if (result.sheetItem) {
setSheetItem(prev => ({ setSheetItem(prev => ({
...(prev ?? {}), ...(prev ?? {}),
...(result.sheetItem as IMusic.IMusicSheetItem), ...(result.sheetItem as IMusic.IMusicSheetItem),
platform: originalSheetItem.platform, platform: originalSheetItem.platform,
})); }));
} }
if (result?.musicList) { if (result.musicList) {
setMusicList(prev => { setMusicList(prev => {
if (currentPageRef.current === 1) { const nextMusicList =
return result?.musicList ?? prev; currentPage === 1
} else { ? result.musicList ?? prev
return [...prev, ...(result.musicList ?? [])]; : [...prev, ...(result.musicList ?? [])];
} musicListRef.current = nextMusicList;
return nextMusicList;
}); });
} }
if (result.isEnd) { const finished =
setRequestState(RequestStateCode.FINISHED); result.isEnd === false
} else { ? RequestStateCode.PARTLY_DONE
setRequestState(RequestStateCode.PARTLY_DONE); : RequestStateCode.FINISHED;
} requestStateRef.current = finished;
setRequestState(finished);
currentPageRef.current += 1; currentPageRef.current += 1;
} catch { } catch {
requestStateRef.current = RequestStateCode.ERROR;
setRequestState(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(() => { useEffect(() => {
getSheetDetail(); getSheetDetail();
}, []); }, [getSheetDetail]);
return [requestState, sheetItem, musicList, getSheetDetail] as const; return [
requestState,
sheetItem,
musicList,
getSheetDetail,
resolveMusicListBeforeAdd,
] as const;
} }
@@ -7,7 +7,13 @@ import i18n from "@/core/i18n";
export default function PluginSheetDetail() { export default function PluginSheetDetail() {
const { sheetInfo } = useParams<"plugin-sheet-detail">(); const { sheetInfo } = useParams<"plugin-sheet-detail">();
const [requestState, sheetItem, musicList, getSheetDetail] = const [
requestState,
sheetItem,
musicList,
getSheetDetail,
resolveMusicListBeforeAdd,
] =
usePluginSheetMusicList(sheetInfo as IMusic.IMusicSheetItem); usePluginSheetMusicList(sheetInfo as IMusic.IMusicSheetItem);
return ( return (
<MusicSheetPage <MusicSheetPage
@@ -18,6 +24,7 @@ export default function PluginSheetDetail() {
state={requestState} state={requestState}
onRetry={getSheetDetail} onRetry={getSheetDetail}
onLoadMore={getSheetDetail} onLoadMore={getSheetDetail}
resolveMusicListBeforeAdd={resolveMusicListBeforeAdd}
/> />
); );
} }
@@ -1,6 +1,7 @@
import { RequestStateCode } from "@/constants/commonConst"; import { RequestStateCode } from "@/constants/commonConst";
import PluginManager from "@/core/pluginManager"; 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( export default function useTopListDetail(
topListItem: IMusic.IMusicSheetItemBase | null, topListItem: IMusic.IMusicSheetItemBase | null,
@@ -12,64 +13,107 @@ export default function useTopListDetail(
); );
const pageRef = useRef(1); const pageRef = useRef(1);
const musicListRef = useRef<IMusic.IMusicItem[]>(topListItem?.musicList ?? []);
const requestStateRef = useRef<RequestStateCode>(RequestStateCode.IDLE);
const pendingLoadRef = useRef<Promise<void> | null>(null);
const [requestState, setRequestState] = useState(RequestStateCode.IDLE); const [requestState, setRequestState] = useState(RequestStateCode.IDLE);
async function loadMore() { const loadMore = useCallback(async () => {
if (!topListItem) { if (
!topListItem ||
requestStateRef.current === RequestStateCode.PENDING_FIRST_PAGE ||
requestStateRef.current === RequestStateCode.PENDING_REST_PAGE ||
requestStateRef.current === RequestStateCode.FINISHED
) {
return; return;
} }
try {
if ( const task = (async () => {
requestState === RequestStateCode.PENDING_FIRST_PAGE || try {
requestState === RequestStateCode.PENDING_REST_PAGE || const currentPage = pageRef.current;
requestState === RequestStateCode.FINISHED const nextState =
) { currentPage === 1
return; ? RequestStateCode.PENDING_FIRST_PAGE
} : RequestStateCode.PENDING_REST_PAGE;
if (pageRef.current === 1) { requestStateRef.current = nextState;
setRequestState(RequestStateCode.PENDING_FIRST_PAGE); setRequestState(nextState);
} else {
setRequestState(RequestStateCode.PENDING_REST_PAGE); const result = await PluginManager.getByHash(
} pluginHash,
const result = await PluginManager.getByHash( )?.methods?.getTopListDetail(topListItem, currentPage);
pluginHash, if (!result) {
)?.methods?.getTopListDetail(topListItem, pageRef.current); throw new Error();
if (!result) { }
throw new Error();
} setMergedTopListItem(prev => {
const currentPage = pageRef.current; const nextMusicList =
setMergedTopListItem( currentPage === 1
prev => ? result.musicList ?? []
({ : [
...(prev?.musicList ?? []),
...(result.musicList ?? []),
];
musicListRef.current = nextMusicList;
return {
...prev, ...prev,
...result.topListItem, ...result.topListItem,
musicList: musicList: nextMusicList,
currentPage === 1 } as IMusic.IMusicSheetItem;
? result.musicList ?? [] });
: [
...(prev?.musicList ?? []),
...(result.musicList ?? []),
],
} as IMusic.IMusicSheetItem),
);
if (result.isEnd === false) { const finished =
setRequestState(RequestStateCode.PARTLY_DONE); result.isEnd === false
} else { ? RequestStateCode.PARTLY_DONE
setRequestState(RequestStateCode.FINISHED); : 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(() => { useEffect(() => {
if (topListItem === null) { if (topListItem === null) {
return; return;
} }
loadMore(); loadMore();
}, []); }, [loadMore, topListItem]);
return [mergedTopListItem, requestState, loadMore] as const;
return [
mergedTopListItem,
requestState,
loadMore,
resolveMusicListBeforeAdd,
] as const;
} }
+2 -1
View File
@@ -6,7 +6,7 @@ import useTopListDetail from "./hooks/useTopListDetail";
export default function TopListDetail() { export default function TopListDetail() {
const { pluginHash, topList } = useParams<"top-list-detail">(); const { pluginHash, topList } = useParams<"top-list-detail">();
const [topListDetail, state, loadMore] = useTopListDetail( const [topListDetail, state, loadMore, resolveMusicListBeforeAdd] = useTopListDetail(
topList, topList,
pluginHash, pluginHash,
); );
@@ -19,6 +19,7 @@ export default function TopListDetail() {
state={state} state={state}
onLoadMore={loadMore} onLoadMore={loadMore}
onRetry={loadMore} onRetry={loadMore}
resolveMusicListBeforeAdd={resolveMusicListBeforeAdd}
/> />
); );
} }
+202 -3
View File
@@ -1,13 +1,26 @@
import Config from "@/core/appConfig"; import Config from "@/core/appConfig";
import { ImgAsset } from "@/constants/assetsConst";
import pathConst from "@/constants/pathConst"; import pathConst from "@/constants/pathConst";
import musicHistory from "@/core/musicHistory"; import musicHistory from "@/core/musicHistory";
import PluginManager from "@/core/pluginManager"; 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 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 { 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 { musicIsPaused } from "@/utils/trackUtils";
import PersistStatus from "@/utils/persistStatus"; import PersistStatus from "@/utils/persistStatus";
import { applyTrackPlayerOptions } from "@/entry/bootstrap/trackPlayerOptions";
( (
globalThis as typeof globalThis & { globalThis as typeof globalThis & {
@@ -17,6 +30,131 @@ import PersistStatus from "@/utils/persistStatus";
let resumeState: State | null; let resumeState: State | null;
let serviceReadyPromise: Promise<void> | null = null; let serviceReadyPromise: Promise<void> | null = null;
let playbackHealthCheckTimer: ReturnType<typeof setInterval> | 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<IMusic.IMusicItem> | 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) { async function ensureServiceReady(from: string) {
if (serviceReadyPromise) { if (serviceReadyPromise) {
@@ -40,10 +178,28 @@ async function ensureServiceReady(from: string) {
await Config.setup(); await Config.setup();
await musicHistory.setup(); await musicHistory.setup();
await PluginManager.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(); await TrackPlayer.setupTrackPlayer();
trace(`[TrackPlayer service] ready from ${from}`); trace(`[TrackPlayer service] ready from ${from}`);
forceTrace("[PlaybackService] ready", { from }); forceTrace("[PlaybackService] ready", { from });
startPlaybackHealthCheck();
} catch (e: any) { } catch (e: any) {
serviceReadyPromise = null; serviceReadyPromise = null;
throw e; throw e;
@@ -97,7 +253,13 @@ module.exports = async function () {
runSafely("RemotePause", () => TrackPlayer.pause()), runSafely("RemotePause", () => TrackPlayer.pause()),
); );
RNTrackPlayer.addEventListener(Event.RemotePrevious, () => 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, () => RNTrackPlayer.addEventListener(Event.RemoteNext, () =>
runSafely("RemoteNext", () => TrackPlayer.skipToNext()), runSafely("RemoteNext", () => TrackPlayer.skipToNext()),
@@ -170,6 +332,43 @@ module.exports = async function () {
RNTrackPlayer.addEventListener(Event.PlaybackProgressUpdated, evt => { RNTrackPlayer.addEventListener(Event.PlaybackProgressUpdated, evt => {
PersistStatus.set("music.progress", evt.position); 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<string, unknown> | null,
activeTrackIndex,
currentMusic: TrackPlayer.currentMusic as
| Record<string, unknown>
| null,
eventCode: evt.code,
eventMessage: evt.message,
persistedTrack: PersistStatus.get("music.musicItem") as
| Record<string, unknown>
| null,
playbackState: toPlaybackStateValue(
playbackState?.state as State | string | null,
),
progress,
queueTrack: queueTrack as Record<string, unknown> | null,
});
playbackAnomalyMonitor.recordPlaybackError({
...diagnostics,
code: evt.code,
diagnostics: diagnostics.diagnostics,
message: evt.message,
});
});
}); });
RNTrackPlayer.addEventListener(Event.RemoteStop, async () => { RNTrackPlayer.addEventListener(Event.RemoteStop, async () => {
@@ -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",
}),
);
});
});
@@ -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<string, unknown> | 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<string, unknown> | 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<void>;
stallThresholdMs: number;
}
const ACTIVE_STALL_STATES = new Set<PlaybackAnomalyState>([
"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 = "";
},
};
}
@@ -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,
);
});
});
@@ -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,
};
}
+1
View File
@@ -58,6 +58,7 @@ export interface ILanguageData {
// 检查更新相关 // 检查更新相关
"checkUpdate.error.latestVersion": string; // 当前已是最新版本 "checkUpdate.error.latestVersion": string; // 当前已是最新版本
"checkUpdate.error.checkFailed": string; // 检查更新失败
// 首页相关 // 首页相关
"home.recommendSheet": string; // 推荐歌单 "home.recommendSheet": string; // 推荐歌单
+58 -1
View File
@@ -83,7 +83,10 @@ describe("checkUpdate", () => {
const result = await checkUpdate(); const result = await checkUpdate();
expect(result).toBeUndefined(); expect(result).toEqual({
needUpdate: false,
error: true,
});
expect(axios.get).toHaveBeenCalledTimes(1); expect(axios.get).toHaveBeenCalledTimes(1);
expect(axios.get).toHaveBeenCalledWith( expect(axios.get).toHaveBeenCalledWith(
"http://10.0.0.2:18080/app/version.json", "http://10.0.0.2:18080/app/version.json",
@@ -99,4 +102,58 @@ describe("checkUpdate", () => {
expect(result).toBeUndefined(); expect(result).toBeUndefined();
expect(axios.get).not.toHaveBeenCalled(); 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,
});
});
}); });
+9 -7
View File
@@ -8,6 +8,7 @@ const musicServerAppVersionPath = "/app/version.json";
interface IUpdateInfo { interface IUpdateInfo {
needUpdate: boolean; needUpdate: boolean;
error?: boolean;
data: { data: {
version: string; version: string;
changeLog: string[]; changeLog: string[];
@@ -74,13 +75,14 @@ export default async function checkUpdate(): Promise<IUpdateInfo | undefined> {
} }
try { try {
const rawInfo = (await axios.get(updateUrl)).data; const rawInfo = (await axios.get(updateUrl)).data;
if (compare(rawInfo.version, currentVersion, ">")) { return {
return { needUpdate: compare(rawInfo.version, currentVersion, ">"),
needUpdate: true, data: rawInfo,
data: rawInfo, };
};
}
} catch { } catch {
return; return {
needUpdate: false,
error: true,
} as IUpdateInfo;
} }
} }
+162
View File
@@ -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");
});
});
+52 -1
View File
@@ -26,6 +26,27 @@ const traceConfig = {
const log = logger.createLogger(config); const log = logger.createLogger(config);
const traceLogger = logger.createLogger(traceConfig); 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( export function trace(
desc: string, desc: string,
@@ -97,7 +118,6 @@ export async function clearLog() {
export async function getErrorLogContent() { export async function getErrorLogContent() {
try { try {
const files = await readDir(pathConst.logPath); const files = await readDir(pathConst.logPath);
console.log(files);
const today = new Date(); const today = new Date();
// 两天的错误日志 // 两天的错误日志
const yesterday = new Date(); const yesterday = new Date();
@@ -120,6 +140,9 @@ export async function getErrorLogContent() {
}-${yesterday.getFullYear()}.log`, }-${yesterday.getFullYear()}.log`,
), ),
); );
const traceLog = files.find(
_ => _.isFile() && _.path.endsWith("trace-log.log"),
);
let logContent = ""; let logContent = "";
if (todayLog) { if (todayLog) {
logContent += await readFile(todayLog.path, "utf8"); logContent += await readFile(todayLog.path, "utf8");
@@ -127,6 +150,23 @@ export async function getErrorLogContent() {
if (yesterdayLog) { if (yesterdayLog) {
logContent += await readFile(yesterdayLog.path, "utf8"); 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; return logContent;
} catch { } catch {
return ""; 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( export function devLog(
method: "log" | "error" | "warn" | "info", method: "log" | "error" | "warn" | "info",
...args: any[] ...args: any[]
@@ -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");
});
});
@@ -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;
}
@@ -8,5 +8,6 @@ MUSIC_SERVER_ADMIN_USERNAME=admin
MUSIC_SERVER_ADMIN_PASSWORD_HASH=sha256$replace-with-sha256-hex MUSIC_SERVER_ADMIN_PASSWORD_HASH=sha256$replace-with-sha256-hex
MUSIC_SERVER_SECRET_ENCRYPTION_KEY=replace-with-a-strong-secret MUSIC_SERVER_SECRET_ENCRYPTION_KEY=replace-with-a-strong-secret
MUSIC_SERVER_CACHE_RECONCILE_INTERVAL_SECONDS=600 MUSIC_SERVER_CACHE_RECONCILE_INTERVAL_SECONDS=600
MUSIC_SERVER_STREAM_TOKEN_TTL_SECONDS=3600
MUSICFREE_VERSION_JSON=/app/release/version.json MUSICFREE_VERSION_JSON=/app/release/version.json
MUSICFREE_APK_PATH=/app/release/MusicFree_latest_release_universal.apk MUSICFREE_APK_PATH=/app/release/MusicFree_latest_release_universal.apk
+1 -1
View File
@@ -1,5 +1,5 @@
param( param(
[string]$HostName = "192.168.5.43", [string]$HostName = "192.168.5.11",
[int]$Port = 222, [int]$Port = 222,
[string]$User = "xiaoming", [string]$User = "xiaoming",
[string]$RemoteAppHome = "/volume4/Music_Cloud/Music_Server", [string]$RemoteAppHome = "/volume4/Music_Cloud/Music_Server",
+1 -1
View File
@@ -23,7 +23,7 @@ def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Upload Music_Server to NAS staging and trigger deploy_and_restart.sh" 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("--port", type=int, default=222)
parser.add_argument("--user", default="xiaoming") parser.add_argument("--user", default="xiaoming")
parser.add_argument( parser.add_argument(
@@ -111,6 +111,39 @@ def _build_stream_url(*, token: str, resolved: dict) -> str:
return f"/mf/v1/media/stream/{token}" 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") @router.post("/media/resolve")
def resolve_media(payload: dict) -> dict: def resolve_media(payload: dict) -> dict:
settings = get_settings() settings = get_settings()
@@ -146,6 +179,8 @@ def resolve_media(payload: dict) -> dict:
secret=settings.access_token, secret=settings.access_token,
song_id=song_id, song_id=song_id,
locator=token_locator, locator=token_locator,
quality=quality,
ttl_seconds=settings.stream_token_ttl_seconds,
) )
selected_source = cached_source or fallback_source or {} selected_source = cached_source or fallback_source or {}
selected_size = None selected_size = None
@@ -175,6 +210,7 @@ def stream_media(token: str, request: Request, ext: str | None = None):
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
song_id = int(parsed["song_id"]) song_id = int(parsed["song_id"])
quality = str(parsed.get("quality") or "standard")
cache_service = _cache_service(settings) cache_service = _cache_service(settings)
cached_source = cache_service.resolve_cached_source(song_id=song_id) 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) return RedirectResponse(url=str(cached_source["public_url"]), status_code=307)
try: try:
resolved = MediaResolver(db_path=settings.catalog_db_path).resolve_by_locator( resolved = _resolve_stream_source(
song_id=song_id, song_id=song_id,
locator=str(parsed["locator"]), locator=str(parsed.get("locator") or ""),
quality=quality,
settings=settings,
) )
except LookupError as exc: except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from 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") 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: if not public_url:
raise HTTPException(status_code=404, detail="public stream url not found") raise HTTPException(status_code=404, detail="public stream url not found")
cache_service.record_stream_play(song_id=song_id, stream_token=token) cache_service.record_stream_play(song_id=song_id, stream_token=token)
+1 -56
View File
@@ -1,7 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from ..auth import require_bearer_token from ..auth import require_bearer_token
from ..services.cache_service import CacheService
from ..services.catalog_reader import CatalogReader from ..services.catalog_reader import CatalogReader
from ..services.player_service import PlayerService from ..services.player_service import PlayerService
from ..settings import get_settings from ..settings import get_settings
@@ -17,29 +16,6 @@ def _catalog_reader() -> CatalogReader:
return CatalogReader(db_path=get_settings().catalog_db_path) return CatalogReader(db_path=get_settings().catalog_db_path)
def _cache_service() -> CacheService:
settings = get_settings()
return CacheService(
player_db_path=settings.player_db_path,
catalog_db_path=settings.catalog_db_path,
secret_encryption_key=settings.secret_encryption_key,
local_library_root=settings.local_library_root,
cache_relay_enabled=settings.cache_relay_enabled,
)
def _to_music_item(row: dict) -> dict:
return {
"id": f"catalogsync:song:{row['song_id']}",
"platform": "catalogsync",
"title": row["name"],
"artist": row.get("singers") or "",
"album": row.get("album") or "",
"artwork": row.get("cover_url") or "",
"duration": int(row.get("duration_ms") or 0) // 1000,
}
@router.get("/home") @router.get("/home")
def home() -> dict: def home() -> dict:
return { return {
@@ -49,37 +25,6 @@ def home() -> dict:
} }
@router.get("/topn")
def topn(limit: int = Query(default=100, ge=1, le=200)) -> dict:
hot_songs = _cache_service().list_hot_song_summaries(limit=limit)
songs = _catalog_reader().list_songs_by_ids(
[int(item["song_id"]) for item in hot_songs]
)
songs_by_id = {int(song["song_id"]): song for song in songs}
music_list = []
for rank, hot_song in enumerate(hot_songs, start=1):
song_id = int(hot_song["song_id"])
song = songs_by_id.get(song_id)
if song is None:
continue
item = _to_music_item(song)
item.update(
{
"rank": rank,
"playCount30d": int(hot_song["play_count_30d"]),
"playCountTotal": int(hot_song["play_count_total"]),
"lastPlayedAt": hot_song.get("last_played_at"),
}
)
music_list.append(item)
return {
"periodDays": 30,
"musicList": music_list,
}
@router.put("/me/favorites/tracks/{track_id}", status_code=status.HTTP_204_NO_CONTENT) @router.put("/me/favorites/tracks/{track_id}", status_code=status.HTTP_204_NO_CONTENT)
def add_favorite_track(track_id: int) -> Response: def add_favorite_track(track_id: int) -> Response:
_player_service().add_favorite_track(track_id=track_id) _player_service().add_favorite_track(track_id=track_id)
@@ -832,7 +832,7 @@ class CacheService:
urls[song_id] = str(best["public_url"]) urls[song_id] = str(best["public_url"])
return urls return urls
def list_hot_song_summaries(self, *, limit: int = 100) -> list[dict[str, Any]]: def list_hot_songs(self, *, limit: int = 100) -> list[dict[str, Any]]:
with closing(connect_sqlite(self._player_db_path)) as conn: with closing(connect_sqlite(self._player_db_path)) as conn:
rows = conn.execute( rows = conn.execute(
""" """
@@ -844,10 +844,7 @@ class CacheService:
""", """,
(limit,), (limit,),
).fetchall() ).fetchall()
return [dict(row) for row in rows] items = [dict(row) for row in rows]
def list_hot_songs(self, *, limit: int = 100) -> list[dict[str, Any]]:
items = self.list_hot_song_summaries(limit=limit)
song_ids = [int(item["song_id"]) for item in items] song_ids = [int(item["song_id"]) for item in items]
names_by_song_id = self._fetch_track_names(song_ids) names_by_song_id = self._fetch_track_names(song_ids)
cache_urls_by_song_id = self._fetch_cached_public_urls(song_ids) cache_urls_by_song_id = self._fetch_cached_public_urls(song_ids)
@@ -325,46 +325,6 @@ class CatalogReader:
).fetchone() ).fetchone()
return cast(SongRow, dict(row)) if row else None return cast(SongRow, dict(row)) if row else None
def list_songs_by_ids(self, song_ids: list[int]) -> list[SongRow]:
normalized_ids = list(dict.fromkeys(int(song_id) for song_id in song_ids))
if not normalized_ids:
return []
placeholders = ",".join("?" for _ in normalized_ids)
with closing(connect_sqlite(self._db_path)) as conn:
rows = conn.execute(
f"""
select
t.song_id,
t.name,
t.singers,
t.album,
t.cover_url,
t.duration_ms,
(
select f.locator
from catalog_track_files f
where f.song_id = t.song_id
and f.status = 'active'
and f.backend_type = 'local_fs'
order by f.is_primary desc, f.locator asc
limit 1
) as local_locator
from catalog_tracks t
where t.song_id in ({placeholders})
and exists (
select 1
from catalog_track_files f
where f.song_id = t.song_id
and f.status = 'active'
)
""",
tuple(normalized_ids),
).fetchall()
rows_by_id = {int(row["song_id"]): cast(SongRow, dict(row)) for row in rows}
return [rows_by_id[song_id] for song_id in normalized_ids if song_id in rows_by_id]
def search_sheets(self, query: str, page: int, page_size: int) -> list[SheetSearchRow]: def search_sheets(self, query: str, page: int, page_size: int) -> list[SheetSearchRow]:
page, page_size = self._normalize_pagination(page, page_size) page, page_size = self._normalize_pagination(page, page_size)
term = str(query or "").strip() term = str(query or "").strip()
@@ -7,21 +7,24 @@ class MediaResolver:
def __init__(self, db_path: str) -> None: def __init__(self, db_path: str) -> None:
self._db_path = db_path 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: 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 from catalog_track_files
where song_id = ? and status = 'active' where song_id = ? and status = 'active'
order by case when quality_label = ? then 0 else 1 end, is_primary desc order by case when quality_label = ? then 0 else 1 end, is_primary desc, locator asc
limit 1
""", """,
(song_id, quality), (song_id, quality),
).fetchone() ).fetchall()
if row is None: 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") raise LookupError("no playable source found")
return dict(row) return rows[0]
def resolve_by_locator(self, song_id: int, locator: str) -> dict: def resolve_by_locator(self, song_id: int, locator: str) -> dict:
with closing(connect_sqlite(self._db_path)) as conn: with closing(connect_sqlite(self._db_path)) as conn:
@@ -13,10 +13,18 @@ def _sign_payload(secret: str, payload_json: str) -> str:
).hexdigest() ).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 = { payload = {
"song_id": int(song_id), "song_id": int(song_id),
"locator": str(locator), "locator": str(locator),
"quality": str(quality or "standard"),
"expires_at": int(time.time()) + int(ttl_seconds), "expires_at": int(time.time()) + int(ttl_seconds),
} }
payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) 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") raise ValueError("invalid stream token")
song_id = int(payload["song_id"]) 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"]) expires_at = int(payload["expires_at"])
if not locator:
raise ValueError("invalid stream token")
if expires_at < int(time.time()): if expires_at < int(time.time()):
raise ValueError("stream token expired") raise ValueError("stream token expired")
return { return {
"song_id": song_id, "song_id": song_id,
"locator": locator, "locator": locator,
"quality": quality,
"expires_at": expires_at, "expires_at": expires_at,
} }
except ValueError: except ValueError:
@@ -28,6 +28,7 @@ class Settings:
admin_password_hash: str admin_password_hash: str
secret_encryption_key: str secret_encryption_key: str
cache_reconcile_interval_seconds: int cache_reconcile_interval_seconds: int
stream_token_ttl_seconds: int
musicfree_version_json_path: str musicfree_version_json_path: str
musicfree_apk_path: str musicfree_apk_path: str
@@ -78,6 +79,10 @@ def get_settings() -> Settings:
"MUSIC_SERVER_CACHE_RECONCILE_INTERVAL_SECONDS", "MUSIC_SERVER_CACHE_RECONCILE_INTERVAL_SECONDS",
600, 600,
), ),
stream_token_ttl_seconds=_env_int(
"MUSIC_SERVER_STREAM_TOKEN_TTL_SECONDS",
3600,
),
musicfree_version_json_path=os.getenv( musicfree_version_json_path=os.getenv(
"MUSICFREE_VERSION_JSON", "MUSICFREE_VERSION_JSON",
str(musicfree_release_dir / "version.json"), str(musicfree_release_dir / "version.json"),
@@ -84,4 +84,3 @@ class AppUpdateRouteTests(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+201
View File
@@ -1,5 +1,6 @@
import sqlite3 import sqlite3
import tempfile import tempfile
import time
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
@@ -156,6 +157,42 @@ class MfMediaRouteTests(unittest.TestCase):
self.assertIn("/mf/v1/media/stream/", payload["stream"]["url"]) self.assertIn("/mf/v1/media/stream/", payload["stream"]["url"])
self.assertTrue(payload["stream"]["url"].endswith(".flac")) 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): def test_media_stream_redirects_to_public_url(self):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db" db_path = Path(tmpdir) / "catalog_read.db"
@@ -353,6 +390,170 @@ class MfMediaRouteTests(unittest.TestCase):
stream_response.headers.get("location"), 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): def test_media_stream_falls_back_when_cached_public_url_is_unreachable(self):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "catalog_read.db" db_path = Path(tmpdir) / "catalog_read.db"
@@ -26,19 +26,6 @@ class PlayerHistoryRouteTests(unittest.TestCase):
playlist_id integer primary key, playlist_id integer primary key,
added_at text not null added_at text not null
); );
create table song_heat_summary (
song_id integer primary key,
play_count_total integer not null default 0,
play_count_30d integer not null default 0,
last_played_at text
);
insert into song_heat_summary (
song_id, play_count_total, play_count_30d, last_played_at
) values
(42, 8, 5, '2026-07-16T10:00:00+00:00'),
(7, 12, 9, '2026-07-16T11:00:00+00:00');
""" """
) )
conn.commit() conn.commit()
@@ -79,44 +66,6 @@ class PlayerHistoryRouteTests(unittest.TestCase):
1, 1,
), ),
) )
conn.executescript(
"""
create table catalog_tracks (
song_id integer primary key,
platform text not null,
remote_track_id text not null,
name text not null,
singers text,
album text,
cover_url text,
duration_ms integer not null
);
create table catalog_track_files (
id integer primary key autoincrement,
song_id integer not null,
quality_label text,
backend_type text not null,
backend_name text,
locator text not null,
public_url text,
status text not null,
is_primary integer not null default 0
);
insert into catalog_tracks (
song_id, platform, remote_track_id, name, singers, album, cover_url, duration_ms
) values
(42, 'kuwo', '42', '热门歌曲二', '歌手乙', '专辑乙', 'https://img/42.jpg', 242000),
(7, 'qq', '7', '热门歌曲一', '歌手甲', '专辑甲', 'https://img/7.jpg', 198000);
insert into catalog_track_files (
song_id, quality_label, backend_type, backend_name, locator, public_url, status, is_primary
) values
(42, 'lossless', 'local_fs', 'default-local', 'kuwo/42.flac', null, 'active', 1),
(7, 'high', 'local_fs', 'default-local', 'qq/7.mp3', null, 'active', 1);
"""
)
conn.commit() conn.commit()
conn.close() conn.close()
@@ -191,37 +140,6 @@ class PlayerHistoryRouteTests(unittest.TestCase):
self.assertEqual(400, invalid_track_id.status_code) self.assertEqual(400, invalid_track_id.status_code)
self.assertEqual(400, invalid_progress.status_code) self.assertEqual(400, invalid_progress.status_code)
def test_topn_returns_playable_tracks_in_heat_order(self):
with tempfile.TemporaryDirectory() as tmpdir:
player_db_path = Path(tmpdir) / "player.db"
catalog_db_path = Path(tmpdir) / "catalog_read.db"
self._prepare_player_db(player_db_path)
self._prepare_catalog_db(catalog_db_path)
with patch.dict(
"os.environ",
{
"PLAYER_DB_PATH": str(player_db_path),
"CATALOG_DB_PATH": str(catalog_db_path),
},
clear=False,
):
response = TestClient(create_app()).get(
"/player/v1/topn?limit=2",
headers=auth_headers(player_db_path),
)
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual(30, payload["periodDays"])
self.assertEqual(
["catalogsync:song:7", "catalogsync:song:42"],
[item["id"] for item in payload["musicList"]],
)
self.assertEqual([1, 2], [item["rank"] for item in payload["musicList"]])
self.assertEqual([9, 5], [item["playCount30d"] for item in payload["musicList"]])
self.assertEqual("歌手甲", payload["musicList"][0]["artist"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()