dsh-music-panel 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### 功能
6
+
7
+ - 记忆播放:启动 / 刷新后恢复上次的队列、当前曲目与播放位置(默认暂停,点 ▶ 继续);设置里可开「恢复后自动播放」与「每首歌记住播放位置」
8
+ - 播放次数按 Last.fm / MusicBee 规则记录:曲目 > 30 秒,且播放到一半或 4 分钟(先到为准)才计一次,不再每次切歌就 +1
9
+ - 有效播放时长:只累计播放中自然前进的时间,拖动进度条不计,单次不足 30 秒的片段不计
10
+ - 统计页:主面板新增「统计」tab,支持最近一周 / 最近一月 / 全部时间,按时长 / 次数 / 最近排序,显示累计时长、播放次数、最近播放与汇总
11
+ - 新增接口:`GET/POST /music/state`、`POST /music/playback`、`GET/POST /music/position`、`GET /music/stats`
12
+ - 设置面板新增「播放」标签页
13
+
14
+ ### 数据
15
+
16
+ - `stats.json` 扩展 `playSeconds`、`lastPlayedAt`、`lastPositionSec` 与最近 31 天的每日分桶;旧文件自动兼容
17
+ - 新增 `<DSH_HOME>/music/playback.json` 保存上次播放状态
18
+
3
19
  ## 0.1.0
4
20
 
5
21
  首个公开版本。
package/README.md CHANGED
@@ -23,8 +23,10 @@ DeepSeek Harness 的本地音乐插件:左侧栏一个音符入口,点开是
23
23
  - **歌曲列表**:封面缩略图、标题、歌手(带 CD 徽标)、专辑、时长、采样率;当前曲高亮。
24
24
  - **播放控制**:列表循环 / 单曲循环 / 不循环,随机播放,上一首、下一首、进度拖动、音量与静音。
25
25
  - **播放队列**:底部「队列」按钮展开当前队列,点击任意条目跳转;「更多」里显示格式 / 码率 / 采样率,可复制文件路径。
26
+ - **记忆播放**:启动 / 刷新后恢复上次的队列、当前曲目与播放位置(默认停在暂停,点 ▶ 继续);设置里可选「恢复后自动播放」和「每首歌记住播放位置」。
27
+ - **统计**:主面板「统计」页按最近一周 / 最近一月 / 全部时间查看每首歌的累计播放时长、播放次数与最近播放,可按时长 / 次数 / 最近排序。播放次数按 Last.fm 规则(曲目 > 30 秒且播放到一半或 4 分钟)记录,播放时长只累计真正在播放的时间。
26
28
  - **列表操作**:搜索、排序(标题 / 文件名 / 专辑 / 艺术家 / 大小 / 年份 / 文件夹 / 播放次数 / 时长 / 修改时间 / 添加时间,点击同项切换升降序)、多选后「播放选中」或加入播放列表、清空列表。
27
- - **设置面板**:主面板右上角齿轮打开,含「外观 / 歌词 / 音乐库」三个标签页:外观调整迷你浮窗与圆形封面显示,歌词调整来源顺序、对齐、字号、翻译与状态栏歌词,音乐库管理音乐文件夹与子文件夹深度(保存后主面板自动重新扫描)。
29
+ - **设置面板**:主面板右上角齿轮打开,含「外观 / 歌词 / 音乐库 / 播放」四个标签页:外观调整迷你浮窗与圆形封面显示,歌词调整来源顺序、对齐、字号、翻译与状态栏歌词,音乐库管理音乐文件夹与子文件夹深度(保存后主面板自动重新扫描),播放控制启动恢复、自动播放与每首歌断点。
28
30
  - **外观设置**:迷你浮窗(右下角可拖动、记忆位置)、播放页圆形封面 / 黑胶底盘 / 封面旋转。
29
31
  - **歌词设置**:歌词来源顺序(内嵌 / LRC,可调)、对齐方式、字体大小与粗细、模糊非当前歌词、翻译开关与翻译字号缩放、状态栏歌词(输入框内或输入框上方显示当前唱词)。
30
32
  - **播放列表**:顶栏「全部歌曲」与每个歌单各占一个 tab,**页面结构完全一致**(搜索、排序、多选、播放全部、循环/随机、队列);「全部歌曲」是不可编辑集合,歌单可点标题改名、删除、逐首移除。右侧 `+` 新建歌单(自动命名,重名递增)并立即选中。歌单存于 `<DSH_HOME>/music/playlists.json`。
@@ -67,6 +69,12 @@ plugin_manager install_bundle D:/SystemFiles/Documents/Project/DSH/plugins/dsh-m
67
69
  | PATCH | `/music/playlists/:id` | 重命名 / 加曲 / 删曲:`{ name?, add?: string[], remove?: string[] }` |
68
70
  | DELETE | `/music/playlists/:id` | 删除播放列表 |
69
71
  | POST | `/music/play` | 记一次播放,body `{ path }` |
72
+ | GET | `/music/state` | 上次播放状态(队列 / 索引 / 位置 / 循环 / 顺序) |
73
+ | POST | `/music/state` | 保存上次播放状态,body `{ queue, index, positionSec, durationSec, loop, order }` |
74
+ | POST | `/music/playback` | 累加有效播放时长,body `{ path, seconds }` |
75
+ | GET | `/music/position?path=…` | 该曲上次停止位置(每首歌断点) |
76
+ | POST | `/music/position` | 保存某曲停止位置,body `{ path, seconds }` |
77
+ | GET | `/music/stats?range=week\|month\|all` | 按范围聚合的播放次数 / 时长 / 最近播放 |
70
78
  | GET | `/music/cover?path=…` | 内嵌封面;无封面返回 404 |
71
79
 
72
80
  `stream` / `cover` 的 `path` 会 `realpath` 后重新校验是否位于已配置的根目录内,越界一律 404。
@@ -87,6 +95,17 @@ patch 的 `config` 是首次启动的种子:
87
95
 
88
96
  用户在设置面板的音乐库标签页保存后,以 `<DSH_HOME>/music/settings.json` 为准(`extensions: []` 表示使用全部支持的格式)。播放次数与首次入库时间存在 `<DSH_HOME>/music/stats.json`。
89
97
 
98
+ ## 播放状态与统计
99
+
100
+ 两个文件都在 `<DSH_HOME>/music/`:
101
+
102
+ - `playback.json`:上次播放的队列(最多 500 条)、当前索引、位置、循环 / 顺序,用于启动恢复。
103
+ - `stats.json`:每首歌的 `addedAt`、`plays`(Last.fm 规则计数)、`playSeconds`(有效播放秒数)、`lastPlayedAt`、`lastPositionSec`(每首歌断点,仅在设置里开启时写入),以及最近 31 天的每日分桶 `days`(供统计页的「最近一周 / 最近一月」使用)。
104
+
105
+ 写入时机:播放中每 5 秒保存一次状态;切歌、暂停、页面隐藏 / 关闭时立即 flush。播放时长只累计播放中自然前进的时间(拖动进度条不计),单次不足 30 秒的片段不进入统计(对齐网易云「有效播放」)。
106
+
107
+ 多标签页同时播放同一首歌时会各自计时,统计可能重复;这一期不做标签页互斥。
108
+
90
109
  ## 播放宿主与后台播放
91
110
 
92
111
  - `<audio>` 元素挂在宿主的 `shell.overlay`(常驻浮层)里,主面板切走、会话切换都不会卸载它,所以播放不中断;播放状态由 `client.js` 里的引擎单例(`window.__dshMusicPlayer`)持有,模块被 HMR 重新执行也复用同一实例。
@@ -96,7 +115,7 @@ patch 的 `config` 是首次启动的种子:
96
115
 
97
116
  ## 已知限制
98
117
 
99
- - 收藏、播放历史尚未实现(播放列表已支持)。
118
+ - 收藏尚未实现(播放列表与统计已支持)。
100
119
  - m4a 的文本标签可读,内嵌封面暂不解析(封面支持 mp3 / flac)。
101
120
  - 扫描缓存 30 秒;文件变化后点「重新扫描」强制刷新。
102
121
  - 播放依赖浏览器的 `<audio>`:DSH 的 Web GUI 在 `http://127.0.0.1` 下可用;远程访问(非回环)会返回 403。
package/client.js CHANGED
@@ -45,6 +45,11 @@ window.__ModuleLoader__.load({
45
45
  translationScale: 80,
46
46
  composerLyrics: false,
47
47
  composerLyricPlace: 'input-left',
48
+ // Resume behaviour follows mainstream desktop players: restore the queue
49
+ // and position on start, but stay paused until the user asks to play.
50
+ resumeOnStart: true,
51
+ autoResume: false,
52
+ rememberPerTrack: false,
48
53
  }
49
54
 
50
55
  // ── locale ────────────────────────────────────────────────────────────
@@ -122,7 +127,23 @@ window.__ModuleLoader__.load({
122
127
  'page.lyricWeight': '字体粗细',
123
128
  'page.lyricBlur': '模糊非当前歌词',
124
129
  'page.translationScale': '翻译字体缩放',
130
+ 'stats.rangeWeek': '最近一周',
131
+ 'stats.rangeMonth': '最近一月',
132
+ 'stats.rangeAll': '全部时间',
133
+ 'stats.sortTime': '按时长',
134
+ 'stats.sortPlays': '按次数',
135
+ 'stats.sortRecent': '按最近',
136
+ 'stats.totalTime': '累计播放时长',
137
+ 'stats.totalPlays': '总播放次数',
138
+ 'stats.trackCount': '有记录曲目',
139
+ 'stats.plays': '{n} 次',
140
+ 'stats.empty': '还没有播放记录',
141
+ 'stats.loading': '加载中…',
142
+ 'stats.loadFailed': '统计加载失败',
143
+ 'stats.retry': '重试',
144
+ 'stats.unavailable': '文件已不可用',
125
145
  'page.tabSongs': '全部歌曲',
146
+ 'page.tabStats': '统计',
126
147
  'page.tabPlaylists': '播放列表',
127
148
  'page.newPlaylist': '新建播放列表',
128
149
  'page.playlistName': '播放列表名称',
@@ -160,6 +181,11 @@ window.__ModuleLoader__.load({
160
181
  'settings.tabAppearance': '外观',
161
182
  'settings.tabLyrics': '歌词',
162
183
  'settings.tabLibrary': '音乐库',
184
+ 'settings.tabPlayback': '播放',
185
+ 'settings.resumeOnStart': '启动时恢复上次播放',
186
+ 'settings.autoResume': '恢复后自动播放',
187
+ 'settings.rememberPerTrack': '每首歌记住播放位置',
188
+ 'settings.playbackHint': '播放次数按 Last.fm 规则记录:曲目超过 30 秒,且播放到一半或 4 分钟(先到为准)才算一次;播放时长只累计真正在播放的时间。',
163
189
  'settings.libraryHint': '音乐文件夹(自动保存)',
164
190
  'settings.removeConfirm': '确定移除这个音乐文件夹?不会删除文件。',
165
191
  'settings.up': '↑ 上级',
@@ -259,7 +285,23 @@ window.__ModuleLoader__.load({
259
285
  'page.lyricWeight': 'Font weight',
260
286
  'page.lyricBlur': 'Blur inactive lines',
261
287
  'page.translationScale': 'Translation scale',
288
+ 'stats.rangeWeek': 'Last week',
289
+ 'stats.rangeMonth': 'Last month',
290
+ 'stats.rangeAll': 'All time',
291
+ 'stats.sortTime': 'By time',
292
+ 'stats.sortPlays': 'By plays',
293
+ 'stats.sortRecent': 'By recent',
294
+ 'stats.totalTime': 'Listening time',
295
+ 'stats.totalPlays': 'Total plays',
296
+ 'stats.trackCount': 'Tracks',
297
+ 'stats.plays': '{n} plays',
298
+ 'stats.empty': 'No listening history yet',
299
+ 'stats.loading': 'Loading…',
300
+ 'stats.loadFailed': 'Failed to load statistics',
301
+ 'stats.retry': 'Retry',
302
+ 'stats.unavailable': 'File unavailable',
262
303
  'page.tabSongs': 'All songs',
304
+ 'page.tabStats': 'Stats',
263
305
  'page.tabPlaylists': 'Playlists',
264
306
  'page.newPlaylist': 'New playlist',
265
307
  'page.playlistName': 'Playlist name',
@@ -297,6 +339,11 @@ window.__ModuleLoader__.load({
297
339
  'settings.tabAppearance': 'Appearance',
298
340
  'settings.tabLyrics': 'Lyrics',
299
341
  'settings.tabLibrary': 'Library',
342
+ 'settings.tabPlayback': 'Playback',
343
+ 'settings.resumeOnStart': 'Resume last playback on start',
344
+ 'settings.autoResume': 'Start playing after restore',
345
+ 'settings.rememberPerTrack': 'Remember each track position',
346
+ 'settings.playbackHint': 'Play counts follow the Last.fm rule: a track longer than 30 seconds counts once after half its duration or 4 minutes, whichever comes first. Listening time only counts time actually played.',
300
347
  'settings.libraryHint': 'Music folders (auto-saved)',
301
348
  'settings.removeConfirm': 'Remove this music folder? Files are not deleted.',
302
349
  'settings.up': '↑ Up',
@@ -388,6 +435,17 @@ window.__ModuleLoader__.load({
388
435
  return String(khz).replace(/\.0$/, '') + ' kHz'
389
436
  }
390
437
 
438
+ /**
439
+ * Last.fm / MusicBee play threshold: half the track or four minutes,
440
+ * whichever comes first. Tracks of 30 seconds or less never count.
441
+ */
442
+ function playThresholdOf(durationSec) {
443
+ var duration = Number(durationSec)
444
+ if (!isFinite(duration) || duration <= 0) return 240
445
+ if (duration <= 30) return duration + 0.5
446
+ return Math.min(duration / 2, 240)
447
+ }
448
+
391
449
  /** Clamp a stored preference to a sane numeric range. */
392
450
  function clampPref(value, min, max, fallback) {
393
451
  var number = Number(value)
@@ -980,6 +1038,184 @@ window.__ModuleLoader__.load({
980
1038
  buffered: 0,
981
1039
  }
982
1040
 
1041
+ // ── resume + listening statistics ───────────────────────────────────
1042
+ // Counting follows the Last.fm / MusicBee rule (half the track or four
1043
+ // minutes, whichever comes first); resume follows the NetEase / Spotify
1044
+ // pattern (restore the queue and position, then stay paused). The clock
1045
+ // only counts time that naturally advanced while playing, so dragging
1046
+ // the seek bar never inflates the statistics.
1047
+ var session = { path: '', seconds: 0, pending: 0, lastTime: 0, counted: false }
1048
+ var lastStateSave = 0
1049
+ var restoreAttempted = false
1050
+ var pendingResumeSeconds = null
1051
+
1052
+ function resetSession(item) {
1053
+ session = {
1054
+ path: item ? item.path : '',
1055
+ seconds: 0,
1056
+ pending: 0,
1057
+ lastTime: 0,
1058
+ counted: false,
1059
+ }
1060
+ }
1061
+
1062
+ function durationOf(item) {
1063
+ if (audio !== null && isFinite(audio.duration) && audio.duration > 0) return audio.duration
1064
+ return item && item.durationSec > 0 ? item.durationSec : 0
1065
+ }
1066
+
1067
+ function recordStatsPlay(item) {
1068
+ try {
1069
+ postJson(API + 'play', { path: item.path }).catch(function () {})
1070
+ } catch (error) {
1071
+ // Statistics never block playback.
1072
+ }
1073
+ }
1074
+
1075
+ /** Flush the seconds that have not been reported yet (>= 30s only). */
1076
+ function flushListening(force) {
1077
+ if (session.path === '' || session.pending <= 0) return
1078
+ if (session.seconds < 30) {
1079
+ if (force) session.pending = 0
1080
+ return
1081
+ }
1082
+ var seconds = session.pending
1083
+ session.pending = 0
1084
+ try {
1085
+ postJson(API + 'playback', { path: session.path, seconds: seconds }).catch(function () {})
1086
+ } catch (error) {
1087
+ // Best effort.
1088
+ }
1089
+ }
1090
+
1091
+ function flushSessionPosition() {
1092
+ if (session.path === '' || readPrefs().rememberPerTrack !== true) return
1093
+ var item = currentItem()
1094
+ if (item === null || item.path !== session.path) return
1095
+ var seconds = state.currentTime
1096
+ if (!(seconds > 0)) return
1097
+ try {
1098
+ postJson(API + 'position', { path: session.path, seconds: seconds }).catch(function () {})
1099
+ } catch (error) {
1100
+ // Best effort.
1101
+ }
1102
+ }
1103
+
1104
+ function savePlaybackState() {
1105
+ var item = currentItem()
1106
+ if (item === null) return
1107
+ var duration = state.duration > 0 ? state.duration : item.durationSec || 0
1108
+ try {
1109
+ postJson(API + 'state', {
1110
+ queue: state.queue.map(function (track) {
1111
+ return track.path
1112
+ }),
1113
+ index: state.index,
1114
+ positionSec: state.currentTime,
1115
+ durationSec: duration,
1116
+ loop: state.loop,
1117
+ order: state.order,
1118
+ }).catch(function () {})
1119
+ } catch (error) {
1120
+ // Best effort.
1121
+ }
1122
+ }
1123
+
1124
+ function trackListening() {
1125
+ if (audio === null) return
1126
+ var item = currentItem()
1127
+ if (item === null) return
1128
+ if (session.path !== item.path) resetSession(item)
1129
+
1130
+ var current = audio.currentTime
1131
+ if (state.playing && !audio.paused) {
1132
+ var delta = current - session.lastTime
1133
+ // A seek produces a large or negative jump; only natural playback
1134
+ // advances count toward listening time.
1135
+ if (delta > 0 && delta <= 3) {
1136
+ session.seconds += delta
1137
+ session.pending += delta
1138
+ }
1139
+ }
1140
+ session.lastTime = current
1141
+
1142
+ var duration = durationOf(item)
1143
+ var threshold = playThresholdOf(duration)
1144
+ if (!session.counted && duration > 30 && session.seconds >= threshold) {
1145
+ session.counted = true
1146
+ recordStatsPlay(item)
1147
+ }
1148
+
1149
+ if (session.seconds >= 30 && session.pending >= 10) flushListening(false)
1150
+
1151
+ var now = Date.now()
1152
+ if (now - lastStateSave >= 5000) {
1153
+ lastStateSave = now
1154
+ savePlaybackState()
1155
+ }
1156
+ }
1157
+
1158
+ function restorePlayback(saved, tracks) {
1159
+ if (!saved || !Array.isArray(saved.queue) || saved.queue.length === 0) return false
1160
+ if (!Array.isArray(tracks) || tracks.length === 0) return false
1161
+ var byPath = {}
1162
+ tracks.forEach(function (track) {
1163
+ byPath[track.path] = track
1164
+ })
1165
+ var queue = []
1166
+ saved.queue.forEach(function (path) {
1167
+ if (byPath[path] !== undefined) queue.push(byPath[path])
1168
+ })
1169
+ if (queue.length === 0) return false
1170
+ var wantedPath = saved.queue[saved.index]
1171
+ var index = 0
1172
+ for (var i = 0; i < queue.length; i += 1) {
1173
+ if (typeof wantedPath === 'string' && queue[i].path === wantedPath) {
1174
+ index = i
1175
+ break
1176
+ }
1177
+ }
1178
+ state = Object.assign({}, state, {
1179
+ queue: queue,
1180
+ index: index,
1181
+ loop: saved.loop === 'one' || saved.loop === 'none' ? saved.loop : 'all',
1182
+ order: saved.order === 'random' ? 'random' : 'list',
1183
+ currentTime: saved.positionSec > 0 ? saved.positionSec : 0,
1184
+ duration: saved.durationSec > 0 ? saved.durationSec : 0,
1185
+ playing: false,
1186
+ })
1187
+ randomOrder = randomOrderOf(queue.length)
1188
+ emit()
1189
+ syncMediaSession()
1190
+ resetSession(queue[index])
1191
+ pendingResumeSeconds = saved.positionSec > 0 ? saved.positionSec : null
1192
+ // Paused by default (NetEase/Spotify behaviour); the setting can ask
1193
+ // for playback to start right after restore.
1194
+ load(index, readPrefs().autoResume === true)
1195
+ return true
1196
+ }
1197
+
1198
+ function tryRestore() {
1199
+ if (restoreAttempted) return
1200
+ restoreAttempted = true
1201
+ if (readPrefs().resumeOnStart !== true) return
1202
+ Promise.all([getJson(API + 'state'), getJson(API + 'library')])
1203
+ .then(function (results) {
1204
+ var saved = results[0] !== null && results[0].state !== undefined ? results[0].state : null
1205
+ var tracks = results[1] !== null && Array.isArray(results[1].tracks) ? results[1].tracks : []
1206
+ restorePlayback(saved, tracks)
1207
+ })
1208
+ .catch(function () {
1209
+ // Resume is best effort.
1210
+ })
1211
+ }
1212
+
1213
+ function flushOnHide() {
1214
+ flushListening(true)
1215
+ flushSessionPosition()
1216
+ savePlaybackState()
1217
+ }
1218
+
983
1219
  function emit() {
984
1220
  listeners.forEach(function (fn) {
985
1221
  fn()
@@ -1059,6 +1295,7 @@ window.__ModuleLoader__.load({
1059
1295
  if (audio === null) return
1060
1296
  state = Object.assign({}, state, { currentTime: audio.currentTime })
1061
1297
  emitTime()
1298
+ trackListening()
1062
1299
  }
1063
1300
 
1064
1301
  function onDuration() {
@@ -1089,6 +1326,9 @@ window.__ModuleLoader__.load({
1089
1326
  }
1090
1327
 
1091
1328
  function onPause() {
1329
+ flushListening(true)
1330
+ flushSessionPosition()
1331
+ savePlaybackState()
1092
1332
  if (state.playing) {
1093
1333
  patch({ playing: false })
1094
1334
  syncMediaSession()
@@ -1096,8 +1336,11 @@ window.__ModuleLoader__.load({
1096
1336
  }
1097
1337
 
1098
1338
  function onEnded() {
1339
+ flushListening(true)
1340
+ flushSessionPosition()
1099
1341
  if (state.queue.length === 0) return
1100
1342
  if (state.loop === 'one') {
1343
+ resetSession(currentItem())
1101
1344
  if (audio) {
1102
1345
  audio.currentTime = 0
1103
1346
  audio.play().catch(function () {})
@@ -1186,23 +1429,17 @@ window.__ModuleLoader__.load({
1186
1429
  function load(at, autoplay) {
1187
1430
  var item = state.queue[at]
1188
1431
  if (!item) return
1432
+ // Close the previous listening session before switching tracks.
1433
+ flushListening(true)
1434
+ flushSessionPosition()
1189
1435
  var token = (loadToken += 1)
1190
1436
  state = Object.assign({}, state, { index: at, currentTime: 0, duration: 0, buffered: 0 })
1191
1437
  emit()
1192
1438
  syncMediaSession()
1193
1439
  pendingAutoplay = autoplay === true
1194
- // One load == one play; recorded so "play count" can be a sort key.
1195
- try {
1196
- window
1197
- .fetch(API + 'play', {
1198
- method: 'POST',
1199
- headers: { 'content-type': 'application/json' },
1200
- body: JSON.stringify({ path: item.path }),
1201
- })
1202
- .catch(function () {})
1203
- } catch (error) {
1204
- // Statistics never block playback.
1205
- }
1440
+ // A new session starts here. The play count is recorded later, and
1441
+ // only when the Last.fm / MusicBee threshold is reached.
1442
+ resetSession(item)
1206
1443
  if (audio === null) return
1207
1444
 
1208
1445
  cancelPendingFetch()
@@ -1228,6 +1465,19 @@ window.__ModuleLoader__.load({
1228
1465
  objectUrl = URL.createObjectURL(blob)
1229
1466
  audio.src = objectUrl
1230
1467
  audio.load()
1468
+ if (pendingResumeSeconds !== null) {
1469
+ var resumeAt = pendingResumeSeconds
1470
+ pendingResumeSeconds = null
1471
+ api.seek(resumeAt)
1472
+ } else if (readPrefs().rememberPerTrack === true) {
1473
+ getJson(API + 'position?path=' + encodeURIComponent(item.path))
1474
+ .then(function (body) {
1475
+ if (token !== loadToken) return
1476
+ var seconds = Number(body.lastPositionSec)
1477
+ if (isFinite(seconds) && seconds > 1) api.seek(seconds)
1478
+ })
1479
+ .catch(function () {})
1480
+ }
1231
1481
  if (pendingAutoplay) {
1232
1482
  pendingAutoplay = false
1233
1483
  audio.play().catch(function () {
@@ -1266,6 +1516,10 @@ window.__ModuleLoader__.load({
1266
1516
  getTime: function () {
1267
1517
  return { currentTime: state.currentTime, duration: state.duration, buffered: state.buffered }
1268
1518
  },
1519
+ /** Restore the last queue/position once (called by the resident host). */
1520
+ tryRestore: tryRestore,
1521
+ /** Flush statistics + resume state when the page is hidden/closed. */
1522
+ flushOnHide: flushOnHide,
1269
1523
  /** Hand the resident <audio> element to the engine (null detaches). */
1270
1524
  attachAudio: function (el) {
1271
1525
  if (audio === el) return
@@ -1455,6 +1709,7 @@ window.__ModuleLoader__.load({
1455
1709
  var ref = useRef(null)
1456
1710
  useEffect(function () {
1457
1711
  player.attachAudio(ref.current)
1712
+ player.tryRestore()
1458
1713
  return function () {
1459
1714
  player.attachAudio(null)
1460
1715
  }
@@ -2220,6 +2475,238 @@ window.__ModuleLoader__.load({
2220
2475
 
2221
2476
  // ── main panel ───────────────────────────────────────────────────────
2222
2477
 
2478
+ // ── listening statistics (NetEase-style listening ranking) ────────────
2479
+
2480
+ function basenameOf(path) {
2481
+ var parts = String(path || '').split(/[\\/]/)
2482
+ return parts.length > 0 ? parts[parts.length - 1] : String(path || '')
2483
+ }
2484
+
2485
+ function fmtListenTime(seconds) {
2486
+ var total = Math.round(Number(seconds) || 0)
2487
+ if (total <= 0) return '—'
2488
+ var hours = Math.floor(total / 3600)
2489
+ var minutes = Math.floor((total % 3600) / 60)
2490
+ if (hours > 0) return hours + ' 小时 ' + minutes + ' 分'
2491
+ if (minutes > 0) return minutes + ' 分'
2492
+ return total + ' 秒'
2493
+ }
2494
+
2495
+ function fmtRecent(ts) {
2496
+ var value = Number(ts)
2497
+ if (!isFinite(value) || value <= 0) return '—'
2498
+ var date = new Date(value)
2499
+ var now = new Date()
2500
+ var startOfDay = function (day) {
2501
+ return new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime()
2502
+ }
2503
+ var days = Math.round((startOfDay(now) - startOfDay(date)) / 86_400_000)
2504
+ if (days <= 0) return '今天'
2505
+ if (days === 1) return '昨天'
2506
+ if (days < 7) return days + ' 天前'
2507
+ return date.getFullYear() + '-' + pad2(date.getMonth() + 1) + '-' + pad2(date.getDate())
2508
+ }
2509
+
2510
+ /**
2511
+ * Listening ranking: last week / month / all time, sorted by time, plays or
2512
+ * recency. Mirrors the NetEase "listening ranking" page, limited to what
2513
+ * the local library can know without a server.
2514
+ */
2515
+ function StatsView(props) {
2516
+ var t = props.t
2517
+ var [range, setRange] = useState('week')
2518
+ var [sortBy, setSortBy] = useState('seconds')
2519
+ var [loading, setLoading] = useState(true)
2520
+ var [error, setError] = useState(null)
2521
+ var [rows, setRows] = useState([])
2522
+ var [reload, setReload] = useState(0)
2523
+
2524
+ useEffect(
2525
+ function () {
2526
+ var alive = true
2527
+ setLoading(true)
2528
+ setError(null)
2529
+ getJson(API + 'stats?range=' + encodeURIComponent(range))
2530
+ .then(function (body) {
2531
+ if (!alive) return
2532
+ var map = body.tracks !== null && typeof body.tracks === 'object' ? body.tracks : {}
2533
+ var list = []
2534
+ Object.keys(map).forEach(function (path) {
2535
+ var stat = map[path]
2536
+ var track = props.trackByPath[path] || null
2537
+ var seconds = Number(stat.playSeconds) || 0
2538
+ var plays = Number(stat.plays) || 0
2539
+ if (seconds <= 0 && plays <= 0) return
2540
+ list.push({
2541
+ path: path,
2542
+ track: track,
2543
+ title: track ? track.title : basenameOf(path),
2544
+ artist: track ? track.artist || '' : '',
2545
+ seconds: seconds,
2546
+ plays: plays,
2547
+ lastPlayedAt: Number(stat.lastPlayedAt) || 0,
2548
+ missing: track === null,
2549
+ })
2550
+ })
2551
+ setRows(list)
2552
+ setLoading(false)
2553
+ })
2554
+ .catch(function (cause) {
2555
+ if (!alive) return
2556
+ setError(cause && cause.message ? cause.message : String(cause))
2557
+ setLoading(false)
2558
+ })
2559
+ return function () {
2560
+ alive = false
2561
+ }
2562
+ },
2563
+ [range, reload],
2564
+ )
2565
+
2566
+ var sorted = useMemo(
2567
+ function () {
2568
+ var list = rows.slice()
2569
+ list.sort(function (left, right) {
2570
+ if (sortBy === 'recent') return right.lastPlayedAt - left.lastPlayedAt
2571
+ if (sortBy === 'plays') return right.plays - left.plays || right.seconds - left.seconds
2572
+ return right.seconds - left.seconds || right.plays - left.plays
2573
+ })
2574
+ return list
2575
+ },
2576
+ [rows, sortBy],
2577
+ )
2578
+
2579
+ var totals = useMemo(
2580
+ function () {
2581
+ var seconds = 0
2582
+ var plays = 0
2583
+ rows.forEach(function (row) {
2584
+ seconds += row.seconds
2585
+ plays += row.plays
2586
+ })
2587
+ return { seconds: seconds, plays: plays, tracks: rows.length }
2588
+ },
2589
+ [rows],
2590
+ )
2591
+
2592
+ var ranges = [
2593
+ ['week', t('stats.rangeWeek')],
2594
+ ['month', t('stats.rangeMonth')],
2595
+ ['all', t('stats.rangeAll')],
2596
+ ]
2597
+ var sorts = [
2598
+ ['seconds', t('stats.sortTime')],
2599
+ ['plays', t('stats.sortPlays')],
2600
+ ['recent', t('stats.sortRecent')],
2601
+ ]
2602
+
2603
+ return h(
2604
+ 'div',
2605
+ { className: 'mu-stats' },
2606
+ h(
2607
+ 'div',
2608
+ { className: 'mu-stats-toolbar' },
2609
+ ranges.map(function (entry) {
2610
+ return h(
2611
+ 'button',
2612
+ {
2613
+ key: entry[0],
2614
+ type: 'button',
2615
+ className: 'mu-chip mu-stats-chip' + (range === entry[0] ? ' is-on' : ''),
2616
+ onClick: function () {
2617
+ setRange(entry[0])
2618
+ },
2619
+ },
2620
+ entry[1],
2621
+ )
2622
+ }),
2623
+ h('span', { className: 'mu-stats-divider' }),
2624
+ sorts.map(function (entry) {
2625
+ return h(
2626
+ 'button',
2627
+ {
2628
+ key: entry[0],
2629
+ type: 'button',
2630
+ className: 'mu-chip mu-stats-chip' + (sortBy === entry[0] ? ' is-on' : ''),
2631
+ onClick: function () {
2632
+ setSortBy(entry[0])
2633
+ },
2634
+ },
2635
+ entry[1],
2636
+ )
2637
+ }),
2638
+ ),
2639
+ h(
2640
+ 'div',
2641
+ { className: 'mu-stats-summary' },
2642
+ h(
2643
+ 'div',
2644
+ { className: 'mu-stats-card' },
2645
+ h('div', { className: 'mu-stats-card-label' }, t('stats.totalTime')),
2646
+ h('div', { className: 'mu-stats-card-value' }, fmtListenTime(totals.seconds)),
2647
+ ),
2648
+ h(
2649
+ 'div',
2650
+ { className: 'mu-stats-card' },
2651
+ h('div', { className: 'mu-stats-card-label' }, t('stats.totalPlays')),
2652
+ h('div', { className: 'mu-stats-card-value' }, String(totals.plays)),
2653
+ ),
2654
+ h(
2655
+ 'div',
2656
+ { className: 'mu-stats-card' },
2657
+ h('div', { className: 'mu-stats-card-label' }, t('stats.trackCount')),
2658
+ h('div', { className: 'mu-stats-card-value' }, String(totals.tracks)),
2659
+ ),
2660
+ ),
2661
+ loading
2662
+ ? h('div', { className: 'mu-state' }, h('div', { className: 'mu-spinner' }), t('stats.loading'))
2663
+ : error !== null
2664
+ ? h(
2665
+ 'div',
2666
+ { className: 'mu-state' },
2667
+ h('div', { className: 'mu-state-title' }, t('stats.loadFailed')),
2668
+ h('div', { className: 'mu-state-sub' }, error),
2669
+ h(
2670
+ 'button',
2671
+ {
2672
+ type: 'button',
2673
+ className: 'mu-btn',
2674
+ onClick: function () {
2675
+ setReload(reload + 1)
2676
+ },
2677
+ },
2678
+ t('stats.retry'),
2679
+ ),
2680
+ )
2681
+ : sorted.length === 0
2682
+ ? h('div', { className: 'mu-state' }, h('div', { className: 'mu-state-sub' }, t('stats.empty')))
2683
+ : h(
2684
+ 'div',
2685
+ { className: 'mu-stats-list' },
2686
+ sorted.map(function (row) {
2687
+ return h(
2688
+ 'div',
2689
+ { className: 'mu-stats-row', key: row.path, title: row.path },
2690
+ h(Cover, { track: row.track, size: 36, radius: 6 }),
2691
+ h(
2692
+ 'div',
2693
+ { className: 'mu-stats-main' },
2694
+ h('div', { className: 'mu-stats-title' }, row.title),
2695
+ h(
2696
+ 'div',
2697
+ { className: 'mu-stats-artist' },
2698
+ row.artist || (row.missing ? t('stats.unavailable') : ''),
2699
+ ),
2700
+ ),
2701
+ h('div', { className: 'mu-stats-seconds' }, fmtListenTime(row.seconds)),
2702
+ h('div', { className: 'mu-stats-plays' }, t('stats.plays', { n: row.plays })),
2703
+ h('div', { className: 'mu-stats-last' }, fmtRecent(row.lastPlayedAt)),
2704
+ )
2705
+ }),
2706
+ ),
2707
+ )
2708
+ }
2709
+
2223
2710
  function MusicPanel() {
2224
2711
  var prefs = useRef(readPrefs()).current
2225
2712
  var [snap, setSnap] = useState(player.getSnapshot())
@@ -3073,6 +3560,17 @@ window.__ModuleLoader__.load({
3073
3560
  },
3074
3561
  t('page.tabSongs'),
3075
3562
  ),
3563
+ h(
3564
+ 'button',
3565
+ {
3566
+ type: 'button',
3567
+ className: 'mu-tab' + (view === 'stats' ? ' is-on' : ''),
3568
+ onClick: function () {
3569
+ setView('stats')
3570
+ },
3571
+ },
3572
+ t('page.tabStats'),
3573
+ ),
3076
3574
  playlists.map(function (playlist) {
3077
3575
  var isOn = view === 'playlists' && playlistId === playlist.id
3078
3576
  return h(
@@ -3168,6 +3666,17 @@ window.__ModuleLoader__.load({
3168
3666
  },
3169
3667
  t('settings.tabLibrary'),
3170
3668
  ),
3669
+ h(
3670
+ 'button',
3671
+ {
3672
+ type: 'button',
3673
+ className: 'mu-prefs-tab' + (prefsTab === 'playback' ? ' is-on' : ''),
3674
+ onClick: function () {
3675
+ setPrefsTab('playback')
3676
+ },
3677
+ },
3678
+ t('settings.tabPlayback'),
3679
+ ),
3171
3680
  ),
3172
3681
  prefsTab === 'appearance'
3173
3682
  ? h(
@@ -3245,7 +3754,8 @@ window.__ModuleLoader__.load({
3245
3754
  },
3246
3755
  ),
3247
3756
  )
3248
- : h(
3757
+ : prefsTab === 'library'
3758
+ ? h(
3249
3759
  'div',
3250
3760
  { className: 'mu-prefs-body' },
3251
3761
  h('p', { className: 'mu-prefs-hint' }, t('settings.libraryHint')),
@@ -3413,6 +3923,20 @@ window.__ModuleLoader__.load({
3413
3923
  ),
3414
3924
  )
3415
3925
  : null,
3926
+ )
3927
+ : h(
3928
+ 'div',
3929
+ { className: 'mu-prefs-body' },
3930
+ settingToggle(t('settings.resumeOnStart'), prefsUi.resumeOnStart, function (value) {
3931
+ updatePref('resumeOnStart', value)
3932
+ }),
3933
+ settingToggle(t('settings.autoResume'), prefsUi.autoResume, function (value) {
3934
+ updatePref('autoResume', value)
3935
+ }),
3936
+ settingToggle(t('settings.rememberPerTrack'), prefsUi.rememberPerTrack, function (value) {
3937
+ updatePref('rememberPerTrack', value)
3938
+ }),
3939
+ h('p', { className: 'mu-prefs-hint' }, t('settings.playbackHint')),
3416
3940
  ),
3417
3941
  )
3418
3942
  : null,
@@ -3464,7 +3988,7 @@ window.__ModuleLoader__.load({
3464
3988
 
3465
3989
  h(
3466
3990
  'div',
3467
- { className: 'mu-chips' },
3991
+ { className: 'mu-chips', style: view === 'stats' ? { display: 'none' } : undefined },
3468
3992
  h(
3469
3993
  'button',
3470
3994
  { type: 'button', className: 'mu-chip', title: loopLabel(), onClick: cycleLoop },
@@ -3562,7 +4086,10 @@ window.__ModuleLoader__.load({
3562
4086
 
3563
4087
  h(
3564
4088
  'div',
3565
- { className: 'mu-list' },
4089
+ { className: 'mu-list' + (view === 'stats' ? ' is-stats' : '') },
4090
+ view === 'stats'
4091
+ ? h(StatsView, { tracks: tracks, trackByPath: trackByPath, t: t })
4092
+ : [
3566
4093
  loading && tracks.length === 0
3567
4094
  ? h('div', { className: 'mu-state' }, h('div', { className: 'mu-spinner' }), t('page.scanning'))
3568
4095
  : null,
@@ -3601,6 +4128,7 @@ window.__ModuleLoader__.load({
3601
4128
  ? h('div', { className: 'mu-warn' }, t('page.warnings') + ':' + warnings.slice(0, 3).join(' · '))
3602
4129
  : null,
3603
4130
  visible.map(renderRow),
4131
+ ],
3604
4132
  ),
3605
4133
 
3606
4134
  h(
@@ -3993,6 +4521,24 @@ window.__ModuleLoader__.load({
3993
4521
  '.mu-tab.is-on{background:var(--dsw-alias-interactive-bg-active,rgba(255,255,255,.1));color:var(--dsw-alias-label-primary,#f4f4f5);font-weight:600}',
3994
4522
  '.mu-tab-add{width:30px;padding:0;display:inline-flex;align-items:center;justify-content:center;border:.5px solid var(--dsw-alias-border-l3,rgba(255,255,255,.12))}',
3995
4523
  '.mu-tab-add:hover{border-color:var(--dsw-alias-border-l4,rgba(255,255,255,.2))}',
4524
+ '.mu-list.is-stats{padding-top:4px}',
4525
+ '.mu-stats{display:flex;flex-direction:column;gap:12px;padding:2px 0 10px}',
4526
+ '.mu-stats-toolbar{display:flex;align-items:center;gap:6px;flex-wrap:wrap}',
4527
+ '.mu-stats-chip{height:26px;padding:0 10px;font-size:12px}',
4528
+ '.mu-stats-divider{width:1px;height:16px;background:var(--dsw-alias-border-l2,rgba(255,255,255,.12));margin:0 2px}',
4529
+ '.mu-stats-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}',
4530
+ '.mu-stats-card{border:.5px solid var(--dsw-alias-border-l1,rgba(255,255,255,.08));border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1,#1b1b1e));padding:10px 12px;min-width:0}',
4531
+ '.mu-stats-card-label{font-size:11px;color:var(--dsw-alias-label-tertiary,#8a8a93);white-space:nowrap}',
4532
+ '.mu-stats-card-value{font-size:17px;font-weight:600;margin-top:2px;color:var(--dsw-alias-label-primary,#f4f4f5);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}',
4533
+ '.mu-stats-list{display:flex;flex-direction:column;gap:2px}',
4534
+ '.mu-stats-row{display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:var(--dsw-radius-sm,6px);min-width:0}',
4535
+ '.mu-stats-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.05))}',
4536
+ '.mu-stats-main{flex:1;min-width:0}',
4537
+ '.mu-stats-title{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
4538
+ '.mu-stats-artist{font-size:11px;color:var(--dsw-alias-label-tertiary,#8a8a93);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:1px}',
4539
+ '.mu-stats-seconds{flex:none;width:104px;text-align:right;font-size:12px;color:var(--dsw-alias-label-secondary,#a1a1aa);font-variant-numeric:tabular-nums}',
4540
+ '.mu-stats-plays{flex:none;width:64px;text-align:right;font-size:11px;color:var(--dsw-alias-label-tertiary,#8a8a93);font-variant-numeric:tabular-nums}',
4541
+ '.mu-stats-last{flex:none;width:78px;text-align:right;font-size:11px;color:var(--dsw-alias-label-tertiary,#8a8a93)}',
3996
4542
  '.mu-view{flex:1;min-height:0;display:flex;flex-direction:column}',
3997
4543
  '.mu-pl-new{display:flex;gap:8px;padding:4px 24px 12px}',
3998
4544
  '.mu-pl-new .mu-input{flex:1}',
@@ -4384,6 +4930,22 @@ window.__ModuleLoader__.load({
4384
4930
  }
4385
4931
  })
4386
4932
 
4933
+ // Flush resume state / listening seconds when the page goes away.
4934
+ ctx.effect(function () {
4935
+ function onVisibility() {
4936
+ if (document.visibilityState === 'hidden') player.flushOnHide()
4937
+ }
4938
+ function onUnload() {
4939
+ player.flushOnHide()
4940
+ }
4941
+ document.addEventListener('visibilitychange', onVisibility)
4942
+ window.addEventListener('beforeunload', onUnload)
4943
+ return function () {
4944
+ document.removeEventListener('visibilitychange', onVisibility)
4945
+ window.removeEventListener('beforeunload', onUnload)
4946
+ }
4947
+ })
4948
+
4387
4949
  function mount(name, registration, component) {
4388
4950
  ctx.effect(function () {
4389
4951
  var dispose = ctx.slots.inject(name, function () {
package/index.js CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { createLibrary } from './lib/library.js'
12
+ import { createPlayback } from './lib/playback.js'
12
13
  import { createPlaylists } from './lib/playlists.js'
13
14
  import { createRoutes } from './lib/routes.js'
14
15
  import { createSettings } from './lib/settings.js'
@@ -28,9 +29,10 @@ export function apply(ctx, rawConfig) {
28
29
  const config = rawConfig !== null && typeof rawConfig === 'object' ? rawConfig : {}
29
30
  const settings = createSettings({ dshHome: config.dshHome, seed: config })
30
31
  const stats = createStats({ dir: settings.dir })
32
+ const playback = createPlayback({ dir: settings.dir })
31
33
  const library = createLibrary({ getSettings: settings.read, stats })
32
34
  const playlists = createPlaylists({ dir: settings.dir })
33
- const handle = createRoutes({ settings, library, playlists, stats })
35
+ const handle = createRoutes({ settings, library, playlists, stats, playback })
34
36
 
35
37
  ctx.effect(() => {
36
38
  const dispose = ctx.webServer.register({
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Last playback state for dsh-music-panel.
3
+ *
4
+ * Mirrors the resume behaviour of mainstream players (NetEase "remember the
5
+ * last playback position on start", Spotify/YouTube Music restoring the queue):
6
+ * the queue, the current index, the position and loop/order are written while
7
+ * playing and read once when the plugin starts again.
8
+ *
9
+ * Stored as `<DSH_HOME>/music/playback.json`. Pure Node.
10
+ *
11
+ * @module dsh-music-panel/playback
12
+ */
13
+
14
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
15
+ import { join } from 'node:path'
16
+
17
+ const MAX_QUEUE = 500
18
+ const LOOPS = new Set(['all', 'one', 'none'])
19
+ const ORDERS = new Set(['list', 'random'])
20
+
21
+ function finiteOr(value, fallback) {
22
+ const number = Number(value)
23
+ return Number.isFinite(number) ? number : fallback
24
+ }
25
+
26
+ function clamp(value, min, max) {
27
+ return Math.min(Math.max(value, min), max)
28
+ }
29
+
30
+ /**
31
+ * Coerce an arbitrary payload into a state the client can trust, or null.
32
+ *
33
+ * @param {unknown} raw
34
+ * @returns {{queue: string[], index: number, positionSec: number, durationSec: number, loop: string, order: string, updatedAt: number} | null}
35
+ */
36
+ export function sanitizePlayback(raw) {
37
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
38
+ const queue = Array.isArray(raw.queue)
39
+ ? raw.queue.filter((path) => typeof path === 'string' && path !== '').slice(0, MAX_QUEUE)
40
+ : []
41
+ const durationSec = Math.max(0, finiteOr(raw.durationSec, 0))
42
+ const positionSec = clamp(Math.max(0, finiteOr(raw.positionSec, 0)), 0, durationSec > 0 ? durationSec : Number.MAX_SAFE_INTEGER)
43
+ const index = queue.length === 0
44
+ ? -1
45
+ : clamp(Math.floor(finiteOr(raw.index, 0)), 0, queue.length - 1)
46
+ const loop = LOOPS.has(raw.loop) ? raw.loop : 'all'
47
+ const order = ORDERS.has(raw.order) ? raw.order : 'list'
48
+ return {
49
+ queue,
50
+ index,
51
+ positionSec: Math.round(positionSec * 10) / 10,
52
+ durationSec,
53
+ loop,
54
+ order,
55
+ updatedAt: Math.max(0, Math.round(finiteOr(raw.updatedAt, 0))),
56
+ }
57
+ }
58
+
59
+ /**
60
+ * @param {{ dir: string }} options `dir` is `<DSH_HOME>/music`
61
+ */
62
+ export function createPlayback({ dir }) {
63
+ const file = join(dir, 'playback.json')
64
+ /** `undefined` = not read yet, `null` = no usable file. */
65
+ let cache
66
+
67
+ async function read() {
68
+ if (cache !== undefined) return cache
69
+ let parsed = null
70
+ try {
71
+ parsed = JSON.parse(await readFile(file, 'utf8'))
72
+ } catch {
73
+ parsed = null
74
+ }
75
+ cache = sanitizePlayback(parsed)
76
+ return cache
77
+ }
78
+
79
+ async function write(raw) {
80
+ const next = sanitizePlayback(raw)
81
+ if (next === null) return null
82
+ next.updatedAt = Date.now()
83
+ await mkdir(dir, { recursive: true })
84
+ await writeFile(file, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
85
+ cache = next
86
+ return next
87
+ }
88
+
89
+ return {
90
+ file,
91
+ read,
92
+ write,
93
+ invalidate() {
94
+ cache = undefined
95
+ },
96
+ }
97
+ }
package/lib/routes.js CHANGED
@@ -9,6 +9,11 @@
9
9
  * GET /music/lyrics?path=... sidecar .lrc as timed lines
10
10
  * GET /music/playlists user playlists
11
11
  * POST /music/play record one playback for a track
12
+ * GET /music/state last playback state (resume)
13
+ * POST /music/state persist last playback state
14
+ * POST /music/playback add actually-listened seconds
15
+ * POST /music/position remember one track's resume point
16
+ * GET /music/stats?range=... aggregated play time / plays (all|week|month)
12
17
  *
13
18
  * Every path parameter is re-resolved against the configured roots, so a
14
19
  * crafted `?path=` can never read outside the folders the user granted.
@@ -200,9 +205,10 @@ async function validateRoots(list) {
200
205
  * remove: (id: string) => Promise<boolean>,
201
206
  * },
202
207
  * stats: { recordPlay: (path: string) => Promise<{ plays: number }> },
208
+ * playback: { read: () => Promise<object | null>, write: (state: unknown) => Promise<object | null> },
203
209
  * }} deps
204
210
  */
205
- export function createRoutes({ settings, library, playlists, stats }) {
211
+ export function createRoutes({ settings, library, playlists, stats, playback }) {
206
212
  return async function handle(req, res) {
207
213
  try {
208
214
  if (!isLoopback(req)) return fail(res, 403, 'NOT_LOOPBACK', '仅允许本机访问(远程访问已禁用音乐面板)')
@@ -359,6 +365,86 @@ export function createRoutes({ settings, library, playlists, stats }) {
359
365
  }
360
366
  }
361
367
 
368
+ if (pathname === '/state' && method === 'GET') {
369
+ const state = playback === undefined ? null : await playback.read()
370
+ return sendJson(res, 200, { ok: true, state })
371
+ }
372
+
373
+ if (pathname === '/state' && method === 'POST') {
374
+ if (playback === undefined) return fail(res, 503, 'UNAVAILABLE', '播放状态持久化未启用')
375
+ let body
376
+ try {
377
+ body = await readJson(req)
378
+ } catch {
379
+ return fail(res, 400, 'BAD_JSON', '请求体不是合法 JSON')
380
+ }
381
+ const state = await playback.write(body)
382
+ if (state === null) return fail(res, 400, 'BAD_STATE', '播放状态格式不正确')
383
+ return sendJson(res, 200, { ok: true, state })
384
+ }
385
+
386
+ if (pathname === '/playback' && method === 'POST') {
387
+ if (stats === undefined) return fail(res, 503, 'UNAVAILABLE', '统计未启用')
388
+ let body
389
+ try {
390
+ body = await readJson(req)
391
+ } catch {
392
+ return fail(res, 400, 'BAD_JSON', '请求体不是合法 JSON')
393
+ }
394
+ const config = await settings.read()
395
+ const file = await resolveTrack(body?.path, config.roots)
396
+ if (file === null) return fail(res, 404, 'NOT_FOUND', '文件不在已配置的音乐目录内')
397
+ const seconds = Number(body?.seconds)
398
+ if (!Number.isFinite(seconds) || seconds <= 0) {
399
+ return fail(res, 400, 'BAD_SECONDS', 'seconds 必须是正数')
400
+ }
401
+ const entry = await stats.addPlayTime(file, seconds)
402
+ if (entry === null) return fail(res, 400, 'BAD_SECONDS', 'seconds 必须是正数')
403
+ if (body?.positionSec !== undefined) {
404
+ await stats.setPosition(file, body.positionSec)
405
+ }
406
+ return sendJson(res, 200, {
407
+ ok: true,
408
+ path: file,
409
+ playSeconds: entry.playSeconds,
410
+ plays: entry.plays,
411
+ })
412
+ }
413
+
414
+ if (pathname === '/position' && method === 'GET') {
415
+ if (stats === undefined) return sendJson(res, 200, { ok: true, lastPositionSec: null })
416
+ const config = await settings.read()
417
+ const file = await resolveTrack(params.get('path'), config.roots)
418
+ if (file === null) return fail(res, 404, 'NOT_FOUND', '文件不在已配置的音乐目录内')
419
+ const entry = await stats.get(file)
420
+ return sendJson(res, 200, { ok: true, path: file, lastPositionSec: entry === null ? null : entry.lastPositionSec })
421
+ }
422
+
423
+ if (pathname === '/position' && method === 'POST') {
424
+ if (stats === undefined) return fail(res, 503, 'UNAVAILABLE', '统计未启用')
425
+ let body
426
+ try {
427
+ body = await readJson(req)
428
+ } catch {
429
+ return fail(res, 400, 'BAD_JSON', '请求体不是合法 JSON')
430
+ }
431
+ const config = await settings.read()
432
+ const file = await resolveTrack(body?.path, config.roots)
433
+ if (file === null) return fail(res, 404, 'NOT_FOUND', '文件不在已配置的音乐目录内')
434
+ const entry = await stats.setPosition(file, body?.seconds)
435
+ if (entry === null) return fail(res, 400, 'BAD_POSITION', 'seconds 必须是非负数')
436
+ return sendJson(res, 200, { ok: true, path: file, lastPositionSec: entry.lastPositionSec })
437
+ }
438
+
439
+ if (pathname === '/stats' && method === 'GET') {
440
+ if (stats === undefined || typeof stats.summarize !== 'function') {
441
+ return sendJson(res, 200, { ok: true, range: 'all', tracks: {} })
442
+ }
443
+ const requested = String(params.get('range') || 'all')
444
+ const range = requested === 'week' || requested === 'month' ? requested : 'all'
445
+ return sendJson(res, 200, { ok: true, range, tracks: await stats.summarize(range) })
446
+ }
447
+
362
448
  if (pathname === '/play' && method === 'POST') {
363
449
  let body
364
450
  try {
package/lib/stats.js CHANGED
@@ -1,6 +1,12 @@
1
1
  /**
2
- * Per-track statistics for dsh-music-panel: when a file first entered the library
3
- * and how many times it was played.
2
+ * Per-track statistics for dsh-music-panel: when a file first entered the
3
+ * library, how many times it was played, how many seconds it was actually
4
+ * played, and (optionally) the last position inside the track.
5
+ *
6
+ * Counting follows the Last.fm / MusicBee convention: a play only counts when
7
+ * the track is longer than 30 seconds and has been played for at least half
8
+ * its duration or 4 minutes (whichever comes first). The caller decides when
9
+ * that threshold is reached; this module only records.
4
10
  *
5
11
  * Stored next to the settings in `<DSH_HOME>/music/stats.json`. Pure Node.
6
12
  *
@@ -12,17 +18,85 @@ import { join } from 'node:path'
12
18
 
13
19
  /** Bound the file so a long-lived library cannot grow without limit. */
14
20
  const MAX_ENTRIES = 20_000
21
+ /** Daily buckets older than this many days are dropped. */
22
+ const KEEP_DAYS = 31
23
+
24
+ const MIN_PLAY_SECONDS = 30
25
+ const FOUR_MINUTES = 240
26
+
27
+ function pad2(value) {
28
+ return value < 10 ? `0${value}` : String(value)
29
+ }
30
+
31
+ /** Local calendar day, so "last week" matches what the user sees. */
32
+ export function dayKey(ts = Date.now()) {
33
+ const date = new Date(ts)
34
+ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
35
+ }
36
+
37
+ function finiteOr(value, fallback) {
38
+ const number = Number(value)
39
+ return Number.isFinite(number) ? number : fallback
40
+ }
41
+
42
+ function sanitizeBucket(raw) {
43
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
44
+ const plays = Math.max(0, Math.floor(finiteOr(raw.plays, 0)))
45
+ const seconds = Math.max(0, Math.round(finiteOr(raw.seconds, 0) * 10) / 10)
46
+ const lastAt = Math.max(0, Math.round(finiteOr(raw.lastAt, 0)))
47
+ return { plays, seconds, lastAt }
48
+ }
15
49
 
16
50
  function sanitizeEntry(raw) {
17
- if (raw === null || typeof raw !== 'object') return null
18
- const addedAt = Number(raw.addedAt)
19
- const plays = Number(raw.plays)
51
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
52
+ const addedAt = finiteOr(raw.addedAt, 0)
53
+ const plays = Math.max(0, Math.floor(finiteOr(raw.plays, 0)))
54
+ const playSeconds = Math.max(0, Math.round(finiteOr(raw.playSeconds, 0) * 10) / 10)
55
+ const lastPlayedAt = raw.lastPlayedAt === null || raw.lastPlayedAt === undefined
56
+ ? null
57
+ : Math.max(0, Math.round(finiteOr(raw.lastPlayedAt, 0))) || null
58
+ const lastPositionSec = raw.lastPositionSec === null || raw.lastPositionSec === undefined
59
+ ? null
60
+ : Math.max(0, Math.round(finiteOr(raw.lastPositionSec, 0) * 10) / 10)
61
+ const days = {}
62
+ if (raw.days !== null && typeof raw.days === 'object' && !Array.isArray(raw.days)) {
63
+ for (const [key, value] of Object.entries(raw.days)) {
64
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) continue
65
+ const bucket = sanitizeBucket(value)
66
+ if (bucket !== null) days[key] = bucket
67
+ }
68
+ }
20
69
  return {
21
- addedAt: Number.isFinite(addedAt) && addedAt > 0 ? addedAt : Date.now(),
22
- plays: Number.isFinite(plays) && plays >= 0 ? Math.floor(plays) : 0,
70
+ addedAt: addedAt > 0 ? addedAt : Date.now(),
71
+ plays,
72
+ playSeconds,
73
+ lastPlayedAt,
74
+ lastPositionSec,
75
+ days,
76
+ }
77
+ }
78
+
79
+ function pruneDays(entry, now = Date.now()) {
80
+ const cutoff = dayKey(now - (KEEP_DAYS - 1) * 86_400_000)
81
+ for (const key of Object.keys(entry.days)) {
82
+ if (key < cutoff) delete entry.days[key]
23
83
  }
24
84
  }
25
85
 
86
+ /**
87
+ * Threshold after which one play is counted (Last.fm / MusicBee default).
88
+ * `durationSec <= 0` means "unknown": the caller should wait for 4 minutes.
89
+ *
90
+ * @param {number} durationSec
91
+ * @returns {number}
92
+ */
93
+ export function playThreshold(durationSec) {
94
+ const duration = finiteOr(durationSec, 0)
95
+ if (duration <= 0) return FOUR_MINUTES
96
+ if (duration <= MIN_PLAY_SECONDS) return duration + 0.5
97
+ return Math.min(duration / 2, FOUR_MINUTES)
98
+ }
99
+
26
100
  /**
27
101
  * @param {{ dir: string }} options `dir` is `<DSH_HOME>/music`
28
102
  */
@@ -51,7 +125,13 @@ export function createStats({ dir }) {
51
125
 
52
126
  async function save() {
53
127
  await mkdir(dir, { recursive: true })
54
- await writeFile(file, `${JSON.stringify({ tracks: cache }, null, 2)}\n`, 'utf8')
128
+ await writeFile(file, `${JSON.stringify({ version: 2, tracks: cache }, null, 2)}\n`, 'utf8')
129
+ }
130
+
131
+ function bucketOf(entry, now) {
132
+ const key = dayKey(now)
133
+ if (entry.days[key] === undefined) entry.days[key] = { plays: 0, seconds: 0, lastAt: 0 }
134
+ return entry.days[key]
55
135
  }
56
136
 
57
137
  return {
@@ -62,7 +142,14 @@ export function createStats({ dir }) {
62
142
  let dirty = false
63
143
  for (const path of paths) {
64
144
  if (tracks[path] === undefined) {
65
- tracks[path] = { addedAt: now, plays: 0 }
145
+ tracks[path] = {
146
+ addedAt: now,
147
+ plays: 0,
148
+ playSeconds: 0,
149
+ lastPlayedAt: null,
150
+ lastPositionSec: null,
151
+ days: {},
152
+ }
66
153
  dirty = true
67
154
  }
68
155
  }
@@ -75,15 +162,115 @@ export function createStats({ dir }) {
75
162
  return tracks[path] ?? null
76
163
  },
77
164
 
165
+ /** Count one qualifying play (the caller applies the Last.fm threshold). */
78
166
  async recordPlay(path) {
79
167
  const tracks = await load()
80
- const entry = tracks[path] ?? { addedAt: Date.now(), plays: 0 }
168
+ const entry = tracks[path] ?? {
169
+ addedAt: Date.now(),
170
+ plays: 0,
171
+ playSeconds: 0,
172
+ lastPlayedAt: null,
173
+ lastPositionSec: null,
174
+ days: {},
175
+ }
176
+ const now = Date.now()
81
177
  entry.plays = (Number(entry.plays) || 0) + 1
178
+ entry.lastPlayedAt = now
179
+ const bucket = bucketOf(entry, now)
180
+ bucket.plays += 1
181
+ bucket.lastAt = now
182
+ pruneDays(entry, now)
183
+ tracks[path] = entry
184
+ await save()
185
+ return { ...entry }
186
+ },
187
+
188
+ /** Add actually-listened seconds (never seeks or paused time). */
189
+ async addPlayTime(path, seconds) {
190
+ const value = finiteOr(seconds, 0)
191
+ if (value <= 0) return null
192
+ const tracks = await load()
193
+ const entry = tracks[path] ?? {
194
+ addedAt: Date.now(),
195
+ plays: 0,
196
+ playSeconds: 0,
197
+ lastPlayedAt: null,
198
+ lastPositionSec: null,
199
+ days: {},
200
+ }
201
+ const now = Date.now()
202
+ entry.playSeconds = Math.round((entry.playSeconds + value) * 10) / 10
203
+ entry.lastPlayedAt = now
204
+ const bucket = bucketOf(entry, now)
205
+ bucket.seconds = Math.round((bucket.seconds + value) * 10) / 10
206
+ bucket.lastAt = now
207
+ pruneDays(entry, now)
82
208
  tracks[path] = entry
83
209
  await save()
84
210
  return { ...entry }
85
211
  },
86
212
 
213
+ /** Per-track resume point, used when "remember per track" is enabled. */
214
+ async setPosition(path, seconds) {
215
+ const value = finiteOr(seconds, -1)
216
+ if (value < 0) return null
217
+ const tracks = await load()
218
+ const entry = tracks[path] ?? {
219
+ addedAt: Date.now(),
220
+ plays: 0,
221
+ playSeconds: 0,
222
+ lastPlayedAt: null,
223
+ lastPositionSec: null,
224
+ days: {},
225
+ }
226
+ entry.lastPositionSec = Math.round(value * 10) / 10
227
+ tracks[path] = entry
228
+ await save()
229
+ return { ...entry }
230
+ },
231
+
232
+ /**
233
+ * Aggregate per track over `all`, `week` (last 7 days) or `month`
234
+ * (last 30 days). Only tracks with activity inside the range are kept.
235
+ *
236
+ * @param {'all'|'week'|'month'} [range]
237
+ */
238
+ async summarize(range = 'all') {
239
+ const tracks = await load()
240
+ const days = range === 'week' ? 7 : range === 'month' ? 30 : 0
241
+ const since = days > 0 ? dayKey(Date.now() - (days - 1) * 86_400_000) : null
242
+ const out = {}
243
+ for (const [path, entry] of Object.entries(tracks)) {
244
+ if (since === null) {
245
+ if (entry.plays > 0 || entry.playSeconds > 0) {
246
+ out[path] = {
247
+ plays: entry.plays,
248
+ playSeconds: entry.playSeconds,
249
+ lastPlayedAt: entry.lastPlayedAt,
250
+ }
251
+ }
252
+ continue
253
+ }
254
+ let plays = 0
255
+ let seconds = 0
256
+ let lastAt = 0
257
+ for (const [day, bucket] of Object.entries(entry.days)) {
258
+ if (day < since) continue
259
+ plays += bucket.plays
260
+ seconds += bucket.seconds
261
+ if (bucket.lastAt > lastAt) lastAt = bucket.lastAt
262
+ }
263
+ if (plays > 0 || seconds > 0) {
264
+ out[path] = {
265
+ plays,
266
+ playSeconds: Math.round(seconds * 10) / 10,
267
+ lastPlayedAt: lastAt || entry.lastPlayedAt,
268
+ }
269
+ }
270
+ }
271
+ return out
272
+ },
273
+
87
274
  /** In-memory view; the file is read once per process. */
88
275
  async snapshot() {
89
276
  return { ...(await load()) }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-music-panel",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Local music library for DeepSeek Harness: scan a folder, browse tracks with cover art and tags, and play through a sidebar-panel player with loop, shuffle and volume. · DSH 本地音乐插件:扫描音乐目录、读取封面与标签、侧栏入口 + 全屏播放器。",
6
6
  "license": "MIT",
@@ -25,8 +25,8 @@
25
25
  "audio"
26
26
  ],
27
27
  "scripts": {
28
- "test": "node test/config.test.mjs && node test/meta.test.mjs && node test/library.test.mjs && node test/routes.test.mjs && node test/lyrics.test.mjs && node test/playlists.test.mjs && node test/stats.test.mjs && node test/client.smoke.test.mjs",
29
- "check": "node --check index.js && node --check client.js && node --check lib/config.js && node --check lib/settings.js && node --check lib/meta.js && node --check lib/library.js && node --check lib/http.js && node --check lib/lyrics.js && node --check lib/playlists.js && node --check lib/stats.js && node --check lib/routes.js",
28
+ "test": "node test/config.test.mjs && node test/meta.test.mjs && node test/library.test.mjs && node test/routes.test.mjs && node test/lyrics.test.mjs && node test/playlists.test.mjs && node test/stats.test.mjs && node test/playback.test.mjs && node test/client.smoke.test.mjs",
29
+ "check": "node --check index.js && node --check client.js && node --check lib/config.js && node --check lib/settings.js && node --check lib/meta.js && node --check lib/library.js && node --check lib/http.js && node --check lib/lyrics.js && node --check lib/playlists.js && node --check lib/stats.js && node --check lib/playback.js && node --check lib/routes.js",
30
30
  "pack:check": "npm pack --dry-run"
31
31
  },
32
32
  "exports": {