diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f86c4..b3086f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ All notable changes to ShuffleMuse are documented in this file. The project follows [Semantic Versioning](https://semver.org/). +## [0.1.1] - 2026-07-24 + +### Added + +- Support for case-insensitive `folder.jpg` and `folder.png` directory artwork + after the existing `cover.jpg` and `cover.png` candidates. +- Lazy display of embedded TITLE metadata in Now Playing while preserving the + original relative file path. +- Deployment guidance for trusted real-IP headers and combined LAN plus + Cloudflare Tunnel access. + +### Changed + +- Metadata and embedded-cover discovery now share one bounded ffprobe, + file-identity cache, and in-flight request. + +### Fixed + +- Prevented the playlist navigation strip from exposing list content through a + spacing seam. +- Kept playback and stream-mode controls from overlapping at intermediate + viewport widths. +- Made the codec-side file path in the bottom player link to its Browse + directory. + ## [0.1.0] - 2026-07-23 ### Added @@ -19,4 +44,5 @@ The project follows [Semantic Versioning](https://semver.org/). - Multi-architecture GHCR image publication for `linux/amd64` and `linux/arm64`. +[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 diff --git a/README.md b/README.md index 7a1c435..1a6d55f 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ ShuffleMuse 是一个面向个人和小型自托管场景的轻量音乐库播 ## 快速部署 要求 Docker Engine 和 Docker Compose 插件。默认配置拉取公开的 -`ghcr.io/coldercoder/shufflemuse:0.1.0`。把音乐文件放入项目根目录的 +`ghcr.io/coldercoder/shufflemuse:0.1.1`。把音乐文件放入项目根目录的 `music/` 后执行: ```bash @@ -177,7 +177,7 @@ Tags 页的 CSV 用于查看和外部处理,没有对应的导入功能,不 ## 版本与镜像 -- 稳定版本由对应 Git 标签发布;`v0.1.0` 对应镜像标签 `0.1.0`、`0.1`、 +- 稳定版本由对应 Git 标签发布;`v0.1.1` 对应镜像标签 `0.1.1`、`0.1`、 `0` 和 `latest`。 - 支持 `linux/amd64` 与 `linux/arm64`。 - `shufflemuse --version` 输出版本、Git commit 与构建时间。 diff --git a/docker-compose.yml b/docker-compose.yml index 34f7308..00d15cb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ name: shufflemuse services: shufflemuse: - image: ghcr.io/coldercoder/shufflemuse:0.1.0 + image: ghcr.io/coldercoder/shufflemuse:0.1.1 ports: - "127.0.0.1:8080:8080" volumes: diff --git a/docs/API.md b/docs/API.md index 6179370..e940deb 100644 --- a/docs/API.md +++ b/docs/API.md @@ -306,6 +306,7 @@ HTTP/1.1 202 Accepted ```json { + "title": "君玉", "codec": "FLAC", "bitrateKbps": 986, "bitrateApproximate": false, @@ -313,17 +314,17 @@ HTTP/1.1 202 Accepted } ``` -ffprobe 优先使用第一条音轨 bitrate,其次使用容器 bitrate;两者都缺失时按文件大小和时长估算,并设置 `bitrateApproximate:true`。 +`title` 来自容器 TITLE,缺失时回退第一条音轨的 TITLE;两者都为空时省略该字段。标题去除首尾空白并限制为 512 个 UTF-8 字节。ffprobe 优先使用第一条音轨 bitrate,其次使用容器 bitrate;两者都缺失时按文件大小和时长估算,并设置 `bitrateApproximate:true`。 错误:`404 NOT_FOUND`、`503 MEDIA_BUSY`、`504 MEDIA_TIMEOUT`、`422 METADATA_ERROR`。 ### `GET|HEAD /api/covers/directory?dir=...` -返回指定相对目录同级、大小写不敏感的 `cover.jpg` 或 `cover.png`,JPEG 优先。`dir` 必须恰好出现一次并使用干净的相对路径;路径穿越、绝对路径、反斜杠和任一目录 symlink 返回 `400 INVALID_DIR`。目录不存在或没有可用封面返回 `404 COVER_NOT_FOUND`。该端点不会递归查找子目录,也不会探测音频内嵌封面。 +按 `cover.jpg` → `cover.png` → `folder.jpg` → `folder.png` 返回指定相对目录同级、大小写不敏感的封面。`dir` 必须恰好出现一次并使用干净的相对路径;路径穿越、绝对路径、反斜杠和任一目录 symlink 返回 `400 INVALID_DIR`。目录不存在或没有可用封面返回 `404 COVER_NOT_FOUND`。该端点不会递归查找子目录、扩展到其他文件格式或探测音频内嵌封面。 ### `GET|HEAD /api/files/{id}/cover` -顺序:同目录 `cover.jpg` → `cover.png` → 音频内嵌第一视频流。外置封面不接受 symlink。文件严格超过 20 MiB、任一边严格超过 8192 或总像素严格超过 40 MP 时返回 `404 COVER_NOT_FOUND`。 +顺序:同目录 `cover.jpg` → `cover.png` → `folder.jpg` → `folder.png` → 音频内嵌第一视频流;目录文件名大小写不敏感。内嵌 descriptor 与 metadata 共享同一次 ffprobe、文件身份缓存和 singleflight,不会再启动独立 descriptor probe。外置封面不接受 symlink。文件严格超过 20 MiB、任一边严格超过 8192 或总像素严格超过 40 MP 时返回 `404 COVER_NOT_FOUND`。 外置封面任一边严格超过 1536 或文件严格超过 1 MiB 时,实时转换为最长边 1024、不放大的 JPEG q3;PNG 先合成白底。JPEG 转换结果不超过原文件 85% 才采用,否则本次请求发送原 JPEG。小型外置封面原样发送,因此未触发转换的 PNG 保留透明度。内嵌封面固定输出最长边 1024 的 JPEG q3。 @@ -333,7 +334,7 @@ ffprobe 优先使用第一条音轨 bitrate,其次使用容器 bitrate;两 - `Content-Disposition: inline`;转换输出文件名为 `cover.jpg`; - `Cache-Control: private, max-age=3600`; - `ETag`:由源路径、大小、mtime、阈值和编码规格生成; -- `X-Cover-Source: embedded|cover.jpg|cover.png`。 +- `X-Cover-Source`:`embedded` 或命中的实际目录封面文件名(保留原始大小写)。 HEAD 和能得到 304 的条件请求只发现 descriptor,不启动 FFmpeg。可能转换的 JPEG/PNG 在 HEAD 中预先声明 `image/jpeg`,但不声明未知的转换后 `Content-Length`。每个非缓存 GET 都重新转换;仅并发中的相同转换 singleflight,不建立服务端图片结果缓存。错误:`404 NOT_FOUND`、`404 COVER_NOT_FOUND`、`503 MEDIA_BUSY`、`504 MEDIA_TIMEOUT`、`500 COVER_ERROR`。等待队列满或等待超时返回 `503 MEDIA_BUSY`;15 秒执行 deadline 返回 504,FFmpeg 失败不会降级发送超大原图。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6cf88b3..e1436ec 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,7 +16,7 @@ flowchart LR API --> Queue[Bounded server queue cache] API --> Manager[Strict media lane manager] Manager --> FFmpeg[ffmpeg transcoding / cover] - Manager --> FFprobe[ffprobe metadata] + Manager --> FFprobe[ffprobe metadata / embedded cover descriptor] Rescanner[Rescanner] -->|atomic publish| Snapshot Rescanner --> Files Rescanner -->|legacy tag migration| Tags @@ -213,15 +213,15 @@ Original 使用 `http.ServeContent`: - 转码 lane:最多 1 个长 Opus; - 辅助 lane:最多 1 个 metadata、cover descriptor 或 cover render。 -两个 lane 有独立等待队列。辅助任务中 metadata/descriptor 为高优先级,连续最多 4 个后若封面转换等待则让出一次。相同文件身份的 metadata/封面在 Acquire 前 singleflight;Manager 统一追踪 active task、取消和关机 WaitGroup。总数为 1 且未显式设置辅助保留时使用旧共享模式。 +两个 lane 有独立等待队列。辅助任务中 metadata/descriptor 为高优先级,连续最多 4 个后若封面转换等待则让出一次。同一文件身份的 metadata 与内嵌封面 descriptor 合并为一次 ffprobe,并在 Acquire 前共享 singleflight;Manager 统一追踪 active task、取消和关机 WaitGroup。总数为 1 且未显式设置辅助保留时使用旧共享模式。 ### Metadata LRU -metadata 缓存键包含绝对路径、大小和 mtime。成功 LRU 固定默认 4096 条;确定性解析失败进入有界 30 秒负缓存,busy、deadline、取消和临时 I/O 不缓存。底层任务使用独立 deadline;单个 waiter 取消不影响其他 waiter,全部离开才取消底层任务。 +共享 probe 一次读取 TITLE、第一音轨 codec/bitrate/duration 与第一视频流尺寸,stdout 上限 64 KiB。缓存键包含绝对路径、大小和 mtime,成功 LRU 固定默认 4096 条;确定性命令或 JSON 失败进入有界 30 秒负缓存,busy、deadline、取消和临时 I/O 不缓存。音频 metadata 与封面尺寸分别校验,任一部分无效不隐藏另一部分。底层任务使用独立 deadline;单个 waiter 取消不影响其他 waiter,全部离开才取消底层任务。 ### 封面 -Loader 先寻找同目录、大小写不敏感的 `cover.jpg`/`cover.png`(JPEG 优先),不存在时才用 ffprobe 发现内嵌封面。外置源超过 20 MiB、8192 单边或 40 MP 时稳定返回 not-found;超过 1536 单边或 1 MiB 才进入 `AuxRender`。FFmpeg 单线程输出最长边 1024、不放大的 JPEG q3,PNG 和带透明度的内嵌图先合成白底。JPEG 结果只有达到 15% 节省才替代原文件。 +Loader 按 `cover.jpg` → `cover.png` → `folder.jpg` → `folder.png` 寻找同目录、大小写不敏感的外置封面,不存在时才消费共享 probe 已发现的内嵌封面 descriptor。查找不递归,也不扩展到其他文件格式。外置源超过 20 MiB、8192 单边或 40 MP 时稳定返回 not-found;超过 1536 单边或 1 MiB 才进入 `AuxRender`。FFmpeg 单线程输出最长边 1024、不放大的 JPEG q3,PNG 和带透明度的内嵌图先合成白底。JPEG 结果只有达到 15% 节省才替代原文件。 HEAD/304 只读取 descriptor,不启动 FFmpeg。未转换外置图使用 `Open` + `ServeContent` 原样发送;转换字节只在当前请求或同一 in-flight 请求组中存在,结束后立即释放,不进入 LRU。服务端只保留小型 descriptor LRU 和 30 秒负缓存。ETag 包含源身份、阈值与编码规格,成功响应允许浏览器私有缓存 1 小时。 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 5740988..e0b6467 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -28,7 +28,7 @@ ShuffleMuse 只从进程环境读取配置,不读取 `.env`。配置在启动 | `MUSIC_MEDIA_TASK_SECONDS` | `15` | `15` | metadata、封面底层任务和 Opus 首字节最长秒数,必须大于 0 | | `MUSIC_STREAM_WRITE_IDLE_SECONDS` | `60` | `60` | Opus 首字节后滚动写空闲 deadline,必须大于 0 | | `MUSIC_MEDIA_NEGATIVE_CACHE_SECONDS` | `30` | `30` | 确定性 metadata 失败和封面未找到的负缓存 TTL | -| `MUSIC_METADATA_CACHE_ENTRIES` | `4096` | `4096` | 元数据 LRU 最大条目数,必须大于 0 | +| `MUSIC_METADATA_CACHE_ENTRIES` | `4096` | `4096` | metadata 与内嵌封面 descriptor 共享 probe LRU 的最大条目数,必须大于 0 | | `MUSIC_COVER_CACHE_ENTRIES` | `128` | `128` | 兼容变量;小型封面 descriptor LRU 条目上限,不缓存图片字节 | | `MUSIC_COVER_CACHE_BYTES` | `67108864` | `67108864` | 兼容变量;descriptor 估算内存上限,不缓存图片字节 | | `MUSIC_QUEUE_CACHE_MAX_QUEUES` | `64` | `64` | 服务端随机队列数量上限 | diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 467ea5c..8143c6e 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -252,7 +252,7 @@ Tags 文件行播放按钮已覆盖 hover、按钮 `:focus-visible` 和行 `:foc ## 当前测试范围 -后端测试覆盖配置、认证、代理、严格请求、扫描、重扫、Browse、stream、media queue、tags、Graveyard、CSV 和静态资源缓存。前端当前有 17 个测试文件、60 个用例,覆盖主要 stores、分页/取消竞态、登录封禁、路由、Search 语义、Playlist 分块、Tags 二级导航与导出、Graveyard 和 Modal 焦点。 +后端测试覆盖配置、认证、代理、严格请求、扫描、重扫、Browse、stream、media queue、tags、Graveyard、CSV 和静态资源缓存。前端当前有 17 个测试文件、64 个用例,覆盖主要 stores、分页/取消竞态、登录封禁、路由、Search 语义、Playlist 分块、Tags 二级导航与导出、Graveyard、Modal 焦点和 metadata 标题切换。 当前明确缺口: @@ -268,7 +268,7 @@ Tags 文件行播放按钮已覆盖 hover、按钮 `:focus-visible` 和行 `:foc ## 依赖与项目元数据 - Go module 为 `github.com/ColderCoder/ShuffleMuse`。 -- 前端和后端版本均为 `0.1.0`;容器构建通过 linker flags 注入版本、commit +- 前端和后端发布版本均为 `0.1.1`;容器构建通过 linker flags 注入版本、commit 和构建时间,`shufflemuse --version` 可直接读取。 - 发布与治理入口包括 LICENSE、CHANGELOG、CONTRIBUTING、SECURITY policy、 完整 CI 和仅 GHCR 的标签发布 workflow。 diff --git a/docs/PROJECT_AUDIT.md b/docs/PROJECT_AUDIT.md index b98bfcf..81837e2 100644 --- a/docs/PROJECT_AUDIT.md +++ b/docs/PROJECT_AUDIT.md @@ -1,6 +1,6 @@ # 项目审计 -审计日期:2026-07-23 +审计日期:2026-07-24 审计对象:当前工作树,包括尚未提交的后端、前端、Compose 和测试变更。 @@ -20,6 +20,8 @@ 2026-07-23 的全项目逻辑与性能复核又关闭了八项问题:默认 Compose 实际暴露范围与文档重新一致;Tags 不再预取完整标签集合;Search/Browse/Preview/Rescan 的过期工作可取消;Browse 的任意深页码不再扩大内存上界;队列选曲不再持有全局锁完成线性扫描;静态哈希资源获得长期缓存且缺失 asset 不再回退 HTML;损坏的本地音量值会被安全归一化;favorite 筛选也不再夹带当前非收藏曲目。 +2026-07-24 的 `0.1.1` 发布复核进一步检查了自 `v0.1.0` 起的全部提交与工作树差异。metadata TITLE 与内嵌封面描述已合并为单次有界 ffprobe,目录封面加入 `folder.jpg`/`folder.png`,播放器中间宽度布局、底栏路径链接和 Playlist 接缝均通过自动化与隔离 Chromium 验证。默认及大陆 Dockerfile 的发布候选镜像均完成构建并正确注入 `0.1.1` 版本。 + ## 审计范围与方法 本次逐项检查了: @@ -47,15 +49,14 @@ | `go test -race -count=1 ./...` | 通过 | 覆盖当前 Go 测试触达的并发路径 | | `go vet ./...` | 通过 | 无 vet 报告 | | `go test -cover -count=1 ./internal/...` | 通过 | 包级覆盖率见下表 | -| `cd web && bun run test:run` | 通过 | 17 个测试文件、61 个用例 | +| `cd web && bun run test:run` | 通过 | 17 个测试文件、64 个用例 | | `cd web && bun run build` | 通过 | 包含 `vue-tsc -b` 与 Vite production build | | 三份 `docker compose ... config --quiet` | 通过 | GHCR、官方源码构建和大陆源码构建配置均能完成解析和插值 | -| `docker compose build --no-cache` | 外部网络失败 | 官方 npm 冷安装与前端构建通过;`proxy.golang.org` 下载 bbolt module 时连接超时 | -| `docker compose build --no-cache --build-arg GOPROXY=https://goproxy.cn,direct` | 外部网络未完成 | Go 下载、官方 npm 冷安装及前后端编译通过;官方 Alpine 仓库停在 FFmpeg 依赖 38/107,连续 3 分钟无进展后人工取消 | +| 默认 Dockerfile 发布候选构建 | 通过 | 官方 Go proxy 首次连接超时;改用受支持的 `GOPROXY=https://goproxy.cn,direct` 参数后完成镜像、版本和 labels 检查 | | `docker compose -f docker-compose.build-cn.yml build --no-cache` | 通过 | DaoCloud、npmmirror、Goproxy.cn、`sum.golang.google.cn` 与阿里云 APK 镜像的完整无缓存构建通过 | -| 默认 Dockerfile + `GOPROXY=https://goproxy.cn,direct` | 通过 | 完整构建、版本信息、OCI labels 与非 root 用户均已核对 | +| 大陆 Dockerfile `0.1.1` 发布候选构建 | 通过 | 前后端构建、FFmpeg 安装、版本信息、OCI labels 与非 root 用户均已核对 | | 非 root 临时卷 tar 备份/恢复 smoke test | 通过 | 在只读根、`cap_drop ALL`、`no-new-privileges` 下完成跨卷 round trip,恢复文件属于 `shufflemuse` | -| 隔离 Chromium 实机冒烟 | 通过 | favorite 只循环收藏曲目;音频请求早于延迟封面;同目录换曲复用 URL/DOM/请求;刷新后封面 `transferSize=0`;无 console/page error | +| 隔离 Chromium 实机冒烟 | 通过 | 在 1240、1100、961/960、761/760 和 375 px 验证布局;TITLE、原始路径、Browse 链接及 Original/Opus 正常;无 console/page error | | `git diff --check` | 通过 | 当前 diff 无空白错误 | `internal` 包级语句覆盖率快照: @@ -65,11 +66,11 @@ | `internal/api` | 81.9% | | `internal/auth` | 92.2% | | `internal/config` | 84.3% | -| `internal/cover` | 78.5% | +| `internal/cover` | 79.1% | | `internal/index` | 76.0% | | `internal/mediaexec` | 78.2% | | `internal/playqueue` | 80.9% | -| `internal/stream` | 84.7% | +| `internal/stream` | 85.5% | | `internal/tags` | 76.9% | 覆盖率只能说明哪些语句被执行,不能代替端到端行为验证。尤其是进程启动/关机、真实反向代理链、浏览器可访问性、Docker 网络和长期内存增长,不能从这些数字推导为安全。 @@ -288,8 +289,8 @@ E2E 优先覆盖登录失效、播放、Tags CSV、Graveyard 与 Modal 焦点。 状态:2026-07-23 已修复 -Go module 已改为 `github.com/ColderCoder/ShuffleMuse`,前后端版本统一为 -`0.1.0`,构建注入 commit/build time 并提供 `shufflemuse --version`。 +Go module 已改为 `github.com/ColderCoder/ShuffleMuse`,前后端发布版本统一为 +`0.1.1`,构建注入 commit/build time 并提供 `shufflemuse --version`。 仓库加入 MIT LICENSE、CHANGELOG、CONTRIBUTING、SECURITY policy、完整 CI 和仅向 GHCR 发布 amd64/arm64 镜像的标签 workflow。`.dockerignore` 也排除 本地二进制、测试产物和 TypeScript build info。 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index eb993ff..59de061 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -96,8 +96,8 @@ HTTP 服务会先开始监听,再在后台完成首次扫描: Home 显示: - 可选标签过滤器; -- 当前曲名和相对路径; -- 同目录 `cover.jpg` / `cover.png`,不存在时回退内嵌封面;两者都不可用时显示缺省封面; +- 当前曲名和相对路径;曲名先显示文件名,metadata TITLE 可用后自动切换,缺失或读取失败时保持文件名; +- 按顺序使用同目录 `cover.jpg` / `cover.png` / `folder.jpg` / `folder.png`(文件名大小写不敏感),不存在时回退内嵌封面;都不可用时显示缺省封面; - 当前路径到 Browse 目录的链接。 切换标签过滤器后,队列严格只包含该标签的曲目。当前歌曲已经带有该标签时, diff --git a/internal/api/browse_test.go b/internal/api/browse_test.go index a8de411..3d9a06b 100644 --- a/internal/api/browse_test.go +++ b/internal/api/browse_test.go @@ -386,6 +386,7 @@ func TestFileMetadataEndpoint(t *testing.T) { env := setupTestEnv(t) defer env.teardown() env.api.Metadata = fixedMediaProbe{metadata: stream.Metadata{ + Title: "Metadata Title", Codec: "FLAC", BitrateKbps: 987, DurationSeconds: 245.5, @@ -400,11 +401,62 @@ func TestFileMetadataEndpoint(t *testing.T) { if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { t.Fatal(err) } - if metadata.Codec != "FLAC" || metadata.BitrateKbps != 987 || metadata.DurationSeconds != 245.5 { + if metadata.Title != "Metadata Title" || metadata.Codec != "FLAC" || metadata.BitrateKbps != 987 || metadata.DurationSeconds != 245.5 { t.Fatalf("unexpected metadata: %+v", metadata) } } +func TestMetadataAndEmbeddedCoverShareOneProbe(t *testing.T) { + probeDir := t.TempDir() + countPath := filepath.Join(probeDir, "count") + script := `#!/bin/sh +printf '1\n' >> "$SHUFFLEMUSE_TEST_FFPROBE_COUNT" +cat <<'JSON' +{"streams":[{"codec_type":"audio","codec_name":"flac","duration":"12","bit_rate":"1000000"},{"codec_type":"video","codec_name":"mjpeg","width":640,"height":640}],"format":{"duration":"12","bit_rate":"1000000","tags":{"TITLE":"Shared Title"}}} +JSON +` + if err := os.WriteFile(filepath.Join(probeDir, "ffprobe"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("SHUFFLEMUSE_TEST_FFPROBE_COUNT", countPath) + t.Setenv("PATH", probeDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + env := setupTestEnv(t) + defer env.teardown() + id := env.idx.Files[0].ID + + response, err := http.Get(env.server.URL + "/api/files/" + id + "/metadata") + if err != nil { + t.Fatal(err) + } + var metadata stream.Metadata + if err := json.NewDecoder(response.Body).Decode(&metadata); err != nil { + response.Body.Close() + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusOK || metadata.Title != "Shared Title" { + t.Fatalf("metadata = %d/%+v", response.StatusCode, metadata) + } + + response, err = http.Head(env.server.URL + "/api/files/" + id + "/cover") + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusOK || response.Header.Get("X-Cover-Source") != "embedded" { + t.Fatalf("cover = %d source=%q", response.StatusCode, response.Header.Get("X-Cover-Source")) + } + + count, err := os.ReadFile(countPath) + if err != nil { + t.Fatal(err) + } + if calls := bytes.Count(count, []byte{'\n'}); calls != 1 { + t.Fatalf("ffprobe calls = %d, log=%q", calls, count) + } +} + type fixedCoverProvider struct { descriptor cover.Descriptor data []byte @@ -571,10 +623,10 @@ func TestDirectoryCoverEndpointHEADConditionalAndStrictPath(t *testing.T) { } } -func TestSmallDirectoryPNGIsPreservedAndWinsBeforeEmbeddedProbe(t *testing.T) { +func TestSmallFolderPNGIsPreservedAndWinsBeforeEmbeddedProbe(t *testing.T) { env := setupTestEnv(t) defer env.teardown() - imagePath := filepath.Join(env.api.Config.MusicDir, "artist1", "cover.png") + imagePath := filepath.Join(env.api.Config.MusicDir, "artist1", "Folder.PNG") img := image.NewNRGBA(image.Rect(0, 0, 2, 2)) img.SetNRGBA(0, 0, color.NRGBA{R: 200, G: 40, B: 20, A: 80}) var encoded bytes.Buffer @@ -601,7 +653,7 @@ func TestSmallDirectoryPNGIsPreservedAndWinsBeforeEmbeddedProbe(t *testing.T) { if !bytes.Equal(body, encoded.Bytes()) || response.Header.Get("Content-Length") != fmt.Sprint(encoded.Len()) { t.Fatalf("GET %s changed small PNG: length=%q body=%d", endpoint, response.Header.Get("Content-Length"), len(body)) } - if response.Header.Get("X-Cover-Source") != "cover.png" { + if response.Header.Get("X-Cover-Source") != "Folder.PNG" { t.Fatalf("GET %s source = %q", endpoint, response.Header.Get("X-Cover-Source")) } } diff --git a/internal/api/handler.go b/internal/api/handler.go index e72cd94..f2f684c 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -86,16 +86,18 @@ func NewAPI(cfg *config.Config, idx *index.Index, tagStore *tags.Store, router S if realIPHeader == "" { realIPHeader = "remote" } + metadataProbe := stream.NewMetadataProbe(media, stream.MetadataConfig{ + Capacity: metadataCapacity, NegativeTTL: cfg.MediaNegativeCache, TaskTimeout: cfg.MediaTaskTimeout, + }) apiHandler := &API{ - Index: idx, - Tags: tagStore, - Stream: router, - Metadata: stream.NewMetadataProbe(media, stream.MetadataConfig{ - Capacity: metadataCapacity, NegativeTTL: cfg.MediaNegativeCache, TaskTimeout: cfg.MediaTaskTimeout, - }), + Index: idx, + Tags: tagStore, + Stream: router, + Metadata: metadataProbe, Covers: cover.NewLoader(media, cover.Config{ Entries: cfg.CoverCacheEntries, Bytes: cfg.CoverCacheBytes, NegativeTTL: cfg.MediaNegativeCache, TaskTimeout: cfg.MediaTaskTimeout, + EmbeddedProbe: metadataProbe.ProbeEmbedded, }), Config: cfg, Auth: a, diff --git a/internal/cover/loader.go b/internal/cover/loader.go index 8aa0c42..e24faa4 100644 --- a/internal/cover/loader.go +++ b/internal/cover/loader.go @@ -6,7 +6,6 @@ import ( "context" "crypto/sha256" "encoding/hex" - "encoding/json" "errors" "fmt" "image" @@ -83,10 +82,11 @@ type Result struct { } type Config struct { - Entries int - Bytes int64 - NegativeTTL time.Duration - TaskTimeout time.Duration + Entries int + Bytes int64 + NegativeTTL time.Duration + TaskTimeout time.Duration + EmbeddedProbe func(context.Context, string) (width, height int, found bool, err error) } type descriptorKind uint8 @@ -148,6 +148,7 @@ type Loader struct { negativeTTL time.Duration taskTimeout time.Duration executor mediaexec.Executor + embeddedProbe func(context.Context, string) (width, height int, found bool, err error) now func() time.Time discover func(context.Context, descriptorKey) (Descriptor, error) transcode func(context.Context, Descriptor) ([]byte, error) @@ -156,6 +157,7 @@ type Loader struct { func NewLoader(executor mediaexec.Executor, options ...Config) *Loader { config := Config{Entries: 128, Bytes: 64 << 20, NegativeTTL: 30 * time.Second, TaskTimeout: 15 * time.Second} if len(options) > 0 { + config.EmbeddedProbe = options[0].EmbeddedProbe if options[0].Entries > 0 { config.Entries = options[0].Entries } @@ -174,16 +176,16 @@ func NewLoader(executor mediaexec.Executor, options ...Config) *Loader { describeCalls: make(map[descriptorKey]*descriptorFlight), renderCalls: make(map[string]*renderFlight), maxEntries: config.Entries, maxBytes: config.Bytes, negativeTTL: config.NegativeTTL, taskTimeout: config.TaskTimeout, - executor: executor, now: time.Now, + executor: executor, embeddedProbe: config.EmbeddedProbe, now: time.Now, } loader.discover = loader.discoverDescriptor loader.transcode = loader.transcodeJPEG return loader } -// DescribeDirectory discovers only a cover.jpg or cover.png in directory. It -// never probes audio files, so all tracks in the directory receive the same -// descriptor and ETag. +// DescribeDirectory discovers cover.jpg, cover.png, folder.jpg, or folder.png +// in directory, in that order. It never probes audio files, so all tracks in +// the directory receive the same descriptor and ETag. func (l *Loader) DescribeDirectory(ctx context.Context, directory string) (Descriptor, error) { if err := ctx.Err(); err != nil { return Descriptor{}, err @@ -196,8 +198,8 @@ func (l *Loader) DescribeDirectory(ctx context.Context, directory string) (Descr return l.describe(ctx, key) } -// Describe gives an external directory cover priority. ffprobe is only used -// when no eligible directory cover exists. +// Describe gives an external directory cover priority. The shared media probe +// is only consulted when no eligible directory cover exists. func (l *Loader) Describe(ctx context.Context, audioPath string) (Descriptor, error) { if err := ctx.Err(); err != nil { return Descriptor{}, err @@ -463,38 +465,17 @@ func validateDimensions(width, height int) error { } func (l *Loader) discoverEmbedded(ctx context.Context, key descriptorKey) (Descriptor, error) { - task, err := mediaexec.Start(l.executor, ctx, mediaexec.AuxHigh) - if err != nil { - return Descriptor{}, err - } - defer task.Done() - commandCtx, cancel := context.WithTimeout(task.Context(), l.taskTimeout) - defer cancel() - cmd := exec.CommandContext(commandCtx, - "ffprobe", "-v", "error", "-select_streams", "v:0", - "-show_entries", "stream=index,width,height", "-of", "json", key.path, - ) - output, err := cmd.Output() - if err != nil { - if errors.Is(commandCtx.Err(), context.DeadlineExceeded) { - return Descriptor{}, fmt.Errorf("cover descriptor deadline: %w", mediaexec.ErrTaskTimeout) - } - if task.Context().Err() != nil { - return Descriptor{}, task.Context().Err() - } + if l.embeddedProbe == nil { return Descriptor{}, ErrNotFound } - var result struct { - Streams []struct { - Width int `json:"width"` - Height int `json:"height"` - } `json:"streams"` + width, height, found, err := l.embeddedProbe(ctx, key.path) + if err != nil { + return Descriptor{}, err } - if err := json.Unmarshal(output, &result); err != nil || len(result.Streams) == 0 { + if !found { return Descriptor{}, ErrNotFound } - stream := result.Streams[0] - if err := validateDimensions(stream.Width, stream.Height); err != nil { + if err := validateDimensions(width, height); err != nil { return Descriptor{}, err } modTime := time.Unix(0, key.modUnixNano) @@ -502,10 +483,10 @@ func (l *Loader) discoverEmbedded(ctx context.Context, key descriptorKey) (Descr Kind: Embedded, ContentType: "image/jpeg", Name: "cover.jpg", Source: "embedded", ModTime: modTime, ETag: makeETag( - "embedded", key.path, key.size, key.modUnixNano, stream.Width, stream.Height, + "embedded", key.path, key.size, key.modUnixNano, width, height, coverThresholdsSpec, coverEncodingSpec, ), - Width: stream.Width, Height: stream.Height, RequiresRender: true, + Width: width, Height: height, RequiresRender: true, audioPath: key.path, audioSize: key.size, audioModNano: key.modUnixNano, }, nil } @@ -624,7 +605,7 @@ func findDirectoryCover(dir string) (string, os.FileInfo, error) { if err != nil { return "", nil, ErrNotFound } - for _, candidate := range []string{"cover.jpg", "cover.png"} { + for _, candidate := range []string{"cover.jpg", "cover.png", "folder.jpg", "folder.png"} { for _, entry := range entries { if !strings.EqualFold(entry.Name(), candidate) || entry.Type()&os.ModeSymlink != 0 { continue diff --git a/internal/cover/loader_test.go b/internal/cover/loader_test.go index bee9496..f734230 100644 --- a/internal/cover/loader_test.go +++ b/internal/cover/loader_test.go @@ -18,6 +18,7 @@ import ( "time" "github.com/ColderCoder/ShuffleMuse/internal/mediaexec" + "github.com/ColderCoder/ShuffleMuse/internal/stream" ) func TestDirectoryCoverPreferenceAndEmbeddedFallbackOrder(t *testing.T) { @@ -56,6 +57,38 @@ func TestDirectoryCoverPreferenceAndEmbeddedFallbackOrder(t *testing.T) { } } +func TestDirectoryCoverPrefersCoverNamesBeforeFolderNames(t *testing.T) { + t.Run("folder JPEG before folder PNG", func(t *testing.T) { + dir := t.TempDir() + jpegPath := filepath.Join(dir, "Folder.JPG") + writePNG(t, filepath.Join(dir, "folder.png"), 8, 6, false) + writeJPEG(t, jpegPath, 8, 6) + + descriptor, err := discoverDirectoryCover(dir) + if err != nil { + t.Fatal(err) + } + if descriptor.Source != "Folder.JPG" || descriptor.FilePath != jpegPath || descriptor.ContentType != "image/jpeg" { + t.Fatalf("folder JPEG preference = %+v", descriptor) + } + }) + + t.Run("cover basename before folder basename", func(t *testing.T) { + dir := t.TempDir() + coverPath := filepath.Join(dir, "cover.png") + writeJPEG(t, filepath.Join(dir, "folder.jpg"), 8, 6) + writePNG(t, coverPath, 8, 6, false) + + descriptor, err := discoverDirectoryCover(dir) + if err != nil { + t.Fatal(err) + } + if descriptor.Source != "cover.png" || descriptor.FilePath != coverPath || descriptor.ContentType != "image/png" { + t.Fatalf("cover basename preference = %+v", descriptor) + } + }) +} + func TestInvalidDirectoryCoverDoesNotProbeEmbedded(t *testing.T) { dir := t.TempDir() audioPath := writeTestFile(t, filepath.Join(dir, "track.flac"), []byte("audio")) @@ -321,7 +354,8 @@ func TestEmbeddedCoverFallsBackTo1024JPEG(t *testing.T) { t.Fatalf("create embedded-cover fixture: %v: %s", err, output) } - loader := NewLoader(nil) + metadataProbe := stream.NewMetadataProbe(nil) + loader := NewLoader(nil, Config{EmbeddedProbe: metadataProbe.ProbeEmbedded}) descriptor, err := loader.Describe(context.Background(), audioPath) if err != nil { t.Fatal(err) diff --git a/internal/stream/metadata.go b/internal/stream/metadata.go index c4b1685..42a7e91 100644 --- a/internal/stream/metadata.go +++ b/internal/stream/metadata.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "math" "os" "os/exec" @@ -14,11 +15,18 @@ import ( "strings" "sync" "time" + "unicode/utf8" "github.com/ColderCoder/ShuffleMuse/internal/mediaexec" ) +const ( + maxProbeOutputBytes = int64(64 << 10) + maxMetadataTitleBytes = 512 +) + type Metadata struct { + Title string `json:"title,omitempty"` Codec string `json:"codec"` BitrateKbps int `json:"bitrateKbps"` BitrateApproximate bool `json:"bitrateApproximate"` @@ -37,9 +45,21 @@ type metadataKey struct { modUnixNano int64 } +type embeddedCoverInfo struct { + width int + height int +} + +type probeData struct { + metadata Metadata + metadataErr error + embeddedCover embeddedCoverInfo + hasEmbeddedCover bool +} + type metadataCacheEntry struct { - key metadataKey - metadata Metadata + key metadataKey + data probeData } type metadataNegativeEntry struct { @@ -55,7 +75,7 @@ type metadataFlight struct { waiters int finished bool abandoned bool - metadata Metadata + data probeData err error } @@ -72,7 +92,7 @@ type MetadataProbe struct { taskTimeout time.Duration executor mediaexec.Executor now func() time.Time - probe func(context.Context, metadataKey) (Metadata, error) + probe func(context.Context, metadataKey) (probeData, error) } func NewMetadataProbe(executor mediaexec.Executor, options ...MetadataConfig) *MetadataProbe { @@ -100,12 +120,41 @@ func NewMetadataProbe(executor mediaexec.Executor, options ...MetadataConfig) *M } func (p *MetadataProbe) Probe(ctx context.Context, path string) (Metadata, error) { - if err := ctx.Err(); err != nil { + data, err := p.inspect(ctx, path) + if err != nil { return Metadata{}, err } + if data.metadataErr != nil { + return Metadata{}, data.metadataErr + } + return data.metadata, nil +} + +// ProbeEmbedded shares the same identity cache and in-flight ffprobe as Probe. +// Probe failures mean no usable embedded artwork, while busy, timeout, and +// cancellation errors remain visible to the cover endpoint. +func (p *MetadataProbe) ProbeEmbedded(ctx context.Context, path string) (width, height int, found bool, err error) { + data, err := p.inspect(ctx, path) + if err != nil { + if mediaexec.IsBusy(err) || mediaexec.IsTimeout(err) || + errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return 0, 0, false, err + } + return 0, 0, false, nil + } + if !data.hasEmbeddedCover { + return 0, 0, false, nil + } + return data.embeddedCover.width, data.embeddedCover.height, true, nil +} + +func (p *MetadataProbe) inspect(ctx context.Context, path string) (probeData, error) { + if err := ctx.Err(); err != nil { + return probeData{}, err + } stat, err := os.Stat(path) if err != nil { - return Metadata{}, err + return probeData{}, err } key := metadataKey{path: path, size: stat.Size(), modUnixNano: stat.ModTime().UnixNano()} now := p.now() @@ -113,16 +162,16 @@ func (p *MetadataProbe) Probe(ctx context.Context, path string) (Metadata, error p.mu.Lock() if element := p.positive[key]; element != nil { p.positiveLRU.MoveToFront(element) - metadata := element.Value.(metadataCacheEntry).metadata + data := element.Value.(metadataCacheEntry).data p.mu.Unlock() - return metadata, nil + return data, nil } if element := p.negative[key]; element != nil { cached := element.Value.(metadataNegativeEntry) if now.Before(cached.expires) { p.negativeLRU.MoveToFront(element) p.mu.Unlock() - return Metadata{}, cached.err + return probeData{}, cached.err } p.negativeLRU.Remove(element) delete(p.negative, key) @@ -140,10 +189,10 @@ func (p *MetadataProbe) Probe(ctx context.Context, path string) (Metadata, error select { case <-flight.done: - return flight.metadata, flight.err + return flight.data, flight.err case <-ctx.Done(): p.leaveFlight(key, flight) - return Metadata{}, ctx.Err() + return probeData{}, ctx.Err() } } @@ -164,20 +213,20 @@ func (p *MetadataProbe) leaveFlight(key metadataKey, flight *metadataFlight) { } func (p *MetadataProbe) runFlight(key metadataKey, flight *metadataFlight) { - metadata, err := p.probe(flight.ctx, key) + data, err := p.probe(flight.ctx, key) if errors.Is(flight.ctx.Err(), context.DeadlineExceeded) && (errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)) { err = fmt.Errorf("metadata deadline: %w", mediaexec.ErrTaskTimeout) } p.mu.Lock() - flight.metadata, flight.err, flight.finished = metadata, err, true + flight.data, flight.err, flight.finished = data, err, true if p.flights[key] == flight { delete(p.flights, key) } if !flight.abandoned { if err == nil { - p.addPositiveLocked(key, metadata) + p.addPositiveLocked(key, data) } else if isDeterministicMetadataError(err) { p.addNegativeLocked(key, err, p.now().Add(p.negativeTTL)) } @@ -187,11 +236,11 @@ func (p *MetadataProbe) runFlight(key metadataKey, flight *metadataFlight) { flight.cancel() } -func (p *MetadataProbe) addPositiveLocked(key metadataKey, metadata Metadata) { +func (p *MetadataProbe) addPositiveLocked(key metadataKey, data probeData) { if existing := p.positive[key]; existing != nil { p.positiveLRU.Remove(existing) } - element := p.positiveLRU.PushFront(metadataCacheEntry{key: key, metadata: metadata}) + element := p.positiveLRU.PushFront(metadataCacheEntry{key: key, data: data}) p.positive[key] = element for p.positiveLRU.Len() > p.capacity { oldest := p.positiveLRU.Back() @@ -220,10 +269,10 @@ func isDeterministicMetadataError(err error) bool { return errors.As(err, &deterministic) } -func (p *MetadataProbe) probeCommand(ctx context.Context, key metadataKey) (Metadata, error) { +func (p *MetadataProbe) probeCommand(ctx context.Context, key metadataKey) (probeData, error) { task, err := mediaexec.Start(p.executor, ctx, mediaexec.AuxHigh) if err != nil { - return Metadata{}, fmt.Errorf("wait for metadata lane: %w", err) + return probeData{}, fmt.Errorf("wait for metadata lane: %w", err) } defer task.Done() commandCtx, cancel := context.WithTimeout(task.Context(), p.taskTimeout) @@ -231,64 +280,107 @@ func (p *MetadataProbe) probeCommand(ctx context.Context, key metadataKey) (Meta cmd := exec.CommandContext(commandCtx, "ffprobe", "-v", "error", - "-select_streams", "a:0", - "-show_entries", "stream=codec_name,bit_rate,duration:format=bit_rate,duration", + "-show_entries", + "stream=codec_type,codec_name,bit_rate,duration,width,height:stream_tags=title:"+ + "format=bit_rate,duration:format_tags=title", "-of", "json", key.path, ) - output, err := cmd.Output() + stdout, err := cmd.StdoutPipe() if err != nil { + return probeData{}, fmt.Errorf("open ffprobe output: %w", err) + } + if err := cmd.Start(); err != nil { + return probeData{}, fmt.Errorf("start ffprobe %s: %w", filepath.Base(key.path), err) + } + output, readErr := io.ReadAll(io.LimitReader(stdout, maxProbeOutputBytes+1)) + outputTooLarge := int64(len(output)) > maxProbeOutputBytes + if readErr != nil || outputTooLarge { + cancel() + } + waitErr := cmd.Wait() + if readErr != nil || waitErr != nil || outputTooLarge { if errors.Is(commandCtx.Err(), context.DeadlineExceeded) { - return Metadata{}, fmt.Errorf("metadata deadline: %w", mediaexec.ErrTaskTimeout) + return probeData{}, fmt.Errorf("metadata deadline: %w", mediaexec.ErrTaskTimeout) } if contextErr := task.Context().Err(); contextErr != nil { - return Metadata{}, contextErr + return probeData{}, contextErr + } + if outputTooLarge { + return probeData{}, deterministicMetadataError{ + fmt.Errorf("ffprobe output for %s exceeds %d bytes", filepath.Base(key.path), maxProbeOutputBytes), + } + } + if readErr != nil { + return probeData{}, fmt.Errorf("read ffprobe output for %s: %w", filepath.Base(key.path), readErr) } var exitError *exec.ExitError - if errors.As(err, &exitError) { - return Metadata{}, deterministicMetadataError{fmt.Errorf("ffprobe %s: %w", filepath.Base(key.path), err)} + if errors.As(waitErr, &exitError) { + return probeData{}, deterministicMetadataError{fmt.Errorf("ffprobe %s: %w", filepath.Base(key.path), waitErr)} } - return Metadata{}, fmt.Errorf("ffprobe %s: %w", filepath.Base(key.path), err) + return probeData{}, fmt.Errorf("ffprobe %s: %w", filepath.Base(key.path), waitErr) } var probe ffprobeOutput if err := json.Unmarshal(output, &probe); err != nil { - return Metadata{}, deterministicMetadataError{fmt.Errorf("parse ffprobe output: %w", err)} + return probeData{}, deterministicMetadataError{fmt.Errorf("parse ffprobe output: %w", err)} } metadata, err := metadataFromProbe(probe, key.path, key.size) + data := probeData{metadata: metadata} if err != nil { - return Metadata{}, deterministicMetadataError{err} + data.metadataErr = deterministicMetadataError{err} } - return metadata, nil + if video := firstStream(probe.Streams, "video"); video != nil { + data.embeddedCover = embeddedCoverInfo{width: video.Width, height: video.Height} + data.hasEmbeddedCover = true + } + return data, nil +} + +type ffprobeTags struct { + Title string `json:"title"` +} + +type ffprobeStream struct { + CodecType string `json:"codec_type"` + CodecName string `json:"codec_name"` + BitRate string `json:"bit_rate"` + Duration string `json:"duration"` + Width int `json:"width"` + Height int `json:"height"` + Tags ffprobeTags `json:"tags"` } type ffprobeOutput struct { - Streams []struct { - CodecName string `json:"codec_name"` - BitRate string `json:"bit_rate"` - Duration string `json:"duration"` - } `json:"streams"` - Format struct { - BitRate string `json:"bit_rate"` - Duration string `json:"duration"` + Streams []ffprobeStream `json:"streams"` + Format struct { + BitRate string `json:"bit_rate"` + Duration string `json:"duration"` + Tags ffprobeTags `json:"tags"` } `json:"format"` } func metadataFromProbe(probe ffprobeOutput, path string, size int64) (Metadata, error) { - metadata := Metadata{Codec: strings.ToUpper(strings.TrimPrefix(filepath.Ext(path), "."))} + metadata := Metadata{ + Codec: strings.ToUpper(strings.TrimPrefix(filepath.Ext(path), ".")), + Title: normalizeMetadataTitle(probe.Format.Tags.Title), + } var streamBitrate string var streamDuration string - if len(probe.Streams) > 0 { - if probe.Streams[0].CodecName != "" { - metadata.Codec = strings.ToUpper(probe.Streams[0].CodecName) + if audio := firstStream(probe.Streams, "audio"); audio != nil { + if audio.CodecName != "" { + metadata.Codec = strings.ToUpper(audio.CodecName) + } + if metadata.Title == "" { + metadata.Title = normalizeMetadataTitle(audio.Tags.Title) } - streamBitrate = probe.Streams[0].BitRate - streamDuration = probe.Streams[0].Duration + streamBitrate = audio.BitRate + streamDuration = audio.Duration } metadata.DurationSeconds = firstPositiveFloat(streamDuration, probe.Format.Duration) if metadata.DurationSeconds <= 0 { - return Metadata{}, fmt.Errorf("audio duration is unavailable") + return metadata, fmt.Errorf("audio duration is unavailable") } bitrate := firstPositiveFloat(streamBitrate, probe.Format.BitRate) @@ -302,6 +394,27 @@ func metadataFromProbe(probe ffprobeOutput, path string, size int64) (Metadata, return metadata, nil } +func firstStream(streams []ffprobeStream, codecType string) *ffprobeStream { + for i := range streams { + if streams[i].CodecType == codecType { + return &streams[i] + } + } + return nil +} + +func normalizeMetadataTitle(title string) string { + title = strings.TrimSpace(title) + if len(title) <= maxMetadataTitleBytes { + return title + } + cut := maxMetadataTitleBytes + for cut > 0 && !utf8.RuneStart(title[cut]) { + cut-- + } + return title[:cut] +} + func firstPositiveFloat(values ...string) float64 { for _, value := range values { parsed, err := strconv.ParseFloat(value, 64) diff --git a/internal/stream/metadata_test.go b/internal/stream/metadata_test.go index 2b36f36..8576cc5 100644 --- a/internal/stream/metadata_test.go +++ b/internal/stream/metadata_test.go @@ -5,20 +5,20 @@ import ( "errors" "os" "path/filepath" + "strings" "sync" "testing" "time" + "unicode/utf8" "github.com/ColderCoder/ShuffleMuse/internal/mediaexec" ) func TestMetadataFromProbeFallsBackToAverageBitrate(t *testing.T) { var probe ffprobeOutput - probe.Streams = append(probe.Streams, struct { - CodecName string `json:"codec_name"` - BitRate string `json:"bit_rate"` - Duration string `json:"duration"` - }{CodecName: "flac", Duration: "10"}) + probe.Streams = append(probe.Streams, ffprobeStream{ + CodecType: "audio", CodecName: "flac", Duration: "10", + }) metadata, err := metadataFromProbe(probe, "track.flac", 1_250_000) if err != nil { @@ -29,6 +29,42 @@ func TestMetadataFromProbeFallsBackToAverageBitrate(t *testing.T) { } } +func TestMetadataFromProbePrefersFormatTitleAndSelectsFirstAudio(t *testing.T) { + var probe ffprobeOutput + probe.Format.Duration = "20" + probe.Format.Tags.Title = " Format Title " + probe.Streams = []ffprobeStream{ + {CodecType: "video", CodecName: "mjpeg", Width: 100, Height: 100}, + {CodecType: "audio", CodecName: "flac", BitRate: "1000000", Tags: ffprobeTags{Title: "Stream Title"}}, + {CodecType: "audio", CodecName: "opus", BitRate: "128000", Tags: ffprobeTags{Title: "Second Audio"}}, + } + + metadata, err := metadataFromProbe(probe, "track.bin", 1) + if err != nil { + t.Fatal(err) + } + if metadata.Title != "Format Title" || metadata.Codec != "FLAC" || metadata.BitrateKbps != 1000 { + t.Fatalf("metadata selection = %+v", metadata) + } + + probe.Format.Tags.Title = "" + metadata, err = metadataFromProbe(probe, "track.bin", 1) + if err != nil || metadata.Title != "Stream Title" { + t.Fatalf("stream title fallback = %+v/%v", metadata, err) + } +} + +func TestNormalizeMetadataTitleCapsUTF8WithoutSplittingRune(t *testing.T) { + title := strings.Repeat("界", maxMetadataTitleBytes) + got := normalizeMetadataTitle(" " + title + " ") + if len(got) > maxMetadataTitleBytes || !utf8.ValidString(got) || !strings.HasPrefix(title, got) { + t.Fatalf("invalid capped title: bytes=%d valid=%v", len(got), utf8.ValidString(got)) + } + if got == "" { + t.Fatal("capped title is empty") + } +} + func TestMetadataProbeLRUEviction(t *testing.T) { dir := t.TempDir() paths := []string{filepath.Join(dir, "a.flac"), filepath.Join(dir, "b.flac"), filepath.Join(dir, "c.flac")} @@ -39,9 +75,9 @@ func TestMetadataProbeLRUEviction(t *testing.T) { } calls := make(map[string]int) probe := NewMetadataProbe(nil, MetadataConfig{Capacity: 2}) - probe.probe = func(_ context.Context, key metadataKey) (Metadata, error) { + probe.probe = func(_ context.Context, key metadataKey) (probeData, error) { calls[key.path]++ - return Metadata{Codec: "FLAC", DurationSeconds: 1}, nil + return probeData{metadata: Metadata{Codec: "FLAC", DurationSeconds: 1}}, nil } for _, path := range []string{paths[0], paths[1], paths[0], paths[2], paths[1]} { if _, err := probe.Probe(context.Background(), path); err != nil { @@ -102,16 +138,20 @@ func TestMetadataProbeSingleflightAndIndependentWaiters(t *testing.T) { var once sync.Once var mu sync.Mutex calls := 0 - probe.probe = func(ctx context.Context, _ metadataKey) (Metadata, error) { + probe.probe = func(ctx context.Context, _ metadataKey) (probeData, error) { mu.Lock() calls++ mu.Unlock() once.Do(func() { close(started) }) select { case <-release: - return Metadata{Codec: "FLAC", DurationSeconds: 1}, nil + return probeData{ + metadata: Metadata{Codec: "FLAC", DurationSeconds: 1}, + embeddedCover: embeddedCoverInfo{width: 640, height: 480}, + hasEmbeddedCover: true, + }, nil case <-ctx.Done(): - return Metadata{}, ctx.Err() + return probeData{}, ctx.Err() } } @@ -121,10 +161,20 @@ func TestMetadataProbeSingleflightAndIndependentWaiters(t *testing.T) { for i := 0; i < waiters; i++ { ctx, cancel := context.WithCancel(context.Background()) contexts[i] = cancel - go func() { - _, err := probe.Probe(ctx, path) - results <- err - }() + if i%2 == 0 { + go func() { + _, err := probe.Probe(ctx, path) + results <- err + }() + } else { + go func() { + width, height, found, err := probe.ProbeEmbedded(ctx, path) + if err == nil && (!found || width != 640 || height != 480) { + err = errors.New("embedded cover result mismatch") + } + results <- err + }() + } } <-started for i := 0; i < waiters/2; i++ { @@ -157,9 +207,9 @@ func TestMetadataProbeNegativeCacheAndIdentityInvalidation(t *testing.T) { probe := NewMetadataProbe(nil, MetadataConfig{Capacity: 4, NegativeTTL: time.Minute}) probe.now = func() time.Time { return now } calls := 0 - probe.probe = func(context.Context, metadataKey) (Metadata, error) { + probe.probe = func(context.Context, metadataKey) (probeData, error) { calls++ - return Metadata{}, deterministicMetadataError{errors.New("invalid audio")} + return probeData{}, deterministicMetadataError{errors.New("invalid audio")} } for range 2 { if _, err := probe.Probe(context.Background(), path); err == nil { @@ -200,9 +250,9 @@ func TestMetadataProbeDoesNotCacheBusyTimeoutOrAbandonedFlight(t *testing.T) { t.Run(name, func(t *testing.T) { probe := NewMetadataProbe(nil) calls := 0 - probe.probe = func(context.Context, metadataKey) (Metadata, error) { + probe.probe = func(context.Context, metadataKey) (probeData, error) { calls++ - return Metadata{}, probeErr + return probeData{}, probeErr } for range 2 { if _, err := probe.Probe(context.Background(), path); !errors.Is(err, probeErr) { @@ -218,11 +268,11 @@ func TestMetadataProbeDoesNotCacheBusyTimeoutOrAbandonedFlight(t *testing.T) { probe := NewMetadataProbe(nil) started := make(chan struct{}) canceled := make(chan struct{}) - probe.probe = func(ctx context.Context, _ metadataKey) (Metadata, error) { + probe.probe = func(ctx context.Context, _ metadataKey) (probeData, error) { close(started) <-ctx.Done() close(canceled) - return Metadata{}, ctx.Err() + return probeData{}, ctx.Err() } ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) @@ -241,3 +291,84 @@ func TestMetadataProbeDoesNotCacheBusyTimeoutOrAbandonedFlight(t *testing.T) { t.Fatal("last waiter did not cancel the underlying metadata flight") } } + +func TestMetadataValidationFailureDoesNotHideEmbeddedCover(t *testing.T) { + path := filepath.Join(t.TempDir(), "track.flac") + if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil { + t.Fatal(err) + } + probe := NewMetadataProbe(nil) + calls := 0 + probe.probe = func(context.Context, metadataKey) (probeData, error) { + calls++ + return probeData{ + metadata: Metadata{Title: "Title", Codec: "FLAC"}, + metadataErr: deterministicMetadataError{errors.New("duration unavailable")}, + embeddedCover: embeddedCoverInfo{width: 800, height: 800}, + hasEmbeddedCover: true, + }, nil + } + + if _, err := probe.Probe(context.Background(), path); err == nil { + t.Fatal("expected metadata validation error") + } + width, height, found, err := probe.ProbeEmbedded(context.Background(), path) + if err != nil || !found || width != 800 || height != 800 { + t.Fatalf("embedded cover = %dx%d found=%v err=%v", width, height, found, err) + } + if calls != 1 { + t.Fatalf("shared probe calls = %d", calls) + } +} + +func TestProbeEmbeddedOnlyPropagatesSchedulingAndCancellationErrors(t *testing.T) { + path := filepath.Join(t.TempDir(), "track.flac") + if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil { + t.Fatal(err) + } + + for name, probeErr := range map[string]error{ + "busy": mediaexec.ErrQueueFull, + "timeout": mediaexec.ErrTaskTimeout, + "canceled": context.Canceled, + } { + t.Run(name, func(t *testing.T) { + probe := NewMetadataProbe(nil) + probe.probe = func(context.Context, metadataKey) (probeData, error) { + return probeData{}, probeErr + } + if _, _, _, err := probe.ProbeEmbedded(context.Background(), path); !errors.Is(err, probeErr) { + t.Fatalf("embedded error = %v, want %v", err, probeErr) + } + }) + } + + probe := NewMetadataProbe(nil) + probe.probe = func(context.Context, metadataKey) (probeData, error) { + return probeData{}, deterministicMetadataError{errors.New("invalid media")} + } + if _, _, found, err := probe.ProbeEmbedded(context.Background(), path); err != nil || found { + t.Fatalf("deterministic embedded result found=%v err=%v", found, err) + } +} + +func TestMetadataProbeRejectsOversizedOutput(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "track.flac") + if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil { + t.Fatal(err) + } + script := "#!/bin/sh\nprintf '%s' '" + strings.Repeat("x", int(maxProbeOutputBytes)+1) + "'\n" + if err := os.WriteFile(filepath.Join(dir, "ffprobe"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + probe := NewMetadataProbe(nil) + if _, err := probe.Probe(context.Background(), path); !isDeterministicMetadataError(err) { + t.Fatalf("oversized output error = %v", err) + } + if _, _, found, err := probe.ProbeEmbedded(context.Background(), path); err != nil || found { + t.Fatalf("oversized embedded result found=%v err=%v", found, err) + } +} diff --git a/web/package.json b/web/package.json index bc7acc0..aec61a7 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "shufflemuse-web", "private": true, - "version": "0.1.0", + "version": "0.1.1", "packageManager": "bun@1.3.14", "type": "module", "scripts": { diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 9b7f2bd..67f3461 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -90,6 +90,7 @@ export interface BrowseResponse { } export interface FileMetadata { + title?: string codec: string bitrateKbps: number bitrateApproximate: boolean diff --git a/web/src/components/NowPlayingBar.test.ts b/web/src/components/NowPlayingBar.test.ts index 08b03d5..ee8e491 100644 --- a/web/src/components/NowPlayingBar.test.ts +++ b/web/src/components/NowPlayingBar.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' import { flushPromises, mount } from '@vue/test-utils' +import { createMemoryHistory, createRouter } from 'vue-router' import NowPlayingBar from './NowPlayingBar.vue' import { usePlayerStore } from '../stores/player' import { useLibraryStore } from '../stores/library' @@ -13,6 +14,19 @@ vi.mock('../api', () => ({ getTags: vi.fn().mockResolvedValue([]), })) +async function mountBar() { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: { template: '
' } }, + { path: '/browse', name: 'browse', component: { template: '
' } }, + ], + }) + await router.push('/') + await router.isReady() + return mount(NowPlayingBar, { global: { plugins: [router] } }) +} + describe('NowPlayingBar', () => { beforeEach(() => { setActivePinia(createPinia()) @@ -34,6 +48,7 @@ describe('NowPlayingBar', () => { streamUrl: '/api/stream/one?mode=original', } player.mediaMetadata = { + title: 'Metadata Title', codec: 'FLAC', bitrateKbps: 987, bitrateApproximate: false, @@ -43,10 +58,15 @@ describe('NowPlayingBar', () => { player.currentTime = 60 const seek = vi.spyOn(player, 'seek').mockResolvedValue(undefined) - const wrapper = mount(NowPlayingBar) + const wrapper = await mountBar() const labels = wrapper.findAll('.np-transport button').map(button => button.attributes('aria-label')) expect(labels).toEqual(['Previous track', 'Play', 'Next track']) expect(wrapper.text()).toContain('FLAC · 987 kbps') + expect(wrapper.get('.np-track-name').text()).toBe('Metadata Title') + const path = wrapper.get('.np-track-path') + expect(path.element.tagName).toBe('A') + expect(decodeURIComponent(path.attributes('href') ?? '')).toBe('/browse?dir=Album') + expect(path.attributes('aria-label')).toBe('Browse folder containing Metadata Title') const slider = wrapper.get('input[aria-label="Playback position"]') expect(slider.attributes('max')).toBe('240') @@ -72,7 +92,7 @@ describe('NowPlayingBar', () => { streamUrl: '/api/stream/one?mode=opus', } - const wrapper = mount(NowPlayingBar) + const wrapper = await mountBar() expect(wrapper.text()).toContain('OPUS · 224 kbps') const opus = wrapper.get('.stream-mode-button:last-child') @@ -95,7 +115,7 @@ describe('NowPlayingBar', () => { filepath: 'one.flac', streamUrl: '/api/stream/one?mode=original', } - const wrapper = mount(NowPlayingBar) + const wrapper = await mountBar() player.currentTrack = { id: 'two', @@ -122,7 +142,7 @@ describe('NowPlayingBar', () => { filepath: 'one.flac', streamUrl: '/api/stream/one?mode=original', } - const wrapper = mount(NowPlayingBar) + const wrapper = await mountBar() await flushPromises() await wrapper.get('.btn-fav').trigger('click') diff --git a/web/src/components/NowPlayingBar.vue b/web/src/components/NowPlayingBar.vue index 20868d4..21eb4dd 100644 --- a/web/src/components/NowPlayingBar.vue +++ b/web/src/components/NowPlayingBar.vue @@ -27,6 +27,10 @@ const playbackDetails = computed(() => { const prefix = player.mediaMetadata.bitrateApproximate ? '~' : '' return `${player.mediaMetadata.codec} · ${prefix}${player.mediaMetadata.bitrateKbps} kbps` }) +const browseTarget = computed(() => { + const dir = player.currentTrack?.dir.replace(/\\/g, '/') ?? '.' + return dir === '.' ? { name: 'browse' } : { name: 'browse', query: { dir } } +}) watch(() => player.currentTime, value => { if (!seeking.value) seekPreview.value = value @@ -136,10 +140,17 @@ async function toggleFavorite() { Choose a track or press Space
- {{ player.currentTrack?.name }} + {{ player.displayTitle }} {{ playbackDetails }} - · {{ player.currentTrack?.filepath }} + + · {{ player.currentTrack?.filepath }} + Buffering {{ player.error }} {{ favoriteError }} @@ -337,6 +348,16 @@ async function toggleFavorite() { font-size: 0.72rem; } +.np-track-path { + color: inherit; +} + +.np-track-path:hover { + color: var(--accent-hover); + text-decoration: underline; + text-underline-offset: 2px; +} + .np-buffering, .np-error-text { margin-left: 0.5rem; @@ -479,6 +500,45 @@ async function toggleFavorite() { } } +@media (min-width: 961px) and (max-width: 1240px) { + .now-playing-bar { + grid-template-columns: minmax(0, 1fr) auto auto; + column-gap: 0.5rem; + padding-right: 0.75rem; + padding-left: 0.75rem; + } + + .np-controls { + grid-template-columns: 36px auto; + gap: 0.375rem; + } + + .np-controls::after { + display: none; + } + + .np-transport { + grid-column: 2; + gap: 0.25rem; + } + + .np-right { + gap: 0.375rem; + } + + .np-volume { + gap: 0.375rem; + } + + .volume-slider { + width: clamp(48px, calc(100vw - 1000px), 80px); + } + + .np-volume-pct { + display: none; + } +} + @media (max-width: 760px) { .now-playing-bar { grid-template-columns: minmax(0, 1fr) auto; diff --git a/web/src/components/PlaylistSidebar.vue b/web/src/components/PlaylistSidebar.vue index e324a2a..d4fbeb1 100644 --- a/web/src/components/PlaylistSidebar.vue +++ b/web/src/components/PlaylistSidebar.vue @@ -110,7 +110,7 @@ function playItem(index: number) { .playlist-list { flex: 1; overflow-y: auto; - padding: 0.25rem; + padding: 0 0.25rem 0.25rem; } .playlist-navigation { diff --git a/web/src/stores/player.test.ts b/web/src/stores/player.test.ts index 2b8ba2f..9b0c346 100644 --- a/web/src/stores/player.test.ts +++ b/web/src/stores/player.test.ts @@ -133,6 +133,57 @@ describe('player store server queues', () => { expect(player.currentTrack).toBeNull() }) + it('shows the filename until the lazy metadata title arrives', async () => { + const pending = deferred() + vi.mocked(api.getFileMetadata).mockReturnValueOnce(pending.promise) + const player = usePlayerStore() + + await player.preparePlaylist() + expect(player.displayTitle).toBe('one') + + pending.resolve({ + title: ' Metadata Title ', + codec: 'FLAC', + bitrateKbps: 987, + bitrateApproximate: false, + durationSeconds: 240, + }) + await vi.waitFor(() => expect(player.displayTitle).toBe('Metadata Title')) + }) + + it('never applies a stale metadata title after changing tracks', async () => { + const oldMetadata = deferred() + vi.mocked(api.createQueue).mockResolvedValue(page( + 'two-tracks', 1, 2, [item(0, 'one'), item(1, 'two')], + )) + vi.mocked(api.getFileMetadata).mockImplementation(id => ( + id === 'one' + ? oldMetadata.promise + : Promise.resolve({ + title: 'Second Title', + codec: 'FLAC', + bitrateKbps: 1000, + bitrateApproximate: false, + durationSeconds: 180, + }) + )) + const player = usePlayerStore() + + await player.preparePlaylist() + await player.playAt(1) + await vi.waitFor(() => expect(player.displayTitle).toBe('Second Title')) + + oldMetadata.resolve({ + title: 'Stale First Title', + codec: 'FLAC', + bitrateKbps: 900, + bitrateApproximate: false, + durationSeconds: 200, + }) + await Promise.resolve() + expect(player.displayTitle).toBe('Second Title') + }) + it('toggles active playback between paused and playing', async () => { const player = usePlayerStore() await player.preparePlaylist() diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts index abc018c..b1ab1d8 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -104,6 +104,7 @@ export const usePlayerStore = defineStore('player', () => { const queuePosition = computed(() => (currentTrack.value && queue.value ? activeIndex.value + 1 : 0)) const queueTotal = computed(() => queue.value?.total ?? 0) + const displayTitle = computed(() => mediaMetadata.value?.title?.trim() || currentTrack.value?.name || '') const queuePageCount = computed(() => Math.max(1, Math.ceil(queueTotal.value / (queue.value?.pageSize ?? apiQueuePageSize())))) const currentPage = computed(() => pageForIndex(activeIndex.value)) const sidebarItems = computed(() => pages.value.get(sidebarPage.value)?.items ?? []) @@ -784,6 +785,7 @@ export const usePlayerStore = defineStore('player', () => { queuePageCount, queuePosition, queueTotal, + displayTitle, cachedPageCount, error, playlistLoading, diff --git a/web/src/views/HomeView.test.ts b/web/src/views/HomeView.test.ts index 8e9f795..2d95dbb 100644 --- a/web/src/views/HomeView.test.ts +++ b/web/src/views/HomeView.test.ts @@ -42,6 +42,38 @@ describe('HomeView', () => { expect(wrapper.findAll('button').some(button => button.text().includes('Randomize'))).toBe(false) }) + it('uses the metadata title for display while retaining the real file path', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const player = usePlayerStore() + player.currentTrack = { + id: 'one', + name: '01. filename.flac', + dir: 'Artist/Album', + filepath: 'Artist/Album/01. filename.flac', + streamUrl: '/api/stream/one', + } + player.mediaMetadata = { + title: 'Metadata Title', + codec: 'FLAC', + bitrateKbps: 1000, + bitrateApproximate: false, + durationSeconds: 180, + } + + const wrapper = await mountHome(pinia) + expect(wrapper.get('.now-playing-track-name').text()).toBe('Metadata Title') + expect(wrapper.get('.now-playing-track-dir').text()).toBe('Artist/Album/01. filename.flac') + expect(wrapper.get('img.now-playing-cover').attributes('alt')).toBe('Cover art for Metadata Title') + + player.mediaMetadata = { + ...player.mediaMetadata, + title: ' ', + } + await wrapper.vm.$nextTick() + expect(wrapper.get('.now-playing-track-name').text()).toBe('01. filename.flac') + }) + it('delays a low-priority directory cover, then falls back to the track and placeholder', async () => { vi.useFakeTimers() const pinia = createPinia() diff --git a/web/src/views/HomeView.vue b/web/src/views/HomeView.vue index 20c7670..b4efccf 100644 --- a/web/src/views/HomeView.vue +++ b/web/src/views/HomeView.vue @@ -154,7 +154,7 @@ onBeforeUnmount(() => {

Now Playing

-

{{ player.currentTrack.name }}

+

{{ player.displayTitle }}

{ v-if="!coverFailed" class="now-playing-cover" :src="coverSrc" - :alt="`Cover art for ${player.currentTrack.name}`" + :alt="`Cover art for ${player.displayTitle}`" decoding="async" fetchpriority="low" @load="handleCoverLoad" @@ -176,7 +176,7 @@ onBeforeUnmount(() => { v-else class="now-playing-cover now-playing-cover--placeholder" role="img" - :aria-label="`No cover art available for ${player.currentTrack.name}`" + :aria-label="`No cover art available for ${player.displayTitle}`" >