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 +16 -0
- package/README.md +21 -2
- package/client.js +577 -15
- package/index.js +3 -1
- package/lib/playback.js +97 -0
- package/lib/routes.js +87 -1
- package/lib/stats.js +197 -10
- package/package.json +3 -3
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
|
-
//
|
|
1195
|
-
|
|
1196
|
-
|
|
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
|
-
:
|
|
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({
|
package/lib/playback.js
ADDED
|
@@ -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
|
|
3
|
-
*
|
|
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 =
|
|
19
|
-
const 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:
|
|
22
|
-
plays
|
|
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] = {
|
|
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] ?? {
|
|
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.
|
|
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": {
|