Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,34 @@ All notable changes to ShuffleMuse are documented in this file.

The project follows [Semantic Versioning](https://semver.org/).

## [0.1.2] - 2026-07-29

### Added

- A Browse action that moves the current folder's directly contained audio
files to the front of the active queue and starts the first track, without
duplicating those files or discarding the rest of the randomized queue.
- Best-effort Web Audio endpoint activation and a 15-second post-pause
keepalive on supported browsers, without a fixed delay before media playback.

### Changed

- Original-file responses now preserve the response writer's optimized
`io.ReaderFrom` copy path through logging and commit-tracking middleware.
- Library status refreshes reuse the initial authentication response, poll every
2 seconds only during scans, fall back to 30 seconds while idle, and pause
while the page is hidden.
- The Home view now gives the current track, artwork, and tag filter a more
compact layout.

### Fixed

- Cancelled stale playback intents after pause, overlapping track selections,
source changes, and player reset so late asynchronous work cannot start old
audio.
- Prevented the stream-mode selector from covering the next-track control
across intermediate viewport widths.

## [0.1.1] - 2026-07-24

### Added
Expand Down Expand Up @@ -44,5 +72,6 @@ The project follows [Semantic Versioning](https://semver.org/).
- Multi-architecture GHCR image publication for `linux/amd64` and
`linux/arm64`.

[0.1.2]: https://github.com/ColderCoder/ShuffleMuse/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/ColderCoder/ShuffleMuse/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/ColderCoder/ShuffleMuse/tree/v0.1.0
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ ShuffleMuse 是一个面向个人和小型自托管场景的轻量音乐库播
## 快速部署

要求 Docker Engine 和 Docker Compose 插件。默认配置拉取公开的
`ghcr.io/coldercoder/shufflemuse:0.1.1`。把音乐文件放入项目根目录的
`ghcr.io/coldercoder/shufflemuse:0.1.2`。把音乐文件放入项目根目录的
`music/` 后执行:

```bash
Expand Down Expand Up @@ -177,7 +177,7 @@ Tags 页的 CSV 用于查看和外部处理,没有对应的导入功能,不

## 版本与镜像

- 稳定版本由对应 Git 标签发布;`v0.1.1` 对应镜像标签 `0.1.1`、`0.1`、
- 稳定版本由对应 Git 标签发布;`v0.1.2` 对应镜像标签 `0.1.2`、`0.1`、
`0` 和 `latest`。
- 支持 `linux/amd64` 与 `linux/arm64`。
- `shufflemuse --version` 输出版本、Git commit 与构建时间。
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: shufflemuse

services:
shufflemuse:
image: ghcr.io/coldercoder/shufflemuse:0.1.1
image: ghcr.io/coldercoder/shufflemuse:0.1.2
ports:
- "127.0.0.1:8080:8080"
volumes:
Expand Down
23 changes: 22 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,11 +278,28 @@ HTTP/1.1 202 Accepted

文件已经在队列中时返回原队列、零基 `queueIndex`、所在页及该页。文件在线但不在队列中时,原子创建以该文件为首、保留原剩余顺序的替代队列,并在响应的 `queue.id` 返回新令牌。

### `POST /api/queues/{id}/prepend-directory`

```json
{"dir":"Artist/Album"}
```

读取目录中直接包含的在线音频文件(不递归),按不区分大小写的文件名
排序,移到当前队列最前并去重。原队列的其他项目保持原顺序。成功后原子
替换队列令牌并返回 `200 OK`;响应包含新队列的第一页以及
`directoryTrackCount`。

目录及 symlink 解析后的目标都必须位于 `MUSIC_DIR` 内。空目录或没有在线
音频的目录返回 `422 NO_AUDIO_FILES`;不存在的目录返回
`404 NOT_FOUND`。

### `DELETE /api/queues/{id}`

幂等返回 `204 No Content`,令牌不存在或格式无效也相同。

队列稳定错误:`404 QUEUE_NOT_FOUND`、`404 FILE_NOT_FOUND`、`503 QUEUE_BUSY`、`503 QUEUE_CAPACITY`。JSON、分页、认证和内部错误沿用公共错误码。
队列稳定错误:`404 QUEUE_NOT_FOUND`、`404 FILE_NOT_FOUND`、
`422 NO_AUDIO_FILES`、`503 QUEUE_BUSY`、`503 QUEUE_CAPACITY`。JSON、
分页、认证和内部错误沿用公共错误码。

### `GET /api/search?q=<term>`

Expand Down Expand Up @@ -363,12 +380,16 @@ HEAD 和能得到 304 的条件请求只发现 descriptor,不启动 FFmpeg。
"playable":false
}
],
"audioCount":0,
"total":2,
"page":1,
"generation":3
}
```

`audioCount` 是该目录全部直接子项中当前可播放的音频文件数,不受当前
分页影响;子目录中的音频不计入。

音频文件还包含 `playable:true`、`audioId` 和不含扩展名的 `trackName`。

错误:`400 INVALID_DIRECTORY`、`400 QUERY_TOO_LONG`、`404 NOT_FOUND`、`500 BROWSE_ERROR`、分页错误。
Expand Down
17 changes: 10 additions & 7 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,13 @@ sequenceDiagram

同 generation 的队列引用同一只读 `Index`,不复制 `Files` 或 `ByID`。
标签候选和 pin 只影响 `uint32` 顺序及小型前缀覆盖;带标签的队列只接受
同样属于该标签的 pin,避免标签外曲目混入。容量核算把不同快照只计一次;
旧 generation 在最后一个队列释放后可回收。队列排列不可变,固定 200 条
分页只做切片。选取任意歌曲时的线性位置扫描在全局 Manager 锁外执行并
响应 request cancellation;重新加锁后必须校验原队列身份,才返回页面或
原子替换队列。
同样属于该标签的 pin,避免标签外曲目混入。Browse 的目录播放会把目录
直接包含的在线音频按文件名排序后写入前缀、从原前缀与随机顺序中去重,
并以新令牌原子替换旧队列。容量核算把不同快照只计一次;旧 generation
在最后一个队列释放后可回收。队列排列不可变,固定 200 条分页只做切片。
选取任意歌曲时的线性位置扫描和目录前缀构建都在全局 Manager 锁外执行
并响应 request cancellation;重新加锁后必须校验原队列身份,才返回页面
或原子替换队列。

重扫发布不修改队列:页面 materialize 时用当前 Index 检查 ID/路径,删除项显示 `available:false`,next/previous 跳过;新增项只进入下一次新建或 Randomize。TTL/LRU 淘汰后前端最多自动恢复一次,以相同标签和当前歌曲 pin 创建新队列,同时保留正在播放的 Audio source。

Expand Down Expand Up @@ -250,8 +252,8 @@ HEAD/304 只读取 descriptor,不启动 FFmpeg。未转换外置图使用 `Ope
| Store | 主要责任 |
| --- | --- |
| `auth` | 四态认证状态机、登录/退出和显式重试 |
| `library` | 2 秒状态轮询、首次扫描、重扫和 generation |
| `player` | Audio 元素、队列描述/全局位置、最多 5 页 LRU、播放意图、模式和 metadata |
| `library` | 扫描时 2 秒/空闲时 30 秒的可见页状态轮询、首次扫描、重扫和 generation |
| `player` | Audio 元素、端点保温、队列描述/全局位置、最多 5 页 LRU、播放意图、模式和 metadata |
| `tags` | 标签云、当前 200 条标签文件页和 selection |

### 异步一致性
Expand All @@ -263,6 +265,7 @@ HEAD/304 只读取 descriptor,不启动 FFmpeg。未转换外置图使用 `Ope
- stop 后状态与重扫请求被取消且不能回写;
- 文本 Preview 卸载时取消仍在进行的内容请求;
- 用户显式选歌后,旧 playlist 请求不能替换它;
- pause、重叠选曲和 reset 会使旧播放意图失效,迟到任务不能启动旧音频;
- 标签文件只保留当前服务端分页;播放列表页面要求同一个 generation;
- logout 和 Session 过期会统一重置播放器、曲库和标签状态。

Expand Down
4 changes: 2 additions & 2 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ Tags 文件行播放按钮已覆盖 hover、按钮 `:focus-visible` 和行 `:foc

## 当前测试范围

后端测试覆盖配置、认证、代理、严格请求、扫描、重扫、Browse、stream、media queue、tags、Graveyard、CSV 和静态资源缓存。前端当前有 17 个测试文件、64 个用例,覆盖主要 stores、分页/取消竞态、登录封禁、路由、Search 语义、Playlist 分块、Tags 二级导航与导出、Graveyard、Modal 焦点和 metadata 标题切换。
后端测试覆盖配置、认证、代理、严格请求、扫描、重扫、Browse、stream、media queue、tags、Graveyard、CSV 和静态资源缓存。前端当前有 17 个测试文件、82 个用例,覆盖主要 stores、分页/取消竞态、登录封禁、路由、Search 语义、Playlist 分块、目录入队、播放意图、Tags 二级导航与导出、Graveyard、Modal 焦点和 metadata 标题切换。

当前明确缺口:

Expand All @@ -268,7 +268,7 @@ Tags 文件行播放按钮已覆盖 hover、按钮 `:focus-visible` 和行 `:foc
## 依赖与项目元数据

- Go module 为 `github.com/ColderCoder/ShuffleMuse`。
- 前端和后端发布版本均为 `0.1.1`;容器构建通过 linker flags 注入版本、commit
- 前端和后端发布版本均为 `0.1.2`;容器构建通过 linker flags 注入版本、commit
和构建时间,`shufflemuse --version` 可直接读取。
- 发布与治理入口包括 LICENSE、CHANGELOG、CONTRIBUTING、SECURITY policy、
完整 CI 和仅 GHCR 的标签发布 workflow。
Expand Down
9 changes: 9 additions & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ HTTP 服务会先开始监听,再在后台完成首次扫描:

切换模式时保留当前绝对播放位置和播放/暂停状态。Original 不占 FFmpeg 并发;Opus 会占一个共享媒体槽。

支持 Web Audio 时,播放器会在播放手势中尽力激活系统音频端点,并在暂停
后继续输出 15 秒零信号,以缓解部分蓝牙/HDMI 端点休眠后的开头丢音。此
过程不会固定等待后才调用媒体播放;Web Audio 不可用或启动失败时会直接
退回普通 HTML 媒体播放。

播放器的 Opus 模式会显示服务器当前生效的转码码率;管理员修改 `MUSIC_OPUS_BITRATE` 后,已认证状态接口和界面文案会同步更新。

### 浏览器保存的设置
Expand Down Expand Up @@ -131,6 +136,10 @@ Home 显示:

Browse 使用 `/browse?dir=<相对目录>` 表示当前位置,面包屑可返回任意上级目录。目录和文件合计每页 50 项;翻页会替换当前页,进入新目录时先取消旧请求并清空旧内容,避免继续扫描或显示错误目录。

目录右上角的 `Play folder` 会把当前目录直接包含的音频按文件名顺序移到
现有随机队列最前,自动播放第一首,并保留队列中其余不重复的曲目。该
操作不递归加入子目录;当前目录没有可播放音频时按钮不可用。

### 文件操作

| 文件类型 | Preview | Download | Play |
Expand Down
11 changes: 10 additions & 1 deletion internal/api/browse.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ func (a *API) handleBrowse(w http.ResponseWriter, r *http.Request) {

initialCapacity := min(retainedLimit, 4096)
retainedCandidates := make(browseCandidateHeap, 0, initialCapacity)
idx, generation := a.currentSnapshot(r)
audioCount := 0
total := 0
for {
if r.Context().Err() != nil {
Expand All @@ -157,6 +159,13 @@ func (a *API) handleBrowse(w http.ResponseWriter, r *http.Request) {
candidate.isDirectory = true
case isRegular:
// Files sort after directories.
if index.AudioExtensions[strings.ToLower(filepath.Ext(entry.Name()))] {
relPath := browseRelativePath(dir, entry.Name())
audio := idx.ByID[index.GenerateID(relPath)]
if audio != nil && filepath.Clean(audio.Filepath) == filepath.Clean(relPath) {
audioCount++
}
}
default:
continue
}
Expand All @@ -181,7 +190,6 @@ func (a *API) handleBrowse(w http.ResponseWriter, r *http.Request) {

directories := make([]browseDirectory, 0, len(pageCandidates))
files := make([]browseFile, 0, len(pageCandidates))
idx, generation := a.currentSnapshot(r)
for _, candidate := range pageCandidates {
if r.Context().Err() != nil {
return
Expand All @@ -204,6 +212,7 @@ func (a *API) handleBrowse(w http.ResponseWriter, r *http.Request) {
"directories": directories,
"files": files,
"total": total,
"audioCount": audioCount,
"page": page,
"generation": generation,
})
Expand Down
14 changes: 14 additions & 0 deletions internal/api/browse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,17 @@ func TestBrowseListsMeaningfulFilesAndDirectories(t *testing.T) {
Directories []browseDirectory `json:"directories"`
Files []browseFile `json:"files"`
Total int `json:"total"`
AudioCount int `json:"audioCount"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatal(err)
}
if len(result.Directories) != 1 || result.Directories[0].Path != "artist1/Scans" {
t.Fatalf("unexpected directories: %+v", result.Directories)
}
if result.AudioCount != 3 {
t.Fatalf("audio count = %d, want 3 direct indexed files", result.AudioCount)
}
names := make(map[string]browseFile)
for _, file := range result.Files {
names[file.Name] = file
Expand Down Expand Up @@ -708,6 +712,16 @@ func TestBrowseAndMetadataRequireAuthentication(t *testing.T) {
t.Errorf("GET %s status = %d, want 401", path, resp.StatusCode)
}
}

resp, body := postJSON(
t,
env.server.URL+"/api/queues/forged/prepend-directory",
`{"dir":"artist1"}`,
nil,
)
if resp.StatusCode != http.StatusUnauthorized || body["code"] != "UNAUTHORIZED" {
t.Errorf("POST prepend directory = %d/%v, want 401/UNAUTHORIZED", resp.StatusCode, body)
}
}

func mustWriteBrowseFile(t *testing.T, path string, content []byte) {
Expand Down
13 changes: 13 additions & 0 deletions internal/api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
Expand Down Expand Up @@ -130,6 +131,7 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("POST /api/queues", a.withLibraryReady(a.handleCreateQueue))
mux.HandleFunc("GET /api/queues/{id}/items", a.withLibraryReady(a.handleQueueItems))
mux.HandleFunc("POST /api/queues/{id}/select", a.withLibraryReady(a.handleQueueSelect))
mux.HandleFunc("POST /api/queues/{id}/prepend-directory", a.withLibraryReady(a.handleQueuePrependDirectory))
mux.HandleFunc("DELETE /api/queues/{id}", a.handleDeleteQueue)
mux.HandleFunc("GET /api/files/{id}/metadata", a.withLibraryReady(a.handleFileMetadata))
mux.HandleFunc("GET /api/files/{id}/cover", a.withLibraryReady(a.handleFileCover))
Expand Down Expand Up @@ -168,6 +170,7 @@ func (a *API) RoutesWithAuth() http.Handler {
mux.HandleFunc("POST /api/queues", a.withAuth(a.withLibraryReady(a.handleCreateQueue)))
mux.HandleFunc("GET /api/queues/{id}/items", a.withAuth(a.withLibraryReady(a.handleQueueItems)))
mux.HandleFunc("POST /api/queues/{id}/select", a.withAuth(a.withLibraryReady(a.handleQueueSelect)))
mux.HandleFunc("POST /api/queues/{id}/prepend-directory", a.withAuth(a.withLibraryReady(a.handleQueuePrependDirectory)))
mux.HandleFunc("DELETE /api/queues/{id}", a.withAuth(a.handleDeleteQueue))
mux.HandleFunc("GET /api/files/{id}/metadata", a.withAuth(a.withLibraryReady(a.handleFileMetadata)))
mux.HandleFunc("GET /api/files/{id}/cover", a.withAuth(a.withLibraryReady(a.handleFileCover)))
Expand Down Expand Up @@ -228,6 +231,16 @@ type commitTrackingWriter struct {

func (w *commitTrackingWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }

func (w *commitTrackingWriter) ReadFrom(r io.Reader) (int64, error) {
if !w.committed {
w.WriteHeader(http.StatusOK)
}
if readerFrom, ok := w.ResponseWriter.(io.ReaderFrom); ok {
return readerFrom.ReadFrom(r)
}
return io.Copy(writerOnly{Writer: w}, r)
}

func (w *commitTrackingWriter) Flush() {
if !w.committed {
w.WriteHeader(http.StatusOK)
Expand Down
14 changes: 14 additions & 0 deletions internal/api/middleware.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package api

import (
"io"
"log"
"net"
"net/http"
Expand Down Expand Up @@ -87,6 +88,13 @@ type loggingWriter struct {

func (lw *loggingWriter) Unwrap() http.ResponseWriter { return lw.ResponseWriter }

func (lw *loggingWriter) ReadFrom(r io.Reader) (int64, error) {
if readerFrom, ok := lw.ResponseWriter.(io.ReaderFrom); ok {
return readerFrom.ReadFrom(r)
}
return io.Copy(writerOnly{Writer: lw}, r)
}

func (lw *loggingWriter) Flush() {
if flusher, ok := lw.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
Expand All @@ -97,3 +105,9 @@ func (lw *loggingWriter) WriteHeader(code int) {
lw.statusCode = code
lw.ResponseWriter.WriteHeader(code)
}

// writerOnly hides optional interfaces such as io.ReaderFrom so fallback copies
// cannot recurse back into a response writer's ReadFrom method.
type writerOnly struct {
io.Writer
}
Loading
Loading