Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5242d54a06 | |||
| e961969207 | |||
| b8b02cb3a7 | |||
| 60cfad47e5 | |||
| 47c8bfdd6c | |||
| ce3baee8f1 | |||
| 2494f0c0cd | |||
| c4e9926352 | |||
| f3b88fad7c | |||
| 2c6646beea | |||
| 83ec494a36 | |||
| b8648f0049 | |||
| 5acfa6292a | |||
| 42343212c5 | |||
| 6584519bd8 | |||
| 565a55b194 | |||
| 9036ad04e7 | |||
| ce4319ec25 | |||
| c1ff55ffbf | |||
| fa41936095 | |||
| 9cc21a47c5 | |||
| 5976a8d310 | |||
| d0b4255aa8 | |||
| b8e6418326 | |||
| c84f2604ed | |||
| 1e6909ddbf | |||
| 8136e6ecb1 | |||
| 1d73374fff | |||
| bad07f7add | |||
| cb539afd2a | |||
| 594166f696 | |||
| e0b07492a5 | |||
| 3be6978e70 | |||
| 065652d758 | |||
| e273b5e87d | |||
| c9c612df1b | |||
| e2cd32af53 | |||
| 28485ed429 | |||
| 34ccf14cda | |||
| 44fde6c5a0 | |||
| 94ef0116b1 | |||
| 016c6687e9 | |||
| b5ce4646cf | |||
| 47899284a5 | |||
| c24ebe181d | |||
| 9b2b592003 | |||
| 0c4668c156 | |||
| 474513f18a | |||
| 0810bfb234 | |||
| 61a7260eaf | |||
| 45f4763b0c | |||
| f4f57759ce | |||
| 6ec9adc976 | |||
| f4abdf78e2 | |||
| ae8db26c1e | |||
| 8017e994f4 | |||
| d56fb53a08 | |||
| 30ca94c79b | |||
| 9c1f9a6e7e | |||
| ec7be38266 | |||
| ebf791c436 | |||
| 83c0b65a0b | |||
| 1153fe4191 | |||
| 93866d169b | |||
| d283d39008 | |||
| fc89d23867 | |||
| 1f14a1e85b | |||
| f0a6b630c6 | |||
| c723664e46 | |||
| 5faa6975d0 | |||
| cbfae0252e | |||
| 57ee482146 | |||
| a9b3110b2c | |||
| ff86c2f597 | |||
| a5731fa963 | |||
| 531e45f677 | |||
| cd3f028f0b | |||
| bd7b7a722b | |||
| 83deffcf45 | |||
| c74f6be250 | |||
| c911a57467 | |||
| 1dbc3f8fe7 | |||
| a563ee11d6 | |||
| 215ebf6942 | |||
| e4c0e85eaa | |||
| 60f05129c9 | |||
| fa4c90d47a | |||
| 1ec8303fec | |||
| b28c0d9247 | |||
| 91c6d9dfd9 | |||
| 95d8126d65 | |||
| 3aa8662c45 | |||
| 5c85478883 | |||
| fd54d42db7 | |||
| 4595b7c5be | |||
| ee3927cdf1 | |||
| 4b34fc4616 |
@@ -115,7 +115,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Install
|
||||
@@ -187,7 +187,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
go-version: '1.26'
|
||||
cache: true
|
||||
- name: Download web/dist
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -196,8 +196,12 @@ jobs:
|
||||
path: web/dist
|
||||
- name: Build binary
|
||||
run: |
|
||||
LDFLAGS="-s -w -X main.version=${{ needs.build-image.outputs.release_tag }}"
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
LDFLAGS="$LDFLAGS -H=windowsgui"
|
||||
fi
|
||||
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${{ needs.build-image.outputs.release_tag }}" \
|
||||
go build -trimpath -ldflags="$LDFLAGS" \
|
||||
-o "dist/mebox-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server
|
||||
- name: Package
|
||||
run: |
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
# 产出:
|
||||
# 1. 前端 + 后端编译验证(go vet / go test / go build)
|
||||
# 2. 多平台可执行二进制 artifact(linux/amd64、linux/arm64、windows/amd64)
|
||||
# 3. ghcr.io/{owner}/mebox:beta 多架构 Docker 镜像(linux/amd64 + linux/arm64)
|
||||
# 3. ghcr.io/{owner}/mebox 多架构 Docker 镜像(linux/amd64 + linux/arm64),
|
||||
# 标签:beta(滚动)+ {基版本}-beta.{短 SHA}(本次构建,只增不改)
|
||||
#
|
||||
# 与 main 分支的发布流(Auto-docker-publish.yml)隔离:beta 不做版本递增、
|
||||
# 不打 release tag,只构建带 -beta 标识的产物供测试。
|
||||
# 与 main 分支发布流(Auto-docker-publish.yml)的关系:镜像的构建方式、标签策略
|
||||
# 和基版本号来源(version 分支)与发布流保持一致,区别是 beta 不做版本递增、
|
||||
# 不打 release tag、不发布 GitHub Release、不部署服务器。
|
||||
|
||||
name: Beta Build
|
||||
|
||||
@@ -22,9 +24,6 @@ permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
BETA_VERSION_PREFIX: beta
|
||||
|
||||
jobs:
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 1) 编译验证 + 多平台二进制产物
|
||||
@@ -32,23 +31,30 @@ jobs:
|
||||
test-and-build:
|
||||
name: Test & build artifacts
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
binary_version: ${{ steps.version.outputs.binary_version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# 基版本号与发布流同源:读 version 分支上由 main 发布流水线维护的 VERSION。
|
||||
# beta 只读不自增,避免与 main 的发布流程争抢版本号。
|
||||
- name: Resolve beta version
|
||||
id: version
|
||||
run: |
|
||||
BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
|
||||
git fetch origin version || true
|
||||
BASE_VERSION=$(git show FETCH_HEAD:VERSION 2>/dev/null || echo "0.0.0")
|
||||
SHA_SHORT=${GITHUB_SHA:0:7}
|
||||
echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
|
||||
echo "binary_version=mebox-v${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The binary embeds the SPA (web/dist) via go:embed, so dist must exist
|
||||
# before the Go toolchain touches the web package.
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Build SPA
|
||||
@@ -59,7 +65,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
go-version: '1.26'
|
||||
cache: true
|
||||
|
||||
- name: go vet
|
||||
@@ -71,13 +77,13 @@ jobs:
|
||||
- name: go build (host)
|
||||
run: go build ./...
|
||||
|
||||
# 多平台可执行文件(嵌入刚构建的 web/dist)
|
||||
# 多平台可执行文件(嵌入刚构建的 web/dist),版本号写入口径与发布流一致
|
||||
- name: Build linux/amd64
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server
|
||||
- name: Build linux/arm64
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server
|
||||
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server
|
||||
- name: Build windows/amd64
|
||||
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
|
||||
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -87,7 +93,8 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta)
|
||||
# 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta + :{基版本}-beta.{短 SHA})
|
||||
# 构建步骤与 Auto-docker-publish.yml 的 build-image 一致,只是不带 deploy。
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
docker-beta:
|
||||
name: Build & push beta Docker image
|
||||
@@ -98,13 +105,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve beta version
|
||||
id: version
|
||||
run: |
|
||||
BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
|
||||
SHA_SHORT=${GITHUB_SHA:0:7}
|
||||
echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -115,6 +115,15 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/mebox
|
||||
tags: |
|
||||
type=raw,value=beta
|
||||
type=raw,value=${{ needs.test-and-build.outputs.version }}
|
||||
|
||||
- name: Build & push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
@@ -123,11 +132,9 @@ jobs:
|
||||
push: true
|
||||
provenance: false
|
||||
sbom: false
|
||||
tags: ghcr.io/${{ github.repository_owner }}/mebox:beta
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.source=${{ github.repository }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.full_version }}
|
||||
VERSION=${{ needs.test-and-build.outputs.binary_version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -15,14 +15,14 @@ jobs:
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25'
|
||||
go-version: '1.26'
|
||||
cache: true
|
||||
|
||||
# The binary embeds the SPA (web/dist) via go:embed, so the dist must exist
|
||||
# before the Go toolchain touches the `web` package.
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Build SPA
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
|
||||
@@ -80,4 +80,5 @@ media/
|
||||
tools/
|
||||
verify-cache/
|
||||
verify-data/
|
||||
.zcode/
|
||||
.zcode/
|
||||
.tmp-src
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# Binaries
|
||||
bin/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# Go workspace
|
||||
go.work
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
|
||||
# Build artifacts
|
||||
web/dist/
|
||||
web/.vite/
|
||||
web/coverage/
|
||||
web/tsconfig.tsbuildinfo
|
||||
dist-release/
|
||||
|
||||
# Data / runtime
|
||||
data/
|
||||
cache/
|
||||
logs/
|
||||
.tmp-deploy-data/
|
||||
.tmp-deploy-smoke-data/
|
||||
.tmp-deploy-smoke-cache/
|
||||
.tmp-deploy-cache/
|
||||
.tmp-deploy-server.*
|
||||
.tmp-live-server.*
|
||||
.mebox.pid
|
||||
*.log
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Editor / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Local configs (keep examples)
|
||||
config/secrets.yaml
|
||||
config.yaml
|
||||
|
||||
# WorkBuddy workspace (local AI assistant memory)
|
||||
.workbuddy/
|
||||
|
||||
# Editor backups
|
||||
*~
|
||||
.tmp_*
|
||||
|
||||
# Runtime / local-only artifacts (清理补充)
|
||||
.tmp/
|
||||
.tmp-live-backups/
|
||||
.tmp-*
|
||||
.codex-*
|
||||
.codex/
|
||||
downloads/
|
||||
media/
|
||||
*.pid
|
||||
|
||||
# 本地开发运行产物
|
||||
.agents/
|
||||
.claude/
|
||||
.dev-cache/
|
||||
.dev-data/
|
||||
.dev-logs/
|
||||
tools/
|
||||
verify-cache/
|
||||
verify-data/
|
||||
.zcode/
|
||||
|
||||
# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)=====
|
||||
.git/
|
||||
.hg/
|
||||
.svn/
|
||||
bower_components/
|
||||
jspm_packages/
|
||||
__pycache__/
|
||||
site-packages/
|
||||
venv/
|
||||
coverage/
|
||||
htmlcov/
|
||||
lcov-report/
|
||||
cmakefiles/
|
||||
cmake-build-*/
|
||||
bazel-*/
|
||||
pods/
|
||||
deriveddata/
|
||||
storybook-static/
|
||||
playwright-report/
|
||||
test-results/
|
||||
allure-results/
|
||||
allure-report/
|
||||
cdk.out/
|
||||
*.egg-info/
|
||||
*.dist-info/
|
||||
eggs/
|
||||
pip-wheel-metadata/
|
||||
wheels/
|
||||
# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
|
||||
# 自定义规则写在下方(本行提示可删除)
|
||||
@@ -2,8 +2,8 @@
|
||||
# =============================================================================
|
||||
# Multi-architecture build for MeBox.
|
||||
#
|
||||
# Stage 1 (frontend) : Node 20 -> static SPA bundle
|
||||
# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0)
|
||||
# Stage 1 (frontend) : Node 20.19+ -> static SPA bundle
|
||||
# Stage 2 (backend) : Go 1.26 -> single static binary (CGO_ENABLED=0)
|
||||
# Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user
|
||||
#
|
||||
# Build:
|
||||
@@ -15,7 +15,7 @@
|
||||
# =============================================================================
|
||||
|
||||
# ---- Stage 1: frontend (always build on the host architecture) -------------
|
||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend
|
||||
FROM --platform=$BUILDPLATFORM node:20.19-alpine AS frontend
|
||||
ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/
|
||||
WORKDIR /app/web
|
||||
COPY web/package*.json ./
|
||||
@@ -25,7 +25,7 @@ COPY web/ .
|
||||
RUN npm run build
|
||||
|
||||
# ---- Stage 2: backend (cross-compiled to TARGETPLATFORM) -------------------
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS backend
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS backend
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG GOPROXY=https://proxy.golang.org,direct
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go&logoColor=white" />
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white" />
|
||||
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111827" />
|
||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
|
||||
<img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" />
|
||||
@@ -199,6 +199,11 @@ environment:
|
||||
**硬链接失败(cross-device link)?**
|
||||
硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 btrfs 子卷或网盘挂载时请改用复制或软链接。
|
||||
|
||||
**日志保留时间太短?**
|
||||
|
||||
默认应用日志为 `20MB x 5`,容器 stdout 日志为 `20m x 3`。排障时可在 compose 中调大
|
||||
`MEBOX_LOGGING_MAX_SIZE_MB`、`MEBOX_LOGGING_MAX_BACKUPS` 与服务的 `logging.options.max-size/max-file`。
|
||||
|
||||
**第三方播放器连不上?**
|
||||
确认地址为 `http://IP:18080`,使用 MeBox 用户账号;反代部署需正确配置外部 URL 与 HTTPS 头。
|
||||
|
||||
@@ -207,6 +212,7 @@ environment:
|
||||
## 开发构建
|
||||
|
||||
后端通过 `go:embed` 嵌入 `web/dist`,**编译前必须先构建前端**。
|
||||
前端构建要求 Node.js `20.19+` 或 `22.12+`。
|
||||
|
||||
```bash
|
||||
npm --prefix web ci
|
||||
@@ -219,6 +225,14 @@ npm --prefix web run dev # http://127.0.0.1:3000
|
||||
|
||||
CI 会在 Release 中提供 Windows / Linux / macOS 的 amd64、arm64 单文件可执行程序。
|
||||
|
||||
Windows 本地打包:
|
||||
|
||||
```powershell
|
||||
.\scripts\build-windows.ps1 -Version dev
|
||||
```
|
||||
|
||||
Windows 可执行程序使用项目 Logo,不显示控制台窗口;启动后会常驻系统托盘。托盘菜单可打开 MeBox、切换开机自启、查看日志、重启或退出。
|
||||
|
||||
---
|
||||
|
||||
## 鸣谢
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go&logoColor=white" />
|
||||
<img alt="Go" src="https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white" />
|
||||
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111827" />
|
||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
|
||||
<img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" />
|
||||
@@ -199,6 +199,14 @@ npm --prefix web run dev
|
||||
|
||||
Release builds ship single-file binaries for Windows, Linux, and macOS on amd64 and arm64.
|
||||
|
||||
Build the Windows executable locally:
|
||||
|
||||
```powershell
|
||||
.\scripts\build-windows.ps1 -Version dev
|
||||
```
|
||||
|
||||
The Windows executable uses the project logo and runs without a console window. It stays in the notification area, with menu actions for opening MeBox, toggling auto-start, viewing logs, restarting, and exiting.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// reader-smoke 是阅读书源兼容性冒烟工具:
|
||||
// 对批量书源逐个跑「搜索 → 详情 → 目录 → 正文」全链路,输出兼容率报告。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// go run ./cmd/reader-smoke -file sources.json -key 斗破苍穹 -c 8
|
||||
// go run ./cmd/reader-smoke -url https://example.com/sources.json -json > report.json
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service/reader"
|
||||
)
|
||||
|
||||
func main() {
|
||||
file := flag.String("file", "", "书源文件路径(JSON 数组/对象/Base64/每行一个)")
|
||||
urlFlag := flag.String("url", "", "书源网络地址(与 -file 二选一)")
|
||||
key := flag.String("key", "斗破苍穹", "搜索关键词")
|
||||
concurrency := flag.Int("c", 4, "并发数")
|
||||
timeout := flag.Int("timeout", 90, "单源全链路超时(秒)")
|
||||
jsonOut := flag.Bool("json", false, "输出完整 JSON 报告(追加在汇总后)")
|
||||
flag.Parse()
|
||||
|
||||
payload := ""
|
||||
switch {
|
||||
case *file != "":
|
||||
data, err := os.ReadFile(*file)
|
||||
if err != nil {
|
||||
fatal("读取文件失败: %v", err)
|
||||
}
|
||||
payload = string(data)
|
||||
case *urlFlag != "":
|
||||
client := helper.NewSiteHTTPClient(30, true)
|
||||
req, err := http.NewRequest("GET", *urlFlag, nil)
|
||||
if err != nil {
|
||||
fatal("构造请求失败: %v", err)
|
||||
}
|
||||
for k, v := range helper.HTTPHeaderPresets() {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fatal("拉取书源失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var sb strings.Builder
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
sb.Write(buf[:n])
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
payload = sb.String()
|
||||
default:
|
||||
fatal("需要 -file 或 -url 指定书源来源")
|
||||
}
|
||||
|
||||
sources := reader.ParseSourcePayload(payload)
|
||||
if len(sources) == 0 {
|
||||
fatal("未从输入中识别到书源")
|
||||
}
|
||||
|
||||
svc := reader.NewReaderService(&config.Config{}, zap.NewNop(), &repository.Container{})
|
||||
ctx := context.Background()
|
||||
|
||||
results := make([]*reader.SmokeChainResult, len(sources))
|
||||
sem := make(chan struct{}, max(1, *concurrency))
|
||||
var wg sync.WaitGroup
|
||||
for i, raw := range sources {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(i int, raw string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
ctxSrc, cancel := context.WithTimeout(ctx, time.Duration(*timeout)*time.Second)
|
||||
defer cancel()
|
||||
res := svc.SmokeSource(ctxSrc, raw, *key)
|
||||
results[i] = res
|
||||
status := "✓"
|
||||
if !res.OK {
|
||||
status = "✗"
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "%s %-24s [%s] hits=%d chapters=%d content=%d %s\n",
|
||||
status, truncate(res.SourceName, 24), stageCN(res), res.SearchHits, res.Chapters, res.ContentLen, res.Error)
|
||||
}(i, raw)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// 汇总
|
||||
var searchOK, infoOK, tocOK, contentOK, allOK int
|
||||
failedAt := map[string]int{}
|
||||
for _, r := range results {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
switch r.FailedAt {
|
||||
case "":
|
||||
allOK++
|
||||
searchOK++
|
||||
infoOK++
|
||||
tocOK++
|
||||
contentOK++
|
||||
case "search":
|
||||
failedAt["search"]++
|
||||
case "info":
|
||||
searchOK++
|
||||
failedAt["info"]++
|
||||
case "toc":
|
||||
searchOK++
|
||||
infoOK++
|
||||
failedAt["toc"]++
|
||||
case "content":
|
||||
searchOK++
|
||||
infoOK++
|
||||
tocOK++
|
||||
failedAt["content"]++
|
||||
}
|
||||
}
|
||||
n := len(results)
|
||||
pct := func(v int) string {
|
||||
if n == 0 {
|
||||
return "0%"
|
||||
}
|
||||
return fmt.Sprintf("%.1f%%", float64(v)/float64(n)*100)
|
||||
}
|
||||
fmt.Printf("\n==== 冒烟报告 ====\n")
|
||||
fmt.Printf("书源总数: %d 关键词: %s\n", n, *key)
|
||||
fmt.Printf("搜索通过: %d (%s)\n", searchOK, pct(searchOK))
|
||||
fmt.Printf("详情通过: %d (%s)\n", infoOK, pct(infoOK))
|
||||
fmt.Printf("目录通过: %d (%s)\n", tocOK, pct(tocOK))
|
||||
fmt.Printf("正文通过: %d (%s)\n", contentOK, pct(contentOK))
|
||||
fmt.Printf("全链路通过: %d (%s)\n", allOK, pct(allOK))
|
||||
for _, stage := range []string{"search", "info", "toc", "content"} {
|
||||
if failedAt[stage] > 0 {
|
||||
fmt.Printf(" 失败于 %s: %d\n", stageCN(&reader.SmokeChainResult{FailedAt: stage}), failedAt[stage])
|
||||
}
|
||||
}
|
||||
|
||||
if *jsonOut {
|
||||
out, err := json.MarshalIndent(results, "", " ")
|
||||
if err != nil {
|
||||
fatal("序列化报告失败: %v", err)
|
||||
}
|
||||
fmt.Println(string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func stageCN(r *reader.SmokeChainResult) string {
|
||||
switch r.FailedAt {
|
||||
case "":
|
||||
return "完成"
|
||||
case "search":
|
||||
return "搜索"
|
||||
case "info":
|
||||
return "详情"
|
||||
case "toc":
|
||||
return "目录"
|
||||
case "content":
|
||||
return "正文"
|
||||
case "parse":
|
||||
return "解析"
|
||||
default:
|
||||
return r.FailedAt
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
rs := []rune(strings.TrimSpace(s))
|
||||
if len(rs) <= n {
|
||||
if len(rs) == 0 {
|
||||
return "(未命名)"
|
||||
}
|
||||
return string(rs)
|
||||
}
|
||||
return string(rs[:n]) + "…"
|
||||
}
|
||||
|
||||
func fatal(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "reader-smoke: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/database"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// application owns the long-running MeBox server and all resources created
|
||||
// during startup. Platform entry points decide how the application is
|
||||
// controlled: a console signal loop or a Windows notification-area icon.
|
||||
type application struct {
|
||||
cfg *config.Config
|
||||
logger *zap.Logger
|
||||
embyCompatLogger *zap.Logger
|
||||
serverManager *serverManager
|
||||
services *service.Container
|
||||
|
||||
closeMu sync.Mutex
|
||||
closeFuncs []func()
|
||||
shutdown sync.Once
|
||||
shutdownErr error
|
||||
}
|
||||
|
||||
func newApplication() (*application, error) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config load failed: %w", err)
|
||||
}
|
||||
|
||||
logger, closeLogger, err := newLoggerWithCloser(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("logger init failed: %w", err)
|
||||
}
|
||||
|
||||
app := &application{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
closeFuncs: []func(){closeLogger},
|
||||
}
|
||||
if err := app.start(); err != nil {
|
||||
app.closeLoggers()
|
||||
return nil, err
|
||||
}
|
||||
return app, nil
|
||||
}
|
||||
|
||||
func (a *application) start() error {
|
||||
embyCompatLogger, closeEmbyCompatLogger, err := newEmbyCompatLogger(a.cfg)
|
||||
if err != nil {
|
||||
a.logger.Error("Emby compatibility logger init failed", zap.Error(err))
|
||||
return fmt.Errorf("Emby compatibility logger init failed: %w", err)
|
||||
}
|
||||
a.embyCompatLogger = embyCompatLogger
|
||||
a.addCloser(closeEmbyCompatLogger)
|
||||
|
||||
appVersion := effectiveVersion(version)
|
||||
a.logger.Info("starting MeBox",
|
||||
zap.String("version", appVersion),
|
||||
zap.Int("port", a.cfg.App.Port),
|
||||
zap.String("data_dir", a.cfg.App.DataDir),
|
||||
zap.String("emby_compat_log", embyCompatLogPath(a.cfg)),
|
||||
)
|
||||
|
||||
for _, dir := range []string{a.cfg.App.DataDir, a.cfg.Cache.CacheDir} {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
a.logger.Error("create dir failed", zap.String("dir", dir), zap.Error(err))
|
||||
return fmt.Errorf("create dir %s failed: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
db, err := database.Open(a.cfg, a.logger)
|
||||
if err != nil {
|
||||
a.logger.Error("database open failed", zap.Error(err))
|
||||
return fmt.Errorf("database open failed: %w", err)
|
||||
}
|
||||
if err := waitForDatabase(db, a.logger); err != nil {
|
||||
a.logger.Error("database not ready", zap.Error(err))
|
||||
return fmt.Errorf("database not ready: %w", err)
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
a.logger.Error("auto-migrate failed", zap.Error(err))
|
||||
return fmt.Errorf("auto-migrate failed: %w", err)
|
||||
}
|
||||
if err := database.MigrateSQLiteToCurrentIfNeeded(a.cfg, db, a.logger); err != nil {
|
||||
a.logger.Error("sqlite to postgres migration failed", zap.Error(err))
|
||||
return fmt.Errorf("sqlite to postgres migration failed: %w", err)
|
||||
}
|
||||
|
||||
repos := repository.New(db)
|
||||
service.ApplyRuntimeSettings(context.Background(), a.cfg, repos, a.logger)
|
||||
applyCPUThreadLimit(a.cfg, a.logger)
|
||||
a.services = service.NewWithVersion(a.cfg, a.logger, repos, appVersion)
|
||||
|
||||
// 一次性清洗历史脏数据: 老版本把单集 episode id / 单集名写进整剧字段, 导致
|
||||
// 同一部剧被拆成多张单集卡。清空被污染的字段并重置为 pending(借后续重刮修正)。
|
||||
if cleaned, err := a.services.NormalizePollutedEpisodeMetadata(context.Background()); err != nil {
|
||||
a.logger.Warn("polluted episode metadata cleanup failed", zap.Error(err))
|
||||
} else if cleaned > 0 {
|
||||
a.logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned))
|
||||
}
|
||||
|
||||
if err := a.services.Auth.SeedAdmin(context.Background()); err != nil {
|
||||
a.logger.Warn("seed admin failed", zap.Error(err))
|
||||
}
|
||||
|
||||
router := buildRouter(a.cfg, a.logger, a.embyCompatLogger, a.services)
|
||||
a.serverManager = newServerManager(a.cfg, a.logger, router)
|
||||
a.services.ReloadHTTPServer = a.serverManager.Reload
|
||||
if err := a.serverManager.Start(); err != nil {
|
||||
a.logger.Error("listen failed", zap.Error(err))
|
||||
return fmt.Errorf("listen failed: %w", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
scheme := "http"
|
||||
if a.cfg.App.HTTPSEnabled {
|
||||
scheme = "https"
|
||||
}
|
||||
if publicIP := getPublicIP(3 * time.Second); publicIP != "" {
|
||||
a.logger.Info("server public endpoint",
|
||||
zap.String("public", fmt.Sprintf("%s://%s:%d", scheme, publicIP, a.cfg.App.Port)),
|
||||
)
|
||||
}
|
||||
}()
|
||||
helper.Go(a.logger, "services.boot", a.services.Boot)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown is idempotent and safe to call from both the tray handler and the
|
||||
// systray exit callback.
|
||||
func (a *application) Shutdown() error {
|
||||
a.shutdown.Do(func() {
|
||||
a.logger.Info("shutdown requested")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
if a.serverManager != nil {
|
||||
if err := a.serverManager.Shutdown(ctx); err != nil {
|
||||
a.shutdownErr = err
|
||||
a.logger.Error("graceful shutdown failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
|
||||
if a.services != nil {
|
||||
a.services.Close()
|
||||
}
|
||||
a.logger.Info("MeBox stopped")
|
||||
_ = a.logger.Sync()
|
||||
if a.embyCompatLogger != nil {
|
||||
_ = a.embyCompatLogger.Sync()
|
||||
}
|
||||
a.closeLoggers()
|
||||
})
|
||||
return a.shutdownErr
|
||||
}
|
||||
|
||||
func (a *application) addCloser(fn func()) {
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
a.closeMu.Lock()
|
||||
a.closeFuncs = append(a.closeFuncs, fn)
|
||||
a.closeMu.Unlock()
|
||||
}
|
||||
|
||||
func (a *application) closeLoggers() {
|
||||
a.closeMu.Lock()
|
||||
closers := append([]func(){}, a.closeFuncs...)
|
||||
a.closeFuncs = nil
|
||||
a.closeMu.Unlock()
|
||||
|
||||
for i := len(closers) - 1; i >= 0; i-- {
|
||||
closers[i]()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *application) localURL() string {
|
||||
config.RuntimeMu.RLock()
|
||||
scheme := "http"
|
||||
if a.cfg.App.HTTPSEnabled {
|
||||
scheme = "https"
|
||||
}
|
||||
port := a.cfg.App.Port
|
||||
config.RuntimeMu.RUnlock()
|
||||
return fmt.Sprintf("%s://127.0.0.1:%d", scheme, port)
|
||||
}
|
||||
@@ -11,21 +11,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/database"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// version is overwritten at build time via -ldflags="-X main.version=...".
|
||||
@@ -46,105 +33,5 @@ func effectiveVersion(buildVersion string) string {
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "config load failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger, err := newLogger(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "logger init failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = logger.Sync() }()
|
||||
|
||||
embyCompatLogger, closeEmbyCompatLogger, err := newEmbyCompatLogger(cfg)
|
||||
if err != nil {
|
||||
logger.Fatal("Emby compatibility logger init failed", zap.Error(err))
|
||||
}
|
||||
defer func() {
|
||||
_ = embyCompatLogger.Sync()
|
||||
closeEmbyCompatLogger()
|
||||
}()
|
||||
|
||||
appVersion := effectiveVersion(version)
|
||||
logger.Info("starting MeBox",
|
||||
zap.String("version", appVersion),
|
||||
zap.Int("port", cfg.App.Port),
|
||||
zap.String("data_dir", cfg.App.DataDir),
|
||||
zap.String("emby_compat_log", embyCompatLogPath(cfg)),
|
||||
)
|
||||
|
||||
// Ensure data / cache / web dirs exist.
|
||||
for _, d := range []string{cfg.App.DataDir, cfg.Cache.CacheDir} {
|
||||
if err := os.MkdirAll(d, 0o750); err != nil {
|
||||
logger.Fatal("create dir failed", zap.String("dir", d), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
db, err := database.Open(cfg, logger)
|
||||
if err != nil {
|
||||
logger.Fatal("database open failed", zap.Error(err))
|
||||
}
|
||||
if err := waitForDatabase(db, logger); err != nil {
|
||||
logger.Fatal("database not ready", zap.Error(err))
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
logger.Fatal("auto-migrate failed", zap.Error(err))
|
||||
}
|
||||
if err := database.MigrateSQLiteToCurrentIfNeeded(cfg, db, logger); err != nil {
|
||||
logger.Fatal("sqlite to postgres migration failed", zap.Error(err))
|
||||
}
|
||||
|
||||
repos := repository.New(db)
|
||||
service.ApplyRuntimeSettings(context.Background(), cfg, repos, logger)
|
||||
applyCPUThreadLimit(cfg, logger)
|
||||
services := service.NewWithVersion(cfg, logger, repos, appVersion)
|
||||
|
||||
// 一次性清洗历史脏数据: 老版本把单集 episode id / 单集名写进整剧字段, 导致
|
||||
// 同一部剧被拆成多张单集卡。清空被污染的字段并重置为 pending(借后续重刮修正)。
|
||||
if cleaned, err := services.NormalizePollutedEpisodeMetadata(context.Background()); err != nil {
|
||||
logger.Warn("polluted episode metadata cleanup failed", zap.Error(err))
|
||||
} else if cleaned > 0 {
|
||||
logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned))
|
||||
}
|
||||
|
||||
if err := services.Auth.SeedAdmin(context.Background()); err != nil {
|
||||
logger.Warn("seed admin failed", zap.Error(err))
|
||||
}
|
||||
|
||||
router := buildRouter(cfg, logger, embyCompatLogger, services)
|
||||
|
||||
serverMgr := newServerManager(cfg, logger, router)
|
||||
services.ReloadHTTPServer = serverMgr.Reload
|
||||
if err := serverMgr.Start(); err != nil {
|
||||
logger.Fatal("listen failed", zap.Error(err))
|
||||
}
|
||||
go func() {
|
||||
scheme := "http"
|
||||
if cfg.App.HTTPSEnabled {
|
||||
scheme = "https"
|
||||
}
|
||||
if publicIP := getPublicIP(3 * time.Second); publicIP != "" {
|
||||
logger.Info("server public endpoint",
|
||||
zap.String("public", fmt.Sprintf("%s://%s:%d", scheme, publicIP, cfg.App.Port)),
|
||||
)
|
||||
}
|
||||
}()
|
||||
helper.Go(logger, "services.boot", services.Boot)
|
||||
|
||||
// Graceful shutdown.
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-stop
|
||||
logger.Info("shutdown requested")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := serverMgr.Shutdown(ctx); err != nil {
|
||||
logger.Error("graceful shutdown failed", zap.Error(err))
|
||||
}
|
||||
services.Close()
|
||||
logger.Info("MeBox stopped")
|
||||
runProgram()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func runProgram() {
|
||||
app, err := newApplication()
|
||||
if err != nil {
|
||||
reportError("MeBox 启动失败", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-stop
|
||||
|
||||
if err := app.Shutdown(); err != nil {
|
||||
reportError("MeBox 退出失败", err)
|
||||
}
|
||||
}
|
||||
|
||||
func reportError(title string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", title, err)
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
|
||||
"fyne.io/systray"
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/brand"
|
||||
)
|
||||
|
||||
const (
|
||||
runRegistryKey = `Software\Microsoft\Windows\CurrentVersion\Run`
|
||||
runRegistryValue = "MeBox"
|
||||
singleInstanceName = `Local\MeBox-Server`
|
||||
)
|
||||
|
||||
var errAlreadyRunning = errors.New("MeBox is already running")
|
||||
|
||||
func runProgram() {
|
||||
if err := prepareWorkingDirectory(); err != nil {
|
||||
reportError("MeBox 启动失败", fmt.Errorf("切换工作目录失败: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
instance, err := acquireSingleInstance()
|
||||
if errors.Is(err, errAlreadyRunning) {
|
||||
reportError("MeBox", errors.New("MeBox 已在运行,请查看右下角托盘图标"))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
reportError("MeBox 启动失败", fmt.Errorf("创建单实例锁失败: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
app, err := newApplication()
|
||||
if err != nil {
|
||||
_ = instance.Close()
|
||||
reportError("MeBox 启动失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
controller := &trayController{app: app}
|
||||
systray.Run(controller.onReady, controller.onExit)
|
||||
|
||||
// Release the mutex before starting the replacement process. The new
|
||||
// process must be able to acquire it immediately after the old one exits.
|
||||
_ = instance.Close()
|
||||
|
||||
if !controller.readyClosed.Load() {
|
||||
_ = app.Shutdown()
|
||||
reportError("MeBox 启动失败", errors.New("系统托盘初始化失败"))
|
||||
return
|
||||
}
|
||||
if controller.restartRequested.Load() {
|
||||
if err := launchSelf(); err != nil {
|
||||
reportError("MeBox 重启失败", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type trayController struct {
|
||||
app *application
|
||||
|
||||
readyClosed atomic.Bool
|
||||
restartRequested atomic.Bool
|
||||
shutdownStarted atomic.Bool
|
||||
}
|
||||
|
||||
func (c *trayController) onReady() {
|
||||
defer func() {
|
||||
c.readyClosed.Store(true)
|
||||
}()
|
||||
|
||||
systray.SetIcon(brand.Icon)
|
||||
systray.SetTooltip("MeBox")
|
||||
|
||||
mOpen := systray.AddMenuItem("打开 MeBox", "在浏览器中打开 MeBox")
|
||||
mAutoStart := systray.AddMenuItemCheckbox("开机自启", "登录 Windows 后自动启动 MeBox", autoStartEnabled())
|
||||
mLogs := systray.AddMenuItem("查看日志", "打开 MeBox 应用日志")
|
||||
systray.AddSeparator()
|
||||
mRestart := systray.AddMenuItem("重启 MeBox", "重启 MeBox 服务")
|
||||
mQuit := systray.AddMenuItem("退出 MeBox", "停止服务并退出")
|
||||
|
||||
initialAutoStart := mAutoStart.Checked()
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-mOpen.ClickedCh:
|
||||
if err := openURL(c.app.localURL()); err != nil {
|
||||
reportError("MeBox", fmt.Errorf("打开 MeBox 失败: %w", err))
|
||||
}
|
||||
case <-mAutoStart.ClickedCh:
|
||||
enable := !mAutoStart.Checked()
|
||||
if err := setAutoStart(enable); err != nil {
|
||||
if initialAutoStart {
|
||||
mAutoStart.Check()
|
||||
} else {
|
||||
mAutoStart.Uncheck()
|
||||
}
|
||||
reportError("MeBox", fmt.Errorf("更新开机自启设置失败: %w", err))
|
||||
continue
|
||||
}
|
||||
if enable {
|
||||
mAutoStart.Check()
|
||||
} else {
|
||||
mAutoStart.Uncheck()
|
||||
}
|
||||
initialAutoStart = enable
|
||||
case <-mLogs.ClickedCh:
|
||||
if err := c.app.openLog(); err != nil {
|
||||
reportError("MeBox", fmt.Errorf("打开日志失败: %w", err))
|
||||
}
|
||||
case <-mRestart.ClickedCh:
|
||||
c.restart()
|
||||
case <-mQuit.ClickedCh:
|
||||
c.quit()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *trayController) onExit() {
|
||||
_ = c.app.Shutdown()
|
||||
}
|
||||
|
||||
func (c *trayController) quit() {
|
||||
if !c.shutdownStarted.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
_ = c.app.Shutdown()
|
||||
systray.Quit()
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *trayController) restart() {
|
||||
if !c.shutdownStarted.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
c.restartRequested.Store(true)
|
||||
go func() {
|
||||
_ = c.app.Shutdown()
|
||||
systray.Quit()
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *application) openLog() error {
|
||||
appLog, _, _ := logFilePaths(a.cfg)
|
||||
if appLog != "" {
|
||||
if _, err := os.Stat(appLog); err == nil {
|
||||
return openPath(appLog)
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Dir(appLog), 0o750)
|
||||
return openPath(filepath.Dir(appLog))
|
||||
}
|
||||
|
||||
logDir := filepath.Join(a.cfg.App.DataDir, "logs")
|
||||
if err := os.MkdirAll(logDir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
return openPath(logDir)
|
||||
}
|
||||
|
||||
func prepareWorkingDirectory() error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exeDir := filepath.Dir(exe)
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if samePath(exeDir, cwd) || looksLikeProjectDirectory(cwd) {
|
||||
return nil
|
||||
}
|
||||
return os.Chdir(exeDir)
|
||||
}
|
||||
|
||||
func looksLikeProjectDirectory(dir string) bool {
|
||||
for _, name := range []string{"go.mod", "config.yaml", "data", filepath.Join("web", "dist")} {
|
||||
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func samePath(left, right string) bool {
|
||||
return strings.EqualFold(filepath.Clean(left), filepath.Clean(right))
|
||||
}
|
||||
|
||||
type singleInstance struct {
|
||||
handle windows.Handle
|
||||
}
|
||||
|
||||
func acquireSingleInstance() (*singleInstance, error) {
|
||||
name, err := windows.UTF16PtrFromString(singleInstanceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handle, err := windows.CreateMutex(nil, false, name)
|
||||
if errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
|
||||
if handle != 0 {
|
||||
_ = windows.CloseHandle(handle)
|
||||
}
|
||||
return nil, errAlreadyRunning
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &singleInstance{handle: handle}, nil
|
||||
}
|
||||
|
||||
func (s *singleInstance) Close() error {
|
||||
if s == nil || s.handle == 0 {
|
||||
return nil
|
||||
}
|
||||
err := windows.CloseHandle(s.handle)
|
||||
s.handle = 0
|
||||
return err
|
||||
}
|
||||
|
||||
func launchSelf() error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command(exe, os.Args[1:]...)
|
||||
cmd.Dir = cwd
|
||||
cmd.Env = os.Environ()
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS,
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
return cmd.Process.Release()
|
||||
}
|
||||
|
||||
func autoStartEnabled() bool {
|
||||
key, err := registry.OpenKey(registry.CURRENT_USER, runRegistryKey, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
value, _, err := key.GetStringValue(runRegistryValue)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(strings.Trim(value, `"`)), filepath.Clean(exe))
|
||||
}
|
||||
|
||||
func setAutoStart(enabled bool) error {
|
||||
key, _, err := registry.CreateKey(registry.CURRENT_USER, runRegistryKey, registry.SET_VALUE)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
if !enabled {
|
||||
if err := key.DeleteValue(runRegistryValue); err != nil && !errors.Is(err, registry.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return key.SetStringValue(runRegistryValue, syscall.EscapeArg(filepath.Clean(exe)))
|
||||
}
|
||||
|
||||
func openURL(url string) error {
|
||||
return shellOpen(url)
|
||||
}
|
||||
|
||||
func openPath(path string) error {
|
||||
return shellOpen(path)
|
||||
}
|
||||
|
||||
func shellOpen(target string) error {
|
||||
targetPtr, err := windows.UTF16PtrFromString(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verbPtr, err := windows.UTF16PtrFromString("open")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return windows.ShellExecute(0, verbPtr, targetPtr, nil, nil, 1)
|
||||
}
|
||||
|
||||
func reportError(title string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
text, textErr := windows.UTF16PtrFromString(title + "\r\n\r\n" + err.Error())
|
||||
if textErr != nil {
|
||||
return
|
||||
}
|
||||
caption, captionErr := windows.UTF16PtrFromString("MeBox")
|
||||
if captionErr != nil {
|
||||
return
|
||||
}
|
||||
_, _ = windows.MessageBox(0, text, caption, windows.MB_OK|windows.MB_ICONERROR|windows.MB_SETFOREGROUND)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
// Regenerate the linked Windows resources after changing the project logo or
|
||||
// manifest:
|
||||
//
|
||||
// go generate ./cmd/server
|
||||
//
|
||||
//go:generate go run github.com/akavel/rsrc@v0.10.2 -arch amd64 -ico ../../internal/brand/logo.ico -manifest winres/mebox.manifest -o rsrc_windows_amd64.syso
|
||||
//go:generate go run github.com/akavel/rsrc@v0.10.2 -arch arm64 -ico ../../internal/brand/logo.ico -manifest winres/mebox.manifest -o rsrc_windows_arm64.syso
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<assemblyIdentity
|
||||
version="1.0.0.0"
|
||||
processorArchitecture="*"
|
||||
name="MeBox"
|
||||
type="win32"
|
||||
/>
|
||||
<description>MeBox media server</description>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#faf8f5" />
|
||||
<meta name="description" content="MeBox — 个人家庭媒体中心" />
|
||||
<link rel="icon" type="image/png" href="/brand/logo-64.png" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/brand/logo-192.png" />
|
||||
<!-- 字体已自托管(见 src/index.css 的 @font-face),不再使用 Google Fonts 外链:
|
||||
自托管环境常无法访问 Google,渲染阻塞外链会拖慢首屏。 -->
|
||||
<title>MeBox</title>
|
||||
<script type="module" crossorigin src="/assets/index-CcRvbC_F.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-B0Z9INg1.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-data-CD8zvwOM.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-react-Cz-iIQjW.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-motion-FEuwvkVZ.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/auth-D3Hf5Dnr.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/createLucideIcon-nIJoh1kU.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BPz3c5Oc.css">
|
||||
</head>
|
||||
<body class="font-body antialiased">
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
@@ -0,0 +1,216 @@
|
||||
# MeBox + MetaTube 一体部署 Docker Compose 部署文件
|
||||
#
|
||||
# 组件:
|
||||
# MeBox + PostgreSQL + Redis + MetaTube
|
||||
#
|
||||
# 与 docker-compose.standard.yml 的区别:
|
||||
# 额外内置一个 MetaTube 后端(番号元数据刮削 / 封面人脸裁剪),
|
||||
# 并与 MeBox 共用同一个 PostgreSQL 实例,不需要再单独起一套数据库。
|
||||
#
|
||||
# 使用方式二选一:
|
||||
# 1. 保存为 docker-compose.yml 后执行:
|
||||
# docker compose up -d
|
||||
# 2. 保留本文件名时执行:
|
||||
# docker compose -f docker-compose.metatube.yml up -d
|
||||
#
|
||||
# 首次启动后需要做一次配置(只有这一处是手动的):
|
||||
# 登录 MeBox → 设置 → 番号刮削:
|
||||
# - 番号刮削引擎:MetaTube 后端服务,或 智能混合
|
||||
# - MetaTube 服务端地址:http://metatube:8080
|
||||
# - 访问 Token:留空(仅内部网络,未启用认证;如需认证两边填同一个值)
|
||||
#
|
||||
# 说明:
|
||||
# - MetaTube 使用独立数据库 metatube,由 metatube-db-init 一次性容器自动创建;
|
||||
# 和 MeBox 的 mebox 库共用同一个 PostgreSQL 实例,只是分成两个库。
|
||||
# - MetaTube 不向宿主机映射端口,只在本 Compose 内部网络提供服务。
|
||||
# - MeBox 故意不依赖 MetaTube:MetaTube 未就绪或挂掉时,智能混合模式会自动回退到内置刮削源。
|
||||
# - 更新全部组件:
|
||||
# docker compose -f docker-compose.metatube.yml pull
|
||||
# docker compose -f docker-compose.metatube.yml up -d
|
||||
# 保留本文件名时,管理面板「系统更新」需要把「Compose 安装目录」指向本文件所在目录,
|
||||
# 或把更新命令改成带 -f docker-compose.metatube.yml 的形式。
|
||||
#
|
||||
# 默认账号:
|
||||
# admin / admin123
|
||||
|
||||
services:
|
||||
mebox:
|
||||
image: ghcr.io/truewhile/mebox:latest
|
||||
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
ports:
|
||||
- "18080:8080"
|
||||
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
volumes:
|
||||
# 程序运行数据:JWT 密钥、运行配置、旧 SQLite 迁移源。
|
||||
- ./data:/data
|
||||
|
||||
# 缓存目录:海报缓存、临时文件等。通常不用备份。
|
||||
- ./cache:/cache
|
||||
|
||||
# 媒体库目录。自动整理/重命名/入库需要读写权限。
|
||||
# NAS 示例:source: /vol1/1000/Media
|
||||
# Windows Docker Desktop 示例:source: D:/Media
|
||||
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
|
||||
- ./media: /media
|
||||
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
|
||||
PUID: "0"
|
||||
PGID: "0"
|
||||
|
||||
MEBOX_APP_HOST: 0.0.0.0
|
||||
MEBOX_APP_PORT: 8080
|
||||
MEBOX_APP_WEB_DIR: /app/web/dist
|
||||
MEBOX_APP_DATA_DIR: /data
|
||||
MEBOX_LOGGING_LEVEL: info
|
||||
MEBOX_LOGGING_FORMAT: console
|
||||
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
||||
MEBOX_LOGGING_MAX_SIZE_MB: "20"
|
||||
MEBOX_LOGGING_MAX_BACKUPS: "5"
|
||||
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
||||
|
||||
MEBOX_DATABASE_TYPE: postgres
|
||||
MEBOX_DATABASE_DSN: postgres://mebox:mebox@postgres:5432/mebox?sslmode=disable
|
||||
MEBOX_DATABASE_DB_PATH: /data/mebox.db
|
||||
|
||||
# Redis 只做热缓存,源数据仍在 PostgreSQL;Redis 丢失可自动重建。
|
||||
MEBOX_CACHE_REDIS_URL: redis://redis:6379/0
|
||||
MEBOX_CACHE_CACHE_DIR: /cache
|
||||
|
||||
MEBOX_UPDATE_IMAGE: ghcr.io/truewhile/mebox:latest
|
||||
|
||||
# 路径换算配置。左边宿主机真实路径要和 volumes 左边保持一致。
|
||||
MEBOX_MEDIA_DIR: /media
|
||||
MEBOX_MEDIA_CONTAINER_DIR: /media
|
||||
MEBOX_DOWNLOAD_DIR: /downloads
|
||||
MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads
|
||||
|
||||
MEBOX_TRANSCODER_ENABLED: "true"
|
||||
MEBOX_TRANSCODER_HARDWARE_ACCEL: "false"
|
||||
MEBOX_TRANSCODER_REALTIME: "true"
|
||||
MEBOX_TRANSCODER_THREADS: "2"
|
||||
MEBOX_TRANSCODER_MAX_CONCURRENT: "1"
|
||||
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
# 首次部署允许拉取;日常更新请只 pull mebox。
|
||||
# MetaTube 与 MeBox 共用本实例(不同数据库),无需第二套 PostgreSQL。
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: mebox
|
||||
POSTGRES_USER: mebox
|
||||
POSTGRES_PASSWORD: mebox
|
||||
TZ: Asia/Shanghai
|
||||
volumes:
|
||||
# 同时存放 mebox 与 metatube 两个数据库,备份这一个目录即可。
|
||||
- ./postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mebox -d mebox"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
# 一次性容器:确保 metatube 库存在。
|
||||
# MetaTube 自己的 -db-auto-migrate 只建表不建库,所以必须先建库。
|
||||
# 数据库已存在时它会直接退出,重复执行无副作用。
|
||||
metatube-db-init:
|
||||
image: postgres:16-alpine
|
||||
pull_policy: missing
|
||||
restart: "no"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PGPASSWORD: mebox
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
psql -h postgres -U mebox -d mebox -tAc "SELECT 1 FROM pg_database WHERE datname='metatube'" | grep -q 1 || psql -h postgres -U mebox -d mebox -c "CREATE DATABASE metatube OWNER mebox"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "5m"
|
||||
max-file: "1"
|
||||
|
||||
metatube:
|
||||
image: ghcr.io/metatube-community/metatube-server:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
metatube-db-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
# 需要走代理时在这里填写,例如 http://192.168.1.2:7890
|
||||
HTTP_PROXY: ""
|
||||
HTTPS_PROXY: ""
|
||||
# 不需要 ports:MeBox 通过内部网络访问 http://metatube:8080。
|
||||
# 只有想让局域网内其他工具直连时才映射,且建议只映射到 127.0.0.1。
|
||||
command:
|
||||
- -dsn
|
||||
- postgres://mebox:mebox@postgres:5432/metatube?sslmode=disable
|
||||
- -port
|
||||
- "8080"
|
||||
- -db-auto-migrate
|
||||
- -db-prepared-stmt
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- redis-server
|
||||
- --appendonly
|
||||
- "yes"
|
||||
- --maxmemory
|
||||
- 256mb
|
||||
- --maxmemory-policy
|
||||
- allkeys-lru
|
||||
volumes:
|
||||
- ./redis:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
@@ -81,8 +81,8 @@ services:
|
||||
MEBOX_LOGGING_LEVEL: info
|
||||
MEBOX_LOGGING_FORMAT: console
|
||||
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
||||
MEBOX_LOGGING_MAX_SIZE_MB: "50"
|
||||
MEBOX_LOGGING_MAX_BACKUPS: "20"
|
||||
MEBOX_LOGGING_MAX_SIZE_MB: "20"
|
||||
MEBOX_LOGGING_MAX_BACKUPS: "5"
|
||||
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
||||
|
||||
MEBOX_DATABASE_TYPE: postgres
|
||||
@@ -124,8 +124,8 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
@@ -147,8 +147,8 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
@@ -172,8 +172,8 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
opensearch:
|
||||
image: opensearchproject/opensearch:2
|
||||
@@ -196,5 +196,5 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
@@ -64,8 +64,8 @@ services:
|
||||
MEBOX_LOGGING_LEVEL: info
|
||||
MEBOX_LOGGING_FORMAT: console
|
||||
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
||||
MEBOX_LOGGING_MAX_SIZE_MB: "50"
|
||||
MEBOX_LOGGING_MAX_BACKUPS: "20"
|
||||
MEBOX_LOGGING_MAX_SIZE_MB: "20"
|
||||
MEBOX_LOGGING_MAX_BACKUPS: "5"
|
||||
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
||||
|
||||
extra_hosts:
|
||||
@@ -82,5 +82,5 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
@@ -44,30 +44,13 @@ services:
|
||||
# NAS 示例:source: /vol1/1000/Media
|
||||
# Windows Docker Desktop 示例:source: D:/Media
|
||||
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
|
||||
- type: bind
|
||||
source: ./media
|
||||
target: /media
|
||||
bind:
|
||||
create_host_path: false
|
||||
|
||||
# 下载目录。需要和下载器保存路径保持一致。
|
||||
# NAS 示例:source: /vol1/1000/Downloads
|
||||
# Windows Docker Desktop 示例:source: D:/Downloads
|
||||
- type: bind
|
||||
source: ./downloads
|
||||
target: /downloads
|
||||
bind:
|
||||
create_host_path: false
|
||||
|
||||
# 管理面板「系统更新」需要访问 Docker 引擎。
|
||||
# 需要一键更新 Docker 镜像时取消下一行注释。
|
||||
# - /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./media: /media
|
||||
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
|
||||
PUID: "1000"
|
||||
PGID: "1000"
|
||||
PUID: "0"
|
||||
PGID: "0"
|
||||
|
||||
MEBOX_APP_HOST: 0.0.0.0
|
||||
MEBOX_APP_PORT: 8080
|
||||
@@ -76,8 +59,8 @@ services:
|
||||
MEBOX_LOGGING_LEVEL: info
|
||||
MEBOX_LOGGING_FORMAT: console
|
||||
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
||||
MEBOX_LOGGING_MAX_SIZE_MB: "50"
|
||||
MEBOX_LOGGING_MAX_BACKUPS: "20"
|
||||
MEBOX_LOGGING_MAX_SIZE_MB: "20"
|
||||
MEBOX_LOGGING_MAX_BACKUPS: "5"
|
||||
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
||||
|
||||
MEBOX_DATABASE_TYPE: postgres
|
||||
@@ -113,8 +96,8 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
@@ -136,8 +119,8 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
@@ -161,5 +144,5 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
@@ -92,8 +92,8 @@ services:
|
||||
MEBOX_LOGGING_LEVEL: info
|
||||
MEBOX_LOGGING_FORMAT: console
|
||||
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
|
||||
MEBOX_LOGGING_MAX_SIZE_MB: "50"
|
||||
MEBOX_LOGGING_MAX_BACKUPS: "20"
|
||||
MEBOX_LOGGING_MAX_SIZE_MB: "20"
|
||||
MEBOX_LOGGING_MAX_BACKUPS: "5"
|
||||
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
||||
|
||||
# 轻量模式默认只使用 PostgreSQL,适合大多数 NAS。
|
||||
@@ -141,8 +141,8 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
@@ -166,5 +166,5 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
# 【开源推荐】MeBox:把 NAS / 网盘 / 远程 Emby 统一家里的观影入口,Docker 一键部署
|
||||
|
||||
> 配图已托管在 GitHub 仓库(`raw.githubusercontent.com` 直链),发帖时可直接引用,或下载 `docs/tutorial-screenshots/` 后作为附件上传。
|
||||
|
||||
---
|
||||
|
||||
## 写在前面
|
||||
|
||||
给论坛的朋友们推荐一个我维护的开源项目 —— **MeBox**,一个面向 NAS 与家庭影音场景的**自托管私人媒体中心**(GPL-3.0,Go + React)。
|
||||
|
||||
GitHub:https://github.com/truewhile/MeBox
|
||||
|
||||
一句话介绍:**部署一个服务,同时获得媒体库后台、网盘 STRM 整理、Emby 客户端协议网关三件套。** 内置完整 Emby/Jellyfin 服务端协议实现——手机、电视、平板上的 Infuse、SenPlayer、Fileball、Emby/Jellyfin 官方客户端直接「添加 Emby 服务器」就能连,一套账号体系全搞定,Emby 老用户零学习成本。
|
||||
|
||||
项目 fork 自 MediaStationGo 并持续二开,围绕网盘播放、任务队列、远程挂载和权限体系做了大量增强。
|
||||
|
||||
---
|
||||
|
||||
## 它能解决什么问题?
|
||||
|
||||
家里看电影电视的痛点,MeBox 基本一把梭:
|
||||
|
||||
| 痛点 | MeBox 的解法 |
|
||||
| --- | --- |
|
||||
| 硬盘散落各处,海报墙乱七八糟 | 多根目录媒体库 + TMDb/Bangumi/Douban 自动刮削,海报墙、继续观看、多季剧集一应俱全 |
|
||||
| 网盘资源看一部下一部太麻烦 | OpenList / CloudDrive2 / 115 / WebDAV 接入,STRM 同步 + 直链/302 播放,不占本地空间 |
|
||||
| 已经有一台 Emby,出门还得开 App | **远程 Emby 挂载**:把远程 Emby 的媒体库直接挂进 MeBox 界面统一浏览 |
|
||||
| 家人乱动设置、小孩看不该看的 | 多用户 + 有效期 + 成人内容开关 + 播放配置 PIN,细粒度权限 |
|
||||
| 每个设备装一套专属 App 太折腾 | **完整兼容 Emby/Jellyfin 客户端**:Infuse、SenPlayer、Fileball、官方客户端按「添加 Emby 服务器」填地址 + MeBox 账号即可,海报墙、观看进度、多用户直接同步 |
|
||||
|
||||
---
|
||||
|
||||
## 特点一览
|
||||
|
||||
**1. 现代化 Web UI,海报墙开箱即用**
|
||||
|
||||

|
||||
|
||||
深色系登录页,默认账号 `admin / admin123`(首次登录请立即改密)。
|
||||
|
||||

|
||||
|
||||
首页自带焦点推荐轮播 + 媒体库入口卡片,继续观看、最近添加直接呈现。
|
||||
|
||||
**2. 媒体库与刮削**
|
||||
|
||||

|
||||
|
||||
20 个媒体库、1600+ 条目一眼尽收:每库自带封面拼贴、条目数统计,支持「全库修复+重刮」「刮削队列」批量处理。
|
||||
|
||||

|
||||
|
||||
库内海报墙带评分、集数角标,支持按最后集添加日期排序,点开即看。
|
||||
|
||||
**3. 详情页与多季管理**
|
||||
|
||||

|
||||
|
||||
剧情简介、类型标签、多季分集(特别篇/第 1-N 季)、每集缩略图与时长;一键立即播放、调用外部播放器、加入收藏。
|
||||
|
||||
**4. Emby/Jellyfin 客户端无缝兼容**
|
||||
|
||||
这是我最想强调的一点:**MeBox 内置了完整的 Emby 服务端协议实现**。手机、电视、平板上的 Infuse、SenPlayer、Fileball,甚至 Emby/Jellyfin 官方客户端,都不需要任何插件或改造——按「添加 Emby 服务器」填入 `http://服务器IP:18080`,用 MeBox 账号登录,海报墙、观看进度、收藏、多用户权限全部无缝衔接。已经习惯 Emby 生态的朋友可以零成本迁移,家人只用电视端 App 也完全无感。
|
||||
|
||||
**5. 网页播放器 + 弹幕自动匹配**
|
||||
|
||||

|
||||
|
||||
内置网页播放器支持 HLS 转码、字幕、播放配置档;**弹幕按剧名自动匹配全季分集**(截图中自动匹配到《一拳超人》39 集),屏幕占比/透明度/字号随意调,追新番体验直接拉满。
|
||||
|
||||
**6. 网盘 STRM:网盘当本地盘用**
|
||||
|
||||

|
||||
|
||||
添加网盘账号(**115 支持二维码扫码登录**)→ 添加同步目录 → 系统把网盘/本地目录里的视频生成 `.strm` 文件,元数据经下载/上传队列双向同步,播放走直链/302 不落盘。
|
||||
|
||||
**7. 远程 Emby 挂载(特色功能)**
|
||||
|
||||

|
||||
|
||||
已有远程 Emby 服务器?填一次账号,按需勾选要挂载的媒体库(支持同服务器多线路自动切换、直连开关、排序),远程库直接出现在 MeBox 首页,不必再开 Emby 客户端。
|
||||
|
||||
**8. 任务队列统一管理**
|
||||
|
||||

|
||||
|
||||
刮削 / 下载 / 上传三类任务统一看板,排队中、进行中、已匹配、失败分类计数,支持搜索与批量清理。
|
||||
|
||||
**9. 下载与自动整理**
|
||||
|
||||

|
||||
|
||||
配合任意下载器(qBittorrent、Transmission 等下载到本地目录即可),MeBox 定时自动整理入媒体库:智能分类子库、自动注册目的地媒体库、复制/移动/硬链/软链多种整理方式,命名规则可配。
|
||||
|
||||
**10. 多用户与权限**
|
||||
|
||||

|
||||
|
||||
管理员/普通用户分级、单实例用户数上限、账号有效期、成人内容开关、播放配置 PIN——给家人开号放心给。
|
||||
|
||||
**11. 运维省心**
|
||||
|
||||

|
||||
|
||||
FFmpeg/FFprobe 一键下载安装、转码与硬件加速开关、TMDb 语言、识别词、弹幕、Adult/NSFW 开关全在设置页分组管理;另有 DLNA 投屏、存储统计、海报墙聚合视图:
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 使用教程:从零到海报墙只要 5 步
|
||||
|
||||
### 第 1 步:Docker 一键部署
|
||||
|
||||
推荐 Docker Compose(仓库提供 4 份互相独立的完整模板,无需 `.env`):
|
||||
|
||||
```bash
|
||||
mkdir -p MeBox && cd MeBox
|
||||
|
||||
# 最省心:单镜像 + 内置 SQLite
|
||||
curl -fsSL https://raw.githubusercontent.com/truewhile/MeBox/main/docker-compose.simple.yml -o docker-compose.yml
|
||||
|
||||
# 多用户/大数据量可选 PostgreSQL 档、Redis 档、OpenSearch 档,见仓库 README「部署档位」
|
||||
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
浏览器访问 `http://服务器IP:18080`,镜像:`ghcr.io/truewhile/mebox:latest`(amd64 / arm64 都有,也提供 Windows/Linux/macOS 单文件可执行程序,不想装 Docker 直接下载跑)。
|
||||
|
||||
### 第 2 步:登录并修改密码
|
||||
|
||||
默认账号 `admin / admin123`,登录后右上角头像 → 个人资料修改密码。
|
||||
|
||||
### 第 3 步:创建媒体库 + 扫库
|
||||
|
||||
后台 → 媒体库 → 管理媒体库,添加本地路径(Docker 部署记得填**容器内**路径,如 `/media/电影`,`volumes` 左侧挂宿主机真实目录)→ 执行扫库。
|
||||
|
||||
### 第 4 步:配置元数据刮削
|
||||
|
||||
系统设置 → 外部 API,填入 TMDb / Bangumi / Douban 等 API Key;媒体库页可对单个库「全库修复+重刮」,刮削进度在任务队列实时可见。
|
||||
|
||||
### 第 5 步(可选但强烈推荐):
|
||||
|
||||
- **网盘用户**:STRM 管理 → 添加网盘账号(115 可扫码)→ 添加同步目录 → 生成 STRM 后直链播放;
|
||||
- **已有 Emby**:Emby 挂载 → 添加 Emby 账号 → 勾选要挂载的媒体库;
|
||||
- **第三方播放器(Emby 客户端全兼容)**:Infuse / SenPlayer / Fileball / Emby、Jellyfin 官方客户端,按「添加 Emby 服务器」填 `http://服务器IP:18080`,用 MeBox 账号登录即可,原有使用习惯完全不变;
|
||||
- **下载党**:qBittorrent 等任意下载器把视频下到下载目录,在文件管理里把它设为整理源,下完自动分类入库。
|
||||
|
||||
### 路径映射小抄(Docker 最常见坑)
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /vol1/1000/Media:/media # 左:宿主机真实路径;右:容器内路径(网页里填这个)
|
||||
environment:
|
||||
MEBOX_MEDIA_DIR: /vol1/1000/Media
|
||||
MEBOX_MEDIA_CONTAINER_DIR: /media
|
||||
```
|
||||
|
||||
硬链接要求同一文件系统/子卷,跨盘请改复制或软链。
|
||||
|
||||
---
|
||||
|
||||
## 部署档位怎么选?
|
||||
|
||||
| 档位 | 文件 | 组件 | 适合 |
|
||||
| --- | --- | --- | --- |
|
||||
| 极简 | `docker-compose.simple.yml` | 单镜像 + SQLite | 个人使用、低配设备 |
|
||||
| 标准 | `docker-compose.yml` | + PostgreSQL | 多用户家庭共享 |
|
||||
| 增强 | `docker-compose.standard.yml` | + Redis | 大媒体库高频访问 |
|
||||
| 搜索 | `docker-compose.search.yml` | + OpenSearch | 超大库全文搜索 |
|
||||
|
||||
---
|
||||
|
||||
## 技术栈与致谢
|
||||
|
||||
- 后端:Go · Gin · GORM · SQLite/PostgreSQL · 可选 Redis / OpenSearch
|
||||
- 前端:React 18 · Vite · TypeScript · Tailwind CSS · Zustand
|
||||
- 部署:Docker Compose 多档模板,amd64/arm64 镜像 + 单文件可执行
|
||||
|
||||
感谢上游 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) 的奠基,网盘同步/STRM/整理部分参考了 [qmediasync](https://github.com/qicfan/qmediasync) 的思路。
|
||||
|
||||
---
|
||||
|
||||
## 链接
|
||||
|
||||
- GitHub:https://github.com/truewhile/MeBox
|
||||
- Issue / PR:欢迎提 bug(附部署方式+复现步骤+日志)与功能建议
|
||||
- License:GPL-3.0
|
||||
|
||||
觉得有用的话求个 Star ⭐,也欢迎论坛里的朋友反馈使用体验,我长期维护。
|
||||
@@ -0,0 +1,144 @@
|
||||
# MeBox 阅读功能实施计划(legado 书源兼容)
|
||||
|
||||
> 分支:`feature/reading`
|
||||
> 目标:在首页增加「影视 / 阅读」模式切换,阅读模式完整兼容阅读 3.0(legado)书源体系,覆盖 **文本(bookSourceType=0)、音频(=1)、漫画/图片(=2)** 三类源。
|
||||
> 实现方式(用户明确要求):**样式与逻辑全部仿造 refgd/legado 本体**,不参考其他重实现项目;相当于用 Go + React 18 + TypeScript 5 重写该项目。
|
||||
> 界面与交互的唯一规格:`docs/reader-ui-spec.md`(从 legado 源码逐屏调研产出)。
|
||||
> 规则引擎的唯一语义基准:legado `app/src/main/java/io/legado/app/model/analyzeRule/` 源码,Go 侧逐方法移植对拍(源码克隆在 `C:\MyProject\_ref\legado`,仅作对照,不进入构建)。
|
||||
|
||||
## 1. 范围
|
||||
|
||||
**做:**
|
||||
- 首页「影视/阅读」切换,阅读模式下有独立首页(书架/搜索/发现/最近阅读)
|
||||
- legado 书源导入与管理(URL 导入、文本/JSON 粘贴导入、启停、分组、排序)
|
||||
- 三类书源的完整链路:搜索 → 详情 → 目录 → 正文/播放列表/图片列表
|
||||
- Go 侧规则引擎:CSS(jsoup 风格) / JSONPath / XPath / 正则 / 内嵌 JS 五种语法及其组合
|
||||
- 文本阅读器、音频播放器、漫画阅读器三套前端 UI
|
||||
- 追更、阅读进度同步(服务端存储,多端一致)、换源、替换净化规则
|
||||
|
||||
**不做(本期明确排除):**
|
||||
- `webView` 类规则(需要无头浏览器,识别后标记该源为不兼容并提示)
|
||||
- `webView` 真人校验类登录(loginUrl 走 WebView 人工过验证码的场景;
|
||||
纯 JS / 表单类登录已支持,见 P4.5)
|
||||
- RSS/订阅源、TTS 朗读、文件类型源(bookSourceType=3)
|
||||
- 本地 TXT/EPUB 导入(列为后续可选)
|
||||
|
||||
## 2. 总体架构
|
||||
|
||||
沿用 MeBox 现有分层,全部新增代码集中在:
|
||||
|
||||
```
|
||||
internal/
|
||||
model/ # 新增 5 张表,注册进 AllModels() 自动迁移
|
||||
repository/ # reader 相关 GORM 封装
|
||||
service/reader/ # 规则引擎 + 书源业务(核心新增,预计占全部后端代码 70%)
|
||||
handler/ # /api/reader/* 路由组
|
||||
web/src/
|
||||
pages/reader/ # 阅读端独立页面群(懒加载路由)
|
||||
components/reader/
|
||||
```
|
||||
|
||||
**基建复用**:`internal/helper/http.go`(浏览器 UA + 代理回退 HTTP 客户端)、`internal/service/runtime_cache.go`(正文/目录/搜索缓存,内存+Redis)、`internal/service/image_proxy*`(封面与漫画图片代理)、`internal/handler/ws.go` 的 WSHub(搜索进度、追更任务推送,新增 `reader:*` topic)。
|
||||
|
||||
**数据流**:书源 JSON 存库 → 搜索/发现时按启用的源并发抓取(errgroup + 信号量限流,超时熔断)→ 结果聚合 → 前端。正文、播放地址、图片列表由服务端组装(含 `nextContentUrl` 翻页合并)后带 TTL 缓存下发;音频流与漫画图片按需经服务端代理补 UA/Referer 头。
|
||||
|
||||
## 3. 规则引擎(核心工作)
|
||||
|
||||
语义基准:gedoor/legado `app/src/main/java/io/legado/app/model/analyzeRule/` 下的 AnalyzeRule / AnalyzeByJSoup / AnalyzeByJSonPath / AnalyzeByRegex / AnalyzeUrl,逐项对拍测试。
|
||||
|
||||
### 3.1 组件与选型
|
||||
|
||||
| 组件 | 选型 | 说明 |
|
||||
|---|---|---|
|
||||
| HTML/CSS | `PuerkitoBio/goquery` | jsoup 等价物;jsoup 特有语法(class.x / id.x / tag.x / text.x / children / @text / @textNodes / @html / 属性选择)自己包一层 |
|
||||
| XPath | `antchfx/htmlquery` | 对齐 JsoupXpath 语义 |
|
||||
| JSONPath | `PaesslerAG/jsonpath`(备选 ohler55/ojg) | Jayway 语义 + 自实现 `\|\|`/`&&` 合并层,选型阶段需验证 |
|
||||
| 正则 | Go regexp(RE2) | legado 部分源用 Java 正则语法,回退换 `dlclark/regexp2` |
|
||||
| JS | `dop251/goja` | ES2017+,跑书源内嵌 JS |
|
||||
| 字符集 | `golang.org/x/text` | GBK/GB18030 解码 |
|
||||
|
||||
### 3.2 引擎能力清单
|
||||
|
||||
- 规则模式识别:`@css:` / `$.`(JSONPath) / `@XPath:`或`//` / `<js>`与`@js:` / `##` 正则替换段
|
||||
- 列表组合符 `&&` / `||` / `%%`,变量存取 `@put:{}` / `@get:{}`,内嵌 JS `{{ }}`
|
||||
- jsoup 分析器、JSONPath 分析器、XPath 分析器、正则分析器,以及混合规则的链式解析(AnalyzeRule 的分段执行语义)
|
||||
- AnalyzeUrl:`{{key}}`/`{{page}}` 变量、`<js>` 生成 URL、URL 后 `,{...}` 选项(method/body/charset/headers/retry/timeout/type/proxy/js/webView)
|
||||
- JS 沙箱:goja 运行时 + 执行超时中断 + 禁止直接 IO;上下文注入 `java`、`source`、`book`、`baseUrl`、`result` 等对象
|
||||
- `java.*` 桥接函数(按书源实际使用频率分批实现):
|
||||
- 网络:ajax / ajaxAll / connect / get / post / head
|
||||
- 编解码:base64Decode/Encode(含 URL-safe)、hexDecode、encodeURI/decodeURI、htmlDecode
|
||||
- 加解密:md5(16/32)、sha1/sha256、AES/DES/3DES/RSA(CBC/ECB + 常见 padding/key 语义,legado 源里最常见的坑)
|
||||
- 字符串与时间:replaceAll/substring/正则族、timeFormat 等
|
||||
- 规则回调:`java.getString/getElement` 等,桥回 Go 规则引擎(JS 与规则互相嵌套的关键)
|
||||
|
||||
### 3.3 兼容策略
|
||||
|
||||
- 引擎按能力分层实现,每个能力配真实书源样本的单测(fixtures 放 `internal/service/reader/testdata/`)
|
||||
- 提供 CLI 冒烟工具(如 `cmd/reader-smoke`):对批量导入的公开书源集跑 搜索/详情/目录/正文 全链路,输出成功率报告,作为每个阶段验收依据
|
||||
- 含 `webView` 选项的源直接判定不兼容并在书源管理页标注
|
||||
|
||||
## 4. 数据模型(新增表)
|
||||
|
||||
| 表 | 关键字段 |
|
||||
|---|---|
|
||||
| book_sources | name, group, type(0/1/2), source_url, json(原文), enabled, custom_order, last_check_at, comment |
|
||||
| books(书架) | source_url, book_url, name, author, cover_url, intro, kind, type(文本/音频/图片), latest_chapter, total_chapters, last_read_chapter_index, last_read_at |
|
||||
| book_chapters | book_id, index, title, url, is_volume, update_time |
|
||||
| read_progress | book_id(唯一), chapter_index, position(滚动/秒/图片序), updated_at |
|
||||
| replace_rules | name, find, replace, scope, is_regex, enabled, order |
|
||||
|
||||
阅读器显示设置(主题/字体/翻页方式)存前端 localStorage,不上服务端。
|
||||
|
||||
## 5. API 设计(/api/reader/*)
|
||||
|
||||
- 书源:`GET/POST/DELETE /sources`、`POST /sources/import`(URL 或 JSON/base64 文本,自动识别格式与类型)、`PATCH /sources/:id`(启停/排序)
|
||||
- 搜索:`POST /search {keyword}` → 后台聚合任务,结果经 WS `reader:search` 增量推送;结果可一键加入书架
|
||||
- 发现:`GET /explore?source=&group=`(解析 exploreUrl 的 `分组名::url` 结构)
|
||||
- 书架:`GET/POST/DELETE /books`、`GET /books/:id/info`、`GET /books/:id/toc`、`POST /books/:id/refresh`(追更)
|
||||
- 本地书籍:`POST /local/books`(multipart 上传 TXT/EPUB,导入即入书架)
|
||||
- 内容:`GET /books/:id/chapters/:idx/content` —— 按书籍类型返回:
|
||||
- 文本:`{type:"text", content:"..."}`(服务端已合并 nextContentUrl 翻页、已应用替换规则)
|
||||
- 音频:`{type:"audio", tracks:[{url,title}]}`(含代理路径与所需请求头)
|
||||
- 图片:`{type:"image", images:[{url, style}]}`(同样经代理)
|
||||
- 进度:`PUT /books/:id/progress`
|
||||
- 替换规则:`/replace-rules` CRUD
|
||||
- 调试:`POST /debug {source_id, rule, url}`(书源调试器后端)
|
||||
- 图片/流代理:复用现有 image_proxy / stream_proxy 模式,按源配置注入 UA/Referer
|
||||
|
||||
## 6. 前端设计
|
||||
|
||||
- **首页切换**:`HomePage.tsx` 顶部加分段控件(仿 `LibraryTagBar` tab 模式),「阅读」切到阅读首页;选择持久化(zustand + localStorage)
|
||||
- **阅读首页**:继续阅读横排 + 书架封面网格 + 搜索入口 + 追更提示
|
||||
- **页面群**(懒加载,仿 `appRoutes.tsx`):`/reader`(首页)、`/reader/search`(多源并发搜索 + 实时进度)、`/reader/explore`、`/reader/book/:id`(详情 + 目录 + 换源)、`/reader/sources`(书源管理 + 调试器)
|
||||
- **三套阅读器**:
|
||||
- 文本:滚动 + 分页双模式(CSS 分栏测量分页)、主题(含夜间)、字体/行距/边距、点击翻页区、章节预加载、进度上报
|
||||
- 音频:`hls.js`(已是依赖)+ `<audio>` 兜底,播放列表、倍速、记忆进度、锁屏/息屏策略
|
||||
- 漫画:上下滚动 / 左右翻页双模式、相邻图片预加载、`imageStyle` 支持
|
||||
|
||||
## 7. 阶段划分
|
||||
|
||||
| 阶段 | 内容 | 交付物 |
|
||||
|---|---|---|
|
||||
| P0 引擎地基 ✅ | 规则引擎核心(四分析器 + 规则拆分/组合/变量)+ AnalyzeUrl v1(GET/POST/charset/headers/变量/页码模式)+ 表结构 + 书源导入/管理 API + 搜索/详情/目录/正文/书架/进度/调试 API | 已完成:`internal/service/reader/rule/`(规则引擎,~2800 行,对齐 AnalyzeRule/AnalyzeByJSoup/AnalyzeByJSonPath/AnalyzeByXPath/AnalyzeByRegex/AnalyzeUrl/RuleAnalyzer)+ 服务层 + `/api/reader/*` 路由 + 单测/端到端测试全绿 |
|
||||
| P1 文本源全链路 + 首页切换 | 搜索聚合(WS 进度)/详情/目录/正文(nextContentUrl 合并、缓存)+ 前端首页切换、书架、搜索、详情、文本阅读器 v1(阅读器样式仿 legado:9 宫格点击、主题、翻页动画) | 用纯规则型文本源完成「搜书→加入→阅读」全流程 |
|
||||
| P2 JS 与兼容率爬坡 ✅ | goja 接入 + `java.*` 桥(网络/编解码/摘要/对称加密全家桶/规则回调,函数名对齐 JsExtensions)+ URL 规则 JS(analyzeJs/{{}}/js/bodyJs)+ cookie jar + 用户替换净化规则(含正则超时保护)+ 替换净化页 + 结构化冒烟链路(SmokeChain)+ `cmd/reader-smoke` 冒烟 CLI | JS 源可用;冒烟 CLI 跑公开书源集出各阶段通过率报告 |
|
||||
| P3 音频源 ✅ | 正文按音频类型返回播放列表(绝对化)+ `/api/reader/media` 签名媒体代理(HMAC 防滥用、Range 透传支持拖动、m3u8 分片/密钥地址重写)+ 听书面板(hls.js 播 m3u8、直链 `<audio>`、上一章/-15s/播放暂停/+15s/下一章、章节选择抽屉、定时关闭 0–180 分钟、倍速 0.5–3.0、跳过片头片尾按书持久化 `PUT /api/reader/books/:id/audio-config`、进度按秒记忆、播完/片尾到点自动下一章) | 音频源可听 |
|
||||
| P4 漫画/图片源 ✅ | 图片列表绝对化 + 经签名代理(带书源 Referer 防盗链头)+ 漫画阅读器(上下滚动/左右单页双模式、图片懒加载与加载失败占位、点击分区翻页/呼菜单、菜单进度条按图片序号、进度按图片序号记忆、下一章预取)+ imageStyle 透传 | 漫画源可看 |
|
||||
| P4.5 书源登录 ✅ | `jsLib` 一次装载(对应 legado SharedJsScope)+ `source.*` 会话方法(getVariable/setVariable/getLoginInfo/putLoginInfo/getLoginHeader/putLoginHeader/get/put)+ `cookie.*`(getCookie/setCookie/replaceCookie/removeCookie/getKey,按 eTLD+1 隔离)+ 请求自动携带 Cookie 与 loginHeader + `loginUrl`/`loginUi`(解析表单 → 按钮 action 拼在 loginUrl 后执行 → result 为表单值)+ `loginCheckJs`(会话失效自动重登/重取)+ 源变量落库 + 登录信息/Cookie 加密存储 + 服务端 toast/startBrowser 回传前端 + 登录面板与变量编辑器 | 登录类书源可登录、可留存登录态 |
|
||||
| P4.6 本地书籍 ✅ | `POST /api/reader/local/books` 上传 TXT / EPUB(上限 64MB)→ 落盘 `data/reader/local/<bookID>.<txt\|epub>` + 解析目录入 `reader_chapters` + 落库为 `origin=""`、`is_local=true` 的书架条目;TXT 自动识别 BOM/UTF-8/GBK/Big5/UTF-16 并按 legado 默认 TXT 目录规则切章(RE2 无 lookbehind,改行首锚定 + 句子启发式过滤),章定位信息存字节区间 `start:end`,读章只读该区间;EPUB 走 container.xml → OPF spine,标题优先取 NCX/NAV,正文去标签与实体;移出书架同步删落盘文件;同名重复导入覆盖更新并尽量保留进度 | 本地书可上传、可读、可删 |
|
||||
| P5 体验完善 | 换源(ChangeBookSourceDialog 四档排序)、追更(定时刷新目录 + 缓存清理)、发现页(exploreUrl 标签条)、阅读器高级设置(页眉页脚提示、点击区域自定义)、书源编辑器六 Tab、备份导出 | 完整体验 |
|
||||
|
||||
P0–P2 是主体(约全部工作量 60–70%),P3/P4 相对独立可并行。
|
||||
|
||||
## 8. 风险与对策
|
||||
|
||||
- **书源质量参差**:单源解析全程超时与重试上限;正文翻页循环设页数上限防死循环
|
||||
- **goja 性能**:复杂 JS 源解析慢 → 正文/目录强缓存、单源并发限 1、全局执行预算
|
||||
- **Java 正则/JSoup 细节差异**:以对拍测试驱动修正,regex 备选 regexp2
|
||||
- **热源防盗链**:图片与音频一律走带请求头的代理
|
||||
- **合规**:不内置任何书源,全部由用户自行导入,管理页附免责说明
|
||||
|
||||
## 9. 参考
|
||||
|
||||
- **唯一语义与样式基准**:https://github.com/refgd/legado (规则引擎 analyzeRule 源码 + UI 布局/交互,见 docs/reader-ui-spec.md)
|
||||
- 书源规则教程:https://mgz0227.github.io/The-tutorial-of-Legado/
|
||||
@@ -0,0 +1,252 @@
|
||||
# legado UI/交互仿制规格(React 18 + Tailwind Web 版实现依据)
|
||||
|
||||
> 来源:对 `refgd/legado`(克隆于 `C:\MyProject\_ref\legado`)源码的调研。
|
||||
> 路径缩写:`<src>` = `app/src/main/java/io/legado/app/`,布局在 `app/src/main/res/layout/`。
|
||||
|
||||
## 1. 首页/书架
|
||||
|
||||
文件:`<src>ui/main/bookshelf/`(BookshelfFragment、BooksAdapterGrid/List)、`fragment_bookshelf1/2.xml`、`item_bookshelf_grid*.xml`、`item_bookshelf_list*.xml`。主界面底部 BottomNavigation(书架/发现/我的)+ 右上悬浮搜索按钮。
|
||||
|
||||
**布局结构(从上到下)**
|
||||
- style1(标签页风格):标题行(书架标题 + 下拉箭头,点开分组切换菜单)→ 搜索按钮 + 更多按钮 → 一级分组 tab 条(tab 长按可删分组)→ 次级分组扩展标签 → ViewPager(每 tab 一个书籍列表/网格)。
|
||||
- style2(单 RecyclerView 混排):title_bar → SwipeRefresh → 书籍列表(根分组时「分组网格项 + 书籍项」混排,进入分组只显示书籍)→ 空态文案。
|
||||
- 网格项:封面 + 本地角标 + 未读数角标 + 加载中 + 书名 + 长按遮罩。
|
||||
- 列表项:封面 + 本地图标 + 有更新标记 + 未读数 + 书名 + 作者 + 最后更新时间 + 当前读到(章节名)+ 最新章节(章节名)+ 长按遮罩。
|
||||
|
||||
**交互逻辑**
|
||||
- 内置分组 id:全部(IdRoot=-100)、本地(IdLocal=-2)、未分组(IdUngrouped=-4),其余用户分组。
|
||||
- 单击书 → 直接进入阅读;单击分组 → 进入该分组。
|
||||
- 长按书 → 跳转书籍详情页;长按分组 → 重命名/删除对话框;style1 长按 tab 删除分组。
|
||||
- 右上菜单:搜索、Wi-Fi 传书、刷新目录、书架布局切换、分组管理、导出/导入书架、下载离线、本地导入、网址添加、日志。
|
||||
|
||||
**MeBox 的「影视 / 阅读」首页模式**(`stores/readerSettings.homeMode` + 顶栏 `LayoutReaderModeToggle`)
|
||||
- 切换入口在**顶栏**,位于搜索框与账号菜单之间,**只显示图标不显示文字**:影视模式显示场记板图标,阅读模式显示书图标(并带品牌色高亮),点一下切到另一个模块、图标随之变化。悬停提示写明「当前是 X 模式,点击切换到 Y」,不在首页时点击会先跳回 `/`。
|
||||
- 两种模式共用一个首页路由 `/`:影视模式渲染媒体首页,阅读模式渲染书架(`ReaderHomeContent embedded`)。首页内容区不再放分段式「影视 / 阅读」开关(原 `ReaderModeSwitch` 仅留给独立布局的 `/reader` 首页兜底)。
|
||||
- 切到阅读模式时,顶部搜索框换成**书搜索**(`LayoutHeaderBookSearch`,占位「搜索书籍…」):聚焦即下拉书架(本地即时过滤书名/作者,点条目进阅读器),回车优先打开书架首条;下拉底部固定一行「在书源中搜索「xxx」」,带 `?key=` 跳到 `/reader/search` 由多源聚合搜索页自动开搜。账号/主题菜单照常显示。
|
||||
- 阅读模式下移动端底部导航(首页/媒体库/收藏/列表/更多)隐藏,避免影视导航混进书架。
|
||||
- `/reader/*` 本来就是独立全屏布局(不套影视 Layout),不受影响。
|
||||
|
||||
**MeBox 书架已实现的布局与信息(`ReaderHomeContent.tsx` + `BookshelfCard.tsx` + `BookshelfSettingsDialog.tsx`)**
|
||||
- 三套布局,设置在顶栏「书架设置」弹窗里(对应 legado dialog_bookshelf_config),存 `stores/readerSettings.ts`:
|
||||
- 网格:封面宫格,列数 2–6 或「自适应」;窄屏自动降到 2–3 列兜底。卡片含封面 + 本地角标 + 未读章数徽标 + 双行书名 + 详情/移出按钮。
|
||||
- 列表:66×90 封面 + 书名 + 作者 + 更新时间 + 读到(当前章)+ 最新章节;对应 legado `item_bookshelf_list.xml` 的四行信息。
|
||||
- 紧凑列表:48×64 小封面 + 书名 +「作者 · 读到」,一屏放更多书(对应 legado `layout_list_compact`)。
|
||||
- 排序六档,对齐 legado `AppConfig.getBookSortByGroupId`:最近阅读(dur_chapter_time,默认)/ 最近更新(latest_chapter_time)/ 综合 / 按书名 / 按作者 / 手动顺序(order)。顶栏有快捷排序下拉,弹窗里也可选。
|
||||
- 显示项开关:未读章数徽标、更新时间。
|
||||
- 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。返回 `total/updated/unchanged/failed` 四档——抓到但章数没变(完结书)记 `unchanged`「已是最新」,只有抓取或写入真的失败才记 `failed`,避免把正常结果报成「更新失败」;失败会打 Warn 日志(含书名与书源)。
|
||||
- 未实现(与 legado 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载。
|
||||
|
||||
**书架分组(`/reader/book-groups`,仿影视模块的媒体库标签)**
|
||||
- 按用户存服务端:`reader_book_groups` 表每个用户一条,`groups` 列是 `[{name, book_ids}]` 的 JSON。结构、语义与影视的媒体库标签(`User.LibraryTags`)同构——组名 → 成员 ID、整份替换、一个成员只归一个组、组内顺序即展示顺序。
|
||||
- 接口:`GET /reader/book-groups`(没有分组返回空数组)、`PUT /reader/book-groups`(整份替换)。服务端落库前做两条需要「知道书是否存在」的收敛:只保留该用户书架上的书(书移出书架后分组里不留死 ID)、一本书只归一个组(越靠前的分组优先)。空分组保留,方便先建组再放书。
|
||||
- 与影视的差异:书架有两个内置页签「全部」「未分组」(未分组是「没有被任何分组认领」的视图,不持久化,只有存在分组时才出现,且固定在分组栏最后,避免挤开用户自己拖拽排好的分组顺序);书籍不进管理弹窗逐个分配,而是在卡片上用「分组」按钮指定(书架可能有上千本,列出来既慢又难找)。
|
||||
- 前端:`utils/readerBookGroups.ts`(纯逻辑)+ `hooks/useBookGroups.ts`(乐观更新、串行提交、失败回滚,接口不可用时退回 localStorage,照搬 `useLibraryTags`)+ `components/BookGroupBar.tsx`(分组栏)+ `ManageBookGroupsDialogView.tsx`(新建/重命名/删除/拖拽排序)+ `BookGroupPickerDialog.tsx`(移到分组)。
|
||||
|
||||
**阅读器偏好按账号同步(跨设备)**
|
||||
- 主题 / 夜间模式 / 排版(字号、行距、段距)/ 翻页或滚动模式 / 听书倍速与定时 / 书架布局·排序·显示项,原先只存浏览器 localStorage(设备级,换设备就丢),现在按用户落库到 `reader_profiles`(`model.ReaderProfile`,`user_id` 唯一索引),接口 `GET|PUT /reader/profile`。
|
||||
- 服务端是权威来源,localStorage(zustand persist 的 `mebox-reader-settings`)退化成首屏缓存:登录后 `hydrateReaderSettings()` 拉一次覆盖本地,之后本地改动防抖 600ms 回写(`utils/readerSettingsSync.ts`)。
|
||||
- 账号还没有偏好记录时 `GET` 返回 `null`,前端把本地现值推上去「播种」,升级前在本机调好的设置不会被重置。
|
||||
- 服务端只做存储 + 范围收敛(数值夹区间、枚举未知回落默认、主题标识限长),不做语义解释;前端另有本地 clamp,两处范围需同步(见 `service/reader/profile.go` 顶部注释)。
|
||||
- 首页的「影视 / 阅读」模式(homeMode)是设备级偏好,故意不参与同步。
|
||||
|
||||
## 2. 阅读界面(重点)
|
||||
|
||||
文件:`<src>ui/book/read/`(ReadBookActivity、ReadMenu、SearchMenu、MangaMenu、config/*Dialog)、`ui/book/read/page/`(ReadView、PageView、ContentTextView、ChapterProvider)。
|
||||
|
||||
**布局结构(单 FrameLayout 栈)**:read_view 正文 → 文字选择光标 → read_menu 主菜单 → search_menu 全文搜索 → 朗读回原文浮条。
|
||||
|
||||
**正文渲染**
|
||||
- ReadView 内含 3 个 PageView(prev/cur/next 缓存),ContentTextView 纯 Canvas 自绘:按字号/行距/段距/缩进/两端对齐排版,Web 版用 CSS multi-column 或 JS 分页等效实现。
|
||||
- 翻页动画(0–5):0 覆盖、1 平移、2 仿真、3 上下滚动、4 无、5 平移覆盖;速度可调(默认 300ms)。
|
||||
- 滚动模式支持背景跟随、自动翻页(定时滚动/定时翻页)。
|
||||
- 图片/漫画书切换独立配置集 + MangaMenu:顶栏 + 底部胶囊(上一页/页码进度条/下一页),支持横滚、缩放。
|
||||
|
||||
**点击区域(9 宫格,边缘留 pageTouchClick px)**
|
||||
- 区块:左上/中上/右上/左中/中/右中/左下/中下/右下。
|
||||
- 默认:左列与上中=上一页;右列与下中=下一页;正中=呼出菜单。
|
||||
- 全部可配动作:0 呼出菜单、1 下一页、2 上一页、3 下一章、4 上一章、7 书签、10 目录、11 搜索、12 同步进度等。
|
||||
|
||||
**主菜单 ReadMenu(自上而下)**
|
||||
- 半透明遮罩(点击收起)。
|
||||
- 顶部 title_bar:返回 + 书名(点击进详情)+ 章节名/章节链接(点击打开原页面)+ 书源按钮(弹菜单:编辑书源/禁用书源等)。
|
||||
- 底部 bottom_menu(圆角面板):上一章 | 进度条 | 下一章 → 亮度条 + 自动亮度 → 快捷按钮行(搜索、自动翻页、替换规则、夜间/日间切换)→ 动作面板行(目录、朗读、界面、设置)。
|
||||
- 菜单配色可跟随页面背景或用主题底色,透明度 35–100。
|
||||
|
||||
**设置面板**
|
||||
- 「界面」三 Tab:文本(字体文件、字重、字号 5–50、字距、行距 0–20、段距、缩进、下划线、阴影);样式(白天/夜间/E-Ink 三态主题:文字色、背景色/图、强调色、菜单底色、菜单透明度、背景透明度,可恢复/管理/分享);页面(6 种翻页动画、状态栏深浅、滚动背景跟随、四边距、页眉页脚提示、简繁转换)。
|
||||
- 「设置」:屏幕方向、保持亮屏、隐藏状态栏、两端/底部对齐、竖排、双页、进度条行为(按页/按章)、音量键/滚轮翻页、点击区域配置、动画速度、自动换源等。
|
||||
- 页眉页脚提示项:无/章节名/时间/电量/电量百分比/页码/总进度/页码+总页/时间+电量/书名;页眉默认 左=时间/右=电量,页脚默认 左=章节名/右=页码/总页。
|
||||
|
||||
**内置主题与配色(assets/defaultData/readConfig.json,6 套 + 自定义)**
|
||||
|
||||
| 名称 | 背景(日/夜) | 文字(日/夜) | 强调(日/夜) | 字号 |
|
||||
|---|---|---|---|---|
|
||||
| 微信读书 | #FFC0EDC6 / #000000 | #FF0B0B0B / #ADADAD | #E53935 / #FE4D55 | 24 |
|
||||
| 预设1 | #FFFFFF / #000000 | #000000 / #FFFFFF | #E53935 / #FE4D55 | 20 |
|
||||
| 预设2(羊皮纸) | #DDC090 / #3C3F43 | #3E3422 / #DCDFE1 | #834E00 / #FE4D55 | 20 |
|
||||
| 预设3(护眼绿) | #C2D8AA / #3C3F43 | #596C44 / #88C16F | #E53935 / #FE4D55 | 20 |
|
||||
| 预设4(粉紫) | #DBB8E2 / #3C3F43 | #68516C / #F6AEAE | #801314 / #90BFF5 | 20 |
|
||||
| 预设5(淡蓝) | #ABCEE0 / #3C3F43 | #3D4C54 / #90BFF5 | #E53935 / #FE4D55 | 20 |
|
||||
|
||||
默认排版:textSize 20、letterSpacing 0.1、lineSpacingExtra 12、paragraphSpacing 2、缩进「 」、padding 上下6/左右16、页脚线 true。未选样式时默认:bg #EEEEEE / 夜 #000000 / E-Ink #FFFFFF,文字 #3E3D3B / 夜 #ADADAD,强调 #E53935 / 夜 #FE4D55。
|
||||
|
||||
**MeBox 文本阅读器已实现的排版/菜单细节**(`ReaderViewPage.tsx`)
|
||||
- 正文留边:左右 16px、上下 8px(对齐 legado 默认左右16/上下6),分页列宽按留边后的视口宽计算,正文不贴屏幕边。
|
||||
- 菜单打开时正文整体下移一个顶栏高度(用 `transform`,不改视口高度、不触发重新分页),顶栏不再压住开头 1–2 行。
|
||||
- 界面面板的字号/行距/段距三个调节组用 `flex-wrap`,窄屏自动折行,不会把「段距」挤出屏幕。
|
||||
- 点击分区:翻页模式渲染一层 `absolute inset-0` 的三分区按钮(左 30% 上一页 / 中 40% 呼出菜单 / 右 30% 下一页);**滚动模式不能渲染这层覆盖层**——它不是滚动容器的子节点,手机上手指落在覆盖层按钮上时浏览器找不到可滚动的祖先,纵向滑动完全失效、只剩点左右能翻屏。滚动模式改为在滚动容器自身的 `onClick` 上按 x 坐标分区(`handleZoneTap`),手指拖动不会产生 click,原生纵向滚动照常。
|
||||
- 滚动模式的滚轮(`useSmoothWheelScroll`):把滚轮格数累加成一个目标位置,再逐帧向它逼近,滚动连续、松手后自己滑行一段,仿手机上下滑动,而不是浏览器整格跳变。只在文本/漫画的上下滚动模式接管;翻页模式仍走下面的翻页监听器。按真实帧间隔换算逼近比例,120Hz 屏上速度不会翻倍;`ctrl/cmd+滚轮` 保留缩放;手指按下(滑动、拖滚动条、点分区)立刻停掉动画把控制权交还用户。
|
||||
- 漫画双页铺开(`hooks/useComicSpreads.ts` + `utils/comicSpread.ts`,legado 无对应项,是桌面端补充):窗口宽度 ≥900px 且漫画处于翻页模式时,一屏并排显示两页(`settings.comicDoublePage` 为设备级偏好,默认开,可在菜单里关;窗口不够宽时自动退回单页)。分组规则同 Tachiyomi 的 dual page——宽度明显大于高度(比值 ≥1.1)的图视为「跨页宽图」独占一屏,其余连续的竖版页两两配对;列表接口只给 URL,尺寸靠 `new Image()` 探测(`<img>` 渲染时顺带回填),只探测当前页往后 8 张的窗口,避免整章预载。进度仍按图片序号记,翻页时换算成「第几屏」,所以关掉再开双页进度不会错位。菜单动作行改为 `flex` 等分,容纳「双页/单页」入口(`grid-cols-N` 得写死列数字面量,多一个按钮无法复用)。
|
||||
- 漫画图片显示尺寸(`utils/comicImageFit.ts`,仅上下滚动模式):legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),桌面端放不大也缩不小,所以补上一组档位,在界面面板的「图片尺寸」一行切换。`default` 就是老样子(900px 居中),`width` 适应宽度(正文列不再封顶、铺满窗口),`height` 适应高度(一屏一页),`long` 适应长边(整页完整可见),`original` 原图(原始像素 1:1,超出部分横向滚动)。`default`/`width` 由外层列宽决定,其余三个由 `<img>` 自身约束,**所以后三种必须放开 900px 列宽上限**,否则会被列再压回去。`height`/`long` 要按「一屏多高」换算,所以量的是滚动容器本身而不是 `window.innerHeight`(正文区是 root 里的 `flex-1`,另挂上下条之后就不等于窗口高度了),并用 `ResizeObserver` 跟随;只在启用了自尺寸档位时才挂观察器,默认档位不白白重渲染整章。`settings.comicImageFit` 是设备级偏好(不同屏幕和源分辨率合适的尺寸不同),和 `comicDoublePage` 一样只存本机、不参与账号同步。翻页模式是整页缩放进视口,本来就不存在「太大/太小」,档位对它不生效、也不显示入口。`ctrl/cmd+滚轮` 依然留给浏览器缩放,本功能只用档位做粗调。
|
||||
- **文本型漫画源按图片下发**(`internal/service/reader` 的 `imageMarkersOnly`):拷贝漫画这类书源 `bookSourceType=0`(文本),正文规则直接给 `<img>`,服务端把整行标签转成 `[img]<签名地址>` 标记行后类型仍是 `text`,前端就会走文本阅读器——分页把每张图当成一列,一屏只看得到一张,桌面端也谈不上双页铺开。现在 `GetContentForBook` / `LocalChapterContent` 在标记化之后判断「非空行全部是 `[img]` 标记」,是则把 `type` 改成 `image` 并给出 `images`,交由漫画阅读器渲染(整页缩放、双页铺开、进度按图片序号)。判定只认「全是标记」:混了正文的章节一律保持 `text`,孤立的 HTML 标签行(`<div>`/`</div>`)与空行当作无内容忽略,绝不吞掉可读文字。
|
||||
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。
|
||||
- 目录:整屏面板(顶部返回 + 书名 + 章数,Virtuoso 虚拟列表,定位并高亮当前章,点章跳转)。**必须渲染在底部菜单之外**:菜单带 `backdrop-blur`,会成为 `fixed` 后代的包含块,放里面 `h-full` 只能拿到菜单高度;历史上它写的是 `top-0 + bottom-full`,两者同时存在时高度被算成 0,整块目录完全看不见。
|
||||
|
||||
**本地书籍(对应 legado 本地 TXT / EPUB)**
|
||||
- 入口:书架页右上「本地导入」按钮(书架为空时另有「上传本地书籍」),支持 TXT / EPUB,单文件上限 64MB,上传后自动入库并直接进入阅读页。
|
||||
- 书架卡片:本地书打「本地」角标,右上角有删除按钮(二次确认),删除会连服务器上的文件一起清掉。
|
||||
- 存储:正文落盘 `data/reader/local/<bookID>.<txt|epub>`;目录信息与网络书共用 `reader_chapters`,用 `Tag` 记定位:TXT 存 UTF-8 规范化后文件内的字节区间 `start:end`,EPUB 存 zip 内的 XHTML 条目路径。读章只取所需区间/条目,不整本载入内存。
|
||||
- TXT:自动识别 BOM(UTF-8/UTF-16) 与 UTF-8 / GBK / Big5,统一转 UTF-8 落盘;目录按 legado 默认 TXT 规则切章(`第X章/节/卷/集/部/篇`、序章、楔子、番外等),并对「第一章的正文内容」这类正文行做启发式过滤,切不出章名时整本当一章「全文」,章前内容(书名/简介)并入第一章。
|
||||
- EPUB:`META-INF/container.xml` → OPF → `spine` 顺序出章。目录标题优先取 NCX / EPUB3 NAV,**逐 token 走并用栈收任意层级的 navPoint**——Epubor 等工具导出的 EPUB 常漏 `</navPoint>`,标题会整棵嵌进上一个节点,按固定层级解会丢掉一大半标题;取不到时依次退回正文首行 → 封面页(文件名含 cover 且该页只有图)标「封面」→ `<title>`(过滤 Cover/Table of Contents 这类无信息量的)→ `第 N 章`。
|
||||
- EPUB 图片:正文里的 `<img>` 在转纯文本时就地换成 `[img]<zip 条目>` 标记行(相对路径按该 XHTML 所在目录解析),下发前把标记换成签名地址 `/api/reader/local/asset?b=&p=&s=`(HMAC,`<img>` 带不了 JWT),前端把 `[img]` 开头的行渲染成居中图片(`max-width:100%` + `max-height:70vh`,保证不撑破分栏)。网络书正文不受影响。
|
||||
- 进度:与网络书同一套 `durChapter*` 字段,跨端一致;同名文件重复上传按覆盖更新处理(章数不变则保留进度)。
|
||||
|
||||
**音频书播放条(AudioPlayActivity + AudioPlayService,即「听书」)**
|
||||
- 背景与封面:书籍封面强模糊(blur 32px)铺满做底,叠一层很淡的主题底色(opacity 0.3)保住日/夜对比度;正中圆形显示封面原图(对应 legado `upCover` 的 ivBg 模糊图 + ivCover 圆图)。封面缺失或加载失败时退回主题色圆点,不留破图。
|
||||
- transport 行:上一章 | -15s | 播放暂停 | +15s | 下一章(SEEK_STEP=15s,进度按秒)。
|
||||
- 动作行(常驻底部,抽屉打开时仍可点):章节(目录选择,见下)、定时关闭、倍速、片头片尾。
|
||||
- 章节选择:底部抽屉列全部章节,定位到当前章、当前章高亮、点章即跳;卷名行不可点。
|
||||
- 定时关闭:0/5/10/15/30/60/90/180 分钟;暂停期间不倒计时;归零自动暂停播放;选定值持久化为下次默认(对应 AppConfig.ttsTimer 在服务启动时 setTimer)。
|
||||
- 倍速:滑杆 0.5–3.0(步进 0.1)+ 0.5/0.75/1/1.25/1.5/1.75/2/2.5/3 快捷档;持久化(对应 AudioPlay.playSpeed,Android 6 以下不支持调速)。
|
||||
- 跳过片头片尾:抽屉内两条滑杆(片头/片尾,秒,0 不跳过,上限 300),按书持久化(Book.openCredits/closeCredits,落库 books.open_credits / close_credits)。语义:全新开播(该章进度为 0)时 seek 到片头秒数;播放到 duration-片尾秒数即等同播完,有下一章则自动续播,末章则停在片尾处。**单位是秒,不是章数。**
|
||||
- 播放进度按秒记忆(节流 5s 上报),跨端一致;播完自动下一章。
|
||||
- legado 的「播放模式」(顺序/单章循环/随机/列表循环)与「音频服务唤醒锁」是客户端能力,Web 端未实现。
|
||||
|
||||
## 3. 搜索
|
||||
|
||||
文件:`<src>ui/book/search/` + `<src>model/webBook/SearchModel.kt`。
|
||||
|
||||
- 布局:顶部 SearchView + 转圈进度条 → 结果流 → 书架命中提示卡 → 搜索历史 chips + 清空 → 右下 开始/停止 悬浮按钮。
|
||||
- 结果项:封面、在书架角标、书源数角标(可换源数)、书名、作者、分类标签、最新章节、简介。
|
||||
- 多源并发:所有启用书源(按搜索范围过滤),固定线程池并发(全局设置 threadCount),单源 30s 超时。
|
||||
- 实时结果流:每源返回即刷新列表;结束后 onSearchFinish(isEmpty, hasMore)(任一源有结果即可翻页,searchPage++)。
|
||||
- 聚合去重 mergeItems 四档:书名或作者等于关键词 > kind 含关键词 > 书名/作者含关键词 > 其他;同名同作者合并为一项并 addOrigin(角标显示可换源数);档内按书源数降序。
|
||||
- 顶部菜单:精准搜索开关、搜索范围(分组/全选)、书源管理、日志。点结果 → 换源对话框/直接打开。
|
||||
|
||||
## 4. 发现
|
||||
|
||||
`<src>ui/main/explore/` + `ui/book/explore/`。布局:标题栏 → SwipeRefresh → 源选择行(当前源名 + 下拉、源内搜索、标签筛选、更多)→ 二级筛选条 → 分类标签条(横滑 chip,可展开)→ 书籍网格(封面+书名)→ 兜底列表。数据来自启用书源的 exploreUrl + ruleExplore;切源刷新标签;点标签拼 URL 加载、分页加载更多。
|
||||
|
||||
## 5. 书籍详情
|
||||
|
||||
`<src>ui/book/info/BookInfoActivity.kt`。布局:模糊封面背景 → 标题栏(返回/刷新/菜单)→ 下拉刷新 → 滚动区:封面 + 书名 + 作者 + 最新章节(点击刷新目录)+ 阅读时长 + 分类标签 + 分组行 + 书源行(换源)+ 目录入口行 → 双 Tab(简介 / 目录预览,可全屏、点章节跳读)→ 底部动作区:tv_shelf 加入/移出书架(描边按钮)+ tv_read 开始阅读/继续阅读(实心主色按钮)。菜单含置顶、去书源网页、登录、删除(勾选删缓存)、分享。
|
||||
|
||||
## 6. 目录页
|
||||
|
||||
`<src>ui/book/toc/`。布局:标题栏(返回 + 书名 + SearchView 搜章节)→ Tab(章节/书签)→ 章节 RecyclerView + 底部信息条(当前位置 + 跳顶/跳底)。菜单:倒序开关、使用净化替换、加载字数、长章分卷合并、TXT 目录规则。倒序即列表反转;卷名行可折叠;已读章节变色;点击章节回传 index 跳读。
|
||||
|
||||
## 7. 书源管理
|
||||
|
||||
- **主列表**:标题栏(SearchView 过滤 + 菜单)→ RecyclerView(item:域名、复选、启停 Switch、编辑、更多菜单、发现、快速调试、响应耗时条)→ 多选操作栏。菜单:排序(手动/自动/名称/URL/更新时间/响应时间/启用)、分组筛选(启用/禁用/需登录/无分组/启用发现/禁用发现)、添加书源(本地/网络/二维码)。
|
||||
- **编辑**:标题栏(保存/调试入口)→ 类型下拉 + 六个开关(启用/启用发现/CookieJar/段评/事件监听/自定义按钮)→ 六个 Tab:基本/搜索/发现/详情/目录/正文,每 Tab 为「标签 + 输入框 + 帮助弹层」表单。
|
||||
- **调试**:顶部 SearchView 输入关键词 → 串行执行 搜索→详情→目录→正文,日志逐条流式显示;可查看各阶段原始 HTML、切换探索分类调试。
|
||||
|
||||
## 8. 替换规则
|
||||
|
||||
- 列表页:标题栏(搜索 + 菜单:分组管理、启停筛选、添加、导入)→ RecyclerView(名称、分组、内容摘要、启停 Switch)→ 多选操作栏。
|
||||
- 编辑页字段:规则名 → 分组 → 替换规则(+ 正则开关 + 帮助)→ 替换为 → 作用于标题 / 作用于正文 → 作用范围(书名) → 排除范围 → 超时毫秒(order 隐含)。
|
||||
|
||||
## 9. 数据实体字段全集
|
||||
|
||||
**BookSource.kt**(rule* 为内嵌对象)
|
||||
|
||||
| 字段 | 类型 | 默认值 |
|
||||
|---|---|---|
|
||||
| bookSourceUrl | String | "" |
|
||||
| bookSourceName | String | "" |
|
||||
| bookSourceGroup | String? | null |
|
||||
| bookSourceType | Int (0文本/1音频/2图片/3文件/4视频) | 0 |
|
||||
| bookUrlPattern | String? | null |
|
||||
| customOrder | Int | 0 |
|
||||
| enabled | Boolean | true |
|
||||
| jsLib | String? | null |
|
||||
| enabledExplore | Boolean | true |
|
||||
| enabledCookieJar | Boolean? | true |
|
||||
| concurrentRate | String? | null |
|
||||
| header | String? | null |
|
||||
| loginUrl / loginUi / loginCheckJs / coverDecodeJs | String? | null |
|
||||
| bookSourceComment | String? | null |
|
||||
| variableComment | String? | null |
|
||||
| lastUpdateTime | Long | 0 |
|
||||
| respondTime | Long | 180000 |
|
||||
| weight | Int | 0 |
|
||||
| exploreUrl / exploreScreen | String? | null |
|
||||
| ruleExplore / ruleSearch / ruleBookInfo / ruleToc / ruleContent / ruleReview | 对象 | null |
|
||||
| eventListener / customButton | Boolean | false |
|
||||
|
||||
**Book.kt**
|
||||
|
||||
| 字段 | 类型 | 默认值 |
|
||||
|---|---|---|
|
||||
| bookUrl (PK) / tocUrl | String | "" / "" |
|
||||
| origin / originName | String | localTag / "" |
|
||||
| name / author | String | "" / "" |
|
||||
| kind / customTag / coverUrl / customCoverUrl / intro / customIntro / charset | String? | null |
|
||||
| type | Int (BookType) | text |
|
||||
| group | Long | 0 |
|
||||
| latestChapterTitle | String? | null |
|
||||
| latestChapterTime / lastCheckTime | Long | now |
|
||||
| lastCheckCount / totalChapterNum | Int | 0 |
|
||||
| durChapterTitle | String? | null |
|
||||
| durChapterIndex / durVolumeIndex / chapterInVolumeIndex / durChapterPos | Int | 0 |
|
||||
| durChapterTime | Long | now |
|
||||
| wordCount | String? | null |
|
||||
| canUpdate | Boolean | true |
|
||||
| order / originOrder | Int | 0 |
|
||||
| variable | String? | null |
|
||||
| readConfig(ReadConfig 内嵌) | reverseToc、pageAnim、reSegment、imageStyle、useReplaceRule、delTag、ttsEngine、splitLongChapter=true、readSimulating、startDate、startChapter、dailyChapters=3、openCredits=0、closeCredits=0、playMode=0、playSpeed=1.0、manga* 系列、mangaPageAnim | |
|
||||
|
||||
**BookChapter.kt**
|
||||
|
||||
| 字段 | 类型 | 默认值 |
|
||||
|---|---|---|
|
||||
| url / title | String | "" / "" |
|
||||
| isVolume | Boolean | false |
|
||||
| baseUrl / bookUrl | String | "" / "" |
|
||||
| index | Int | 0 |
|
||||
| isVip / isPay | Boolean | false |
|
||||
| resourceUrl / tag / wordCount | String? | null |
|
||||
| start / end | Long? | null |
|
||||
| startFragmentId / endFragmentId / variable / imgUrl | String? | null |
|
||||
|
||||
**ReplaceRule.kt**
|
||||
|
||||
| 字段 | 类型 | 默认值 |
|
||||
|---|---|---|
|
||||
| id | Long | now |
|
||||
| name | String | "" |
|
||||
| group | String? | null |
|
||||
| pattern | String | "" |
|
||||
| replacement | String | "" |
|
||||
| scope | String? | null |
|
||||
| scopeTitle | Boolean | false |
|
||||
| scopeContent | Boolean | true |
|
||||
| excludeScope | String? | null |
|
||||
| isEnabled | Boolean | true |
|
||||
| isRegex | Boolean | true |
|
||||
| timeoutMillisecond | Long | 3000 |
|
||||
| order | Int | Int.MIN_VALUE |
|
||||
|
||||
## 10. 主题与视觉
|
||||
|
||||
- 全局:主背景 `#F7F7FA`、主文字 `#DE000000`、品牌色 `#FFF6FBF8`(浅绿白调)。
|
||||
- 界面主题由 ThemeConfig(themes.json)控制 6 套;阅读菜单/底栏用 bottomBackground(跟随所选界面主题),沉浸模式(readBarStyleFollowPage)直接取页面背景/文字色。
|
||||
- 强调色 accentColor 用于选中态、光标、Tab 选中;E-Ink 模式菜单改描边。
|
||||
- 视觉风格:大圆角面板、1dp 描边、玻璃拟态半透明条、图标+文字标签的动作面板。
|
||||
|
||||
## Web 版实现要点
|
||||
|
||||
- 正文分页仿 ReadView 三页缓存 + 9 宫格点击映射。
|
||||
- 主题模型照抄 ReadBookConfig.Config(含日/夜/E-Ink 三态字段),内置 6 套主题色值直接使用上表。
|
||||
- 搜索并发用 Promise 池 + 流式合并,合并逻辑照抄 mergeItems 四档排序。
|
||||
- 书源/替换编辑器按第 7/8 节 Tab/字段一一对应。
|
||||
@@ -0,0 +1,36 @@
|
||||
# Segment Coverage Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** IntroDB prewarm + same-season intro propagation + shorter miss TTL + playback-only duration probe (option C).
|
||||
|
||||
**Architecture:** Extend `MediaSegmentService` for merge/propagate/prewarm; add scheduler job `segment_prewarm`; repository queries for candidates and season siblings.
|
||||
|
||||
**Tech Stack:** Go, GORM/SQLite, existing SchedulerService + IntroDB client.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Negative cache TTL + ledgerFresh tests
|
||||
|
||||
**Files:** `internal/service/media_segment.go`, `internal/service/media_segment_test.go`
|
||||
|
||||
- Change `segmentMissingTTL` to 24h; update tests for fresh/stale miss.
|
||||
|
||||
### Task 2: Playback merge + propagation
|
||||
|
||||
**Files:** `media_segment.go`, `media_segment_repository.go`, `media_repository.go`, tests
|
||||
|
||||
- Constant `PropagatedSource = "propagated"`.
|
||||
- `ListForPlayback` merges sources with priority manual > theintrodb > propagated.
|
||||
- After successful IntroDB refresh with intro, propagate to same-season siblings (shared `tm_db_id` or `series_id`).
|
||||
|
||||
### Task 3: Prewarm API + scheduler job
|
||||
|
||||
**Files:** `media_segment.go`, `media_segment_repository.go`, `scheduler.go`, `scheduler_segment_jobs.go`, `service_builder.go`, tests
|
||||
|
||||
- `Prewarm(ctx, limit)` rate-limited refreshes.
|
||||
- Job `segment_prewarm` every 6h.
|
||||
|
||||
### Task 4: Verify
|
||||
|
||||
- `go test` for affected packages.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Segment Coverage Improvements (1–4, option C)
|
||||
|
||||
## Goal
|
||||
|
||||
Raise intro/outro skip availability without Chromaprint or full-library STRM probing.
|
||||
|
||||
## Scope (approved)
|
||||
|
||||
1. **IntroDB prewarm** — background job for all queryable media (TMDb + season/episode).
|
||||
2. **Duration** — playback-path only (no full STRM scan); keep `ListForPlayback` → `EnsureAsync`.
|
||||
3. **Same-season intro propagation** — copy intro to sibling episodes after a hit.
|
||||
4. **Negative cache** — miss TTL 24h; 403/429/timeout must not write miss ledger.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Chromaprint, manual segment UI, library-name filters, batch STRM duration scan.
|
||||
|
||||
## Behavior
|
||||
|
||||
### Prewarm (`segment_prewarm`)
|
||||
|
||||
- Scheduler job, interval 6h, initial delay 6h (avoid restart spike).
|
||||
- **Opt-in via `segment.prewarm_enabled` (default off)** — system settings toggle「后台预热 IntroDB 片头片段」.
|
||||
- Candidates: `tm_db_id > 0`, season/episode or movie, ledger missing or stale.
|
||||
- Order: recently played first, then others.
|
||||
- Rate: ~1 req/s, stop on context cancel; respect IntroDB 429 retry already in client.
|
||||
- Reuse `MediaSegmentService` refresh + propagation.
|
||||
- Manual `RunNow` can bypass the toggle (same pattern as organize).
|
||||
|
||||
### Propagation
|
||||
|
||||
- After TheIntroDB refresh finds at least one `intro` span, write the same intro window to same-season siblings that lack a `theintrodb` intro.
|
||||
- Source tag: `propagated`.
|
||||
- Do not propagate credits/preview/recap.
|
||||
- Playback merge priority per kind: `manual` > `theintrodb` > `propagated`.
|
||||
|
||||
### Negative cache
|
||||
|
||||
- `segmentMissingTTL`: 24h (was 7d).
|
||||
- `segmentFoundTTL`: 30d (unchanged).
|
||||
- Provider errors (non-404) continue to skip ledger writes.
|
||||
|
||||
### Duration (C)
|
||||
|
||||
- No new batch probe job.
|
||||
- Existing `defer ensureMediaProbe` on playback remains the only STRM duration path.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Prewarm increases `media_segment_fetches` over time without blocking play.
|
||||
- One IntroDB intro hit can surface skip on sibling episodes via `propagated`.
|
||||
- Misses are retried within ~24h (sooner if recently played and prewarm runs).
|
||||
|
Before Width: | Height: | Size: 620 KiB |
|
Before Width: | Height: | Size: 743 KiB |
|
Before Width: | Height: | Size: 793 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 612 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 376 KiB |
|
Before Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
@@ -1,8 +1,9 @@
|
||||
module github.com/truewhile/MeBox
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
fyne.io/systray v1.12.2
|
||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/gin-contrib/gzip v1.2.6
|
||||
@@ -12,16 +13,16 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/metatube-community/metatube-sdk-go v1.4.0
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
github.com/redis/go-redis/v9 v9.7.3
|
||||
github.com/shirou/gopsutil/v3 v3.24.5
|
||||
github.com/spf13/viper v1.18.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/ulikunitz/xz v0.5.12
|
||||
github.com/ulikunitz/xz v0.5.15
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/image v0.37.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/image v0.45.0
|
||||
golang.org/x/sync v0.23.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/time v0.15.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
@@ -29,6 +30,12 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/PaesslerAG/gval v1.0.0 // indirect
|
||||
github.com/PaesslerAG/jsonpath v0.1.1 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.13.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.4 // indirect
|
||||
github.com/antchfx/htmlquery v1.3.6 // indirect
|
||||
github.com/antchfx/xpath v1.3.6 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
@@ -37,6 +44,9 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09 // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.5.2 // indirect
|
||||
github.com/dop251/goja v0.0.0-20260926152631-39ec2650adc9 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
@@ -46,12 +56,16 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
@@ -69,7 +83,7 @@ require (
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
@@ -88,8 +102,8 @@ require (
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/arch v0.25.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
|
||||
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/PaesslerAG/gval v1.0.0 h1:GEKnRwkWDdf9dOmKcNrar9EA1bz1z9DqPIO1+iLzhd8=
|
||||
github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I=
|
||||
github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8=
|
||||
github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk=
|
||||
github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY=
|
||||
github.com/PuerkitoBio/goquery v1.13.0 h1:mqHbjD7Jmnul4DTR24LKTjo1uUmHUh072kteGV+xpFM=
|
||||
github.com/PuerkitoBio/goquery v1.13.0/go.mod h1:Hip5mdBL8K2wEGKJdr27sRaNwIdDajmCwB/ExUPwW+g=
|
||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2 h1:40yUSXwdkWN851BHCq6uiDhleh7A4+0yIBS+IUAqZVY=
|
||||
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
|
||||
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
|
||||
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
|
||||
github.com/antchfx/htmlquery v1.3.6 h1:RNHHL7YehO5XdO8IM8CynwLKONwRHWkrghbYhQIk9ag=
|
||||
github.com/antchfx/htmlquery v1.3.6/go.mod h1:kcVUqancxPygm26X2rceEcagZFFVkLEE7xgLkGSDl/4=
|
||||
github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI=
|
||||
github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
@@ -22,6 +37,12 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09 h1:MJFqtdxTq94XqUgg7DcGCaOIXrDTJE/tPHK66Jshguc=
|
||||
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0=
|
||||
github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dop251/goja v0.0.0-20260926152631-39ec2650adc9 h1:Ksd3Vil12b/ykT5NjGzd3qNc688cJB9OUXJZZChEktI=
|
||||
github.com/dop251/goja v0.0.0-20260926152631-39ec2650adc9/go.mod h1:u8yZRUavu+N4EnFFy6J5fVtjE7lEcZ2YyV2GcBXY9c8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47 h1:48iGRx9HamDuG4pCbPG5IXt4bKHhgn33KGynzHUgeIA=
|
||||
@@ -52,13 +73,21 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -77,8 +106,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
@@ -121,10 +150,10 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
|
||||
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
@@ -171,8 +200,9 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
@@ -187,33 +217,104 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
||||
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Package brand contains the embedded MeBox application artwork.
|
||||
package brand
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// Icon is the Windows ICO used by both the executable resource and the
|
||||
// notification-area icon.
|
||||
//
|
||||
//go:embed logo.ico
|
||||
var Icon []byte
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
@@ -44,9 +44,10 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("logging.max_backups", 10)
|
||||
|
||||
v.SetDefault("cache.cache_dir", "./cache")
|
||||
v.SetDefault("cache.images_max_size_mb", 500)
|
||||
v.SetDefault("cache.images_max_size_mb", 2000)
|
||||
v.SetDefault("cache.images_originals_max_size_mb", 256)
|
||||
v.SetDefault("cache.images_originals_ttl_hours", 168)
|
||||
v.SetDefault("cache.memory_max_size_mb", DefaultCacheMemoryMaxSizeMB)
|
||||
v.SetDefault("cache.cleanup_interval_min", 60)
|
||||
v.SetDefault("cache.redis_url", "")
|
||||
v.SetDefault("cache.redis_prefix", "mebox")
|
||||
v.SetDefault("cache.media_ttl_seconds", 90)
|
||||
|
||||
@@ -47,6 +47,12 @@ func (c *Config) normalize() error {
|
||||
if c.Cache.ImagesMaxSizeMB < 0 {
|
||||
c.Cache.ImagesMaxSizeMB = 0
|
||||
}
|
||||
if c.Cache.ImagesOriginalsMaxSizeMB < 0 {
|
||||
c.Cache.ImagesOriginalsMaxSizeMB = 0
|
||||
}
|
||||
if c.Cache.ImagesOriginalsTTLHours < 0 {
|
||||
c.Cache.ImagesOriginalsTTLHours = 0
|
||||
}
|
||||
if c.Cache.MemoryMaxSizeMB <= 0 {
|
||||
c.Cache.MemoryMaxSizeMB = DefaultCacheMemoryMaxSizeMB
|
||||
}
|
||||
|
||||
@@ -117,15 +117,17 @@ type LoggingConfig struct {
|
||||
type CacheConfig struct {
|
||||
CacheDir string `mapstructure:"cache_dir"`
|
||||
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
|
||||
// ImagesOriginalsMaxSizeMB 限制 cache/images 下原图子配额(MB)。原图只是
|
||||
// 生成缩略图的原料,丢了可以重新回源;0 表示不单独限制(只受总量约束)。
|
||||
ImagesOriginalsMaxSizeMB int `mapstructure:"images_originals_max_size_mb"`
|
||||
// ImagesOriginalsTTLHours 是原图的保留时长(小时),超过即删除;0 表示
|
||||
// 不按时间淘汰。缩短它能让原图缓存保持在一个滚动窗口内。
|
||||
ImagesOriginalsTTLHours int `mapstructure:"images_originals_ttl_hours"`
|
||||
// MemoryMaxSizeMB 限制进程内 L1 缓存总字节数,JSON/对象缓存共用该预算。
|
||||
MemoryMaxSizeMB int `mapstructure:"memory_max_size_mb"`
|
||||
MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"`
|
||||
TTLHours int `mapstructure:"ttl_hours"`
|
||||
AutoCleanup bool `mapstructure:"auto_cleanup"`
|
||||
CleanupIntervalMin int `mapstructure:"cleanup_interval_min"`
|
||||
RedisURL string `mapstructure:"redis_url"`
|
||||
RedisPrefix string `mapstructure:"redis_prefix"`
|
||||
MediaTTLSeconds int `mapstructure:"media_ttl_seconds"`
|
||||
MemoryMaxSizeMB int `mapstructure:"memory_max_size_mb"`
|
||||
RedisURL string `mapstructure:"redis_url"`
|
||||
RedisPrefix string `mapstructure:"redis_prefix"`
|
||||
MediaTTLSeconds int `mapstructure:"media_ttl_seconds"`
|
||||
// EmbyLatestTTLSeconds 是 Emby「最新添加」(Items/Latest) 的缓存时长。
|
||||
// 客户端刷新首页时会并发请求全部媒体库的 Latest(生产环境观察到 73 个
|
||||
// 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机
|
||||
|
||||
@@ -127,5 +127,10 @@ func (z zapStdLogger) Printf(format string, args ...interface{}) {
|
||||
if z.log == nil {
|
||||
return
|
||||
}
|
||||
z.log.Sugar().Infof(format, args...)
|
||||
message := fmt.Sprintf(format, args...)
|
||||
if strings.Contains(strings.ToLower(message), "context canceled") {
|
||||
z.log.Debug(message)
|
||||
return
|
||||
}
|
||||
z.log.Info(message)
|
||||
}
|
||||
|
||||
@@ -99,6 +99,11 @@ func ensurePerformanceIndexes(db *gorm.DB) error {
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_library_episode_active ON media(library_id, season_num, episode_num, created_at DESC) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_library_root_active ON media(library_id, library_root_id) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_series_active ON media(series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_library_series_active ON media(library_id, series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_library_tmdb_active ON media(library_id, tm_db_id) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_library_bangumi_active ON media(library_id, bangumi_id) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_library_douban_active ON media(library_id, douban_id) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_library_thetvdb_active ON media(library_id, thetvdb_id) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_favorites_user_media_active ON favorites(user_id, media_id) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_playback_histories_user_media_active ON playback_histories(user_id, media_id, watched_at DESC) WHERE deleted_at IS NULL`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_playback_histories_resume_active ON playback_histories(user_id, completed, watched_at DESC) WHERE deleted_at IS NULL`,
|
||||
|
||||
@@ -20,6 +20,12 @@ type settingReq struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// maskedSettingKeys 里的设置值绝不能被完整下发:它们是可用于对外操作的凭据。
|
||||
// 下发脱敏值,保存时再靠 isMaskedSettingValue 还原为「保持原值」。
|
||||
var maskedSettingKeys = map[string]bool{
|
||||
service.SettingTelegramBotToken: true,
|
||||
}
|
||||
|
||||
func listSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
settings, err := svc.Repo.Setting.All(c.Request.Context())
|
||||
@@ -27,10 +33,21 @@ func listSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
for i := range settings {
|
||||
if maskedSettingKeys[settings[i].Key] {
|
||||
settings[i].Value = service.MaskSecret(settings[i].Value)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
// isMaskedSettingValue 识别「前端把脱敏值原样提交回来」的情况。此时必须保留
|
||||
// 已存的真实值,否则一次保存就会把凭据覆盖成 ***。
|
||||
func isMaskedSettingValue(value string) bool {
|
||||
return strings.Contains(value, "***")
|
||||
}
|
||||
|
||||
func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req settingReq
|
||||
@@ -38,6 +55,11 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 脱敏值回传 == 用户没改这个凭据,保留库里已存的真实值。
|
||||
if maskedSettingKeys[req.Key] && isMaskedSettingValue(req.Value) {
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
oldValue := ""
|
||||
if req.Key == service.AdultLibraryIDsSettingKey {
|
||||
oldValue, _ = svc.Repo.Setting.Get(c.Request.Context(), req.Key)
|
||||
@@ -65,8 +87,10 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if req.Key == "transcode.hw_enabled" || req.Key == "transcode.hw_accel" || req.Key == "transcoder.hardware_accel" || req.Key == "transcoder.encoder" {
|
||||
svc.Transcoder.StopAll()
|
||||
}
|
||||
if req.Key == "cache.images_max_size_mb" && svc.Scheduler != nil {
|
||||
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup")
|
||||
if req.Key == "cache.images_max_size_mb" || req.Key == "cache.images_originals_max_size_mb" || req.Key == "cache.images_originals_ttl_hours" {
|
||||
if svc.Scheduler != nil {
|
||||
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup")
|
||||
}
|
||||
}
|
||||
if req.Key == "cache.memory_max_size_mb" && svc.Cache != nil {
|
||||
svc.Cache.SetMaxSizeMB(svc.Cfg.Cache.MemoryMaxSizeMB)
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func mediaPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc == nil || svc.Cloud115 == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
|
||||
return
|
||||
}
|
||||
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if !enforceScopedPlaybackToken(c, m.ID) {
|
||||
return
|
||||
}
|
||||
quality, _ := strconv.Atoi(c.Query("quality"))
|
||||
info, err := svc.Cloud115.PlaybackInfo(c.Request.Context(), m.ID, quality)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrMediaNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
}
|
||||
|
||||
func mediaTranscodeHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc == nil || svc.Cloud115 == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
|
||||
return
|
||||
}
|
||||
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if !enforceScopedPlaybackToken(c, m.ID) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Definition int `json:"definition"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
info, err := svc.Cloud115.StartTranscode(c.Request.Context(), m.ID, req.Definition)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
}
|
||||
|
||||
func cloud115HLSMasterHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc == nil || svc.Cloud115 == nil || svc.Cloud115.HLSProxy() == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
|
||||
return
|
||||
}
|
||||
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if !enforceScopedPlaybackToken(c, m.ID) {
|
||||
return
|
||||
}
|
||||
definition, _ := strconv.Atoi(c.Query("definition"))
|
||||
err = svc.Cloud115.HLSProxy().ServeMaster(c.Request.Context(), c.Writer, c.Request, m.ID, definition)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if c.Writer.Written() {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrCloud115TranscodePending) {
|
||||
retryAfter := 5
|
||||
message := "115 正在转码,请稍候"
|
||||
var info *service.PlaybackInfo
|
||||
if latest, infoErr := svc.Cloud115.PlaybackInfo(c.Request.Context(), m.ID, definition); infoErr == nil && latest != nil {
|
||||
info = latest
|
||||
if latest.Transcode.Message != "" {
|
||||
message = latest.Transcode.Message
|
||||
}
|
||||
if latest.Transcode.RetryAfterSec > 0 {
|
||||
retryAfter = latest.Transcode.RetryAfterSec
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"code": "transcode_pending",
|
||||
"message": message,
|
||||
"retry_after_sec": retryAfter,
|
||||
"playback": info,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
func cloud115HLSSessionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc == nil || svc.Cloud115 == nil || svc.Cloud115.HLSProxy() == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
|
||||
return
|
||||
}
|
||||
mediaID := c.Query("media_id")
|
||||
if mediaID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing media_id"})
|
||||
return
|
||||
}
|
||||
if !enforceScopedPlaybackToken(c, mediaID) {
|
||||
return
|
||||
}
|
||||
err := svc.Cloud115.HLSProxy().ServeChild(
|
||||
c.Request.Context(),
|
||||
c.Writer,
|
||||
c.Request,
|
||||
c.Param("session"),
|
||||
c.Param("key"),
|
||||
)
|
||||
if err == nil || c.Writer.Written() {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, service.ErrCloud115HLSSessionNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "hls session not found"})
|
||||
case errors.Is(err, service.ErrCloud115HLSUpstreamExpired):
|
||||
c.JSON(http.StatusGone, gin.H{"error": "hls upstream expired"})
|
||||
default:
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,72 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsPersistsVr360GuideSeen(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
// 默认未看过:配置接口必须先如实告知前端,否则首次操作说明会被跳过。
|
||||
initial := svc.Danmaku.ConfigForUser(t.Context(), "user-1")
|
||||
if initial.Vr360GuideSeen {
|
||||
t.Fatal("vr360 guide should be unread for a fresh user")
|
||||
}
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"vr360_guide_seen":true}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var cfg service.DanmakuRenderConfig
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !cfg.Vr360GuideSeen {
|
||||
t.Fatalf("config should reflect the persisted guide flag: %+v", cfg)
|
||||
}
|
||||
|
||||
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("read persisted user: %v", err)
|
||||
}
|
||||
if !user.PlayerVr360GuideSeen {
|
||||
t.Fatalf("guide flag not persisted on the user: %+v", user)
|
||||
}
|
||||
}
|
||||
|
||||
// 播放倍速只作用于当前视频:接口既不下发也不保存它。老客户端仍可能带上
|
||||
// playback_rate,服务端应当忽略而不是报错。
|
||||
func TestDanmakuSettingsIgnorePlaybackRate(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1")
|
||||
getDanmakuConfigHandler(svc)(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if bytes.Contains(w.Body.Bytes(), []byte("playback_rate")) {
|
||||
t.Fatalf("config must not expose playback rate: %s", w.Body.String())
|
||||
}
|
||||
|
||||
c2, w2 := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"volume":0.5,"playback_rate":1.5}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c2)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w2.Code, w2.Body.String())
|
||||
}
|
||||
if bytes.Contains(w2.Body.Bytes(), []byte("playback_rate")) {
|
||||
t.Fatalf("settings response must not expose playback rate: %s", w2.Body.String())
|
||||
}
|
||||
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
|
||||
if err != nil || user == nil {
|
||||
t.Fatalf("read user: %v", err)
|
||||
}
|
||||
if user.PlayerVolume != 0.5 {
|
||||
t.Fatalf("volume not persisted: %v", user.PlayerVolume)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsRejectsInvalidSource(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// 设备管理接口。
|
||||
//
|
||||
// 普通用户只能操作自己的设备(路由挂在 /me 下,用户 ID 始终取自会话);
|
||||
// 管理员通过 /admin/users/:id/devices 代管任意用户。两组接口共用同一份
|
||||
// DeviceService,因此「谁上线过、谁被踢掉」只有一处事实来源。
|
||||
|
||||
// deviceListPayload 是设备列表的下发形状。Fingerprint 不外发:它是防共享
|
||||
// 判定用的内部标识,暴露出去只会方便伪造。
|
||||
type deviceListPayload struct {
|
||||
Devices []devicePayload `json:"devices"`
|
||||
}
|
||||
|
||||
type devicePayload struct {
|
||||
ID string `json:"id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name,omitempty"`
|
||||
Client string `json:"client,omitempty"`
|
||||
LastIP string `json:"last_ip,omitempty"`
|
||||
LastSeenAt string `json:"last_seen_at,omitempty"`
|
||||
LastPlayAt string `json:"last_play_at,omitempty"`
|
||||
Kicked bool `json:"kicked"`
|
||||
Online bool `json:"online"`
|
||||
Playing bool `json:"playing"`
|
||||
Warnings int `json:"warnings"`
|
||||
}
|
||||
|
||||
func toDevicePayload(d model.UserDevice) devicePayload {
|
||||
out := devicePayload{
|
||||
ID: d.ID,
|
||||
DeviceID: d.DeviceID,
|
||||
DeviceName: d.DeviceName,
|
||||
Client: d.Client,
|
||||
LastIP: d.LastIP,
|
||||
Kicked: d.Kicked,
|
||||
Online: d.Online,
|
||||
Playing: d.Playing,
|
||||
Warnings: d.Warnings,
|
||||
}
|
||||
if !d.LastSeenAt.IsZero() {
|
||||
out.LastSeenAt = d.LastSeenAt.Format("2006-01-02T15:04:05Z07:00")
|
||||
}
|
||||
if d.LastPlayAt != nil && !d.LastPlayAt.IsZero() {
|
||||
out.LastPlayAt = d.LastPlayAt.Format("2006-01-02T15:04:05Z07:00")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func deviceListResponse(devices []model.UserDevice) deviceListPayload {
|
||||
items := make([]devicePayload, 0, len(devices))
|
||||
for _, d := range devices {
|
||||
items = append(items, toDevicePayload(d))
|
||||
}
|
||||
return deviceListPayload{Devices: items}
|
||||
}
|
||||
|
||||
// myDevicesHandler 返回当前会话用户的设备列表。
|
||||
func myDevicesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := sessionUserID(c)
|
||||
devices, err := svc.Device.ListDevices(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, deviceListResponse(devices))
|
||||
}
|
||||
}
|
||||
|
||||
// myKickDeviceHandler 踢掉当前用户的一台设备。
|
||||
func myKickDeviceHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := sessionUserID(c)
|
||||
deviceID := strings.TrimSpace(c.Param("deviceID"))
|
||||
if deviceID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "device id required"})
|
||||
return
|
||||
}
|
||||
if err := svc.Device.KickDevice(c.Request.Context(), userID, deviceID); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// myKickAllDevicesHandler 踢掉当前用户的全部设备。
|
||||
func myKickAllDevicesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := sessionUserID(c)
|
||||
if err := svc.Device.KickAllDevices(c.Request.Context(), userID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// adminUserDevicesHandler 返回指定用户的设备列表。
|
||||
func adminUserDevicesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := strings.TrimSpace(c.Param("id"))
|
||||
// FindByID 对「不存在」返回 (nil, nil),必须判空而不是判 error,
|
||||
// 否则「用户不存在」会伪装成「该用户没有设备」的空列表。
|
||||
user, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
devices, err := svc.Device.ListDevices(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, deviceListResponse(devices))
|
||||
}
|
||||
}
|
||||
|
||||
// adminKickUserDeviceHandler 由管理员踢掉指定用户的一台设备。
|
||||
func adminKickUserDeviceHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := strings.TrimSpace(c.Param("id"))
|
||||
deviceID := strings.TrimSpace(c.Param("deviceID"))
|
||||
if deviceID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "device id required"})
|
||||
return
|
||||
}
|
||||
if err := svc.Device.KickDevice(c.Request.Context(), userID, deviceID); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// adminKickAllUserDevicesHandler 由管理员踢掉指定用户的全部设备。
|
||||
func adminKickAllUserDevicesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := strings.TrimSpace(c.Param("id"))
|
||||
if err := svc.Device.KickAllDevices(c.Request.Context(), userID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// sessionUserID 读取会话用户 ID。调用方路由都挂在鉴权中间件之后,因此这里
|
||||
// 只做类型断言兜底,不做权限判断。
|
||||
func sessionUserID(c *gin.Context) string {
|
||||
if v, ok := c.Get(middleware.CtxUserID); ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// newDeviceTestEnv 搭一个只挂设备/Telegram 路由的最小环境,并预置两个用户,
|
||||
// 用于验证「只能操作自己的设备」这条边界。
|
||||
func newDeviceTestEnv(t *testing.T) (*gin.Engine, *service.Container) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.UserDevice{}, &model.Setting{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
|
||||
svc.Device = service.NewDeviceService(zap.NewNop(), repos)
|
||||
svc.Device.SetSessionTracker(service.NewSessionTrackerService(zap.NewNop()))
|
||||
svc.Telegram = service.NewTelegramService(zap.NewNop(), repos)
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
authed := router.Group("/api", func(c *gin.Context) {
|
||||
// 测试里直接注入会话身份,绕开真实 JWT 解析。
|
||||
if uid := c.GetHeader("X-Test-User"); uid != "" {
|
||||
c.Set(middleware.CtxUserID, uid)
|
||||
c.Set(middleware.CtxUserRole, c.GetHeader("X-Test-Role"))
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
authed.GET("/me/devices", myDevicesHandler(svc))
|
||||
authed.POST("/me/devices/kick-all", myKickAllDevicesHandler(svc))
|
||||
authed.POST("/me/devices/:deviceID/kick", myKickDeviceHandler(svc))
|
||||
authed.GET("/me/telegram", getTelegramStatusHandler(svc))
|
||||
authed.POST("/me/telegram/bind-code", startTelegramBindHandler(svc))
|
||||
authed.DELETE("/me/telegram", unbindTelegramHandler(svc))
|
||||
authed.GET("/admin/users/:id/devices", adminUserDevicesHandler(svc))
|
||||
authed.POST("/admin/users/:id/devices/:deviceID/kick", adminKickUserDeviceHandler(svc))
|
||||
_ = secret
|
||||
return router, svc
|
||||
}
|
||||
|
||||
func seedDeviceUsers(t *testing.T, svc *service.Container) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
for _, id := range []string{"user-a", "user-b"} {
|
||||
if err := svc.Repo.User.Create(ctx, &model.User{
|
||||
Base: model.Base{ID: id}, Username: id, PasswordHash: "x", Role: "user", IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func doJSON(t *testing.T, router *gin.Engine, method, path, userID, role string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req.Header.Set("X-Test-User", userID)
|
||||
if role != "" {
|
||||
req.Header.Set("X-Test-Role", role)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// /me/devices 只能返回调用者自己的设备。
|
||||
func TestMyDevicesScopedToCaller(t *testing.T) {
|
||||
router, svc := newDeviceTestEnv(t)
|
||||
seedDeviceUsers(t, svc)
|
||||
ctx := context.Background()
|
||||
|
||||
svc.Device.RecordLogin(ctx, "user-a", "dev-a", "A-Phone", "Infuse", "1.1.1.1")
|
||||
svc.Device.RecordLogin(ctx, "user-b", "dev-b", "B-Phone", "Infuse", "2.2.2.2")
|
||||
|
||||
w := doJSON(t, router, http.MethodGet, "/api/me/devices", "user-a", "user")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Devices []struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
} `json:"devices"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(payload.Devices) != 1 || payload.Devices[0].DeviceID != "dev-a" {
|
||||
t.Fatalf("devices = %+v, want only dev-a", payload.Devices)
|
||||
}
|
||||
// 设备指纹属于内部判定标识,不能下发。
|
||||
if strings.Contains(w.Body.String(), "fingerprint") {
|
||||
t.Fatalf("response must not expose fingerprint: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 踢别人的设备必须失败:/me 路由用会话身份,deviceID 属于他人时查不到。
|
||||
func TestKickForeignDeviceFails(t *testing.T) {
|
||||
router, svc := newDeviceTestEnv(t)
|
||||
seedDeviceUsers(t, svc)
|
||||
svc.Device.RecordLogin(context.Background(), "user-b", "dev-b", "B-Phone", "Infuse", "2.2.2.2")
|
||||
|
||||
w := doJSON(t, router, http.MethodPost, "/api/me/devices/dev-b/kick", "user-a", "user")
|
||||
if w.Code == http.StatusNoContent {
|
||||
t.Fatal("user-a must not be able to kick user-b's device")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMyKickOwnDeviceSucceeds(t *testing.T) {
|
||||
router, svc := newDeviceTestEnv(t)
|
||||
seedDeviceUsers(t, svc)
|
||||
svc.Device.RecordLogin(context.Background(), "user-a", "dev-a", "A-Phone", "Infuse", "1.1.1.1")
|
||||
|
||||
w := doJSON(t, router, http.MethodPost, "/api/me/devices/dev-a/kick", "user-a", "user")
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 管理员接口对不存在的用户返回 404,避免把「用户不存在」和「用户没有设备」
|
||||
// 混成同一个空列表。
|
||||
func TestAdminDevicesUnknownUserReturns404(t *testing.T) {
|
||||
router, svc := newDeviceTestEnv(t)
|
||||
seedDeviceUsers(t, svc)
|
||||
|
||||
w := doJSON(t, router, http.MethodGet, "/api/admin/users/nope/devices", "admin-1", "admin")
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramStatusAndBindCode(t *testing.T) {
|
||||
router, svc := newDeviceTestEnv(t)
|
||||
seedDeviceUsers(t, svc)
|
||||
|
||||
w := doJSON(t, router, http.MethodGet, "/api/me/telegram", "user-a", "user")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"bound":false`) {
|
||||
t.Fatalf("body = %s, want bound=false", w.Body.String())
|
||||
}
|
||||
|
||||
w = doJSON(t, router, http.MethodPost, "/api/me/telegram/bind-code", "user-a", "user")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var code struct {
|
||||
Code string `json:"code"`
|
||||
ExpiresIn int `json:"expires_in_seconds"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(code.Code) != 6 {
|
||||
t.Fatalf("code = %q, want 6 chars", code.Code)
|
||||
}
|
||||
if code.ExpiresIn <= 0 {
|
||||
t.Fatalf("expires_in_seconds = %d, want > 0", code.ExpiresIn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettingsMasksBotToken(t *testing.T) {
|
||||
router, svc := newDeviceTestEnv(t)
|
||||
ctx := context.Background()
|
||||
if err := svc.Repo.Setting.Set(ctx, service.SettingTelegramBotToken, "123456:AAHsecretTOKEN"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router.GET("/api/admin/settings", listSettingsHandler(svc))
|
||||
router.PUT("/api/admin/settings", updateSettingHandler(svc))
|
||||
|
||||
w := doJSON(t, router, http.MethodGet, "/api/admin/settings", "admin-1", "admin")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", w.Code)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "AAHsecretTOKEN") {
|
||||
t.Fatalf("bot token leaked: %s", w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "***") {
|
||||
t.Fatalf("bot token should be masked: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 把脱敏值原样提交回来时,必须保留库里真实 Token —— 否则一次保存就把凭据毁掉。
|
||||
func TestSavingMaskedTokenKeepsRealValue(t *testing.T) {
|
||||
router, svc := newDeviceTestEnv(t)
|
||||
ctx := context.Background()
|
||||
const real = "123456:AAHsecretTOKEN"
|
||||
if err := svc.Repo.Setting.Set(ctx, service.SettingTelegramBotToken, real); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router.PUT("/api/admin/settings", updateSettingHandler(svc))
|
||||
|
||||
body := `{"key":"telegram.bot_token","value":"12***EN"}`
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Test-User", "admin-1")
|
||||
req.Header.Set("X-Test-Role", "admin")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
got, err := svc.Repo.Setting.Get(ctx, service.SettingTelegramBotToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != real {
|
||||
t.Fatalf("stored token = %q, want the original value preserved", got)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,13 @@ func embyUserID(c *gin.Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// embyEffectiveUserID 返回当前鉴权用户。收藏、续播、播放进度等用户态数据必须
|
||||
// 绑定到登录身份;路径/Query 里的 UserId 只是 Emby URL 形态兼容,不能覆盖鉴权结果,
|
||||
// 否则多用户会串到同一个 UserId 上。
|
||||
func embyEffectiveUserID(c *gin.Context) string {
|
||||
return embyUserID(c)
|
||||
}
|
||||
|
||||
const embyCompatSessionTTL = 30 * time.Minute
|
||||
|
||||
type embyCompatSession struct {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// Emby 发现类接口的 handler:NextUp / Similar / Genres。
|
||||
//
|
||||
// 这三个接口此前返回空列表,导致第三方客户端首页「接下来播放」、详情页
|
||||
// 「相似推荐」、按类型浏览全部为空白。它们必须始终返回 200 + 合法信封,
|
||||
// 因为客户端在首页刷新时会并发请求,任何 4xx/5xx 都会被判定为服务端异常。
|
||||
|
||||
func embyNextUpHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := embyScopedUserID(c)
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), ""))
|
||||
// YamBy / Emby 进剧集详情会带 SeriesId 请求「本剧下一集」。
|
||||
// 忽略该参数会把全站 NextUp 第一条塞进详情页「继续播放」。
|
||||
// 注意:不要把普通 ParentId(媒体库)当成 SeriesId,否则首页 NextUp 会被滤空。
|
||||
seriesID := firstQueryValue(c, "SeriesId", "seriesId", "seriesid")
|
||||
if seriesID == "" {
|
||||
if parentID := firstQueryValue(c, "ParentId", "parentId", "parentid"); parentID != "" {
|
||||
if strings.HasPrefix(parentID, "msgo-series-") || service.IsEmbyRemoteID(parentID) {
|
||||
seriesID = parentID
|
||||
}
|
||||
}
|
||||
}
|
||||
out, err := svc.Emby.NextUp(c.Request.Context(), userID, seriesID, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||
return
|
||||
}
|
||||
embyAttachRequestTokenToMediaSources(c, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// embyShowNextUpHandler 处理 /Shows/{id}/NextUp:把路径上的剧集 ID 当作 SeriesId。
|
||||
func embyShowNextUpHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := embyScopedUserID(c)
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||
return
|
||||
}
|
||||
seriesID := strings.TrimSpace(c.Param("id"))
|
||||
if seriesID == "" || strings.EqualFold(seriesID, "NextUp") {
|
||||
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), ""))
|
||||
out, err := svc.Emby.NextUp(c.Request.Context(), userID, seriesID, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||
return
|
||||
}
|
||||
embyAttachRequestTokenToMediaSources(c, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func embySimilarHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
mediaID := strings.TrimSpace(c.Param("id"))
|
||||
if mediaID == "" {
|
||||
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), ""))
|
||||
out, err := svc.Emby.SimilarItems(c.Request.Context(), mediaID, embyEffectiveUserID(c), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||
return
|
||||
}
|
||||
embyAttachRequestTokenToMediaSources(c, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func embyGenresHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
parentID := firstQueryValue(c, "ParentId", "parentId", "parentid")
|
||||
out, err := svc.Emby.Genres(c.Request.Context(), embyEffectiveUserID(c), parentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// embyScopedUserID 解析「按用户请求」的 Emby 接口的生效用户。
|
||||
//
|
||||
// 路由上带 :userId 时(/Users/{uid}/Shows/NextUp),只允许查询自己:客户端
|
||||
// 偶尔会带着别人的 id 请求,直接采信等于开放他人观看历史的读取。管理员同样
|
||||
// 按自己处理,避免出现一条无人使用的越权路径。
|
||||
func embyScopedUserID(c *gin.Context) string {
|
||||
caller := embyEffectiveUserID(c)
|
||||
requested := strings.TrimSpace(c.Param("userId"))
|
||||
if requested == "" {
|
||||
return caller
|
||||
}
|
||||
if requested == caller {
|
||||
return caller
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// embyEmptyItemsPayload 与 embyEmptyItemsHandler 保持同一形状。
|
||||
func embyEmptyItemsPayload() gin.H {
|
||||
return gin.H{"Items": []any{}, "TotalRecordCount": 0}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// newEmbyDiscoveryEnv 搭一个跑在内存库上的 Emby 路由环境。
|
||||
func newEmbyDiscoveryEnv(t *testing.T) (*gin.Engine, *service.Container, string) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&model.User{}, &model.Library{}, &model.Media{}, &model.PlaybackHistory{},
|
||||
&model.Setting{}, &model.Favorite{}, &model.UserDevice{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
|
||||
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
|
||||
svc.Emby = service.NewEmbyService(cfg, zap.NewNop(), repos).
|
||||
SetDiscovery(service.NewMediaDiscoveryService(zap.NewNop(), repos))
|
||||
|
||||
const userID = "user-1"
|
||||
if err := repos.User.Create(context.Background(), &model.User{
|
||||
Base: model.Base{ID: userID}, Username: "tester", PasswordHash: "x",
|
||||
Role: "user", IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, svc)
|
||||
return router, svc, userID
|
||||
}
|
||||
|
||||
func seedEmbyLibrary(t *testing.T, svc *service.Container, typ string) string {
|
||||
t.Helper()
|
||||
lib := &model.Library{Name: "库-" + typ, Path: "/media/" + typ, Type: typ, Enabled: true}
|
||||
if err := svc.Repo.Library.Create(context.Background(), lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return lib.ID
|
||||
}
|
||||
|
||||
func embyGet(t *testing.T, router *gin.Engine, path, token string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
if token != "" {
|
||||
req.Header.Set("X-Emby-Token", token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func decodeItemsEnvelope(t *testing.T, body []byte) []map[string]any {
|
||||
t.Helper()
|
||||
var payload struct {
|
||||
Items []map[string]any `json:"Items"`
|
||||
TotalRecordCount int64 `json:"TotalRecordCount"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("decode %s: %v", string(body), err)
|
||||
}
|
||||
return payload.Items
|
||||
}
|
||||
|
||||
// NextUp 必须真的返回下一集,而不是空数组。
|
||||
func TestEmbyNextUpReturnsNextEpisode(t *testing.T) {
|
||||
router, svc, userID := newEmbyDiscoveryEnv(t)
|
||||
libID := seedEmbyLibrary(t, svc, "tv")
|
||||
watchedAt := time.Now().Add(-time.Hour)
|
||||
|
||||
for episode, watched := range map[int]bool{1: true, 2: false, 3: false} {
|
||||
m := &model.Media{
|
||||
LibraryID: libID, SeriesID: "series-1", Title: "剧一",
|
||||
SeasonNum: 1, EpisodeNum: episode,
|
||||
Path: "/media/tv/S1E" + string(rune('0'+episode)) + ".mkv",
|
||||
}
|
||||
if err := svc.Repo.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if watched {
|
||||
h := &model.PlaybackHistory{
|
||||
UserID: userID, MediaID: m.ID, PositionMs: 2000, DurationMs: 2000,
|
||||
WatchedAt: watchedAt, Completed: true, // 第 1 集已看完
|
||||
}
|
||||
if err := svc.Repo.DB.Create(h).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w := embyGet(t, router, "/emby/Shows/NextUp", signedTestToken(t, "test-secret"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
items := decodeItemsEnvelope(t, w.Body.Bytes())
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %d, want 1 (body=%s)", len(items), w.Body.String())
|
||||
}
|
||||
if index, ok := items[0]["IndexNumber"].(float64); !ok || int(index) != 2 {
|
||||
t.Fatalf("IndexNumber = %v, want 2 (body=%s)", items[0]["IndexNumber"], w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:用户在剧集详情页点播放、只看了几秒就退出(历史行 completed=false)后,
|
||||
// Yamby 再次进入详情页带的 NextUp 仍要指向那一集本身,否则「继续播放」会跳到下一集。
|
||||
func TestEmbyNextUpKeepsPartiallyWatchedEpisode(t *testing.T) {
|
||||
router, svc, userID := newEmbyDiscoveryEnv(t)
|
||||
libID := seedEmbyLibrary(t, svc, "tv")
|
||||
|
||||
episodeIDs := map[int]string{}
|
||||
for ep := 1; ep <= 3; ep++ {
|
||||
m := &model.Media{
|
||||
LibraryID: libID, SeriesID: "series-1", Title: "剧一",
|
||||
SeasonNum: 1, EpisodeNum: ep,
|
||||
Path: "/media/tv/S1E" + strconv.Itoa(ep) + ".mkv",
|
||||
}
|
||||
if err := svc.Repo.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
episodeIDs[ep] = m.ID
|
||||
}
|
||||
|
||||
// 第 2 集播放了 3 秒后退出:有进度、未标记看完。
|
||||
if err := svc.Repo.DB.Create(&model.PlaybackHistory{
|
||||
UserID: userID, MediaID: episodeIDs[2], PositionMs: 3582, DurationMs: 1440064,
|
||||
WatchedAt: time.Now(), Completed: false,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := embyGet(t, router, "/emby/Shows/NextUp?SeriesId=series-1&Limit=1", signedTestToken(t, "test-secret"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
items := decodeItemsEnvelope(t, w.Body.Bytes())
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %d, want 1 (body=%s)", len(items), w.Body.String())
|
||||
}
|
||||
if id, _ := items[0]["Id"].(string); id != episodeIDs[2] {
|
||||
t.Fatalf("Id = %q, want the partially watched episode %q (body=%s)", id, episodeIDs[2], w.Body.String())
|
||||
}
|
||||
if index, ok := items[0]["IndexNumber"].(float64); !ok || int(index) != 2 {
|
||||
t.Fatalf("IndexNumber = %v, want 2 (body=%s)", items[0]["IndexNumber"], w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 没有历史时必须返回合法空信封,不能 404/500。
|
||||
func TestEmbyNextUpEmptyWithoutHistory(t *testing.T) {
|
||||
router, _, _ := newEmbyDiscoveryEnv(t)
|
||||
|
||||
w := embyGet(t, router, "/emby/Shows/NextUp", signedTestToken(t, "test-secret"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if items := decodeItemsEnvelope(t, w.Body.Bytes()); len(items) != 0 {
|
||||
t.Fatalf("items = %d, want 0", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
// 小写别名路由同样要走到真实实现(客户端路径大小写并不统一)。
|
||||
func TestEmbyNextUpLowercaseAlias(t *testing.T) {
|
||||
router, svc, _ := newEmbyDiscoveryEnv(t)
|
||||
_ = seedEmbyLibrary(t, svc, "tv")
|
||||
|
||||
w := embyGet(t, router, "/emby/shows/nextup", signedTestToken(t, "test-secret"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Similar 对不存在的条目返回空列表(客户端详情页会无条件请求)。
|
||||
func TestEmbySimilarUnknownItemReturnsEmpty(t *testing.T) {
|
||||
router, _, _ := newEmbyDiscoveryEnv(t)
|
||||
|
||||
w := embyGet(t, router, "/emby/Items/does-not-exist/Similar", signedTestToken(t, "test-secret"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if items := decodeItemsEnvelope(t, w.Body.Bytes()); len(items) != 0 {
|
||||
t.Fatalf("items = %d, want 0", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbySimilarReturnsCandidates(t *testing.T) {
|
||||
router, svc, _ := newEmbyDiscoveryEnv(t)
|
||||
libID := seedEmbyLibrary(t, svc, "movie")
|
||||
|
||||
source := &model.Media{
|
||||
LibraryID: libID, Title: "源片", Genres: "Action", Year: 2010, Rating: 8,
|
||||
Path: "/media/movie/source.mkv",
|
||||
}
|
||||
if err := svc.Repo.DB.Create(source).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := &model.Media{
|
||||
LibraryID: libID, Title: "同类片", Genres: "Action", Year: 2011, Rating: 8,
|
||||
Path: "/media/movie/other.mkv",
|
||||
}
|
||||
if err := svc.Repo.DB.Create(other).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := embyGet(t, router, "/emby/Items/"+source.ID+"/Similar", signedTestToken(t, "test-secret"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
items := decodeItemsEnvelope(t, w.Body.Bytes())
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %d, want 1 (body=%s)", len(items), w.Body.String())
|
||||
}
|
||||
if name, _ := items[0]["Name"].(string); name != "同类片" {
|
||||
t.Fatalf("Name = %q, want 同类片", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Genres 必须返回真实类型与计数。
|
||||
func TestEmbyGenresReturnsCounts(t *testing.T) {
|
||||
router, svc, _ := newEmbyDiscoveryEnv(t)
|
||||
libID := seedEmbyLibrary(t, svc, "movie")
|
||||
|
||||
for i, genres := range []string{"Action,Drama", "Action"} {
|
||||
m := &model.Media{
|
||||
LibraryID: libID, Title: "片" + string(rune('A'+i)), Genres: genres,
|
||||
Path: "/media/movie/m" + string(rune('0'+i)) + ".mkv",
|
||||
}
|
||||
if err := svc.Repo.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
w := embyGet(t, router, "/emby/Genres", signedTestToken(t, "test-secret"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
items := decodeItemsEnvelope(t, w.Body.Bytes())
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items = %d, want 2 (body=%s)", len(items), w.Body.String())
|
||||
}
|
||||
// 排序按计数降序:Action(2) 在前。
|
||||
if name, _ := items[0]["Name"].(string); name != "Action" {
|
||||
t.Fatalf("first Name = %q, want Action", name)
|
||||
}
|
||||
if count, ok := items[0]["ItemCount"].(float64); !ok || int(count) != 2 {
|
||||
t.Fatalf("ItemCount = %v, want 2", items[0]["ItemCount"])
|
||||
}
|
||||
if id, _ := items[0]["Id"].(string); len(id) == 0 {
|
||||
t.Fatal("genre item must carry a stable Id")
|
||||
}
|
||||
}
|
||||
|
||||
// 按别人的 userId 请求 NextUp 不允许泄露他人历史。
|
||||
func TestEmbyNextUpRejectsForeignUserID(t *testing.T) {
|
||||
router, _, _ := newEmbyDiscoveryEnv(t)
|
||||
|
||||
w := embyGet(t, router, "/emby/Users/someone-else/Shows/NextUp", signedTestToken(t, "test-secret"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if items := decodeItemsEnvelope(t, w.Body.Bytes()); len(items) != 0 {
|
||||
t.Fatalf("items = %d, want 0", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
// YamBy 等客户端进入剧集详情会带 SeriesId 调 NextUp;必须只返回该剧的下一集,
|
||||
// 不能回落成全站「继续观看」第一条,否则详情页播放会串到别的片子。
|
||||
func TestEmbyNextUpFiltersBySeriesID(t *testing.T) {
|
||||
router, svc, userID := newEmbyDiscoveryEnv(t)
|
||||
libID := seedEmbyLibrary(t, svc, "tv")
|
||||
recent := time.Now().Add(-time.Minute)
|
||||
older := time.Now().Add(-2 * time.Hour)
|
||||
|
||||
seedSeries := func(seriesID, title string, watchedAt time.Time) (watchedID, nextID string) {
|
||||
t.Helper()
|
||||
for ep := 1; ep <= 3; ep++ {
|
||||
m := &model.Media{
|
||||
LibraryID: libID, SeriesID: seriesID, Title: title,
|
||||
SeasonNum: 1, EpisodeNum: ep,
|
||||
Path: "/media/tv/" + seriesID + "/S1E" + strconv.Itoa(ep) + ".mkv",
|
||||
}
|
||||
if err := svc.Repo.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch ep {
|
||||
case 1:
|
||||
watchedID = m.ID
|
||||
h := &model.PlaybackHistory{
|
||||
UserID: userID, MediaID: m.ID, PositionMs: 2000, DurationMs: 2000,
|
||||
WatchedAt: watchedAt, Completed: true, // 第 1 集已看完,下一集是 S1E2
|
||||
}
|
||||
if err := svc.Repo.DB.Create(h).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case 2:
|
||||
nextID = m.ID
|
||||
}
|
||||
}
|
||||
return watchedID, nextID
|
||||
}
|
||||
|
||||
_, _ = seedSeries("series-hot", "热门剧", recent)
|
||||
_, wantNext := seedSeries("series-cold", "目标剧", older)
|
||||
|
||||
token := signedTestToken(t, "test-secret")
|
||||
global := embyGet(t, router, "/emby/Shows/NextUp?Limit=10", token)
|
||||
if global.Code != http.StatusOK {
|
||||
t.Fatalf("global status = %d body=%s", global.Code, global.Body.String())
|
||||
}
|
||||
if items := decodeItemsEnvelope(t, global.Body.Bytes()); len(items) < 2 {
|
||||
t.Fatalf("global items = %d, want >= 2 (body=%s)", len(items), global.Body.String())
|
||||
}
|
||||
|
||||
scoped := embyGet(t, router, "/emby/Shows/NextUp?SeriesId=series-cold&Limit=10", token)
|
||||
if scoped.Code != http.StatusOK {
|
||||
t.Fatalf("scoped status = %d body=%s", scoped.Code, scoped.Body.String())
|
||||
}
|
||||
items := decodeItemsEnvelope(t, scoped.Body.Bytes())
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("scoped items = %d, want 1 (body=%s)", len(items), scoped.Body.String())
|
||||
}
|
||||
if id, _ := items[0]["Id"].(string); id != wantNext {
|
||||
t.Fatalf("scoped Id = %q, want %q (body=%s)", id, wantNext, scoped.Body.String())
|
||||
}
|
||||
if seriesID, _ := items[0]["SeriesId"].(string); seriesID != "series-cold" {
|
||||
t.Fatalf("scoped SeriesId = %q, want series-cold", seriesID)
|
||||
}
|
||||
|
||||
empty := embyGet(t, router, "/emby/Shows/NextUp?SeriesId=series-never-watched", token)
|
||||
if empty.Code != http.StatusOK {
|
||||
t.Fatalf("empty status = %d body=%s", empty.Code, empty.Body.String())
|
||||
}
|
||||
if items := decodeItemsEnvelope(t, empty.Body.Bytes()); len(items) != 0 {
|
||||
t.Fatalf("never-watched items = %d, want 0 (body=%s)", len(items), empty.Body.String())
|
||||
}
|
||||
|
||||
pathScoped := embyGet(t, router, "/emby/Shows/series-cold/NextUp?Limit=10", token)
|
||||
if pathScoped.Code != http.StatusOK {
|
||||
t.Fatalf("path scoped status = %d body=%s", pathScoped.Code, pathScoped.Body.String())
|
||||
}
|
||||
pathItems := decodeItemsEnvelope(t, pathScoped.Body.Bytes())
|
||||
if len(pathItems) != 1 {
|
||||
t.Fatalf("path scoped items = %d, want 1 (body=%s)", len(pathItems), pathScoped.Body.String())
|
||||
}
|
||||
if id, _ := pathItems[0]["Id"].(string); id != wantNext {
|
||||
t.Fatalf("path scoped Id = %q, want %q", id, wantNext)
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,7 @@ import (
|
||||
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50"))
|
||||
offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
splitOpt := func(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
@@ -46,9 +40,28 @@ func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
|
||||
SortOrder: firstQueryValue(c, "SortOrder", "sortOrder", "sortorder"),
|
||||
Limit: limit,
|
||||
StartIndex: offset,
|
||||
SeasonIndex: parseEmbySeasonIndexQuery(c),
|
||||
}
|
||||
}
|
||||
|
||||
// parseEmbySeasonIndexQuery 读取客户端请求的季序号。
|
||||
//
|
||||
// Emby 客户端有两种表达方式:SeasonId(虚拟季 ID)与 Season / SeasonIndex
|
||||
// (季序号,特别篇为 0)。两者都是合法入参,SeasonId 更精确。这里只解析季序号,
|
||||
// 返回 nil 表示客户端没有按季过滤(区别于 Season=0 的特别篇)。
|
||||
func parseEmbySeasonIndexQuery(c *gin.Context) *int {
|
||||
raw := firstQueryValue(c, "Season", "season", "SeasonIndex", "seasonIndex", "seasonindex")
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
// 客户端偶尔传入季名称之类的非数字值;按「未过滤」处理,避免整季空结果。
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func embyFirstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
@@ -73,10 +86,7 @@ func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
out, err := svc.Emby.Item(c.Request.Context(), id, uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -106,13 +116,7 @@ func embyUserItemByIDHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
||||
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit)
|
||||
if err != nil {
|
||||
@@ -126,15 +130,12 @@ func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = firstQueryValue(c, "UserId", "userId", "userid")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
||||
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
|
||||
startIndex, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
|
||||
// ParentId / SeriesId 收窄到当前库或当前剧,避免详情页继续播放串到全站历史。
|
||||
parentID := firstQueryValue(c, "ParentId", "parentId", "parentid", "SeriesId", "seriesId", "seriesid")
|
||||
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, parentID, limit, startIndex)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -147,13 +148,7 @@ func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func embyItemsCountsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc != nil && svc.Emby != nil {
|
||||
uid := firstQueryValue(c, "UserId", "userId")
|
||||
if uid == "" {
|
||||
uid = c.Param("userId")
|
||||
}
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
out, err := svc.Emby.ItemCounts(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -209,7 +204,7 @@ func embySaveDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc {
|
||||
func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
params := service.ItemsParams{
|
||||
UserID: firstQueryValue(c, "UserId", "userId"),
|
||||
UserID: embyEffectiveUserID(c),
|
||||
ParentID: c.Param("id"),
|
||||
Limit: 500,
|
||||
}
|
||||
@@ -226,15 +221,35 @@ func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
parentID := firstQueryValue(c, "SeasonId", "seasonId")
|
||||
// 客户端常用季序号而不是虚拟季 ID 请求剧集。缺了这层过滤,
|
||||
// /Shows/{id}/Episodes?Season=2 会把整部剧的所有季都返回。
|
||||
seasonIndex := parseEmbySeasonIndexQuery(c)
|
||||
if parentID == "" {
|
||||
parentID = c.Param("id")
|
||||
} else {
|
||||
// SeasonId 已经限定了具体季,忽略同时传来的季序号,避免两者
|
||||
// 不一致时把结果过滤成空集。
|
||||
seasonIndex = nil
|
||||
}
|
||||
// Emby 客户端按 StartIndex/Limit 分页拉取分集,并在已收条目数小于
|
||||
// TotalRecordCount 时继续请求下一页。忽略这两个参数会让客户端永远
|
||||
// 停在第一页、反复重发同一请求(现象是分集列表一直加载不出来)。
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "500"))
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
}
|
||||
startIndex, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
|
||||
if startIndex < 0 {
|
||||
startIndex = 0
|
||||
}
|
||||
params := service.ItemsParams{
|
||||
UserID: firstQueryValue(c, "UserId", "userId"),
|
||||
UserID: embyEffectiveUserID(c),
|
||||
ParentID: parentID,
|
||||
IncludeItemTypes: []string{"Episode"},
|
||||
Recursive: true,
|
||||
Limit: 500,
|
||||
Limit: limit,
|
||||
StartIndex: startIndex,
|
||||
SeasonIndex: seasonIndex,
|
||||
}
|
||||
out, err := svc.Emby.Items(c.Request.Context(), params)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// Package handler — Emby / Jellyfin 媒体分段(片头、片尾)兼容接口。
|
||||
//
|
||||
// GET /MediaSegments/{itemId}
|
||||
// GET /Items/{itemId}/MediaSegments
|
||||
// GET /Users/{userId}/Items/{itemId}/MediaSegments
|
||||
//
|
||||
// 契约对齐 Jellyfin 10.10 引入的 Media Segments API(Emby 采用同一形状),
|
||||
// 也是 TheIntroDB 官方 Jellyfin 插件走的同一条路:
|
||||
//
|
||||
// QueryResult<MediaSegmentDto> = {"Items": [...], "TotalRecordCount": N}
|
||||
// MediaSegmentDto = {"Id", "ItemId", "Type", "StartTicks", "EndTicks"}
|
||||
//
|
||||
// 时间是 .NET ticks(1 tick = 100ns,即每秒 10,000,000、每毫秒 10,000);
|
||||
// Type 是枚举名字符串 Intro / Outro / Recap / Preview / Commercial。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// 第三方客户端会在起播前后同步请求分段,不能被一次外网抓取无限拖住。超时后
|
||||
// 退回已有缓存(可能为空),请求本身永远不失败。
|
||||
const embyMediaSegmentsFetchBudget = 5 * time.Second
|
||||
|
||||
// 1 秒 = 10,000,000 ticks => 1 毫秒 = 10,000 ticks。
|
||||
const embyTicksPerMillisecond int64 = 10_000
|
||||
|
||||
func embyMediaSegmentsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 未知条目、虚拟剧集/季、远程 Emby 挂载、以及当前不可见的内容一律返回
|
||||
// 空结果而不是 404:客户端会把 404 判成「条目损坏」(同 emby_routes.go
|
||||
// 里 AdditionalParts 的说明),而「没有可跳过的片段」本来就是个合法状态。
|
||||
empty := gin.H{"Items": []any{}, "TotalRecordCount": 0}
|
||||
if svc == nil || svc.Repo == nil || svc.Segments == nil {
|
||||
c.JSON(http.StatusOK, empty)
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
// 远程 Emby 挂载的条目是上游库的投影,本地没有可查询的外部 ID 关联。
|
||||
if service.IsEmbyRemoteID(id) {
|
||||
c.JSON(http.StatusOK, empty)
|
||||
return
|
||||
}
|
||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), id)
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusOK, empty)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), embyMediaSegmentsFetchBudget)
|
||||
defer cancel()
|
||||
rows, listErr := svc.Segments.ListForPlayback(ctx, m)
|
||||
if listErr != nil && svc.Log != nil {
|
||||
svc.Log.Debug("emby media segments lookup failed",
|
||||
zap.String("media_id", m.ID), zap.Error(listErr))
|
||||
}
|
||||
|
||||
items := embySegmentItems(m, rows, embyRequestedSegmentTypes(c))
|
||||
c.JSON(http.StatusOK, gin.H{"Items": items, "TotalRecordCount": len(items)})
|
||||
}
|
||||
}
|
||||
|
||||
// embySegmentItems 把库内片段转换成 MediaSegmentDto 列表。
|
||||
func embySegmentItems(m *model.Media, rows []model.MediaSegment, want map[string]bool) []gin.H {
|
||||
// 末段在库内用 end_ms = 0 表示「一直到片尾」(TheIntroDB 对片尾返回 end_ms: null),
|
||||
// 这里必须换算成真实结束时间;拿不到时长就丢弃该分段,否则会给出一个零长度区间,
|
||||
// 客户端要么忽略要么画出一个错误的跳转点。
|
||||
durationMs := int64(m.DurationSec) * 1000
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
typeName := embySegmentTypeName(row.Kind)
|
||||
if typeName == "" {
|
||||
continue
|
||||
}
|
||||
if len(want) > 0 && !want[typeName] {
|
||||
continue
|
||||
}
|
||||
endMs := row.EndMs
|
||||
if endMs <= 0 {
|
||||
if durationMs <= 0 {
|
||||
continue
|
||||
}
|
||||
endMs = durationMs
|
||||
}
|
||||
if endMs <= row.StartMs {
|
||||
continue
|
||||
}
|
||||
items = append(items, gin.H{
|
||||
"Id": row.ID,
|
||||
"ItemId": m.ID,
|
||||
"Type": typeName,
|
||||
"StartTicks": row.StartMs * embyTicksPerMillisecond,
|
||||
"EndTicks": endMs * embyTicksPerMillisecond,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// embySegmentTypeName 把库内 kind 映射成 Emby/Jellyfin 的 MediaSegmentType 名字。
|
||||
// 库内的 credits 取自 TheIntroDB 的字段名,在 Emby 一侧对应 Outro。
|
||||
func embySegmentTypeName(kind string) string {
|
||||
switch kind {
|
||||
case model.SegmentKindIntro:
|
||||
return "Intro"
|
||||
case model.SegmentKindRecap:
|
||||
return "Recap"
|
||||
case model.SegmentKindCredits:
|
||||
return "Outro"
|
||||
case model.SegmentKindPreview:
|
||||
return "Preview"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// embyRequestedSegmentTypes 解析 includeSegmentTypes(Jellyfin 的过滤参数)。
|
||||
// 支持重复参数与逗号分隔两种写法;返回空集合表示不过滤。
|
||||
//
|
||||
// 只认枚举名字符串。数字枚举虽然 ASP.NET 模型绑定也接受,但各家定义的顺序并
|
||||
// 不一致,猜错会把过滤结果算错;认不出来时按「不过滤」处理,返回的是超集,
|
||||
// 客户端自己仍会再过滤一次。
|
||||
func embyRequestedSegmentTypes(c *gin.Context) map[string]bool {
|
||||
raw := make([]string, 0, 4)
|
||||
for _, key := range []string{"includeSegmentTypes", "IncludeSegmentTypes", "includesegmenttypes"} {
|
||||
raw = append(raw, c.QueryArray(key)...)
|
||||
}
|
||||
want := make(map[string]bool, len(raw))
|
||||
for _, value := range raw {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
for _, name := range []string{"Intro", "Outro", "Recap", "Preview", "Commercial"} {
|
||||
if strings.EqualFold(part, name) {
|
||||
want[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return want
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
type embySegmentsPayload struct {
|
||||
Items []struct {
|
||||
ID string `json:"Id"`
|
||||
ItemID string `json:"ItemId"`
|
||||
Type string `json:"Type"`
|
||||
StartTicks int64 `json:"StartTicks"`
|
||||
EndTicks int64 `json:"EndTicks"`
|
||||
} `json:"Items"`
|
||||
TotalRecordCount int `json:"TotalRecordCount"`
|
||||
}
|
||||
|
||||
// 电影:intro 有明确结束点;credits 的 end_ms 为 null(库内落成 0),
|
||||
// 必须用媒体时长补齐 —— 这是最容易写错的一处。
|
||||
const embySegmentsProviderBody = `{"tmdb_id":27205,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}],"credits":[{"start_ms":6480000,"end_ms":null}]}`
|
||||
|
||||
const (
|
||||
embySegmentsMovieDurationSec = 8880
|
||||
embySegmentsTicksPerSecond = 10_000_000
|
||||
)
|
||||
|
||||
func newEmbySegmentsTestRouter(t *testing.T, durationSec int) (*gin.Engine, string, *int32) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lib := model.Library{Base: model.Base{ID: "lib-movies"}, Name: "电影", Path: "D:\\media\\movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "movie-1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "Inception",
|
||||
Path: "D:\\media\\movies\\Inception.mkv",
|
||||
DurationSec: durationSec,
|
||||
TMDbID: 27205,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var calls int32
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(embySegmentsProviderBody))
|
||||
}))
|
||||
t.Cleanup(provider.Close)
|
||||
|
||||
segments := service.NewMediaSegmentService(zap.NewNop(), repos).
|
||||
SetIntroDB(service.NewIntroDBService(zap.NewNop()).SetBaseURL(provider.URL))
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
Segments: segments,
|
||||
Log: zap.NewNop(),
|
||||
})
|
||||
return router, secret, &calls
|
||||
}
|
||||
|
||||
func embySegmentsRequest(t *testing.T, router *gin.Engine, secret, path string) embySegmentsPayload {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d body=%s", path, w.Code, w.Body.String())
|
||||
}
|
||||
var payload embySegmentsPayload
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("%s decode: %v", path, err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func TestEmbyMediaSegmentsReturnsTicksAndMapsCreditsToOutro(t *testing.T) {
|
||||
router, secret, _ := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
|
||||
|
||||
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1")
|
||||
if payload.TotalRecordCount != 2 || len(payload.Items) != 2 {
|
||||
t.Fatalf("segments = %#v (total %d), want 2", payload.Items, payload.TotalRecordCount)
|
||||
}
|
||||
|
||||
intro := payload.Items[0]
|
||||
if intro.Type != "Intro" || intro.ItemID != "movie-1" || intro.ID == "" {
|
||||
t.Fatalf("intro segment = %#v", intro)
|
||||
}
|
||||
// start_ms null 表示从片头开始。
|
||||
if intro.StartTicks != 0 || intro.EndTicks != 38*embySegmentsTicksPerSecond {
|
||||
t.Fatalf("intro ticks = %d..%d, want 0..%d",
|
||||
intro.StartTicks, intro.EndTicks, 38*embySegmentsTicksPerSecond)
|
||||
}
|
||||
|
||||
// 库内 credits 在 Emby 一侧是 Outro;end_ms = 0 必须按媒体时长补齐,
|
||||
// 否则客户端会拿到一个零长度区间。
|
||||
outro := payload.Items[1]
|
||||
if outro.Type != "Outro" {
|
||||
t.Fatalf("credits should map to Outro, got %q", outro.Type)
|
||||
}
|
||||
if outro.StartTicks != 6480*embySegmentsTicksPerSecond {
|
||||
t.Fatalf("outro StartTicks = %d, want %d", outro.StartTicks, 6480*embySegmentsTicksPerSecond)
|
||||
}
|
||||
if outro.EndTicks != embySegmentsMovieDurationSec*embySegmentsTicksPerSecond {
|
||||
t.Fatalf("outro EndTicks = %d, want the media duration %d",
|
||||
outro.EndTicks, embySegmentsMovieDurationSec*embySegmentsTicksPerSecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyMediaSegmentsIsServedFromTheSameCacheAsTheWebPlayer(t *testing.T) {
|
||||
router, secret, calls := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
|
||||
|
||||
// 多条路径 + 大小写变体都应命中同一份缓存,而不是各自再打一次外网。
|
||||
for _, path := range []string{
|
||||
"/MediaSegments/movie-1",
|
||||
"/mediasegments/movie-1",
|
||||
"/Items/movie-1/MediaSegments",
|
||||
"/items/movie-1/mediasegments",
|
||||
"/Users/user-1/Items/movie-1/MediaSegments",
|
||||
} {
|
||||
payload := embySegmentsRequest(t, router, secret, path)
|
||||
if len(payload.Items) != 2 {
|
||||
t.Fatalf("%s returned %#v, want 2 segments", path, payload.Items)
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt32(calls); got != 1 {
|
||||
t.Fatalf("provider calls = %d, want 1 (all routes share the cached rows)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyMediaSegmentsHonoursIncludeSegmentTypes(t *testing.T) {
|
||||
router, secret, _ := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
|
||||
|
||||
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1?includeSegmentTypes=Intro")
|
||||
if payload.TotalRecordCount != 1 || len(payload.Items) != 1 {
|
||||
t.Fatalf("filtered segments = %#v (total %d), want only Intro", payload.Items, payload.TotalRecordCount)
|
||||
}
|
||||
if payload.Items[0].Type != "Intro" {
|
||||
t.Fatalf("filtered type = %q, want Intro", payload.Items[0].Type)
|
||||
}
|
||||
|
||||
// 认不出的枚举名按「不过滤」处理:返回超集比返回空集安全。
|
||||
payload = embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1?includeSegmentTypes=NotAType")
|
||||
if payload.TotalRecordCount != 2 {
|
||||
t.Fatalf("unknown filter returned %d segments, want the unfiltered set", payload.TotalRecordCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyMediaSegmentsDropsOpenEndedRangeWhenDurationUnknown(t *testing.T) {
|
||||
// 时长未知(STRM/云盘媒体探测前)时,credits 无法换算成真实结束点,
|
||||
// 只能丢弃;有明确结束点的 intro 必须保留。
|
||||
router, secret, _ := newEmbySegmentsTestRouter(t, 0)
|
||||
|
||||
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1")
|
||||
if payload.TotalRecordCount != 1 || len(payload.Items) != 1 {
|
||||
t.Fatalf("segments = %#v (total %d), want only the intro", payload.Items, payload.TotalRecordCount)
|
||||
}
|
||||
if payload.Items[0].Type != "Intro" {
|
||||
t.Fatalf("kept segment = %#v, want Intro", payload.Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyMediaSegmentsReturnsEmptyInsteadOfNotFound(t *testing.T) {
|
||||
router, secret, calls := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
|
||||
|
||||
// 未知条目必须 200 + 空数组:客户端会把 404 判成「条目损坏」。
|
||||
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/does-not-exist")
|
||||
if payload.Items == nil || len(payload.Items) != 0 || payload.TotalRecordCount != 0 {
|
||||
t.Fatalf("unknown item payload = %#v", payload)
|
||||
}
|
||||
// 远程 Emby 条目同理(本地没有可查询的外部 ID 关联)。
|
||||
payload = embySegmentsRequest(t, router, secret, "/MediaSegments/embyremote~acct1~item1")
|
||||
if len(payload.Items) != 0 {
|
||||
t.Fatalf("remote emby item payload = %#v, want empty", payload.Items)
|
||||
}
|
||||
if got := atomic.LoadInt32(calls); got != 0 {
|
||||
t.Fatalf("provider calls = %d, want 0 for unresolvable items", got)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -13,10 +16,7 @@ import (
|
||||
|
||||
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -27,10 +27,100 @@ func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
embyAttachRequestTokenToMediaSources(c, out)
|
||||
// 在后台把本次条目的云盘直链换好:播放器拿到 PlaybackInfo 后通常还要
|
||||
// 1–2 秒才请求 /Videos/{id}/stream,把换链开销落在这段等待里。
|
||||
embyPrewarmPlaybackTargets(svc, c, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// embyPrewarmTimeout 是单次预热的等待上限。115 开放平台在跨太平洋线路上单次
|
||||
// 换链实测 0.4–1.1s,这里给足余量;超时只是没预热成功,不影响后续播放。
|
||||
const embyPrewarmTimeout = 10 * time.Second
|
||||
|
||||
// embyPrewarmInFlight 去重同一个条目的并发预热(首页刷新会并发请求多个接口,
|
||||
// 同一条目可能在短时间内被多次请求)。
|
||||
var embyPrewarmInFlight sync.Map
|
||||
|
||||
// embyPrewarmSlots 限制同时进行的预热数量。客户端可能批量预取 PlaybackInfo
|
||||
// (逐个剧集的预取请求),预热只是优化,不能反过来把 115 换链接口打出突发。
|
||||
// 名额满时直接跳过:排队等待的预热往往等真正播放时已经没意义了。
|
||||
var embyPrewarmSlots = make(chan struct{}, 4)
|
||||
|
||||
// embyPrewarmPlaybackTargets 在后台预热本次 PlaybackInfo 涉及条目的云盘直链。
|
||||
//
|
||||
// 起播链路里最贵的一步是「服务端拿 pickcode 去 115 开放平台换直链」:服务器在
|
||||
// 洛杉矶、115 接口在国内,冷启动实测 0.4–1.1s;之后 45 分钟内命中进程内缓存。
|
||||
// 播放器在 PlaybackInfo 与真正拉流之间有几秒间隔,这里把换链放到那段间隔里,
|
||||
// 起播时就只剩纯网络耗时。
|
||||
//
|
||||
// 只处理云盘/strm 条目,且失败一律静默忽略:预热是尽力而为的优化,不能影响
|
||||
// PlaybackInfo 的正常返回。
|
||||
func embyPrewarmPlaybackTargets(svc *service.Container, c *gin.Context, out map[string]any) {
|
||||
if svc == nil || svc.Strm == nil || svc.Repo == nil || svc.Repo.Media == nil || out == nil {
|
||||
return
|
||||
}
|
||||
ids := embyPrewarmMediaIDs(out)
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
// 预热是给「后续请求」用的:即便本次 PlaybackInfo 的连接断开,
|
||||
// 也要把换链跑完。
|
||||
base := context.WithoutCancel(c.Request.Context())
|
||||
for _, mediaID := range ids {
|
||||
if _, loaded := embyPrewarmInFlight.LoadOrStore(mediaID, struct{}{}); loaded {
|
||||
continue
|
||||
}
|
||||
go func(id string) {
|
||||
defer embyPrewarmInFlight.Delete(id)
|
||||
select {
|
||||
case embyPrewarmSlots <- struct{}{}:
|
||||
defer func() { <-embyPrewarmSlots }()
|
||||
default:
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(base, embyPrewarmTimeout)
|
||||
defer cancel()
|
||||
m, err := svc.Repo.Media.FindByID(ctx, id)
|
||||
if err != nil || m == nil {
|
||||
return
|
||||
}
|
||||
// 与播放路径(StreamService.ServeFileWithCloudMode)同一套目标解析:
|
||||
// STRMURL 为空时回读 .strm 文件内容,预热才能覆盖同一批条目。
|
||||
raw := service.MediaSTRMTarget(m)
|
||||
if raw == "" || !service.IsStrmMediaRow(m) {
|
||||
return
|
||||
}
|
||||
// 解析结果由 strm 层按 pickcode+UA 缓存;已缓存时这里是空转。
|
||||
_, _ = svc.Strm.ResolvePlayTargetWithUA(ctx, raw, userAgent)
|
||||
}(mediaID)
|
||||
}
|
||||
}
|
||||
|
||||
// embyPrewarmMediaIDs 取出 PlaybackInfo 载荷里 MediaSources 的条目 ID。
|
||||
func embyPrewarmMediaIDs(out map[string]any) []string {
|
||||
sources, ok := out["MediaSources"].([]map[string]any)
|
||||
if !ok || len(sources) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(sources))
|
||||
seen := make(map[string]struct{}, len(sources))
|
||||
for _, src := range sources {
|
||||
id, _ := src["Id"].(string)
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[id]; dup {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// embySubtitleStreamHandler serves an external subtitle track advertised in a
|
||||
// MediaSource's MediaStreams via its Emby index
|
||||
// (/Videos/:id/Subtitles/:index/Stream). The index maps to a discovered
|
||||
@@ -47,10 +137,7 @@ func embySubtitleStreamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
return
|
||||
}
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
ctx := c.Request.Context()
|
||||
// The official-format route carries a :format suffix (Stream.ass /
|
||||
// Stream.vtt); prefer it for the Content-Type when present, otherwise
|
||||
@@ -324,9 +411,10 @@ func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
uid := embyUserID(c)
|
||||
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil || item == nil || svc.Stream == nil {
|
||||
// 只需确认媒体行存在且对当前用户可见;Emby.Item 会构建完整条目载荷,
|
||||
// 转码播放下每个分片请求都跑一遍太浪费。
|
||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) || svc.Stream == nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
@@ -351,9 +439,8 @@ func embyVideoHLSSegmentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
uid := embyUserID(c)
|
||||
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil || item == nil || svc.Stream == nil {
|
||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) || svc.Stream == nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func newPrewarmTestContainer(t *testing.T) *service.Container {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Media{}, &model.Setting{}, &model.StrmAccount{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
// 内存库 + 后台预热协程:限制单连接,避免新连接拿到空白的 :memory:。
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
return &service.Container{
|
||||
Log: zap.NewNop(),
|
||||
Repo: repos,
|
||||
Strm: service.NewStrmService(&config.Config{}, zap.NewNop(), repos, nil),
|
||||
}
|
||||
}
|
||||
|
||||
func newPrewarmTestContext() *gin.Context {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/emby/Items/media-1/PlaybackInfo", nil)
|
||||
c.Request.Header.Set("User-Agent", "RodelPlayer/2.2607.7.0")
|
||||
return c
|
||||
}
|
||||
|
||||
func TestEmbyPrewarmMediaIDsExtractsDeduplicates(t *testing.T) {
|
||||
out := map[string]any{
|
||||
"MediaSources": []map[string]any{
|
||||
{"Id": "src-1"},
|
||||
{"Id": " src-1 "},
|
||||
{"Id": "src-2"},
|
||||
{"Id": ""},
|
||||
{"Name": "no id"},
|
||||
},
|
||||
}
|
||||
got := embyPrewarmMediaIDs(out)
|
||||
if len(got) != 2 || got[0] != "src-1" || got[1] != "src-2" {
|
||||
t.Fatalf("ids = %v, want [src-1 src-2]", got)
|
||||
}
|
||||
if got := embyPrewarmMediaIDs(map[string]any{}); len(got) != 0 {
|
||||
t.Fatalf("missing MediaSources should yield no ids, got %v", got)
|
||||
}
|
||||
if got := embyPrewarmMediaIDs(map[string]any{"MediaSources": []any{}}); len(got) != 0 {
|
||||
t.Fatalf("foreign payload shape should yield no ids, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 预热是异步的:调用必须立即返回,并且协程结束后不能残留去重标记。
|
||||
func TestEmbyPrewarmPlaybackTargetsRunsAsyncAndCleansUp(t *testing.T) {
|
||||
svc := newPrewarmTestContainer(t)
|
||||
if err := svc.Repo.DB.Create(&model.Media{
|
||||
Base: model.Base{ID: "media-1"},
|
||||
Title: "Cloud",
|
||||
Path: "cloud://cloud115/Movie.mkv",
|
||||
Container: "strm",
|
||||
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=missing&pickcode=pc1",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
embyPrewarmInFlight.Delete("media-1")
|
||||
out := map[string]any{"MediaSources": []map[string]any{{"Id": "media-1"}}}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
embyPrewarmPlaybackTargets(svc, newPrewarmTestContext(), out)
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("embyPrewarmPlaybackTargets blocked the caller")
|
||||
}
|
||||
|
||||
// 后台协程应很快跑完并释放去重标记,否则同一条目后续再也预热不了。
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, busy := embyPrewarmInFlight.Load("media-1"); !busy {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("prewarm in-flight marker leaked")
|
||||
}
|
||||
|
||||
// 各种缺数据的情况都不允许 panic 或阻塞:预热只是尽力而为的优化。
|
||||
func TestEmbyPrewarmPlaybackTargetsIsNilSafe(t *testing.T) {
|
||||
svc := newPrewarmTestContainer(t)
|
||||
c := newPrewarmTestContext()
|
||||
out := map[string]any{"MediaSources": []map[string]any{{"Id": "media-1"}}}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
svc *service.Container
|
||||
out map[string]any
|
||||
}{
|
||||
{name: "空容器", svc: &service.Container{}, out: out},
|
||||
{name: "无 Strm", svc: &service.Container{Repo: svc.Repo}, out: out},
|
||||
{name: "无 Repo", svc: &service.Container{Strm: svc.Strm}, out: out},
|
||||
{name: "nil 载荷", svc: svc, out: nil},
|
||||
{name: "无 MediaSources", svc: svc, out: map[string]any{}},
|
||||
{name: "条目不存在", svc: svc, out: out},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
embyPrewarmPlaybackTargets(tc.svc, c, tc.out)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 本地文件条目不该触发换链预热(没有云盘直链可预热)。
|
||||
func TestEmbyPrewarmSkipsLocalMedia(t *testing.T) {
|
||||
svc := newPrewarmTestContainer(t)
|
||||
if err := svc.Repo.DB.Create(&model.Media{
|
||||
Base: model.Base{ID: "local-1"},
|
||||
Title: "Local",
|
||||
Path: "/media/movies/Local.mkv",
|
||||
LibraryID: "lib-1",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
embyPrewarmInFlight.Delete("local-1")
|
||||
embyPrewarmPlaybackTargets(svc, newPrewarmTestContext(),
|
||||
map[string]any{"MediaSources": []map[string]any{{"Id": "local-1"}}})
|
||||
|
||||
// 协程要么已经跑完(标记被清掉),要么根本没起;两种都不该留下标记。
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, busy := embyPrewarmInFlight.Load("local-1"); !busy {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("local media must not leave a prewarm marker")
|
||||
}
|
||||
@@ -92,10 +92,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
mid := c.Param("itemId")
|
||||
if uid == "" || mid == "" {
|
||||
c.Status(http.StatusBadRequest)
|
||||
@@ -116,10 +113,7 @@ func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
|
||||
|
||||
func embyMarkPlayedHandler(svc *service.Container, played bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
mid := c.Param("itemId")
|
||||
if uid == "" || mid == "" {
|
||||
c.Status(http.StatusBadRequest)
|
||||
|
||||
@@ -196,15 +196,20 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
|
||||
auth.GET("/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
|
||||
auth.GET("/Users/:userId/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
|
||||
auth.GET("/Users/:userId/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
|
||||
auth.GET("/Shows/NextUp", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Users/:userId/Shows/NextUp", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/MediaSegments/:id", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Shows/NextUp", embyNextUpHandler(svc))
|
||||
auth.GET("/Users/:userId/Shows/NextUp", embyNextUpHandler(svc))
|
||||
// 部分客户端用路径形式 /Shows/{seriesId}/NextUp,而不是 query SeriesId。
|
||||
auth.GET("/Shows/:id/NextUp", embyShowNextUpHandler(svc))
|
||||
auth.GET("/Users/:userId/Shows/:id/NextUp", embyShowNextUpHandler(svc))
|
||||
auth.GET("/MediaSegments/:id", embyMediaSegmentsHandler(svc))
|
||||
auth.GET("/Items/:id/MediaSegments", embyMediaSegmentsHandler(svc))
|
||||
auth.GET("/Users/:userId/Items/:id/MediaSegments", embyMediaSegmentsHandler(svc))
|
||||
auth.GET("/Artists", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Persons", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Genres", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Genres", embyGenresHandler(svc))
|
||||
auth.GET("/Shows/Upcoming", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Users/:userId/Shows/Upcoming", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Items/:id/Similar", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Items/:id/Similar", embySimilarHandler(svc))
|
||||
auth.GET("/Items/:id/ThumbnailSet", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Items/:id/ThemeMedia", embyThemeMediaHandler(svc))
|
||||
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
|
||||
|
||||
@@ -40,15 +40,19 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
|
||||
auth.GET("/shows/:id/episodes", embyShowEpisodesHandler(svc))
|
||||
auth.GET("/users/:userId/shows/:id/seasons", embyShowSeasonsHandler(svc))
|
||||
auth.GET("/users/:userId/shows/:id/episodes", embyShowEpisodesHandler(svc))
|
||||
auth.GET("/shows/nextup", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/users/:userId/shows/nextup", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/mediasegments/:id", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/shows/nextup", embyNextUpHandler(svc))
|
||||
auth.GET("/users/:userId/shows/nextup", embyNextUpHandler(svc))
|
||||
auth.GET("/shows/:id/nextup", embyShowNextUpHandler(svc))
|
||||
auth.GET("/users/:userId/shows/:id/nextup", embyShowNextUpHandler(svc))
|
||||
auth.GET("/mediasegments/:id", embyMediaSegmentsHandler(svc))
|
||||
auth.GET("/items/:id/mediasegments", embyMediaSegmentsHandler(svc))
|
||||
auth.GET("/users/:userId/items/:id/mediasegments", embyMediaSegmentsHandler(svc))
|
||||
auth.GET("/artists", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/persons", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/genres", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/genres", embyGenresHandler(svc))
|
||||
auth.GET("/shows/upcoming", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/users/:userId/shows/upcoming", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/items/:id/similar", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/items/:id/similar", embySimilarHandler(svc))
|
||||
auth.GET("/items/:id/thumbnailset", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/items/:id/thememedia", embyThemeMediaHandler(svc))
|
||||
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// embySeasonEpisodesRouter builds an Emby-compatible router over one series with
|
||||
// two seasons, so /Shows/{id}/Episodes can be exercised with the query forms
|
||||
// real clients send.
|
||||
func embySeasonEpisodesRouter(t *testing.T) (*gin.Engine, string, string) {
|
||||
t.Helper()
|
||||
return embySeriesRouter(t, []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "s1e1"},
|
||||
Title: "Test Show",
|
||||
Path: "D:\\media\\tv\\Test Show\\Season 01\\Test Show - S01E01.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
Container: "mkv",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "s2e1"},
|
||||
Title: "Test Show",
|
||||
Path: "D:\\media\\tv\\Test Show\\Season 02\\Test Show - S02E01.mkv",
|
||||
SeasonNum: 2,
|
||||
EpisodeNum: 1,
|
||||
Container: "mkv",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// embySeriesRouter builds an Emby-compatible router with a single tv library and
|
||||
// one series made of the given episode rows. Callers omit LibraryID: it is filled
|
||||
// in here once the library exists.
|
||||
func embySeriesRouter(t *testing.T, rows []model.Media) (*gin.Engine, string, string) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Series{}, &model.Media{}, &model.Favorite{}, &model.PlaybackHistory{}, &model.Setting{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
lib := model.Library{Name: "剧集", Path: "D:\\media\\tv", Type: "tv", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
for i := range rows {
|
||||
rows[i].LibraryID = lib.ID
|
||||
if err := db.Create(&rows[i]).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
})
|
||||
|
||||
// Resolve the virtual series id the way a client would.
|
||||
seriesReq := httptest.NewRequest(http.MethodGet, "/Items?ParentId="+lib.ID+"&IncludeItemTypes=Series", nil)
|
||||
seriesReq.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
seriesRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(seriesRec, seriesReq)
|
||||
if seriesRec.Code != http.StatusOK {
|
||||
t.Fatalf("series lookup status=%d body=%s", seriesRec.Code, seriesRec.Body.String())
|
||||
}
|
||||
var seriesPayload struct {
|
||||
Items []map[string]any `json:"Items"`
|
||||
}
|
||||
if err := json.Unmarshal(seriesRec.Body.Bytes(), &seriesPayload); err != nil {
|
||||
t.Fatalf("decode series: %v", err)
|
||||
}
|
||||
if len(seriesPayload.Items) != 1 {
|
||||
t.Fatalf("want one series, got %#v", seriesPayload.Items)
|
||||
}
|
||||
seriesID, _ := seriesPayload.Items[0]["Id"].(string)
|
||||
if seriesID == "" {
|
||||
t.Fatalf("series payload has no Id: %#v", seriesPayload.Items[0])
|
||||
}
|
||||
return router, secret, seriesID
|
||||
}
|
||||
|
||||
func fetchSeasonID(t *testing.T, router *gin.Engine, secret, seriesID string, index int) string {
|
||||
t.Helper()
|
||||
path := "/Shows/" + seriesID + "/Seasons"
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("seasons status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var seasons struct {
|
||||
Items []map[string]any `json:"Items"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &seasons); err != nil {
|
||||
t.Fatalf("decode seasons: %v", err)
|
||||
}
|
||||
for _, s := range seasons.Items {
|
||||
if got, ok := s["IndexNumber"].(float64); ok && int(got) == index {
|
||||
if id, _ := s["Id"].(string); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("season %d not found in %#v", index, seasons.Items)
|
||||
return ""
|
||||
}
|
||||
|
||||
func fetchEpisodeItems(t *testing.T, router *gin.Engine, secret, path string) ([]map[string]any, int) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s status=%d body=%s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
Items []map[string]any `json:"Items"`
|
||||
TotalRecordCount float64 `json:"TotalRecordCount"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode %s: %v", path, err)
|
||||
}
|
||||
return payload.Items, int(payload.TotalRecordCount)
|
||||
}
|
||||
|
||||
func fetchEpisodeIDs(t *testing.T, router *gin.Engine, secret, path string) ([]string, int) {
|
||||
t.Helper()
|
||||
items, total := fetchEpisodeItems(t, router, secret, path)
|
||||
ids := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
id, _ := item["Id"].(string)
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, total
|
||||
}
|
||||
|
||||
// TestEmbyShowEpisodesHonoursSeasonQueryParam is the client-facing regression
|
||||
// test: Emby clients scope episodes with ?Season=<index> (not only SeasonId), and
|
||||
// the endpoint used to ignore it and return every season of the series.
|
||||
func TestEmbyShowEpisodesHonoursSeasonQueryParam(t *testing.T) {
|
||||
router, secret, seriesID := embySeasonEpisodesRouter(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
query string
|
||||
wantIDs []string
|
||||
}{
|
||||
{name: "season 1", query: "Season=1", wantIDs: []string{"s1e1"}},
|
||||
{name: "season 2", query: "Season=2", wantIDs: []string{"s2e1"}},
|
||||
{name: "season index alias", query: "SeasonIndex=2", wantIDs: []string{"s2e1"}},
|
||||
{name: "no season returns all", query: "", wantIDs: []string{"s1e1", "s2e1"}},
|
||||
{name: "unknown season is empty", query: "Season=9", wantIDs: []string{}},
|
||||
{name: "non numeric season falls back to all", query: "Season=Specials", wantIDs: []string{"s1e1", "s2e1"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := "/Shows/" + seriesID + "/Episodes"
|
||||
if tc.query != "" {
|
||||
path += "?" + tc.query
|
||||
}
|
||||
ids, total := fetchEpisodeIDs(t, router, secret, path)
|
||||
if len(ids) != len(tc.wantIDs) {
|
||||
t.Fatalf("episode ids = %#v (total=%d), want %#v", ids, total, tc.wantIDs)
|
||||
}
|
||||
for i := range tc.wantIDs {
|
||||
if ids[i] != tc.wantIDs[i] {
|
||||
t.Fatalf("episode ids = %#v, want %#v", ids, tc.wantIDs)
|
||||
}
|
||||
}
|
||||
if total != len(tc.wantIDs) {
|
||||
t.Fatalf("TotalRecordCount = %d, want %d", total, len(tc.wantIDs))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbyShowEpisodesSeasonIdStillWins pins that the virtual SeasonId form keeps
|
||||
// working, and that a conflicting Season query cannot empty it out.
|
||||
func TestEmbyShowEpisodesSeasonIdStillWins(t *testing.T) {
|
||||
router, secret, seriesID := embySeasonEpisodesRouter(t)
|
||||
|
||||
season1ID := fetchSeasonID(t, router, secret, seriesID, 1)
|
||||
|
||||
ids, _ := fetchEpisodeIDs(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+season1ID)
|
||||
if len(ids) != 1 || ids[0] != "s1e1" {
|
||||
t.Fatalf("SeasonId episodes = %#v, want [s1e1]", ids)
|
||||
}
|
||||
|
||||
// A stale/conflicting season number must not override the explicit season id.
|
||||
ids, _ = fetchEpisodeIDs(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+season1ID+"&Season=2")
|
||||
if len(ids) != 1 || ids[0] != "s1e1" {
|
||||
t.Fatalf("SeasonId+Season episodes = %#v, want [s1e1]", ids)
|
||||
}
|
||||
}
|
||||
|
||||
// foldedDuplicateSeriesRows models a season where two files resolve to the same
|
||||
// episode number — 3月的狮子 S01E11 together with S01E11.5, which the episode
|
||||
// parser reads as S01E11 and stores as a second row for season 1 episode 2 here.
|
||||
func foldedDuplicateSeriesRows() []model.Media {
|
||||
return []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "e1"},
|
||||
Title: "三月的狮子",
|
||||
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E01.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
Container: "mkv",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "e2"},
|
||||
Title: "三月的狮子",
|
||||
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E02.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 2,
|
||||
Container: "mkv",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "e2half"},
|
||||
Title: "三月的狮子",
|
||||
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E02.5.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 2,
|
||||
Container: "mkv",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbyShowEpisodesFoldedDuplicateKeepsTotalsConsistent is the regression test
|
||||
// for the "第一季分集加载不出来" lock-up: two rows sharing one season/episode are
|
||||
// folded into a single Emby item with two MediaSources. TotalRecordCount used to
|
||||
// be computed before that fold, so clients that page until they have
|
||||
// TotalRecordCount items kept re-requesting the same page and never rendered the
|
||||
// season.
|
||||
func TestEmbyShowEpisodesFoldedDuplicateKeepsTotalsConsistent(t *testing.T) {
|
||||
router, secret, seriesID := embySeriesRouter(t, foldedDuplicateSeriesRows())
|
||||
seasonID := fetchSeasonID(t, router, secret, seriesID, 1)
|
||||
|
||||
items, total := fetchEpisodeItems(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+seasonID)
|
||||
if total != len(items) {
|
||||
t.Fatalf("TotalRecordCount = %d but %d items returned, want them equal", total, len(items))
|
||||
}
|
||||
if total != 2 {
|
||||
t.Fatalf("TotalRecordCount = %d, want 2 distinct episodes", total)
|
||||
}
|
||||
// The folded episode must still carry both files so clients can switch version.
|
||||
versioned := 0
|
||||
for _, item := range items {
|
||||
if sources, ok := item["MediaSources"].([]any); ok && len(sources) > 1 {
|
||||
versioned++
|
||||
}
|
||||
}
|
||||
if versioned != 1 {
|
||||
t.Fatalf("items with multiple MediaSources = %d, want 1 (%#v)", versioned, items)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbyShowEpisodesPagingAdvances pins that the client's StartIndex/Limit reach
|
||||
// the episode list. Ignoring them made every page identical, so a client paging
|
||||
// until it has TotalRecordCount items could never finish loading the season.
|
||||
func TestEmbyShowEpisodesPagingAdvances(t *testing.T) {
|
||||
router, secret, seriesID := embySeriesRouter(t, foldedDuplicateSeriesRows())
|
||||
base := "/Shows/" + seriesID + "/Episodes?Season=1"
|
||||
|
||||
first, total := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=0&Limit=1")
|
||||
if total != 2 || len(first) != 1 {
|
||||
t.Fatalf("page 1 = %#v (total=%d), want one item of two", first, total)
|
||||
}
|
||||
second, _ := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=1&Limit=1")
|
||||
if len(second) != 1 || second[0] == first[0] {
|
||||
t.Fatalf("page 2 = %#v, want the remaining episode (page 1 = %#v)", second, first)
|
||||
}
|
||||
third, _ := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=2&Limit=1")
|
||||
if len(third) != 0 {
|
||||
t.Fatalf("page 3 = %#v, want an empty page so clients stop paging", third)
|
||||
}
|
||||
}
|
||||
|
||||
// 半集(S01E11.5)在 Emby 侧必须和第 11 集并列返回:一旦被折成一个条目,
|
||||
// TotalRecordCount 就会比 Items 多一条,按总数翻页的客户端会一直重发同一页
|
||||
// (这就是「第一季分集加载不出来」的根因)。Emby 的 IndexNumber 只能是整数,
|
||||
// 所以集号仍占 11,小数通过条目名体现。
|
||||
func TestEmbyShowEpisodesKeepsHalfEpisodeVisible(t *testing.T) {
|
||||
router, secret, seriesID := embySeriesRouter(t, []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "e11"},
|
||||
Title: "三月的狮子",
|
||||
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E11.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 11,
|
||||
Container: "mkv",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "e11half"},
|
||||
Title: "三月的狮子",
|
||||
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E11.5.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 11,
|
||||
EpisodeFraction: 0.5,
|
||||
Container: "mkv",
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "e12"},
|
||||
Title: "三月的狮子",
|
||||
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E12.mkv",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 12,
|
||||
Container: "mkv",
|
||||
},
|
||||
})
|
||||
|
||||
items, total := fetchEpisodeItems(t, router, secret, "/Shows/"+seriesID+"/Episodes?Season=1")
|
||||
if total != 3 || len(items) != 3 {
|
||||
t.Fatalf("TotalRecordCount = %d, items = %d, want 3/3", total, len(items))
|
||||
}
|
||||
wantOrder := []string{"e11", "e11half", "e12"}
|
||||
for i, want := range wantOrder {
|
||||
id, _ := items[i]["Id"].(string)
|
||||
if id != want {
|
||||
t.Fatalf("items[%d] = %q, want %q (order %#v)", i, id, want, items)
|
||||
}
|
||||
}
|
||||
for _, item := range items {
|
||||
sources, _ := item["MediaSources"].([]any)
|
||||
if len(sources) != 1 {
|
||||
t.Fatalf("item %v folded %d sources, want 1", item["Id"], len(sources))
|
||||
}
|
||||
}
|
||||
|
||||
halfName, _ := items[1]["Name"].(string)
|
||||
if !strings.HasPrefix(halfName, "第 11.5 集") {
|
||||
t.Fatalf("half episode name = %q, want prefix 第 11.5 集", halfName)
|
||||
}
|
||||
if idx, _ := items[1]["IndexNumber"].(float64); int(idx) != 11 {
|
||||
t.Fatalf("half episode IndexNumber = %v, want 11 (Emby only supports integers)", items[1]["IndexNumber"])
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,7 @@ import (
|
||||
|
||||
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
}
|
||||
uid := embyEffectiveUserID(c)
|
||||
out, err := svc.Emby.Views(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -19,12 +19,27 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
||||
api.Use(middleware.GzipAPI())
|
||||
{
|
||||
api.GET("/health", healthCheck)
|
||||
api.HEAD("/health", healthCheck)
|
||||
api.GET("/version", versionInfo)
|
||||
api.GET("/public/ui-config", publicUIConfigHandler(svc))
|
||||
|
||||
// STRM 播放端点:strm 文件内容指向这里,Emby/Infuse 直接请求(无 JWT)。
|
||||
api.GET("/strm/play/:provider/:file", strmPlayHandler(svc))
|
||||
api.HEAD("/strm/play/:provider/:file", strmPlayHandler(svc))
|
||||
// 阅读媒体代理:音频流/漫画图片,鉴权靠 HMAC 签名(audio/img 元素带不了 JWT)。
|
||||
api.GET("/reader/media", readerMediaProxyHandler(svc))
|
||||
// 本地书籍内嵌资源(EPUB 图片):同样靠 HMAC 签名鉴权。
|
||||
api.GET("/reader/local/asset", readerLocalAssetHandler(svc))
|
||||
// 本地有声书音频流:同样靠 HMAC 签名鉴权,服务端处理 Range。
|
||||
api.GET("/reader/local/audio", readerLocalAudioHandler(svc))
|
||||
api.HEAD("/reader/local/audio", readerLocalAudioHandler(svc))
|
||||
// 需要转码的音轨(WMA 等浏览器解不了的格式):签名鉴权 + 服务端 ffmpeg 转码。
|
||||
api.GET("/reader/audio/transcode", readerAudioTranscodeHandler(svc))
|
||||
api.HEAD("/reader/audio/transcode", readerAudioTranscodeHandler(svc))
|
||||
// 阅读书源浏览器页面与资源代理:java.startBrowserAwait 承载的页面填进
|
||||
// <iframe src>,同样带不了 JWT,鉴权靠绑定待办 ID 的 HMAC 签名。
|
||||
api.GET("/reader/browser/page", readerBrowserPageHandler(svc))
|
||||
api.GET("/reader/browser/asset", readerBrowserAssetHandler(svc))
|
||||
// 115 中继/CloudDrive 授权回跳(authorization_id 会话 + 共享密钥校验)
|
||||
api.POST("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
|
||||
api.GET("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// 媒体库筛选面板接口:facets 提供可选项,random 提供「随便看看」。
|
||||
//
|
||||
// 两者都走与列表完全相同的可见性判定(mediaVisibilityForRequest)与筛选解析
|
||||
// (parseLibraryFilters),因此不会出现「列表里有、facets 里没有」或「随机跳
|
||||
// 到了筛选条件之外的条目」这类不一致。
|
||||
|
||||
func libraryFacetsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
libraryID := c.Param("id")
|
||||
facets, err := svc.Media.LibraryFacets(
|
||||
c.Request.Context(),
|
||||
libraryID,
|
||||
mediaVisibilityForRequest(c, svc),
|
||||
svc.Discovery,
|
||||
)
|
||||
if err != nil {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, facets)
|
||||
}
|
||||
}
|
||||
|
||||
func libraryRandomHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
libraryID := c.Param("id")
|
||||
filters := parseLibraryFilters(c)
|
||||
// 随机只取一条,因此不带分页参数;未观看筛选仍需要会话用户。
|
||||
media, err := svc.Media.RandomMedia(
|
||||
c.Request.Context(),
|
||||
libraryID,
|
||||
mediaVisibilityForRequest(c, svc),
|
||||
filters,
|
||||
)
|
||||
if err != nil {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
}
|
||||
if media == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no media matches the current filters"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, media)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func newLibraryFilterEnv(t *testing.T) (*gin.Engine, *service.Container, string, string) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&model.User{}, &model.Library{}, &model.Media{}, &model.PlaybackHistory{}, &model.Setting{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
|
||||
svc.Media = service.NewMediaService(nil, zap.NewNop(), repos)
|
||||
svc.Discovery = service.NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||
|
||||
const userID = "user-1"
|
||||
if err := repos.User.Create(context.Background(), &model.User{
|
||||
Base: model.Base{ID: userID}, Username: "tester", PasswordHash: "x", Role: "user", IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lib := &model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||
if err := repos.Library.Create(context.Background(), lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
authed := router.Group("/api", func(c *gin.Context) {
|
||||
c.Set(middleware.CtxUserID, userID)
|
||||
c.Set(middleware.CtxUserRole, "user")
|
||||
c.Next()
|
||||
})
|
||||
authed.GET("/libraries/:id/media", listMediaHandler(svc))
|
||||
authed.GET("/libraries/:id/facets", libraryFacetsHandler(svc))
|
||||
authed.GET("/libraries/:id/random", libraryRandomHandler(svc))
|
||||
return router, svc, userID, lib.ID
|
||||
}
|
||||
|
||||
func seedLibraryMedia(t *testing.T, svc *service.Container, rows ...*model.Media) {
|
||||
t.Helper()
|
||||
for _, row := range rows {
|
||||
if err := svc.Repo.DB.Create(row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getJSON(t *testing.T, router *gin.Engine, path string) (int, []byte) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w.Code, w.Body.Bytes()
|
||||
}
|
||||
|
||||
func TestLibraryFacetsReturnGenresAndYears(t *testing.T) {
|
||||
router, svc, _, libID := newLibraryFilterEnv(t)
|
||||
seedLibraryMedia(t, svc,
|
||||
&model.Media{LibraryID: libID, Title: "A", Genres: "Action,Drama", Year: 1999, Path: "/a.mkv"},
|
||||
&model.Media{LibraryID: libID, Title: "B", Genres: "Action", Year: 2021, Path: "/b.mkv"},
|
||||
)
|
||||
|
||||
code, body := getJSON(t, router, "/api/libraries/"+libID+"/facets")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", code, body)
|
||||
}
|
||||
var facets struct {
|
||||
Genres []struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
} `json:"genres"`
|
||||
YearMin int `json:"year_min"`
|
||||
YearMax int `json:"year_max"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &facets); err != nil {
|
||||
t.Fatalf("decode %s: %v", body, err)
|
||||
}
|
||||
if facets.YearMin != 1999 || facets.YearMax != 2021 {
|
||||
t.Fatalf("year range = %d..%d, want 1999..2021", facets.YearMin, facets.YearMax)
|
||||
}
|
||||
if len(facets.Genres) != 2 {
|
||||
t.Fatalf("genres = %+v, want 2 entries", facets.Genres)
|
||||
}
|
||||
if facets.Genres[0].Name != "Action" || facets.Genres[0].Count != 2 {
|
||||
t.Fatalf("first genre = %+v, want Action:2", facets.Genres[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 空库时 facets 必须返回空数组而不是 null,前端无需额外判空。
|
||||
func TestLibraryFacetsEmptyLibrary(t *testing.T) {
|
||||
router, _, _, libID := newLibraryFilterEnv(t)
|
||||
|
||||
code, body := getJSON(t, router, "/api/libraries/"+libID+"/facets")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", code, body)
|
||||
}
|
||||
if !containsSubstring(string(body), `"genres":[]`) {
|
||||
t.Fatalf("body = %s, want genres:[]", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 列表筛选:按类型过滤后只返回命中的条目。
|
||||
func TestListMediaAppliesGenreFilter(t *testing.T) {
|
||||
router, svc, _, libID := newLibraryFilterEnv(t)
|
||||
seedLibraryMedia(t, svc,
|
||||
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
|
||||
&model.Media{LibraryID: libID, Title: "喜剧", Genres: "Comedy", Path: "/b.mkv"},
|
||||
)
|
||||
|
||||
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&genre=Action")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", code, body)
|
||||
}
|
||||
var payload struct {
|
||||
Items []model.Media `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("decode %s: %v", body, err)
|
||||
}
|
||||
if payload.Total != 1 || len(payload.Items) != 1 || payload.Items[0].Title != "动作" {
|
||||
t.Fatalf("payload = %+v, want only 动作", payload)
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选条件必须进入缓存键:先请求未筛选列表、再筛选时不能命中旧缓存。
|
||||
func TestListMediaFilterBypassesUnfilteredCache(t *testing.T) {
|
||||
router, svc, _, libID := newLibraryFilterEnv(t)
|
||||
seedLibraryMedia(t, svc,
|
||||
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
|
||||
&model.Media{LibraryID: libID, Title: "喜剧", Genres: "Comedy", Path: "/b.mkv"},
|
||||
)
|
||||
|
||||
// 先拉全量(可能写缓存),再拉筛选结果。
|
||||
if code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0"); code != http.StatusOK {
|
||||
t.Fatalf("unfiltered status = %d body=%s", code, body)
|
||||
}
|
||||
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&genre=Comedy")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("filtered status = %d body=%s", code, body)
|
||||
}
|
||||
var payload struct {
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Total != 1 {
|
||||
t.Fatalf("total = %d, want 1 (filtered response must not be served from the unfiltered cache)", payload.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// 未观看筛选:已看完的不出现,看了一半的仍出现。
|
||||
func TestListMediaUnwatchedFilter(t *testing.T) {
|
||||
router, svc, userID, libID := newLibraryFilterEnv(t)
|
||||
seedLibraryMedia(t, svc,
|
||||
&model.Media{Base: model.Base{ID: "m-done"}, LibraryID: libID, Title: "看完", Path: "/a.mkv"},
|
||||
&model.Media{Base: model.Base{ID: "m-half"}, LibraryID: libID, Title: "看一半", Path: "/b.mkv"},
|
||||
)
|
||||
for _, h := range []*model.PlaybackHistory{
|
||||
{UserID: userID, MediaID: "m-done", Completed: true},
|
||||
{UserID: userID, MediaID: "m-half", Completed: false},
|
||||
} {
|
||||
if err := svc.Repo.DB.Create(h).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&unwatched=1")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", code, body)
|
||||
}
|
||||
var payload struct {
|
||||
Items []model.Media `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(payload.Items) != 1 || payload.Items[0].Title != "看一半" {
|
||||
t.Fatalf("items = %+v, want only 看一半", payload.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryRandomReturnsMedia(t *testing.T) {
|
||||
router, svc, _, libID := newLibraryFilterEnv(t)
|
||||
seedLibraryMedia(t, svc,
|
||||
&model.Media{LibraryID: libID, Title: "唯一", Genres: "Action", Path: "/a.mkv"},
|
||||
)
|
||||
|
||||
code, body := getJSON(t, router, "/api/libraries/"+libID+"/random")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", code, body)
|
||||
}
|
||||
var media model.Media
|
||||
if err := json.Unmarshal(body, &media); err != nil {
|
||||
t.Fatalf("decode %s: %v", body, err)
|
||||
}
|
||||
if media.Title != "唯一" {
|
||||
t.Fatalf("title = %q, want 唯一", media.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选后没有命中时返回 404,前端据此提示「没有符合条件的媒体」。
|
||||
func TestLibraryRandomEmptyResultIs404(t *testing.T) {
|
||||
router, svc, _, libID := newLibraryFilterEnv(t)
|
||||
seedLibraryMedia(t, svc,
|
||||
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
|
||||
)
|
||||
|
||||
code, body := getJSON(t, router, "/api/libraries/"+libID+"/random?genre=Nonexistent")
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d body=%s, want 404", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// axios 默认把数组序列化为 genre[]=Action 格式;后端必须把它当作 genre=Action 处理。
|
||||
func TestListMediaAcceptsBracketGenreParam(t *testing.T) {
|
||||
router, svc, _, libID := newLibraryFilterEnv(t)
|
||||
seedLibraryMedia(t, svc,
|
||||
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
|
||||
&model.Media{LibraryID: libID, Title: "喜剧", Genres: "Comedy", Path: "/b.mkv"},
|
||||
)
|
||||
|
||||
// genre[]=Action — axios bracket format without custom paramsSerializer
|
||||
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&genre[]=Action")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", code, body)
|
||||
}
|
||||
var payload struct {
|
||||
Items []model.Media `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("decode %s: %v", body, err)
|
||||
}
|
||||
if payload.Total != 1 || len(payload.Items) != 1 || payload.Items[0].Title != "动作" {
|
||||
t.Fatalf("payload = %+v, want only 动作 for genre[]=Action", payload)
|
||||
}
|
||||
}
|
||||
|
||||
// 随机也遵守筛选:只命中 Action 时,带 Comedy 筛选必须 404。
|
||||
func TestLibraryRandomHonoursFilters(t *testing.T) {
|
||||
router, svc, _, libID := newLibraryFilterEnv(t)
|
||||
seedLibraryMedia(t, svc,
|
||||
&model.Media{LibraryID: libID, Title: "A", Genres: "Action", Year: 2001, Path: "/a.mkv"},
|
||||
&model.Media{LibraryID: libID, Title: "B", Genres: "Comedy", Year: 2002, Path: "/b.mkv"},
|
||||
)
|
||||
|
||||
code, body := getJSON(t, router, "/api/libraries/"+libID+"/random?genre=Comedy&year_min=2002")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", code, body)
|
||||
}
|
||||
var media model.Media
|
||||
if err := json.Unmarshal(body, &media); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media.Title != "B" {
|
||||
t.Fatalf("title = %q, want B", media.Title)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -33,10 +34,10 @@ type createLibraryReq struct {
|
||||
// 统一结构(远程库附加 is_remote_emby / remote_source 只读标记)。
|
||||
type webLibraryPayload struct {
|
||||
model.Library
|
||||
IsRemoteEmby bool `json:"is_remote_emby,omitempty"`
|
||||
RemoteSource string `json:"remote_source,omitempty"`
|
||||
Total int64 `json:"total,omitempty"`
|
||||
Cards []service.SeriesCard `json:"cards,omitempty"`
|
||||
IsRemoteEmby bool `json:"is_remote_emby,omitempty"`
|
||||
RemoteSource string `json:"remote_source,omitempty"`
|
||||
Total int64 `json:"total,omitempty"`
|
||||
Cards []service.SeriesCardView `json:"cards,omitempty"`
|
||||
}
|
||||
|
||||
// remoteLibraryItemTypes 远程库内容拉取时按 CollectionType 过滤直属条目,
|
||||
@@ -115,7 +116,11 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
for _, p := range previews {
|
||||
out = append(out, webLibraryPayload{Library: p.Library, Total: p.Total, Cards: p.Cards})
|
||||
out = append(out, webLibraryPayload{
|
||||
Library: p.Library,
|
||||
Total: p.Total,
|
||||
Cards: service.NewSeriesCardViews(p.Cards),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
@@ -202,7 +207,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
remotePayloads[i].Total = total
|
||||
}
|
||||
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
|
||||
remotePayloads[i].Cards = cards
|
||||
remotePayloads[i].Cards = service.NewSeriesCardViews(cards)
|
||||
}
|
||||
})
|
||||
}()
|
||||
@@ -403,6 +408,92 @@ func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// parseLibraryFilters 解析媒体库列表的筛选查询参数。
|
||||
//
|
||||
// 全部参数都是可选的:缺省时返回零值,`MediaListFilters.empty()` 为真,列表
|
||||
// 行为与此前完全一致(不引入任何默认筛选)。
|
||||
//
|
||||
// 参数约定:
|
||||
// - genre=Action&genre=Comedy 类型多选(或关系,整词匹配)
|
||||
// - year_min / year_max 年份区间,0 或非法值表示不限
|
||||
// - rating_min 评分下限(浮点)
|
||||
// - unwatched=1 仅显示未看完;用户 ID 取自会话
|
||||
func parseLibraryFilters(c *gin.Context) service.MediaListFilters {
|
||||
filters := service.MediaListFilters{
|
||||
Genres: parseRepeatedQueryValues(c, "genre"),
|
||||
YearMin: parseNonNegativeInt(firstQueryValue(c, "year_min", "yearMin")),
|
||||
YearMax: parseNonNegativeInt(firstQueryValue(c, "year_max", "yearMax")),
|
||||
RatingMin: parseNonNegativeFloat(firstQueryValue(c, "rating_min", "ratingMin")),
|
||||
}
|
||||
if isTruthyQuery(firstQueryValue(c, "unwatched", "unwatched_only", "unwatchedOnly")) {
|
||||
filters.Unwatched = true
|
||||
filters.UserID = toString(mustSessionUserID(c))
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
||||
// parseRepeatedQueryValues 读取可重复出现的查询参数,去重并丢弃空值。
|
||||
// 同时接受 key[] 括号格式(axios 1.x 默认序列化方式)作为向后兼容回退,
|
||||
// 在前端 paramsSerializer 未正确配置时不会静默返回空结果。
|
||||
func parseRepeatedQueryValues(c *gin.Context, key string) []string {
|
||||
raw := c.QueryArray(key)
|
||||
if len(raw) == 0 {
|
||||
// fallback: axios bracket format (e.g. genre[]=Action&genre[]=Comedy)
|
||||
raw = c.QueryArray(key + "[]")
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(raw))
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, value := range raw {
|
||||
// 客户端可能把多值拼成一次逗号分隔,两种形式都要接受。
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseNonNegativeInt(raw string) int {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || value < 0 {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseNonNegativeFloat(raw string) float64 {
|
||||
value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
||||
if err != nil || value < 0 || math.IsNaN(value) {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func isTruthyQuery(raw string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// mustSessionUserID 取会话用户 ID,缺失时返回空串(筛选逻辑会忽略它)。
|
||||
func mustSessionUserID(c *gin.Context) any {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
return uid
|
||||
}
|
||||
|
||||
func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
@@ -446,9 +537,10 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if sortSpec.Field == "last_played" {
|
||||
history = mediaHistoryMap(c, svc)
|
||||
}
|
||||
filters := parseLibraryFilters(c)
|
||||
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
|
||||
if !groupVersions {
|
||||
items, total, err := svc.Media.ListMediaVisible(ctx, id, page, size, mediaVisibilityForRequest(c, svc))
|
||||
items, total, err := svc.Media.ListMediaVisibleFiltered(ctx, id, page, size, mediaVisibilityForRequest(c, svc), filters)
|
||||
if err != nil {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
@@ -464,7 +556,7 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
})
|
||||
return
|
||||
}
|
||||
grouped, err := svc.Media.GroupedMediaVisible(ctx, id, mediaVisibilityForRequest(c, svc))
|
||||
grouped, err := svc.Media.GroupedMediaVisibleFiltered(ctx, id, mediaVisibilityForRequest(c, svc), filters)
|
||||
if err != nil {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
@@ -718,7 +810,9 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if mount.ProxyPlay {
|
||||
// 远程 Emby 条目的同源转发:VR 全景需要浏览器读帧(见 stream_proxy.go),
|
||||
// 与挂载账号的 proxy_play 开关等价。
|
||||
if mount.ProxyPlay || wantSameOriginProxy(c) {
|
||||
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
@@ -754,6 +848,15 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if !enforceScopedPlaybackToken(c, m.ID) {
|
||||
return
|
||||
}
|
||||
// ?proxy=1:把网盘/STRM 直链改为服务端同源转发(网页端读帧、VR 全景用),
|
||||
// 画质与原文件一致,不触发转码。
|
||||
if wantSameOriginProxy(c) {
|
||||
handled, proxyErr := proxySTRMStream(c, svc, m)
|
||||
if handled {
|
||||
writeProxyError(c, proxyErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
err = svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
|
||||
if errors.Is(err, service.ErrMediaNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
@@ -13,6 +14,45 @@ type pinnedLibrariesReq struct {
|
||||
LibraryIDs []string `json:"library_ids"`
|
||||
}
|
||||
|
||||
type libraryTagsReq struct {
|
||||
Tags []model.LibraryTagSet `json:"tags"`
|
||||
}
|
||||
|
||||
func getLibraryTagsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
tags, err := svc.Profile.GetLibraryTags(c.Request.Context(), uid.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []model.LibraryTagSet{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"tags": tags})
|
||||
}
|
||||
}
|
||||
|
||||
func setLibraryTagsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req libraryTagsReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
tags, err := svc.Profile.SetLibraryTags(c.Request.Context(), uid.(string), req.Tags)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []model.LibraryTagSet{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"tags": tags})
|
||||
}
|
||||
}
|
||||
|
||||
func getPinnedLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
@@ -173,10 +175,20 @@ func externalPlaybackToken(c *gin.Context, svc *service.Container, mediaID strin
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
u, err := svc.Repo.User.FindByID(c.Request.Context(), toString(uid))
|
||||
if err != nil || u == nil {
|
||||
if svc.Log != nil {
|
||||
svc.Log.Warn("external playback token: user lookup failed",
|
||||
zap.String("media_id", mediaID), zap.Error(err))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
token, err := svc.Auth.IssueExternalPlaybackToken(u, mediaID, durationSec)
|
||||
if err != nil {
|
||||
// 静默返回空串会让 stream_url 变成 ?token=(空)、外部播放器只看到
|
||||
// 401 无从排查,至少在服务端日志里留下原因。
|
||||
if svc.Log != nil {
|
||||
svc.Log.Warn("issue external playback token failed",
|
||||
zap.String("media_id", mediaID), zap.Error(err))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return token
|
||||
|
||||
@@ -346,6 +346,9 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
|
||||
&model.Library{},
|
||||
&model.Media{},
|
||||
&model.PlayProfile{},
|
||||
&model.Series{},
|
||||
&model.MediaSegment{},
|
||||
&model.MediaSegmentFetch{},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -408,6 +411,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
|
||||
api := router.Group("/api")
|
||||
api.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
|
||||
api.GET("/playback/:id/info", playbackInfoHandler(svc))
|
||||
api.GET("/playback/:id/segments", playbackSegmentsHandler(svc))
|
||||
api.GET("/playback/:id/external-url", externalURLHandler(svc))
|
||||
api.GET("/playback/:id/external-players", externalPlayersHandler(svc))
|
||||
api.GET("/stream/:id", streamHandler(svc))
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Package handler — 片头/片尾片段接口。
|
||||
//
|
||||
// GET /playback/:id/segments
|
||||
//
|
||||
// 单独开一个接口而不是塞进 /media/:id/playback,有两个原因:一是抓取外部数据
|
||||
// 可能要几秒,不能拖慢决定能否起播的那个请求;二是片段与播放来源(本地 / 云盘 /
|
||||
// 远程 Emby)无关,独立出来对所有媒体一致。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// playbackSegmentsHandler returns the skippable ranges for one media item.
|
||||
// The client calls it after playback has already started, so the provider
|
||||
// lookup never delays a play.
|
||||
func playbackSegmentsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
autoSkip := resolveAutoSkipFlag(c, svc)
|
||||
segments := []service.SegmentView{}
|
||||
|
||||
m, err := findMediaForPlaybackEndpoint(c, svc, c.Param("id"))
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||
return
|
||||
}
|
||||
// 远程 Emby 挂载的条目是上游库的投影,本地没有可查询的外部 ID 关联。
|
||||
if svc.Segments != nil && !service.IsEmbyRemoteID(m.ID) {
|
||||
rows, listErr := svc.Segments.ListForPlayback(c.Request.Context(), m)
|
||||
if listErr != nil && svc.Log != nil {
|
||||
svc.Log.Debug("list media segments failed",
|
||||
zap.String("media_id", m.ID), zap.Error(listErr))
|
||||
}
|
||||
segments = service.ToSegmentViews(rows)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"segments": segments, "auto_skip": autoSkip})
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAutoSkipFlag reads the「自动跳过片头」switch off whichever profile is
|
||||
// currently in effect. It reuses selectedPlayProfile so the server agrees with
|
||||
// the UI about which profile is active (explicit header first, then the user's
|
||||
// default profile), and a PIN-locked profile never silently skips for the user.
|
||||
func resolveAutoSkipFlag(c *gin.Context, svc *service.Container) bool {
|
||||
profile, locked := selectedPlayProfile(c, svc)
|
||||
if locked || profile == nil {
|
||||
return false
|
||||
}
|
||||
return profile.SkipIntro
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
type segmentPayload struct {
|
||||
Segments []struct {
|
||||
Kind string `json:"kind"`
|
||||
StartMs int64 `json:"start_ms"`
|
||||
EndMs int64 `json:"end_ms"`
|
||||
} `json:"segments"`
|
||||
AutoSkip bool `json:"auto_skip"`
|
||||
}
|
||||
|
||||
const segmentsProviderBody = `{"tmdb_id":27205,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}],"credits":[{"start_ms":6480000,"end_ms":null}]}`
|
||||
|
||||
func TestPlaybackSegmentsReturnsProviderDataAndAutoSkip(t *testing.T) {
|
||||
router, svc, secret := newPlaybackScopeTestRouter(t)
|
||||
// 片段数据与播放来源无关,云盘媒体同样适用,只要它能解析出外部 ID。
|
||||
// 注意列名是 tm_db_id(GORM 对 TMDbID 的默认命名)。
|
||||
if err := svc.Repo.DB.Model(&model.Media{}).
|
||||
Where("id = ?", "media-1").Update("tm_db_id", 27205).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var calls int32
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(segmentsProviderBody))
|
||||
}))
|
||||
defer provider.Close()
|
||||
svc.Segments = service.NewMediaSegmentService(zap.NewNop(), svc.Repo).
|
||||
SetIntroDB(service.NewIntroDBService(zap.NewNop()).SetBaseURL(provider.URL))
|
||||
|
||||
// 默认档案打开「自动跳过片头」,接口应把开关原样带出来。
|
||||
if err := svc.Repo.DB.Create(&model.PlayProfile{
|
||||
Base: model.Base{ID: "profile-1"},
|
||||
UserID: "user-1",
|
||||
Name: "主档案",
|
||||
IsDefault: true,
|
||||
SkipIntro: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loginToken := signedTestToken(t, secret)
|
||||
fetch := func() segmentPayload {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-1/segments", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+loginToken)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var payload segmentPayload
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
first := fetch()
|
||||
if !first.AutoSkip {
|
||||
t.Fatal("auto_skip should reflect the active profile's skip_intro switch")
|
||||
}
|
||||
if len(first.Segments) != 2 {
|
||||
t.Fatalf("segments = %#v, want 2", first.Segments)
|
||||
}
|
||||
// start_ms: null -> 0;end_ms: null -> 0(延续到片尾,由客户端按时长补齐)。
|
||||
if first.Segments[0].Kind != "intro" || first.Segments[0].StartMs != 0 || first.Segments[0].EndMs != 38_000 {
|
||||
t.Fatalf("intro segment = %#v", first.Segments[0])
|
||||
}
|
||||
if first.Segments[1].Kind != "credits" || first.Segments[1].StartMs != 6_480_000 || first.Segments[1].EndMs != 0 {
|
||||
t.Fatalf("credits segment = %#v", first.Segments[1])
|
||||
}
|
||||
|
||||
// 第二次播放必须走本地缓存,不再打外网。
|
||||
if second := fetch(); len(second.Segments) != 2 {
|
||||
t.Fatalf("second fetch segments = %#v", second.Segments)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("provider calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackSegmentsAutoSkipIsFalseWithoutProfile(t *testing.T) {
|
||||
router, svc, secret := newPlaybackScopeTestRouter(t)
|
||||
svc.Segments = service.NewMediaSegmentService(zap.NewNop(), svc.Repo)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-1/segments", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+signedTestToken(t, secret))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var payload segmentPayload
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if payload.AutoSkip {
|
||||
t.Fatal("auto_skip must default to false")
|
||||
}
|
||||
// 即使一条片段都没有,也必须返回空数组而不是 null,前端才能无条件遍历。
|
||||
if payload.Segments == nil {
|
||||
t.Fatal("segments must serialise as an empty array, not null")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackSegmentsForUnknownMediaReturnsNotFound(t *testing.T) {
|
||||
router, svc, secret := newPlaybackScopeTestRouter(t)
|
||||
svc.Segments = service.NewMediaSegmentService(zap.NewNop(), svc.Repo)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/does-not-exist/segments", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+signedTestToken(t, secret))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d body=%s, want 404", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// 书源宿主浏览器的 HTTP 层测试:轮询待办 → 承载页面 → 回传 DOM。
|
||||
//
|
||||
// 这条链路对应真实书源的「切换线路」「用户后台」:
|
||||
// java.startBrowserAwait 会阻塞在 POST /sources/:id/login 上,
|
||||
// 前端必须能从 /reader/browser/pending 拿到页面,再把 DOM POST 回去。
|
||||
|
||||
// browserHandlerSource 构造一个用 startBrowserAwait 切换线路的书源。
|
||||
func browserHandlerSource(sourceURL string) string {
|
||||
loginJS := `function switchLine() {
|
||||
let html = '<!DOCTYPE html><html><head></head><body><span id="serverValue">线路甲</span></body></html>';
|
||||
let body = java.startBrowserAwait('data:text/html;base64,' + java.base64Encode(html), '线路设置', false).body();
|
||||
let m = body.match(/id="serverValue"\s*>\s*([^<]*?)\s*<\/span>/);
|
||||
source.setVariable(JSON.stringify({线路: m ? m[1] : ''}));
|
||||
}`
|
||||
src := map[string]any{
|
||||
"bookSourceUrl": sourceURL,
|
||||
"bookSourceName": "浏览器面板源",
|
||||
"loginUrl": loginJS,
|
||||
"loginUi": `[{"name":"切换线路","type":"button","action":"switchLine()"}]`,
|
||||
}
|
||||
out, _ := json.Marshal(src)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// registerBrowserRoutesForTest 挂载阅读路由 + 承载页面/资源代理(跳过鉴权)。
|
||||
// 生产环境里后两个是公开路由(iframe 带不了 JWT),鉴权靠 HMAC 签名。
|
||||
func registerBrowserRoutesForTest(cfg *service.Container) *gin.Engine {
|
||||
r := registerReaderRoutesForTest(cfg)
|
||||
r.GET("/api/reader/browser/page", readerBrowserPageHandler(cfg))
|
||||
r.GET("/api/reader/browser/asset", readerBrowserAssetHandler(cfg))
|
||||
return r
|
||||
}
|
||||
|
||||
func TestReaderBrowserPanelRoutes(t *testing.T) {
|
||||
container := newReaderHandlerContainer(t)
|
||||
router := registerBrowserRoutesForTest(container)
|
||||
|
||||
// 导入书源
|
||||
body, _ := json.Marshal(map[string]string{"text": browserHandlerSource("https://panel.example.com")})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/reader/sources/import", strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("导入书源 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources", nil))
|
||||
var list struct {
|
||||
Sources []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"sources"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &list)
|
||||
if len(list.Sources) != 1 {
|
||||
t.Fatalf("应导入 1 个书源: %s", w.Body.String())
|
||||
}
|
||||
sourceID := list.Sources[0].ID
|
||||
|
||||
// 执行切换线路:这个请求会阻塞等待用户操作,放到后台跑
|
||||
actionDone := make(chan *httptest.ResponseRecorder, 1)
|
||||
go func() {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/reader/sources/"+sourceID+"/login",
|
||||
strings.NewReader(`{"action":"switchLine()","fields":{}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
actionDone <- rec
|
||||
}()
|
||||
|
||||
// 轮询待办页面
|
||||
var page struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Mode string `json:"mode"`
|
||||
PageURL string `json:"page_url"`
|
||||
SourceID string `json:"source_id"`
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("等待超时:未出现待办页面")
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet,
|
||||
"/api/reader/browser/pending?source_id="+sourceID, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("轮询 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var pending struct {
|
||||
Pages []json.RawMessage `json:"pages"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil {
|
||||
t.Fatalf("待办返回不是 JSON: %s", w.Body.String())
|
||||
}
|
||||
if len(pending.Pages) > 0 {
|
||||
if err := json.Unmarshal(pending.Pages[0], &page); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if page.Mode != "wait" || page.Title != "线路设置" {
|
||||
t.Fatalf("待办描述异常: %+v", page)
|
||||
}
|
||||
if page.SourceID != sourceID {
|
||||
t.Fatalf("待办应带书源 ID: %+v", page)
|
||||
}
|
||||
|
||||
// 承载页面:签名正确应返回 HTML,签名错误应 403
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, page.PageURL, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("承载页面 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `id="serverValue"`) {
|
||||
t.Fatalf("承载页面内容异常: %s", w.Body.String())
|
||||
}
|
||||
// 服务端注入的 DOM 回传脚本必须在(父窗口靠它取回用户操作后的页面)
|
||||
if !strings.Contains(w.Body.String(), "data-mebox-browser-bridge") {
|
||||
t.Fatalf("承载页面缺少 DOM 回传脚本: %s", w.Body.String())
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/browser/page?id="+page.ID+"&s=forged", nil))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("伪造签名应 403,实际 %d", w.Code)
|
||||
}
|
||||
|
||||
// 回传用户操作后的 DOM(模拟点了 √)
|
||||
result, _ := json.Marshal(map[string]any{
|
||||
"id": page.ID,
|
||||
"body": `<html><body><span id="serverValue">线路乙</span></body></html>`,
|
||||
})
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/reader/browser/result", strings.NewReader(string(result)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("回传结果 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 阻塞的登录动作应被放行
|
||||
select {
|
||||
case rec := <-actionDone:
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("登录动作 status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var res struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &res)
|
||||
if !res.OK {
|
||||
t.Fatalf("动作未成功: %s", rec.Body.String())
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("回传后阻塞未解除")
|
||||
}
|
||||
|
||||
// 线路应已写进源变量
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources/"+sourceID+"/login", nil))
|
||||
var info struct {
|
||||
Variable string `json:"variable"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &info)
|
||||
if !strings.Contains(info.Variable, "线路乙") {
|
||||
t.Fatalf("线路未写入源变量: %q", info.Variable)
|
||||
}
|
||||
|
||||
// 完成后待办清空
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet,
|
||||
"/api/reader/browser/pending?source_id="+sourceID, nil))
|
||||
var after struct {
|
||||
Pages []json.RawMessage `json:"pages"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &after)
|
||||
if len(after.Pages) != 0 {
|
||||
t.Fatalf("完成后待办未清理: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderBrowserResultRejectsUnknownPage(t *testing.T) {
|
||||
container := newReaderHandlerContainer(t)
|
||||
router := registerBrowserRoutesForTest(container)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/reader/browser/result",
|
||||
strings.NewReader(`{"id":"not-a-real-page","body":"x"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("未知待办应 400,实际 %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderBrowserResultRequiresID(t *testing.T) {
|
||||
container := newReaderHandlerContainer(t)
|
||||
router := registerBrowserRoutesForTest(container)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/reader/browser/result", strings.NewReader(`{}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("缺少 id 应 400,实际 %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderBrowserPageBadQuery 承载地址参数缺失/非法时不应 500。
|
||||
func TestReaderBrowserPageBadQuery(t *testing.T) {
|
||||
container := newReaderHandlerContainer(t)
|
||||
router := registerBrowserRoutesForTest(container)
|
||||
|
||||
for _, target := range []string{"/api/reader/browser/page", "/api/reader/browser/page?id=x",
|
||||
"/api/reader/browser/asset", "/api/reader/browser/asset?id=x&u=!!!&s=y"} {
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s 应 403,实际 %d", target, w.Code)
|
||||
}
|
||||
}
|
||||
// 合法 base64 但不匹配的签名
|
||||
w := httptest.NewRecorder()
|
||||
q := url.Values{"id": {"x"}, "u": {"aHR0cHM6Ly9leGFtcGxlLmNvbS8"}, "s": {"deadbeef"}}
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/browser/asset?"+q.Encode(), nil))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("签名不匹配应 403,实际 %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 段评打开接口的 HTTP 层契约:路由存在、参数校验生效、内网地址被拒。
|
||||
//
|
||||
// 正常路径(真的去抓评论页)依赖外部站点,放在服务层解析单测里覆盖;
|
||||
// 这里只钉住契约与安全边界,避免把测试绑到网络。
|
||||
func TestReaderOpenCommentRejectsBadURL(t *testing.T) {
|
||||
container := newReaderHandlerContainer(t)
|
||||
router := registerReaderRoutesForTest(container)
|
||||
|
||||
post := func(body string) int {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/reader/comments/open", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
return w.Code
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"缺少 url", `{"book_id":"x"}`},
|
||||
{"非 http 协议", `{"book_id":"x","url":"javascript:alert(1)"}`},
|
||||
{"回环地址", `{"book_id":"x","url":"http://127.0.0.1/c"}`},
|
||||
{"内网地址", `{"book_id":"x","url":"http://192.168.1.1/c"}`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if code := post(c.body); code != http.StatusBadRequest {
|
||||
t.Fatalf("%s:应 400,得到 %d", c.name, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
"github.com/truewhile/MeBox/internal/service/reader"
|
||||
)
|
||||
|
||||
// 书架分组接口(/reader/book-groups)的 HTTP 契约测试。
|
||||
//
|
||||
// 分组仿影视模块的媒体库标签:整份替换、一本书只归一个组、按用户隔离。
|
||||
// 另外两条是书籍特有的:只保留书架上的书(书移出后不留死 ID)、空分组保留。
|
||||
|
||||
func addBookForGroups(t *testing.T, svc *service.Container, userID, name, bookURL string) *model.ReaderBook {
|
||||
t.Helper()
|
||||
book, err := svc.Reader.AddBook(t.Context(), userID, reader.SearchOrigin{
|
||||
Origin: "https://example.com",
|
||||
BookURL: bookURL,
|
||||
}, name, "作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
return book
|
||||
}
|
||||
|
||||
func setBookGroups(t *testing.T, router *gin.Engine, user string, groups []model.BookGroupSet) ([]model.BookGroupSet, int) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]any{"groups": groups})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/reader/book-groups", strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Test-User", user)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
return nil, w.Code
|
||||
}
|
||||
var res struct {
|
||||
Groups []model.BookGroupSet `json:"groups"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
return res.Groups, w.Code
|
||||
}
|
||||
|
||||
func getBookGroups(t *testing.T, router *gin.Engine, user string) ([]model.BookGroupSet, int) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/reader/book-groups", nil)
|
||||
req.Header.Set("X-Test-User", user)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
return nil, w.Code
|
||||
}
|
||||
var res struct {
|
||||
Groups []model.BookGroupSet `json:"groups"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
return res.Groups, w.Code
|
||||
}
|
||||
|
||||
func namesOf(groups []model.BookGroupSet) string {
|
||||
out := make([]string, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
out = append(out, g.Name)
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
func idsOf(groups []model.BookGroupSet, name string) string {
|
||||
for _, g := range groups {
|
||||
if g.Name == name {
|
||||
return strings.Join(g.BookIDs, ",")
|
||||
}
|
||||
}
|
||||
return "<missing>"
|
||||
}
|
||||
|
||||
// TestBookGroupsEmptyStateIsArray 没有分组时应返回空数组,前端可直接遍历。
|
||||
func TestBookGroupsEmptyStateIsArray(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/reader/book-groups", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
// 必须是 [] 而不是 null
|
||||
if !strings.Contains(w.Body.String(), `"groups":[]`) {
|
||||
t.Fatalf("空态应返回 [],实际 %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookGroupsNormalizeFilterAndDedupe(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
b1 := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
|
||||
b2 := addBookForGroups(t, svc, "u1", "书二", "https://example.com/2")
|
||||
b3 := addBookForGroups(t, svc, "u1", "书三", "https://example.com/3")
|
||||
foreign := addBookForGroups(t, svc, "u2", "别人的书", "https://example.com/9")
|
||||
|
||||
got, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
|
||||
{Name: "科幻", BookIDs: []string{b1.ID, b2.ID}},
|
||||
{Name: "在读", BookIDs: []string{b1.ID}}, // b1 已被「科幻」认领 → 应变空
|
||||
{Name: "玄幻", BookIDs: []string{b3.ID}},
|
||||
{Name: "空组", BookIDs: []string{}},
|
||||
{Name: " ", BookIDs: []string{b2.ID}}, // 空名 → 丢弃整个分组
|
||||
{Name: "外来", BookIDs: []string{foreign.ID}}, // 不属于 u1 的书架 → 过滤掉
|
||||
})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("保存失败 status=%d", code)
|
||||
}
|
||||
if names := namesOf(got); names != "科幻,在读,玄幻,空组,外来" {
|
||||
t.Fatalf("分组顺序/数量 = %q,期望 科幻,在读,玄幻,空组,外来", names)
|
||||
}
|
||||
if ids := idsOf(got, "科幻"); ids != b1.ID+","+b2.ID {
|
||||
t.Fatalf("科幻 = %q,期望 %s,%s", ids, b1.ID, b2.ID)
|
||||
}
|
||||
if ids := idsOf(got, "在读"); ids != "" {
|
||||
t.Fatalf("在读 应为空(b1 归了科幻),实际 %q", ids)
|
||||
}
|
||||
if ids := idsOf(got, "玄幻"); ids != b3.ID {
|
||||
t.Fatalf("玄幻 = %q,期望 %s", ids, b3.ID)
|
||||
}
|
||||
if ids := idsOf(got, "外来"); ids != "" {
|
||||
t.Fatalf("外来 应被过滤为空,实际 %q", ids)
|
||||
}
|
||||
|
||||
// 读回来应当一致
|
||||
loaded, code := getBookGroups(t, router, "u1")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("读回 status=%d", code)
|
||||
}
|
||||
if names := namesOf(loaded); names != namesOf(got) {
|
||||
t.Fatalf("读回分组 = %q,期望 %q", names, namesOf(got))
|
||||
}
|
||||
if ids := idsOf(loaded, "玄幻"); ids != b3.ID {
|
||||
t.Fatalf("读回 玄幻 = %q,期望 %s", ids, b3.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBookGroupsDropsBooksRemovedFromShelf 书被移出书架后,分组里不应留下死 ID。
|
||||
func TestBookGroupsDropsBooksRemovedFromShelf(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
b1 := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
|
||||
b2 := addBookForGroups(t, svc, "u1", "书二", "https://example.com/2")
|
||||
|
||||
if _, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
|
||||
{Name: "科幻", BookIDs: []string{b1.ID, b2.ID}},
|
||||
}); code != http.StatusOK {
|
||||
t.Fatalf("保存失败 status=%d", code)
|
||||
}
|
||||
|
||||
if err := svc.Reader.RemoveBook(t.Context(), "u1", b2.ID); err != nil {
|
||||
t.Fatalf("移出书架失败: %v", err)
|
||||
}
|
||||
|
||||
loaded, _ := getBookGroups(t, router, "u1")
|
||||
if ids := idsOf(loaded, "科幻"); ids != b1.ID {
|
||||
t.Fatalf("科幻 = %q,期望只剩 %s", ids, b1.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookGroupsArePerUser(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
u1Book := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
|
||||
u2Book := addBookForGroups(t, svc, "u2", "书二", "https://example.com/2")
|
||||
|
||||
if _, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
|
||||
{Name: "u1 的分组", BookIDs: []string{u1Book.ID}},
|
||||
}); code != http.StatusOK {
|
||||
t.Fatalf("u1 保存失败 status=%d", code)
|
||||
}
|
||||
|
||||
// u2 看不到 u1 的分组
|
||||
if groups, code := getBookGroups(t, router, "u2"); code != http.StatusOK || len(groups) != 0 {
|
||||
t.Fatalf("u2 分组 = %+v (status=%d),期望空", groups, code)
|
||||
}
|
||||
|
||||
// u2 把 u1 的书塞进自己的分组也不会生效(不属于 u2 的书架)
|
||||
if groups, code := setBookGroups(t, router, "u2", []model.BookGroupSet{
|
||||
{Name: "抢过来", BookIDs: []string{u1Book.ID, u2Book.ID}},
|
||||
}); code != http.StatusOK || idsOf(groups, "抢过来") != u2Book.ID {
|
||||
t.Fatalf("u2 分组 = %+v (status=%d),期望只含自己的书", groups, code)
|
||||
}
|
||||
|
||||
// u1 的分组不受影响
|
||||
back, _ := getBookGroups(t, router, "u1")
|
||||
if names := namesOf(back); names != "u1 的分组" || idsOf(back, "u1 的分组") != u1Book.ID {
|
||||
t.Fatalf("u1 分组被 u2 影响: %+v", back)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBookGroupsSaveTwiceReplacesWholeSet 整份替换语义:第二次保存覆盖第一次。
|
||||
func TestBookGroupsSaveTwiceReplacesWholeSet(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
b1 := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
|
||||
|
||||
if _, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
|
||||
{Name: "旧组", BookIDs: []string{b1.ID}},
|
||||
}); code != http.StatusOK {
|
||||
t.Fatalf("首次保存 status=%d", code)
|
||||
}
|
||||
got, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
|
||||
{Name: "新组", BookIDs: []string{}},
|
||||
})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("二次保存 status=%d", code)
|
||||
}
|
||||
if names := namesOf(got); names != "新组" {
|
||||
t.Fatalf("二次保存后 = %q,期望只有 新组", names)
|
||||
}
|
||||
|
||||
// 清空分组
|
||||
if groups, code := setBookGroups(t, router, "u1", nil); code != http.StatusOK || len(groups) != 0 {
|
||||
t.Fatalf("清空后 = %+v (status=%d),期望空", groups, code)
|
||||
}
|
||||
if groups, _ := getBookGroups(t, router, "u1"); len(groups) != 0 {
|
||||
t.Fatalf("清空后读回 = %+v,期望空", groups)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestReaderLocalAudioStream 本地有声书音频流端点:
|
||||
// 签名鉴权 + Range 透传(播放器拖进度靠它)。
|
||||
func TestReaderLocalAudioStream(t *testing.T) {
|
||||
container := newReaderHandlerContainer(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/api/reader/local/audio", readerLocalAudioHandler(container))
|
||||
router.HEAD("/api/reader/local/audio", readerLocalAudioHandler(container))
|
||||
|
||||
payload := []byte("0123456789abcdefghij")
|
||||
path := filepath.Join(t.TempDir(), "track.mp3")
|
||||
if err := os.WriteFile(path, payload, 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signed := container.Reader.LocalAudioURL("book-1", path)
|
||||
|
||||
// 全量拉取
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("全量拉取 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if w.Body.String() != string(payload) {
|
||||
t.Fatalf("全量拉取 body=%q", w.Body.String())
|
||||
}
|
||||
|
||||
// Range 请求要能拖进度
|
||||
w = httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, signed, nil)
|
||||
req.Header.Set("Range", "bytes=2-5")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusPartialContent {
|
||||
t.Fatalf("Range 请求 status=%d", w.Code)
|
||||
}
|
||||
if w.Body.String() != "2345" {
|
||||
t.Fatalf("Range 请求 body=%q", w.Body.String())
|
||||
}
|
||||
|
||||
// 篡改签名应被拒绝
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed+"x", nil))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("坏签名 status=%d,期望 403", w.Code)
|
||||
}
|
||||
|
||||
// 文件不存在 → 404(而不是 500)
|
||||
missing := container.Reader.LocalAudioURL("book-1", filepath.Join(t.TempDir(), "nope.mp3"))
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, missing, nil))
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("缺失文件 status=%d,期望 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderAudioTranscodeRoute 转码端点:签名鉴权,命中缓存后按 Range 下发。
|
||||
// 用预置的缓存文件代替真实转码,避免测试依赖 ffmpeg。
|
||||
func TestReaderAudioTranscodeRoute(t *testing.T) {
|
||||
container := newReaderHandlerContainer(t)
|
||||
container.Cfg.App.DataDir = t.TempDir() // 转码缓存落在临时目录,别写进仓库
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/api/reader/audio/transcode", readerAudioTranscodeHandler(container))
|
||||
router.HEAD("/api/reader/audio/transcode", readerAudioTranscodeHandler(container))
|
||||
|
||||
const bookID = "book-1"
|
||||
source := `D:\media\斗破苍穹\001.wma`
|
||||
payload := []byte("ID3FAKEMP3PAYLOAD0123456789")
|
||||
|
||||
cachePath, err := container.Reader.AudioTranscodeCachePath(bookID, source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(cachePath, payload, 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
signed := container.Reader.AudioTranscodeURL(bookID, source)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed, nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("全量拉取 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if w.Body.String() != string(payload) {
|
||||
t.Fatalf("全量拉取 body=%q", w.Body.String())
|
||||
}
|
||||
|
||||
// 拖进度靠 Range
|
||||
w = httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, signed, nil)
|
||||
req.Header.Set("Range", "bytes=3-7")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusPartialContent {
|
||||
t.Fatalf("Range 请求 status=%d", w.Code)
|
||||
}
|
||||
if w.Body.String() != "FAKEM" {
|
||||
t.Fatalf("Range 请求 body=%q", w.Body.String())
|
||||
}
|
||||
|
||||
// 篡改签名应被拒绝
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed+"x", nil))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("坏签名 status=%d,期望 403", w.Code)
|
||||
}
|
||||
|
||||
// 换一本书(签名不匹配)同样拒绝
|
||||
forged := strings.Replace(signed, "b=book-1", "b=book-2", 1)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, forged, nil))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("换书后 status=%d,期望 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderAudioTranscodeRouteWithoutFFmpeg 没装 ffmpeg 且无缓存时返回明确错误,
|
||||
// 而不是空响应(前端据此提示用户去装 ffmpeg)。
|
||||
func TestReaderAudioTranscodeRouteWithoutFFmpeg(t *testing.T) {
|
||||
container := newReaderHandlerContainer(t)
|
||||
container.Cfg.App.DataDir = t.TempDir()
|
||||
container.Cfg.App.FFmpegPath = filepath.Join(t.TempDir(), "definitely-missing-ffmpeg")
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/api/reader/audio/transcode", readerAudioTranscodeHandler(container))
|
||||
|
||||
signed := container.Reader.AudioTranscodeURL("book-1", `D:\media\001.wma`)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed, nil))
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status=%d,期望 502", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "ffmpeg") {
|
||||
t.Fatalf("错误正文应提到 ffmpeg: %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderLocalAudioRejectsOtherBook 换一本书的签名不通用:防止拿到别人的音频地址。
|
||||
func TestReaderLocalAudioRejectsOtherBook(t *testing.T) {
|
||||
container := newReaderHandlerContainer(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/api/reader/local/audio", readerLocalAudioHandler(container))
|
||||
|
||||
path := filepath.Join(t.TempDir(), "track.mp3")
|
||||
if err := os.WriteFile(path, []byte("x"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signed := container.Reader.LocalAudioURL("book-1", path)
|
||||
forged := strings.Replace(signed, "b=book-1", "b=book-2", 1)
|
||||
if forged == signed {
|
||||
t.Fatal("测试用例未改写 book 参数")
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, forged, nil))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("换书后签名应失效,status=%d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
"github.com/truewhile/MeBox/internal/service/reader"
|
||||
)
|
||||
|
||||
// 书源登录相关的 HTTP 层测试:路由注册与 JSON 契约。
|
||||
|
||||
func newReaderHandlerContainer(t *testing.T) *service.Container {
|
||||
t.Helper()
|
||||
// 唯一库名 + cache=shared:阅读链路会并发写会话状态,
|
||||
// 而 :memory: 下每个连接各自一个库,并发写入对后续读取不可见。
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
return &service.Container{
|
||||
Repo: repos,
|
||||
Cfg: cfg,
|
||||
Log: zap.NewNop(),
|
||||
Reader: reader.NewReaderService(cfg, zap.NewNop(), repos),
|
||||
}
|
||||
}
|
||||
|
||||
// loginHandlerSource 返回一个声明了 loginUrl/loginUi 的书源 JSON。
|
||||
func loginHandlerSource(server string) string {
|
||||
loginJS := `function login(flag) {
|
||||
var payload = JSON.stringify({register_email: result['邮箱'], password: result['密码']});
|
||||
var res = java.ajax(baseUrl + '/login_api,{"method":"POST","headers":{"Content-Type":"application/json"},"body":' + JSON.stringify(payload) + '}');
|
||||
var data = JSON.parse(res);
|
||||
if (data.code == 0) { cookie.setCookie(baseUrl, 'qttoken=' + data.key); java.toast('登录成功'); return true; }
|
||||
java.toast(data.msg || '登录失败'); return false;
|
||||
}`
|
||||
ui, _ := json.Marshal([]map[string]any{
|
||||
{"name": "邮箱", "type": "text"},
|
||||
{"name": "密码", "type": "password"},
|
||||
{"name": "登录", "type": "button", "action": "login(true)"},
|
||||
})
|
||||
src := map[string]any{
|
||||
"bookSourceUrl": server,
|
||||
"bookSourceName": "登录源",
|
||||
"bookSourceType": 0,
|
||||
"loginUrl": loginJS,
|
||||
"loginUi": string(ui),
|
||||
"searchUrl": server + "/search",
|
||||
"ruleSearch": map[string]any{
|
||||
"bookList": "class.item",
|
||||
"name": "tag.h3@tag.a@text",
|
||||
"bookUrl": "tag.h3@tag.a@href",
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(src)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// loginHandlerServer 模拟需要登录的站点。
|
||||
func loginHandlerServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/login_api":
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["register_email"] != "u@e.com" || body["password"] != "pw" {
|
||||
_, _ = w.Write([]byte(`{"code":1,"msg":"账号或密码错误"}`))
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "qttoken", Value: "HTOKEN_1234567890", Path: "/"})
|
||||
_, _ = w.Write([]byte(`{"code":0,"key":"HTOKEN_1234567890"}`))
|
||||
case "/search":
|
||||
if !strings.Contains(r.Header.Get("Cookie"), "qttoken=HTOKEN_1234567890") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`<html>未登录</html>`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`<html><body><div class="item"><h3><a href="/book/1">登录后可见</a></h3></div></body></html>`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// registerReaderRoutesForTest 挂载阅读路由(跳过鉴权中间件)。
|
||||
func registerReaderRoutesForTest(cfg *service.Container) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
registerReaderRoutes(r.Group("/api"), cfg)
|
||||
return r
|
||||
}
|
||||
|
||||
// TestReaderLoginRoutesEndToEnd 走完整 HTTP 路由:
|
||||
// 查看登录信息 → 执行登录 → 搜索携带 Cookie 成功。
|
||||
func TestReaderLoginRoutesEndToEnd(t *testing.T) {
|
||||
srv := loginHandlerServer(t)
|
||||
defer srv.Close()
|
||||
cfg := newReaderHandlerContainer(t)
|
||||
router := registerReaderRoutesForTest(cfg)
|
||||
|
||||
// 导入书源
|
||||
body, _ := json.Marshal(map[string]string{"text": loginHandlerSource(srv.URL)})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/reader/sources/import", strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("导入书源 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 列表应标注 has_login
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("书源列表 status=%d", w.Code)
|
||||
}
|
||||
var listResp struct {
|
||||
Sources []struct {
|
||||
ID string `json:"id"`
|
||||
HasLogin bool `json:"has_login"`
|
||||
} `json:"sources"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &listResp); err != nil || len(listResp.Sources) != 1 {
|
||||
t.Fatalf("书源列表解析失败: %s", w.Body.String())
|
||||
}
|
||||
if !listResp.Sources[0].HasLogin {
|
||||
t.Fatal("has_login 应为 true")
|
||||
}
|
||||
sourceID := listResp.Sources[0].ID
|
||||
|
||||
// GET 登录信息:应返回表单字段与未登录状态
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources/"+sourceID+"/login", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("登录信息 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var info struct {
|
||||
HasLoginJS bool `json:"has_login_js"`
|
||||
LoggedIn bool `json:"logged_in"`
|
||||
Fields []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
} `json:"fields"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil {
|
||||
t.Fatalf("登录信息解析失败: %s", w.Body.String())
|
||||
}
|
||||
if !info.HasLoginJS || info.LoggedIn {
|
||||
t.Fatalf("初始状态异常: %+v", info)
|
||||
}
|
||||
var names []string
|
||||
for _, f := range info.Fields {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
for _, want := range []string{"邮箱", "密码", "登录"} {
|
||||
if !slices.Contains(names, want) {
|
||||
t.Fatalf("缺少表单字段 %q: %v", want, names)
|
||||
}
|
||||
}
|
||||
|
||||
// 密码错误 → ok=false,提示透传
|
||||
loginBody, _ := json.Marshal(map[string]any{
|
||||
"fields": map[string]string{"邮箱": "u@e.com", "密码": "bad"},
|
||||
})
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/reader/sources/"+sourceID+"/login", strings.NewReader(string(loginBody)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("登录 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var failRes struct {
|
||||
OK bool `json:"ok"`
|
||||
LoggedIn bool `json:"logged_in"`
|
||||
Toasts []string `json:"toasts"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &failRes)
|
||||
if failRes.LoggedIn {
|
||||
t.Fatalf("密码错误不应登录成功: %s", w.Body.String())
|
||||
}
|
||||
if !strings.Contains(strings.Join(failRes.Toasts, " "), "密码错误") {
|
||||
t.Fatalf("失败提示未透传: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// 正确密码 → ok=true 且已登录
|
||||
loginBody, _ = json.Marshal(map[string]any{
|
||||
"fields": map[string]string{"邮箱": "u@e.com", "密码": "pw"},
|
||||
})
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/reader/sources/"+sourceID+"/login", strings.NewReader(string(loginBody)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("登录 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var okRes struct {
|
||||
OK bool `json:"ok"`
|
||||
LoggedIn bool `json:"logged_in"`
|
||||
Cookies map[string]string `json:"cookies"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &okRes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !okRes.OK || !okRes.LoggedIn || len(okRes.Cookies) == 0 {
|
||||
t.Fatalf("登录应成功并带 Cookie: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// 搜索应携带 Cookie 并拿到结果
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/reader/search", strings.NewReader(`{"key":"任意"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
var searchRes struct {
|
||||
Books []struct{ Name string } `json:"books"`
|
||||
Skipped []struct {
|
||||
Reason string `json:"reason"`
|
||||
} `json:"skipped"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &searchRes)
|
||||
if len(searchRes.Books) != 1 || searchRes.Books[0].Name != "登录后可见" {
|
||||
t.Fatalf("登录后搜索应成功: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// 登出 → 登录态清空
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/reader/sources/"+sourceID+"/login", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("登出 status=%d", w.Code)
|
||||
}
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources/"+sourceID+"/login", nil))
|
||||
var afterLogout struct {
|
||||
LoggedIn bool `json:"logged_in"`
|
||||
Cookies map[string]string `json:"cookies"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &afterLogout)
|
||||
if afterLogout.LoggedIn || len(afterLogout.Cookies) != 0 {
|
||||
t.Fatalf("登出后应无登录态: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderSetSourceVariableRoute 源变量接口应保存合法 JSON 并拒绝非法 JSON。
|
||||
func TestReaderSetSourceVariableRoute(t *testing.T) {
|
||||
cfg := newReaderHandlerContainer(t)
|
||||
router := registerReaderRoutesForTest(cfg)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"text": `{"bookSourceUrl":"https://v.example.com","bookSourceName":"变量源"}`})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/reader/sources/import", strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources", nil))
|
||||
var list struct {
|
||||
Sources []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"sources"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &list)
|
||||
if len(list.Sources) != 1 {
|
||||
t.Fatalf("应导入 1 个书源: %s", w.Body.String())
|
||||
}
|
||||
id := list.Sources[0].ID
|
||||
|
||||
// 合法 JSON
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/reader/sources/"+id+"/variable",
|
||||
strings.NewReader(`{"variable":"{\"线路\":\"https://v2.example.com\"}"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("保存变量 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 非法 JSON 应 400
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/reader/sources/"+id+"/variable",
|
||||
strings.NewReader(`{"variable":"not-json"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("非法变量应 400,实际 %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
"github.com/truewhile/MeBox/internal/service/reader"
|
||||
)
|
||||
|
||||
// 阅读器偏好接口(/reader/profile)的 HTTP 契约测试。
|
||||
//
|
||||
// 这些设置原先只存在浏览器 localStorage(设备级),换设备就丢。现在按用户落库,
|
||||
// 所以要保证:新用户没有记录、写读一致、越界值收敛、以及用户之间互不影响。
|
||||
|
||||
// profileRouterForTest 用请求头 X-Test-User 指定调用者(默认 u1),
|
||||
// 便于在一个测试里切换用户验证隔离。
|
||||
func profileRouterForTest(t *testing.T, cfg *service.Container) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
user := c.GetHeader("X-Test-User")
|
||||
if user == "" {
|
||||
user = "u1"
|
||||
}
|
||||
c.Set(middleware.CtxUserID, user)
|
||||
c.Next()
|
||||
})
|
||||
registerReaderRoutes(r.Group("/api"), cfg)
|
||||
return r
|
||||
}
|
||||
|
||||
func getProfile(t *testing.T, router *gin.Engine, user string) (*reader.ReaderSettings, int) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/reader/profile", nil)
|
||||
req.Header.Set("X-Test-User", user)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
return nil, w.Code
|
||||
}
|
||||
var res struct {
|
||||
Profile *reader.ReaderSettings `json:"profile"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
return res.Profile, w.Code
|
||||
}
|
||||
|
||||
func putProfile(t *testing.T, router *gin.Engine, user, body string) (*reader.ReaderSettings, int) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/reader/profile", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Test-User", user)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
return nil, w.Code
|
||||
}
|
||||
var res struct {
|
||||
Profile *reader.ReaderSettings `json:"profile"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
return res.Profile, w.Code
|
||||
}
|
||||
|
||||
func TestReaderProfileNullBeforeFirstSave(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
profile, code := getProfile(t, router, "u1")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status=%d", code)
|
||||
}
|
||||
// 没有记录时返回 null,由前端用它本地的值播种
|
||||
if profile != nil {
|
||||
t.Fatalf("新用户应返回 null,实际 %+v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderProfileRoundTrip(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
body := `{
|
||||
"theme_id":"preset2","night":true,"page_mode":"scroll",
|
||||
"font_size":24,"line_height":2.1,"paragraph_spacing":12,
|
||||
"audio_speed":1.5,"audio_timer_minutes":30,
|
||||
"shelf_layout":"compact","shelf_grid_columns":4,"shelf_sort":"update",
|
||||
"shelf_show_unread":false,"shelf_show_update_time":true
|
||||
}`
|
||||
saved, code := putProfile(t, router, "u1", body)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("保存失败 status=%d", code)
|
||||
}
|
||||
if saved == nil {
|
||||
t.Fatal("保存应返回偏好")
|
||||
}
|
||||
|
||||
got, code := getProfile(t, router, "u1")
|
||||
if code != http.StatusOK || got == nil {
|
||||
t.Fatalf("读回失败 status=%d profile=%+v", code, got)
|
||||
}
|
||||
if *got != *saved {
|
||||
t.Fatalf("读回与写入不一致:\n写入 %+v\n读回 %+v", *saved, *got)
|
||||
}
|
||||
if got.ThemeID != "preset2" || !got.Night || got.PageMode != "scroll" || got.FontSize != 24 ||
|
||||
got.LineHeight != 2.1 || got.ParagraphSpacing != 12 || got.AudioSpeed != 1.5 ||
|
||||
got.AudioTimerMinutes != 30 || got.ShelfLayout != "compact" || got.ShelfGridColumns != 4 ||
|
||||
got.ShelfSort != "update" || got.ShelfShowUnread || !got.ShelfShowUpdateTime {
|
||||
t.Fatalf("字段未按原值往返: %+v", *got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderProfileClampsAndFallsBack(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
// 全部越界 / 枚举非法,服务端应收敛而不是原样入库
|
||||
body := `{
|
||||
"theme_id":" ","night":false,"page_mode":"nope",
|
||||
"font_size":999,"line_height":0.1,"paragraph_spacing":-5,
|
||||
"audio_speed":99,"audio_timer_minutes":9999,
|
||||
"shelf_layout":"diagonal","shelf_grid_columns":42,"shelf_sort":"random",
|
||||
"shelf_show_unread":true,"shelf_show_update_time":false
|
||||
}`
|
||||
got, code := putProfile(t, router, "u1", body)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("保存失败 status=%d", code)
|
||||
}
|
||||
if got.ThemeID != "preset1" {
|
||||
t.Fatalf("空主题应回落到 preset1,实际 %q", got.ThemeID)
|
||||
}
|
||||
if got.PageMode != "page" {
|
||||
t.Fatalf("非法 page_mode 应回落到 page,实际 %q", got.PageMode)
|
||||
}
|
||||
if got.FontSize != 32 {
|
||||
t.Fatalf("font_size 应夹到上限 32,实际 %d", got.FontSize)
|
||||
}
|
||||
if got.LineHeight != 1.4 {
|
||||
t.Fatalf("line_height 应夹到下限 1.4,实际 %v", got.LineHeight)
|
||||
}
|
||||
if got.ParagraphSpacing != 0 {
|
||||
t.Fatalf("paragraph_spacing 应夹到下限 0,实际 %d", got.ParagraphSpacing)
|
||||
}
|
||||
if got.AudioSpeed != 3 {
|
||||
t.Fatalf("audio_speed 应夹到上限 3,实际 %v", got.AudioSpeed)
|
||||
}
|
||||
if got.AudioTimerMinutes != 180 {
|
||||
t.Fatalf("audio_timer_minutes 应夹到上限 180,实际 %d", got.AudioTimerMinutes)
|
||||
}
|
||||
if got.ShelfLayout != "grid" {
|
||||
t.Fatalf("非法 shelf_layout 应回落到 grid,实际 %q", got.ShelfLayout)
|
||||
}
|
||||
if got.ShelfGridColumns != 6 {
|
||||
t.Fatalf("shelf_grid_columns 应夹到上限 6,实际 %d", got.ShelfGridColumns)
|
||||
}
|
||||
if got.ShelfSort != "recent" {
|
||||
t.Fatalf("非法 shelf_sort 应回落到 recent,实际 %q", got.ShelfSort)
|
||||
}
|
||||
// 布尔值不参与收敛,必须原样保留
|
||||
if !got.ShelfShowUnread || got.ShelfShowUpdateTime {
|
||||
t.Fatalf("布尔项被改动: unread=%v update=%v", got.ShelfShowUnread, got.ShelfShowUpdateTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderProfileIsPerUser(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
if _, code := putProfile(t, router, "u1", `{
|
||||
"theme_id":"preset3","night":true,"page_mode":"page","font_size":18,
|
||||
"line_height":1.5,"paragraph_spacing":4,"audio_speed":1,
|
||||
"audio_timer_minutes":0,"shelf_layout":"list","shelf_grid_columns":0,
|
||||
"shelf_sort":"name","shelf_show_unread":true,"shelf_show_update_time":true
|
||||
}`); code != http.StatusOK {
|
||||
t.Fatalf("u1 保存失败 status=%d", code)
|
||||
}
|
||||
|
||||
// 另一个用户不应看到 u1 的偏好,也不应在 u1 之外建出记录
|
||||
other, code := getProfile(t, router, "u2")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("u2 读取 status=%d", code)
|
||||
}
|
||||
if other != nil {
|
||||
t.Fatalf("u2 应返回 null,实际 %+v", other)
|
||||
}
|
||||
|
||||
if _, code := putProfile(t, router, "u2", `{
|
||||
"theme_id":"preset4","night":false,"page_mode":"scroll","font_size":20,
|
||||
"line_height":1.8,"paragraph_spacing":8,"audio_speed":2,
|
||||
"audio_timer_minutes":60,"shelf_layout":"grid","shelf_grid_columns":3,
|
||||
"shelf_sort":"author","shelf_show_unread":false,"shelf_show_update_time":false
|
||||
}`); code != http.StatusOK {
|
||||
t.Fatalf("u2 保存失败 status=%d", code)
|
||||
}
|
||||
|
||||
// u1 的值不能被 u2 覆盖
|
||||
back, code := getProfile(t, router, "u1")
|
||||
if code != http.StatusOK || back == nil {
|
||||
t.Fatalf("u1 读回失败 status=%d", code)
|
||||
}
|
||||
if back.ThemeID != "preset3" || back.ShelfLayout != "list" || back.ShelfSort != "name" || !back.Night {
|
||||
t.Fatalf("u1 的偏好被 u2 覆盖: %+v", *back)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderProfileSaveTwiceKeepsOneRow(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
first := `{"theme_id":"preset1","page_mode":"page","font_size":20,"line_height":1.8,
|
||||
"paragraph_spacing":8,"audio_speed":1,"shelf_layout":"grid","shelf_sort":"recent"}`
|
||||
if _, code := putProfile(t, router, "u1", first); code != http.StatusOK {
|
||||
t.Fatalf("首次保存 status=%d", code)
|
||||
}
|
||||
second := `{"theme_id":"preset5","page_mode":"scroll","font_size":22,"line_height":2.0,
|
||||
"paragraph_spacing":10,"audio_speed":1.25,"shelf_layout":"compact","shelf_sort":"mixed"}`
|
||||
if _, code := putProfile(t, router, "u1", second); code != http.StatusOK {
|
||||
t.Fatalf("二次保存 status=%d", code)
|
||||
}
|
||||
|
||||
got, _ := getProfile(t, router, "u1")
|
||||
if got == nil || got.ThemeID != "preset5" || got.ShelfSort != "mixed" {
|
||||
t.Fatalf("二次保存未覆盖旧值: %+v", got)
|
||||
}
|
||||
|
||||
// 一个用户只应有一条记录(唯一索引 + upsert)
|
||||
var count int64
|
||||
if err := svc.Repo.DB.Model(&model.ReaderProfile{}).
|
||||
Where("user_id = ?", "u1").Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("u1 的记录数 = %d,期望 1", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
"github.com/truewhile/MeBox/internal/service/reader"
|
||||
)
|
||||
|
||||
// 阅读进度接口的 HTTP 契约测试。
|
||||
//
|
||||
// 听书(音频)的进度是 audio.currentTime,天然带小数;漫画是图片序号、文本是页码,
|
||||
// 都是整数。同一个接口必须同时接住这两种形态,否则音频进度会被 400 丢掉
|
||||
// (前端是 fire-and-forget,失败不报错),表现为「听书退出后从头开始」。
|
||||
|
||||
func progressRouterForTest(cfg *service.Container, userID string) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set(middleware.CtxUserID, userID)
|
||||
c.Next()
|
||||
})
|
||||
registerReaderRoutes(r.Group("/api"), cfg)
|
||||
return r
|
||||
}
|
||||
|
||||
func putProgress(t *testing.T, router *gin.Engine, bookID, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/reader/books/"+bookID+"/progress", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestSaveProgressAcceptsFractionalPos(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
ctx := t.Context()
|
||||
|
||||
book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{
|
||||
Origin: "https://example.com",
|
||||
BookURL: "https://example.com/book/1",
|
||||
}, "有声书", "作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
|
||||
router := progressRouterForTest(svc, "u1")
|
||||
// 37.5 秒:音频进度上报的真实形态
|
||||
w := putProgress(t, router, book.ID, `{"chapter_index":2,"pos":37.5,"chapter_title":"第 3 章"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("音频进度应被接受,实际 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
books, err := svc.Reader.ListBooks(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(books) != 1 {
|
||||
t.Fatalf("书架应有 1 本,实际 %d", len(books))
|
||||
}
|
||||
// 小数秒截断成整数秒落库(dur_chapter_pos 是 int)
|
||||
if books[0].DurChapterPos != 37 {
|
||||
t.Fatalf("进度 = %d,期望 37(37.5 截断)", books[0].DurChapterPos)
|
||||
}
|
||||
if books[0].DurChapterIndex != 2 || books[0].DurChapterTitle != "第 3 章" {
|
||||
t.Fatalf("章节/标题未保存: index=%d title=%q", books[0].DurChapterIndex, books[0].DurChapterTitle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveProgressAcceptsIntegerPos(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
ctx := t.Context()
|
||||
|
||||
book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{
|
||||
Origin: "https://example.com",
|
||||
BookURL: "https://example.com/book/2",
|
||||
}, "文本", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
|
||||
router := progressRouterForTest(svc, "u1")
|
||||
// 文本/漫画仍是整数页码,不能被破坏
|
||||
w := putProgress(t, router, book.ID, `{"chapter_index":7,"pos":12,"chapter_title":"第 8 章"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("页码进度应被接受,实际 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
books, err := svc.Reader.ListBooks(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if books[0].DurChapterPos != 12 || books[0].DurChapterIndex != 7 {
|
||||
t.Fatalf("进度 = pos %d / index %d,期望 12 / 7", books[0].DurChapterPos, books[0].DurChapterIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveProgressRejectsNegativePos(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
ctx := t.Context()
|
||||
|
||||
book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{
|
||||
Origin: "https://example.com",
|
||||
BookURL: "https://example.com/book/3",
|
||||
}, "文本", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
|
||||
router := progressRouterForTest(svc, "u1")
|
||||
// 负数不是合法进度,落库前应被夹到 0,而不是写入负值
|
||||
w := putProgress(t, router, book.ID, `{"chapter_index":0,"pos":-5,"chapter_title":"第 1 章"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("负数进度不应 500,实际 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
books, err := svc.Reader.ListBooks(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if books[0].DurChapterPos != 0 {
|
||||
t.Fatalf("进度 = %d,期望夹到 0", books[0].DurChapterPos)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveProgressRoundTripsThroughJSON 防回归:进度写进去要能原样读回来。
|
||||
func TestSaveProgressRoundTripsThroughJSON(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
ctx := t.Context()
|
||||
|
||||
book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{
|
||||
Origin: "https://example.com",
|
||||
BookURL: "https://example.com/book/4",
|
||||
}, "有声书", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
|
||||
router := progressRouterForTest(svc, "u1")
|
||||
if w := putProgress(t, router, book.ID, `{"chapter_index":0,"pos":123.75,"chapter_title":"第 1 章"}`); w.Code != http.StatusOK {
|
||||
t.Fatalf("保存进度失败 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 走列表接口(前端书架/阅读器恢复进度读的就是它)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/reader/books", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("书架列表 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var res struct {
|
||||
Books []struct {
|
||||
DurChapterPos int `json:"dur_chapter_pos"`
|
||||
} `json:"books"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("解析书架响应失败: %v", err)
|
||||
}
|
||||
if len(res.Books) != 1 || res.Books[0].DurChapterPos != 123 {
|
||||
t.Fatalf("读回的进度 = %+v,期望 123", res.Books)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,940 @@
|
||||
// Package handler — 阅读(legado 书源兼容)子系统路由。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
"github.com/truewhile/MeBox/internal/service/reader"
|
||||
)
|
||||
|
||||
func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
g := authed.Group("/reader")
|
||||
|
||||
// 书源管理
|
||||
g.GET("/sources", readerListSourcesHandler(svc))
|
||||
g.POST("/sources/import", readerImportSourcesHandler(svc))
|
||||
g.PATCH("/sources/:id", readerUpdateSourceHandler(svc))
|
||||
g.DELETE("/sources/:id", readerDeleteSourceHandler(svc))
|
||||
g.POST("/sources/:id/debug", readerDebugSourceHandler(svc))
|
||||
|
||||
// 书源登录与源变量(登录类书源必需)
|
||||
g.GET("/sources/:id/login", readerSourceLoginInfoHandler(svc))
|
||||
g.POST("/sources/:id/login", readerSourceLoginActionHandler(svc))
|
||||
g.DELETE("/sources/:id/login", readerSourceLogoutHandler(svc))
|
||||
g.PUT("/sources/:id/variable", readerSetSourceVariableHandler(svc))
|
||||
g.PUT("/sources/:id/login-info", readerSetSourceLoginInfoHandler(svc))
|
||||
|
||||
// 书源 JS 的宿主浏览器(java.startBrowser / startBrowserAwait):
|
||||
// 前端轮询待办 → 在 iframe 里承载页面 → 用户点 √ 回传 DOM。
|
||||
g.GET("/browser/pending", readerBrowserPendingHandler(svc))
|
||||
g.POST("/browser/result", readerBrowserResultHandler(svc))
|
||||
// 页面内的 fetch/XHR 经此转发(iframe 是不透明源,请求带不上书源 Cookie)
|
||||
g.POST("/browser/xhr", readerBrowserXHRHandler(svc))
|
||||
|
||||
// 段评:用宿主浏览器打开评论地址(带书源 Cookie/登录态,对应书源的 showCmt)
|
||||
g.POST("/comments/open", readerOpenCommentHandler(svc))
|
||||
|
||||
// 搜索(多源聚合)
|
||||
g.POST("/search", readerSearchHandler(svc))
|
||||
|
||||
// 详情 / 目录 / 正文
|
||||
g.GET("/book-info", readerBookInfoHandler(svc))
|
||||
g.GET("/toc", readerTocHandler(svc))
|
||||
g.GET("/content", readerContentHandler(svc))
|
||||
|
||||
// 书架
|
||||
g.GET("/books", readerListBooksHandler(svc))
|
||||
g.POST("/books", readerAddBookHandler(svc))
|
||||
// 本地书籍(TXT / EPUB 上传导入)
|
||||
g.POST("/local/books", readerImportLocalBookHandler(svc))
|
||||
// 服务器已有文件/目录导入:原地引用不复制,仅管理员(会读取允许根目录内的文件)
|
||||
g.POST("/local/books/from-path", middleware.AdminRequired(), readerImportLocalBookFromPathHandler(svc))
|
||||
g.POST("/local/audiobooks", middleware.AdminRequired(), readerImportLocalAudioDirHandler(svc))
|
||||
g.DELETE("/books/:id", readerRemoveBookHandler(svc))
|
||||
g.PUT("/books/:id/progress", readerSaveProgressHandler(svc))
|
||||
// 更新目录:重抓书架里全部网络书籍的目录,刷新章节缓存与「最近更新」时间
|
||||
g.POST("/shelf/refresh-toc", readerRefreshBooksTocHandler(svc))
|
||||
|
||||
// 阅读器偏好(每个用户一条):主题 / 排版 / 听书 / 书架展示设置,跨设备同步
|
||||
g.GET("/profile", readerGetProfileHandler(svc))
|
||||
g.PUT("/profile", readerSaveProfileHandler(svc))
|
||||
|
||||
// 书架分组(每个用户一份):组名 → 书籍 ID,仿影视模块的媒体库标签
|
||||
g.GET("/book-groups", readerGetBookGroupsHandler(svc))
|
||||
g.PUT("/book-groups", readerSetBookGroupsHandler(svc))
|
||||
// 换源:把书架里的书切到另一个书源(保留阅读进度,目录缓存按新源重建)
|
||||
g.POST("/books/:id/origin", readerSwitchOriginHandler(svc))
|
||||
g.PUT("/books/:id/audio-config", readerSaveAudioConfigHandler(svc))
|
||||
g.GET("/books/:id/chapters", readerListChaptersHandler(svc))
|
||||
g.POST("/books/:id/chapters", readerReplaceChaptersHandler(svc))
|
||||
g.GET("/books/:id/content", readerBookContentHandler(svc))
|
||||
|
||||
// 替换净化规则
|
||||
g.GET("/replace-rules", readerListReplaceRulesHandler(svc))
|
||||
g.POST("/replace-rules", readerCreateReplaceRuleHandler(svc))
|
||||
g.PATCH("/replace-rules/:id", readerUpdateReplaceRuleHandler(svc))
|
||||
g.DELETE("/replace-rules/:id", readerDeleteReplaceRuleHandler(svc))
|
||||
}
|
||||
|
||||
func readerListSourcesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
sources, err := svc.Reader.ListSources(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"sources": sources})
|
||||
}
|
||||
}
|
||||
|
||||
func readerImportSourcesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Text string `json:"text" binding:"required"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
imported, err := svc.Reader.ImportSources(c.Request.Context(), body.Text)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"imported": imported})
|
||||
}
|
||||
}
|
||||
|
||||
func readerUpdateSourceHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Enabled == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled 字段必填"})
|
||||
return
|
||||
}
|
||||
if err := svc.Reader.UpdateSourceEnabled(c.Request.Context(), c.Param("id"), *body.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func readerDeleteSourceHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Reader.DeleteSource(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func readerDebugSourceHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
logs, err := svc.Reader.Debug(c.Request.Context(), c.Param("id"), body.Key)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"logs": logs})
|
||||
}
|
||||
}
|
||||
|
||||
func readerSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
info, err := svc.Reader.GetSourceLogin(c.Request.Context(), userID, c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
}
|
||||
|
||||
func readerSourceLoginActionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
// Action 为 loginUi 里按钮的 action;留空表示执行 login()(确认登录)。
|
||||
Action string `json:"action"`
|
||||
Fields map[string]string `json:"fields"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
res, err := svc.Reader.RunLoginAction(c.Request.Context(), userID, c.Param("id"), body.Action, body.Fields)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
}
|
||||
|
||||
// readerBrowserPendingHandler 前端轮询:该用户在某书源下待用户完成的页面。
|
||||
// 书源的 java.startBrowserAwait 会阻塞在服务端,前端据此把页面呈现出来。
|
||||
func readerBrowserPendingHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
pages := svc.Reader.PendingBrowserPages(userID, c.Query("source_id"))
|
||||
c.JSON(http.StatusOK, gin.H{"pages": pages})
|
||||
}
|
||||
}
|
||||
|
||||
// readerBrowserResultHandler 用户完成页面后回传 DOM(或取消),解除服务端阻塞。
|
||||
func readerBrowserResultHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
ID string `json:"id" binding:"required"`
|
||||
Body string `json:"body"`
|
||||
URL string `json:"url"`
|
||||
Cancelled bool `json:"cancelled"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
if err := svc.Reader.ResolveBrowser(body.ID, userID, body.Body, body.URL, body.Cancelled); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// readerBrowserXHRHandler 转发承载页面内的接口请求。
|
||||
//
|
||||
// 页面在 iframe 里是不透明源,自己的 XHR 既带不上书源 Cookie 也会被 CORS 拦,
|
||||
// 所以由父窗口(持 JWT)把请求转交到这里,服务端补上书源凭据再发。
|
||||
func readerBrowserXHRHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
ID string `json:"id" binding:"required"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
Method string `json:"method"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
res, err := svc.Reader.ProxyBrowserXHR(
|
||||
c.Request.Context(), body.ID, body.Method, body.URL, body.Headers, body.Body)
|
||||
if err != nil {
|
||||
// 交给页面自己处理失败,别把 4xx 泄成框架错误
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
}
|
||||
|
||||
// readerOpenCommentHandler 打开一条段评:复用书源登录态在宿主浏览器里承载评论页。
|
||||
// 书源的 showCmt 内部就是「java.ajax 取评论页 → java.showBrowser 展示」,
|
||||
// 这里用承载登录页的同一套机制等价实现(见 reader.OpenContentComment)。
|
||||
func readerOpenCommentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
BookID string `json:"book_id" binding:"required"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
page, err := svc.Reader.OpenContentComment(c.Request.Context(), userID, body.BookID, body.URL, body.Title)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, page)
|
||||
}
|
||||
}
|
||||
|
||||
// readerBrowserPageHandler 承载待办页面本体。//
|
||||
// 鉴权走 HMAC 签名而非 JWT:这个地址要填进 <iframe src>,而 iframe 的请求
|
||||
// 带不上 Authorization 头。签名绑定待办 ID,链接随待办一起过期。
|
||||
func readerBrowserPageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
snap, err := svc.Reader.VerifyBrowserPage(c.Query("id"), c.Query("s"))
|
||||
if err != nil {
|
||||
c.String(http.StatusForbidden, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(snap.HTML))
|
||||
}
|
||||
}
|
||||
|
||||
// readerBrowserAssetHandler 页面资源/表单/站内链接的同源代理。
|
||||
// 服务端补上书源 Cookie 与请求头,使「用户后台」这类页面在 iframe 里保持登录态。
|
||||
func readerBrowserAssetHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
target, sourceURL, err := svc.Reader.VerifyBrowserAsset(c.Query("id"), c.Query("u"), c.Query("s"))
|
||||
if err != nil {
|
||||
c.String(http.StatusForbidden, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
contentType, status, data, err := svc.Reader.FetchBrowserAsset(c.Request.Context(), sourceURL, target)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadGateway, "资源加载失败: %s", err.Error())
|
||||
return
|
||||
}
|
||||
if strings.Contains(strings.ToLower(contentType), "text/css") {
|
||||
data = []byte(svc.Reader.RewriteBrowserCSS(string(data), target, c.Query("id")))
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Data(status, contentType, data)
|
||||
}
|
||||
}
|
||||
|
||||
func readerSourceLogoutHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Reader.ClearSourceLogin(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func readerSetSourceVariableHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Variable string `json:"variable"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := svc.Reader.SetSourceVariable(c.Request.Context(), c.Param("id"), body.Variable); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func readerSetSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Fields map[string]string `json:"fields"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := svc.Reader.SetSourceLoginInfo(c.Request.Context(), c.Param("id"), body.Fields); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
// SourceIDs 搜索范围:空表示全部启用书源(默认),非空则只搜这些书源。
|
||||
SourceIDs []string `json:"source_ids"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"books": books, "skipped": skipped})
|
||||
}
|
||||
}
|
||||
|
||||
func readerBookInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
info, err := svc.Reader.GetBookInfo(
|
||||
c.Request.Context(),
|
||||
c.Query("source_id"), c.Query("source_url"), c.Query("book_url"),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
}
|
||||
|
||||
func readerTocHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
chapters, err := svc.Reader.GetToc(
|
||||
c.Request.Context(),
|
||||
userID, c.Query("source_id"), c.Query("source_url"), c.Query("book_url"), c.Query("toc_url"),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// nil 切片会被编码成 null,前端拿到 null 再取 .length 就是一句
|
||||
// 「Cannot read properties of null」。空目录统一给 []。
|
||||
if chapters == nil {
|
||||
chapters = []reader.TocChapter{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"chapters": chapters})
|
||||
}
|
||||
}
|
||||
|
||||
func readerContentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
content, err := svc.Reader.GetContent(
|
||||
c.Request.Context(),
|
||||
c.Query("source_id"), c.Query("source_url"), c.Query("book_url"), c.Query("chapter_url"),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
}
|
||||
|
||||
func readerListBooksHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
books, err := svc.Reader.ListBooks(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"books": books})
|
||||
}
|
||||
}
|
||||
|
||||
func readerAddBookHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Origin reader.SearchOrigin `json:"origin" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Author string `json:"author"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
book, err := svc.Reader.AddBook(c.Request.Context(), userID, body.Origin, body.Name, body.Author, body.CoverURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 后台补目录缓存,让书架能显示未读章数;失败不影响加入书架本身。
|
||||
svc.Reader.WarmUpBookChaptersAsync(c.Request.Context(), userID, book)
|
||||
c.JSON(http.StatusOK, book)
|
||||
}
|
||||
}
|
||||
|
||||
// readerSwitchOriginHandler 换源:把书架里的书切到另一个书源。
|
||||
// 阅读进度保留;旧源目录缓存清空后按新源后台重新预热。
|
||||
func readerSwitchOriginHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Origin reader.SearchOrigin `json:"origin" binding:"required"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
book, err := svc.Reader.SwitchOrigin(c.Request.Context(), userID, c.Param("id"), body.Origin)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
svc.Reader.WarmUpBookChaptersAsync(c.Request.Context(), userID, book)
|
||||
c.JSON(http.StatusOK, book)
|
||||
}
|
||||
}
|
||||
|
||||
// readerImportLocalBookHandler 上传本地书籍(TXT / EPUB)并加入书架。
|
||||
// 正文落盘到 data/reader/local,目录切分后与网络书籍共用阅读器链路。
|
||||
func readerImportLocalBookHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 先卡住请求体大小,避免超大文件把内存打满(多给 1MB 放 multipart 头)
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, int64(reader.LocalBookMaxBytes)+(1<<20))
|
||||
header, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少上传文件(表单字段 file),或文件超过大小上限"})
|
||||
return
|
||||
}
|
||||
f, err := header.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "读取上传文件失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(f, int64(reader.LocalBookMaxBytes)+1))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "读取上传文件失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if len(data) > reader.LocalBookMaxBytes {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "文件超过大小上限"})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
book, err := svc.Reader.ImportLocalBook(c.Request.Context(), userID, header.Filename, data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, book)
|
||||
}
|
||||
}
|
||||
|
||||
// readerImportLocalBookFromPathHandler 从服务器已有文件导入书籍(TXT / EPUB),原地引用。
|
||||
// 仅管理员:会读取服务器上允许根目录内的任意文件。
|
||||
func readerImportLocalBookFromPathHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Path string `json:"path" binding:"required"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
abs, err := svc.FileManager.ResolvePath(body.Path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
book, err := svc.Reader.ImportLocalBookFromPath(c.Request.Context(), userID, abs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, book)
|
||||
}
|
||||
}
|
||||
|
||||
// readerImportLocalAudioDirHandler 把一个服务器目录导入为一本有声书,原地引用。
|
||||
// 仅管理员;目录下的音频文件与 .strm 播放指针按相对路径排序成为章节。
|
||||
func readerImportLocalAudioDirHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Path string `json:"path" binding:"required"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
abs, err := svc.FileManager.ResolvePath(body.Path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
book, err := svc.Reader.ImportLocalAudioDir(c.Request.Context(), userID, abs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, book)
|
||||
}
|
||||
}
|
||||
|
||||
// readerRefreshBooksTocHandler 更新目录:重抓书架里全部网络书籍的目录。
|
||||
// 本地书籍与没有书源信息的书籍跳过;单本失败只计数,不影响其它书。
|
||||
func readerRefreshBooksTocHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
res, err := svc.Reader.RefreshBooksToc(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
}
|
||||
|
||||
func readerRemoveBookHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
if err := svc.Reader.RemoveBook(c.Request.Context(), userID, c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// readerGetProfileHandler 读当前用户的阅读器偏好。
|
||||
// 没保存过时返回 {"profile": null},前端据此用本地值播种。
|
||||
func readerGetProfileHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
settings, err := svc.Reader.GetReaderSettings(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"profile": settings})
|
||||
}
|
||||
}
|
||||
|
||||
// readerSaveProfileHandler 覆盖保存当前用户的阅读器偏好(服务端做范围收敛)。
|
||||
func readerSaveProfileHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body reader.ReaderSettings
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
saved, err := svc.Reader.SaveReaderSettings(c.Request.Context(), userID, body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"profile": saved})
|
||||
}
|
||||
}
|
||||
|
||||
// readerGetBookGroupsHandler 读当前用户的书架分组。
|
||||
// 没有分组时返回空数组(不是 null),前端可以直接遍历。
|
||||
func readerGetBookGroupsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
groups, err := svc.Reader.GetBookGroups(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"groups": groups})
|
||||
}
|
||||
}
|
||||
|
||||
// readerSetBookGroupsHandler 覆盖保存当前用户的书架分组(整份替换)。
|
||||
func readerSetBookGroupsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Groups []model.BookGroupSet `json:"groups"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
groups, err := svc.Reader.SetBookGroups(c.Request.Context(), userID, body.Groups)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"groups": groups})
|
||||
}
|
||||
}
|
||||
|
||||
// maxReaderChapterPos 进度上限:秒(听书)/ 页码(文本)/ 图片序号(漫画)都远小于它,
|
||||
// 只用来挡住异常大的浮点数转 int 时溢出。约 115 天,足够覆盖任何单章。
|
||||
const maxReaderChapterPos = 10_000_000
|
||||
|
||||
func readerSaveProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
ChapterIndex int `json:"chapter_index"`
|
||||
// Pos 用 float64 接:听书的进度是 audio.currentTime(秒,天然带小数),
|
||||
// 漫画是图片序号、文本是页码(都是整数)。用 int 接小数会让整个请求
|
||||
// 400,而前端是 fire-and-forget,音频进度就被静默丢掉了。
|
||||
Pos float64 `json:"pos"`
|
||||
ChapterTitle string `json:"chapter_title"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// dur_chapter_pos 落库是 int,截断成秒;负数没有意义,夹到 0。
|
||||
posFloat := body.Pos
|
||||
if !(posFloat > 0) { // 同时挡住 0 与负数(JSON 不会给出 NaN)
|
||||
posFloat = 0
|
||||
}
|
||||
if posFloat > maxReaderChapterPos {
|
||||
posFloat = maxReaderChapterPos
|
||||
}
|
||||
pos := int(posFloat)
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
if err := svc.Reader.SaveProgress(c.Request.Context(), userID, c.Param("id"), body.ChapterIndex, pos, body.ChapterTitle); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// readerSaveAudioConfigHandler 保存听书片头/片尾跳过秒数(0 为不跳过)。
|
||||
func readerSaveAudioConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
OpenCredits int `json:"open_credits"`
|
||||
CloseCredits int `json:"close_credits"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
if err := svc.Reader.SaveAudioConfig(
|
||||
c.Request.Context(), userID, c.Param("id"), body.OpenCredits, body.CloseCredits); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func readerListChaptersHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
chapters, err := svc.Reader.ListChapters(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"chapters": chapters})
|
||||
}
|
||||
}
|
||||
|
||||
func readerReplaceChaptersHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Chapters []reader.ChapterInput `json:"chapters"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := svc.Reader.SaveChapters(c.Request.Context(), c.Param("id"), body.Chapters); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func readerListReplaceRulesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
rules, err := svc.Reader.ListReplaceRules(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"rules": rules})
|
||||
}
|
||||
}
|
||||
|
||||
func readerCreateReplaceRuleHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body reader.ReplaceRuleInput
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
rule, err := svc.Reader.CreateReplaceRule(c.Request.Context(), userID, body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, rule)
|
||||
}
|
||||
}
|
||||
|
||||
func readerUpdateReplaceRuleHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body reader.ReplaceRuleInput
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
if err := svc.Reader.UpdateReplaceRule(c.Request.Context(), userID, c.Param("id"), body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func readerDeleteReplaceRuleHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
if err := svc.Reader.DeleteReplaceRule(c.Request.Context(), userID, c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// readerMediaProxyHandler 音频流/漫画图片签名代理:
|
||||
// 校验 HMAC 签名 → 携书源防盗链头拉取 → Range 透传(音频拖动)/ m3u8 重写。
|
||||
func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
bookID := c.Query("b")
|
||||
rawURL, err := svc.Reader.VerifyProxyURL(bookID, c.Query("u"), c.Query("s"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 http(s) 媒体地址"})
|
||||
return
|
||||
}
|
||||
book, err := svc.Reader.GetBook(c.Request.Context(), bookID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "书籍不存在"})
|
||||
return
|
||||
}
|
||||
resp, err := svc.Reader.FetchMedia(c.Request.Context(), book, rawURL, c.GetHeader("Range"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "媒体拉取失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
isPlaylist := strings.Contains(ct, "mpegurl") || strings.Contains(ct, "m3u8") ||
|
||||
strings.HasSuffix(strings.ToLower(rawURL), ".m3u8")
|
||||
if isPlaylist {
|
||||
// m3u8:改写分片/密钥地址为签名代理后返回,hls.js 无感续播
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
rewritten := svc.Reader.RewritePlaylist(bookID, rawURL, string(data))
|
||||
c.Data(http.StatusOK, "application/vnd.apple.mpegurl", []byte(rewritten))
|
||||
return
|
||||
}
|
||||
// 流式透传(含 206 Partial Content,支持音频拖动进度)
|
||||
for _, h := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges"} {
|
||||
if v := resp.Header.Get(h); v != "" {
|
||||
c.Header(h, v)
|
||||
}
|
||||
}
|
||||
c.Status(resp.StatusCode)
|
||||
_, _ = io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// readerLocalAssetHandler 本地书籍内嵌资源(EPUB 图片等):
|
||||
// 鉴权走 HMAC 签名(<img src> 带不上 JWT),与 /reader/media 同一套做法。
|
||||
func readerLocalAssetHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
bookID := c.Query("b")
|
||||
entry, err := svc.Reader.VerifyLocalAssetURL(bookID, c.Query("p"), c.Query("s"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
data, contentType, err := svc.Reader.ReadLocalAsset(c.Request.Context(), bookID, entry)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 同一本书的图片不会变,可长缓存
|
||||
c.Header("Cache-Control", "private, max-age=604800")
|
||||
c.Data(http.StatusOK, contentType, data)
|
||||
}
|
||||
}
|
||||
|
||||
// readerAudioTranscodeHandler 需要转码的有声书音轨:签名鉴权(<audio src> 带不上 JWT),
|
||||
// 首次请求跑 ffmpeg 转成 mp3 落缓存,之后按 Range 下发,播放器可以拖动进度。
|
||||
func readerAudioTranscodeHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
bookID := c.Query("b")
|
||||
source, err := svc.Reader.VerifyAudioTranscodeURL(bookID, c.Query("u"), c.Query("s"))
|
||||
if err != nil {
|
||||
c.String(http.StatusForbidden, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
path, err := svc.Reader.EnsureTranscodedAudio(c.Request.Context(), bookID, source)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadGateway, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
f, err := os.Open(path) // #nosec G304 -- 路径由签名校验 + 缓存目录哈希生成
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "转码结果已丢失")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "转码结果不可用")
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "private, max-age=604800")
|
||||
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), f)
|
||||
}
|
||||
}
|
||||
|
||||
// readerLocalAudioHandler 本地有声书音频流:签名鉴权(<audio src> 带不上 JWT),
|
||||
// 交给 http.ServeContent 处理 Range,播放器才能拖动进度。
|
||||
func readerLocalAudioHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path, err := svc.Reader.VerifyLocalAudioURL(c.Query("b"), c.Query("p"), c.Query("s"))
|
||||
if err != nil {
|
||||
c.String(http.StatusForbidden, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
f, info, err := svc.Reader.OpenLocalAudio(path)
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), f)
|
||||
}
|
||||
}
|
||||
|
||||
// readerBookContentHandler 书架维度正文(服务端应用替换净化规则)。
|
||||
func readerBookContentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
chapter, err := strconv.Atoi(c.DefaultQuery("chapter", "0"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "chapter 参数需为整数"})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
content, err := svc.Reader.GetContentForBook(c.Request.Context(), userID, c.Param("id"), chapter)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
}
|
||||
@@ -116,8 +116,13 @@ func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
|
||||
admin.PATCH("/users/:id/libraries", updateUserLibrariesHandler(svc))
|
||||
admin.DELETE("/users/:id", deleteUserHandler(svc))
|
||||
// 设备代管:管理员可查看并踢掉任意用户的设备。
|
||||
admin.GET("/users/:id/devices", adminUserDevicesHandler(svc))
|
||||
admin.POST("/users/:id/devices/kick-all", adminKickAllUserDevicesHandler(svc))
|
||||
admin.POST("/users/:id/devices/:deviceID/kick", adminKickUserDeviceHandler(svc))
|
||||
admin.GET("/settings", listSettingsHandler(svc))
|
||||
admin.PUT("/settings", updateSettingHandler(svc))
|
||||
admin.POST("/telegram/test", testTelegramHandler(svc))
|
||||
admin.POST("/adult/test-scraper", testAdultScraperHandler(svc))
|
||||
admin.GET("/logs", recentLogsHandler(svc))
|
||||
}
|
||||
|
||||
@@ -30,4 +30,5 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc *
|
||||
registerAuthedDLNAControlRoutes(authed, svc)
|
||||
registerAuthedFavoriteAndMediaActionRoutes(authed, svc)
|
||||
registerAuthedPlaybackExtraRoutes(authed, svc)
|
||||
registerReaderRoutes(authed, svc)
|
||||
}
|
||||
|
||||
@@ -12,11 +12,23 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co
|
||||
authed.PATCH("/me", updateProfileHandler(svc))
|
||||
authed.GET("/me/pinned-libraries", getPinnedLibrariesHandler(svc))
|
||||
authed.PUT("/me/pinned-libraries", setPinnedLibrariesHandler(svc))
|
||||
authed.GET("/me/library-tags", getLibraryTagsHandler(svc))
|
||||
authed.PUT("/me/library-tags", setLibraryTagsHandler(svc))
|
||||
authed.POST("/me/password", changePasswordHandler(svc))
|
||||
authed.POST("/me/logout", logoutHandler(svc))
|
||||
authed.GET("/me/temporary-password", temporaryPasswordHandler(svc))
|
||||
authed.POST("/me/temporary-password", temporaryPasswordHandler(svc))
|
||||
|
||||
// 设备管理:路由挂在 /me 下,用户 ID 一律取自会话,天然只能管自己的设备。
|
||||
authed.GET("/me/devices", myDevicesHandler(svc))
|
||||
authed.POST("/me/devices/kick-all", myKickAllDevicesHandler(svc))
|
||||
authed.POST("/me/devices/:deviceID/kick", myKickDeviceHandler(svc))
|
||||
|
||||
// Telegram 通知绑定:一次性码 + Bot /bind <code>。
|
||||
authed.GET("/me/telegram", getTelegramStatusHandler(svc))
|
||||
authed.POST("/me/telegram/bind-code", startTelegramBindHandler(svc))
|
||||
authed.DELETE("/me/telegram", unbindTelegramHandler(svc))
|
||||
|
||||
authed.GET("/auth/permissions", getMyPermissionsHandler(svc))
|
||||
}
|
||||
|
||||
@@ -36,6 +48,8 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
|
||||
authed.POST("/libraries/:id/scrape", middleware.AdminRequired(), scrapeLibraryHandler(svc))
|
||||
|
||||
authed.GET("/libraries/:id/media", listMediaHandler(svc))
|
||||
authed.GET("/libraries/:id/facets", libraryFacetsHandler(svc))
|
||||
authed.GET("/libraries/:id/random", libraryRandomHandler(svc))
|
||||
authed.GET("/libraries/:id/series", listLibrarySeriesHandler(svc))
|
||||
authed.GET("/libraries/:id/series/episodes", listLibrarySeriesEpisodesHandler(svc))
|
||||
authed.GET("/libraries/:id/seasons", listSeasonsHandler(svc))
|
||||
@@ -44,6 +58,8 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
|
||||
func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/media/:id", getMediaHandler(svc))
|
||||
authed.GET("/media/:id/episodes", listMediaEpisodesHandler(svc))
|
||||
authed.GET("/media/:id/playback", mediaPlaybackInfoHandler(svc))
|
||||
authed.POST("/media/:id/transcode", mediaTranscodeHandler(svc))
|
||||
authed.GET("/media", searchMediaHandler(svc))
|
||||
authed.PATCH("/media/:id/metadata", middleware.AdminRequired(), updateMediaMetadataHandler(svc))
|
||||
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
|
||||
@@ -65,6 +81,10 @@ func registerAuthedPlaybackAndProxyRoutes(authed *gin.RouterGroup, svc *service.
|
||||
authed.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc))
|
||||
authed.GET("/hls/:id/:seg", hlsSegmentHandler(svc))
|
||||
authed.DELETE("/hls/:id", stopTranscodeHandler(svc))
|
||||
authed.GET("/cloud115/media/:id/master.m3u8", cloud115HLSMasterHandler(svc))
|
||||
authed.HEAD("/cloud115/media/:id/master.m3u8", cloud115HLSMasterHandler(svc))
|
||||
authed.GET("/cloud115/hls/:session/:key", cloud115HLSSessionHandler(svc))
|
||||
authed.HEAD("/cloud115/hls/:session/:key", cloud115HLSSessionHandler(svc))
|
||||
|
||||
authed.GET("/img", imageProxyHandler(svc))
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ func registerAuthedUISurfaceRoutes(authed *gin.RouterGroup, svc *service.Contain
|
||||
authed.PUT("/danmaku/settings", updateDanmakuSettingsHandler(svc))
|
||||
|
||||
authed.GET("/watch-history", historyListHandler(svc))
|
||||
authed.GET("/watch-history/stats", historyStatsHandler(svc))
|
||||
authed.GET("/watch-history/stats", requirePermission(svc, "can_view_history"), historyStatsHandler(svc))
|
||||
authed.GET("/watch-history/continue", historyContinueHandler(svc))
|
||||
authed.DELETE("/watch-history", historyDeleteHandler(svc))
|
||||
authed.DELETE("/watch-history/:id", historyDeleteOneHandler(svc))
|
||||
@@ -73,6 +73,7 @@ func registerAuthedFavoriteAndMediaActionRoutes(authed *gin.RouterGroup, svc *se
|
||||
func registerAuthedPlaybackExtraRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
authed.GET("/playback/:id/info", playbackInfoHandler(svc))
|
||||
authed.GET("/playback/:id/resume", playbackResumeHandler(svc))
|
||||
authed.GET("/playback/:id/segments", playbackSegmentsHandler(svc))
|
||||
authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
|
||||
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
|
||||
authed.GET("/playback/:id/external-url", externalURLHandler(svc))
|
||||
|
||||
@@ -27,6 +27,8 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
|
||||
"GET /api/me",
|
||||
"GET /api/me/pinned-libraries",
|
||||
"PUT /api/me/pinned-libraries",
|
||||
"GET /api/me/library-tags",
|
||||
"PUT /api/me/library-tags",
|
||||
"GET /api/auth/permissions",
|
||||
"GET /api/libraries",
|
||||
"GET /api/media",
|
||||
@@ -34,6 +36,7 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
|
||||
"GET /api/storage",
|
||||
"GET /api/watch-history",
|
||||
"GET /api/playback/:id/info",
|
||||
"GET /api/playback/:id/segments",
|
||||
} {
|
||||
if !routes[want] {
|
||||
t.Fatalf("%s route is not registered", want)
|
||||
|
||||
@@ -89,7 +89,7 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
}
|
||||
cards = service.SortSeriesCards(cards, sortSpec.Field, sortSpec.Order, history)
|
||||
cards = sortRemoteSeriesCards(cards, sortSpec, history)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
|
||||
if page < 1 {
|
||||
@@ -124,7 +124,9 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
}
|
||||
items, total, err := svc.Media.ListLibrarySeriesCards(c.Request.Context(), libID, mediaVisibilityForRequest(c, svc))
|
||||
items, total, err := svc.Media.ListLibrarySeriesCardsFiltered(
|
||||
c.Request.Context(), libID, mediaVisibilityForRequest(c, svc), parseLibraryFilters(c),
|
||||
)
|
||||
if err != nil {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// sortRemoteSeriesCards keeps the DateLastContentAdded order returned by the
|
||||
// remote Emby server when LastAddedAt is not exposed in the Series DTO.
|
||||
func sortRemoteSeriesCards(cards []service.SeriesCard, spec service.MediaSortSpec, history map[string]time.Time) []service.SeriesCard {
|
||||
if spec.Field != "updated_at" {
|
||||
return service.SortSeriesCards(cards, spec.Field, spec.Order, history)
|
||||
}
|
||||
|
||||
out := append([]service.SeriesCard(nil), cards...)
|
||||
// RemoteSeriesCards always fetches DateLastContentAdded in descending order.
|
||||
if spec.Order == "asc" {
|
||||
slices.Reverse(out)
|
||||
}
|
||||
return service.SortSeriesCards(out, spec.Field, spec.Order, history)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func TestSortRemoteSeriesCardsUpdatedAtUsesRemoteOrder(t *testing.T) {
|
||||
cards := []service.SeriesCard{{Key: "newest"}, {Key: "middle"}, {Key: "oldest"}}
|
||||
|
||||
desc := sortRemoteSeriesCards(cards, service.MediaSortSpec{Field: "updated_at", Order: "desc"}, nil)
|
||||
if desc[0].Key != "newest" || desc[1].Key != "middle" || desc[2].Key != "oldest" {
|
||||
t.Fatalf("descending order = %q, %q, %q", desc[0].Key, desc[1].Key, desc[2].Key)
|
||||
}
|
||||
|
||||
asc := sortRemoteSeriesCards(cards, service.MediaSortSpec{Field: "updated_at", Order: "asc"}, nil)
|
||||
if asc[0].Key != "oldest" || asc[1].Key != "middle" || asc[2].Key != "newest" {
|
||||
t.Fatalf("ascending order = %q, %q, %q", asc[0].Key, asc[1].Key, asc[2].Key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// 直连流的同源转发:网页端需要读取视频帧的场景(VR 全景渲染走 WebGL 纹理)
|
||||
// 不能使用会跳到网盘 CDN 的跨域直链,这里把直链改为服务端转发。
|
||||
//
|
||||
// 只在客户端显式带上 ?proxy=1 时生效,普通播放仍走原来的 302 直连,
|
||||
// 避免把网盘流量无谓地压到服务器上。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// wantSameOriginProxy 客户端是否要求把直连流改为服务端同源转发。
|
||||
func wantSameOriginProxy(c *gin.Context) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(c.Query("proxy"))) {
|
||||
case "1", "true", "yes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// proxySTRMStream 处理 STRM/网盘媒体的同源转发请求。返回 handled=false 表示
|
||||
// 该媒体不需要(或无法)代理,调用方继续按原有逻辑处理。
|
||||
func proxySTRMStream(c *gin.Context, svc *service.Container, m *model.Media) (bool, error) {
|
||||
if c == nil || svc == nil || svc.Strm == nil || m == nil {
|
||||
return false, nil
|
||||
}
|
||||
if !service.IsStrmMediaRow(m) {
|
||||
return false, nil
|
||||
}
|
||||
if err := svc.Strm.ProxyMediaDirect(c.Request.Context(), c.Writer, c.Request, m); err != nil {
|
||||
if errors.Is(err, service.ErrStrmProxyNotApplicable) {
|
||||
return false, nil
|
||||
}
|
||||
return true, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// writeProxyError 在尚未写入任何响应内容时回一个明确的网关错误。
|
||||
func writeProxyError(c *gin.Context, err error) {
|
||||
if c == nil || err == nil || c.Writer.Written() {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
@@ -138,25 +138,35 @@ func testStrmAccountHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
acct.LastTestAt = &now
|
||||
if acct.Provider == model.StrmProviderEmbyRemote && svc.EmbyRemote != nil {
|
||||
result := ""
|
||||
ok := false
|
||||
if err := svc.EmbyRemote.TestConnection(c.Request.Context(), acct); err != nil {
|
||||
acct.LastTestResult = err.Error()
|
||||
acct.LastTestOK = false
|
||||
result = err.Error()
|
||||
} else {
|
||||
acct.LastTestResult = "ok"
|
||||
acct.LastTestOK = true
|
||||
result = "ok"
|
||||
ok = true
|
||||
}
|
||||
} else {
|
||||
acct = svc.Strm.TestStrmAccount(c.Request.Context(), id)
|
||||
if acct == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"})
|
||||
acct.LastTestAt = &now
|
||||
acct.LastTestResult = result
|
||||
acct.LastTestOK = ok
|
||||
if err := svc.Repo.StrmAccount.UpdateTestResult(c.Request.Context(), acct.ID, now, result, ok); err != nil {
|
||||
// 写库失败时仍返回本地测试结果;不要回退到整行 Update,
|
||||
// 那会覆盖 TestConnection 期间可能刷新的账号配置。
|
||||
c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0])
|
||||
return
|
||||
}
|
||||
if fresh, err := svc.Repo.StrmAccount.FindByID(c.Request.Context(), acct.ID); err == nil && fresh != nil {
|
||||
acct = fresh
|
||||
}
|
||||
c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0])
|
||||
return
|
||||
}
|
||||
_ = svc.Repo.StrmAccount.Update(c.Request.Context(), acct)
|
||||
acct = svc.Strm.TestStrmAccount(c.Request.Context(), id)
|
||||
if acct == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// Telegram 绑定与测试接口。
|
||||
//
|
||||
// 绑定刻意做成「网页生成一次性码 → 用户在 Bot 里发 /bind <码>」:服务端不需要
|
||||
// 用户手工填写 chat id,也不需要站点暴露 Bot 命令以外任何能力。
|
||||
|
||||
type telegramBindCodePayload struct {
|
||||
Code string `json:"code"`
|
||||
ExpiresIn int `json:"expires_in_seconds"`
|
||||
}
|
||||
|
||||
// startTelegramBindHandler 生成一次性绑定码。同一用户重复调用时旧码作废。
|
||||
func startTelegramBindHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc.Telegram == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "telegram service unavailable"})
|
||||
return
|
||||
}
|
||||
userID := sessionUserID(c)
|
||||
code, err := svc.Telegram.StartBind(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, telegramBindCodePayload{Code: code, ExpiresIn: 300})
|
||||
}
|
||||
}
|
||||
|
||||
// getTelegramStatusHandler 返回绑定状态与脱敏会话 ID。
|
||||
func getTelegramStatusHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc.Telegram == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"bound": false})
|
||||
return
|
||||
}
|
||||
bound, masked := svc.Telegram.Status(c.Request.Context(), sessionUserID(c))
|
||||
payload := gin.H{"bound": bound}
|
||||
if bound {
|
||||
payload["chat_id_masked"] = masked
|
||||
}
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// unbindTelegramHandler 解除当前用户的 Telegram 绑定。
|
||||
func unbindTelegramHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc.Telegram == nil {
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if err := svc.Telegram.Unbind(c.Request.Context(), sessionUserID(c)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// testTelegramHandler 向管理员会话发送一条测试消息,用于验证 Token/会话 ID。
|
||||
func testTelegramHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc.Telegram == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "telegram service unavailable"})
|
||||
return
|
||||
}
|
||||
if !svc.Telegram.Configured(c.Request.Context()) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"error": "请先启用 Telegram 通知并填写 Bot Token 与管理员 Chat ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := svc.Telegram.SendToAdminChecked(c.Request.Context(), "✅ MeBox 测试消息:通知通道工作正常。"); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -48,11 +51,16 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// historyStatsHandler returns aggregate watch time + completion counts
|
||||
// for the caller. Used by the WatchHistoryPage hero card.
|
||||
// for the caller. Used by the WatchHistoryPage hero card and the dedicated
|
||||
// personal statistics page.
|
||||
//
|
||||
// 统计口径全部来自 PlaybackHistory 本身,不新增统计表:position_ms 是「已看
|
||||
// 时长」的近似值,足以支撑趋势图;精确到秒的播放时长另有会话统计负责。
|
||||
func historyStatsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
userID := toString(uid)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var total int64
|
||||
_ = svc.Repo.DB.Model(&model.PlaybackHistory{}).
|
||||
@@ -77,28 +85,209 @@ func historyStatsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
last = &lastT
|
||||
}
|
||||
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
daily, byType, recent := historyStatsBreakdowns(ctx, svc, userID, visibility)
|
||||
|
||||
inProgress := total - completed
|
||||
if inProgress < 0 {
|
||||
inProgress = 0
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"total": total,
|
||||
"completed": completed,
|
||||
"watched_ms": watchedMs,
|
||||
"watched_hours": float64(watchedMs) / 1000.0 / 3600.0,
|
||||
"last_watched": last,
|
||||
"total": total,
|
||||
"completed": completed,
|
||||
"in_progress": inProgress,
|
||||
"watched_ms": watchedMs,
|
||||
"watched_hours": float64(watchedMs) / 1000.0 / 3600.0,
|
||||
"last_watched": last,
|
||||
"daily": daily,
|
||||
"by_library_type": byType,
|
||||
"recent": recent,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// historyStatsDailyDays 是趋势图回看的天数。
|
||||
const historyStatsDailyDays = 30
|
||||
|
||||
// historyStatsRecentLimit 是「最近看过」返回的条数。
|
||||
const historyStatsRecentLimit = 8
|
||||
|
||||
type historyDailyStat struct {
|
||||
Day string `json:"day"`
|
||||
WatchMs int64 `json:"watch_ms"`
|
||||
Plays int64 `json:"plays"`
|
||||
}
|
||||
|
||||
type historyTypeStat struct {
|
||||
Type string `json:"type"`
|
||||
WatchMs int64 `json:"watch_ms"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// historyStatsBreakdowns 产出每日趋势、按媒体库类型分布与最近记录。
|
||||
//
|
||||
// 分桶在 Go 里做而不是用 SQL 的日期函数:SQLite 的 strftime 与 PostgreSQL 的
|
||||
// to_char 语法不同,写两份 SQL 会在方言差异上长期出错,而历史行数受用户规模
|
||||
// 约束(每人一行一部媒体),一次全量读取是可以接受的。
|
||||
//
|
||||
// visibility 控制哪些媒体对调用者可见(播放档案、成人锁等)。
|
||||
func historyStatsBreakdowns(ctx context.Context, svc *service.Container, userID string, visibility service.MediaVisibility) ([]historyDailyStat, []historyTypeStat, []map[string]any) {
|
||||
daily := make([]historyDailyStat, 0, historyStatsDailyDays)
|
||||
byType := make([]historyTypeStat, 0)
|
||||
recent := make([]map[string]any, 0, historyStatsRecentLimit)
|
||||
|
||||
var rows []model.PlaybackHistory
|
||||
if err := svc.Repo.DB.WithContext(ctx).
|
||||
Where("user_id = ?", userID).
|
||||
Order("watched_at desc").
|
||||
Find(&rows).Error; err != nil || len(rows) == 0 {
|
||||
return daily, byType, recent
|
||||
}
|
||||
|
||||
// 每日趋势:只回看最近 N 天,且按「本地日」分桶,避免跨时区偏移。
|
||||
now := time.Now()
|
||||
cutoff := now.AddDate(0, 0, -(historyStatsDailyDays - 1))
|
||||
startOfDay := func(t time.Time) time.Time {
|
||||
local := t.In(time.Local)
|
||||
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.Local)
|
||||
}
|
||||
buckets := make(map[string]*historyDailyStat, historyStatsDailyDays)
|
||||
for i := 0; i < historyStatsDailyDays; i++ {
|
||||
day := startOfDay(cutoff).AddDate(0, 0, i).Format("2006-01-02")
|
||||
buckets[day] = &historyDailyStat{Day: day}
|
||||
}
|
||||
for _, r := range rows {
|
||||
watched := r.WatchedAt.In(time.Local)
|
||||
if watched.Before(startOfDay(cutoff)) {
|
||||
continue
|
||||
}
|
||||
if bucket, ok := buckets[watched.Format("2006-01-02")]; ok {
|
||||
bucket.WatchMs += r.PositionMs
|
||||
bucket.Plays++
|
||||
}
|
||||
}
|
||||
for i := 0; i < historyStatsDailyDays; i++ {
|
||||
day := startOfDay(cutoff).AddDate(0, 0, i).Format("2006-01-02")
|
||||
if bucket, ok := buckets[day]; ok && bucket.Plays > 0 {
|
||||
daily = append(daily, *bucket)
|
||||
}
|
||||
}
|
||||
|
||||
mediaIDs := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
mediaIDs = append(mediaIDs, r.MediaID)
|
||||
}
|
||||
var medias []model.Media
|
||||
_ = svc.Repo.DB.WithContext(ctx).Where("id IN ?", mediaIDs).Find(&medias).Error
|
||||
mediaByID := make(map[string]*model.Media, len(medias))
|
||||
for i := range medias {
|
||||
mediaByID[medias[i].ID] = &medias[i]
|
||||
}
|
||||
|
||||
libraryTypes := make(map[string]string)
|
||||
var libraries []model.Library
|
||||
if svc.Repo.Library != nil {
|
||||
if libs, err := svc.Repo.Library.List(ctx); err == nil {
|
||||
libraries = libs
|
||||
}
|
||||
}
|
||||
for _, lib := range libraries {
|
||||
libraryTypes[lib.ID] = lib.Type
|
||||
}
|
||||
|
||||
typeAcc := make(map[string]*historyTypeStat)
|
||||
order := make([]string, 0, 4)
|
||||
for _, r := range rows {
|
||||
media := mediaByID[r.MediaID]
|
||||
var key string
|
||||
if media == nil {
|
||||
// 媒体记录已删除(含 Emby 远程缓存失效):计入 "other" 桶而非丢弃,
|
||||
// 这样类型分布总数才能与播放历史总数吻合。
|
||||
key = "other"
|
||||
} else {
|
||||
// 如果调用者的可见性策略排除了该媒体,则跳过统计(visibility leak fix)。
|
||||
if !visibility.Allows(media) {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(libraryTypes[media.LibraryID])
|
||||
if key == "" {
|
||||
key = "other"
|
||||
}
|
||||
}
|
||||
acc, ok := typeAcc[key]
|
||||
if !ok {
|
||||
acc = &historyTypeStat{Type: key}
|
||||
typeAcc[key] = acc
|
||||
order = append(order, key)
|
||||
}
|
||||
acc.WatchMs += r.PositionMs
|
||||
acc.Count++
|
||||
}
|
||||
// 顺序按观看时长降序,让「我主要在看什么」一眼可见。
|
||||
for _, key := range order {
|
||||
byType = append(byType, *typeAcc[key])
|
||||
}
|
||||
sort.SliceStable(byType, func(i, j int) bool {
|
||||
if byType[i].WatchMs != byType[j].WatchMs {
|
||||
return byType[i].WatchMs > byType[j].WatchMs
|
||||
}
|
||||
return byType[i].Type < byType[j].Type
|
||||
})
|
||||
|
||||
for _, r := range rows {
|
||||
if len(recent) >= historyStatsRecentLimit {
|
||||
break
|
||||
}
|
||||
entry := map[string]any{"history": r}
|
||||
if media := mediaByID[r.MediaID]; media != nil {
|
||||
// 可见性检查:隐藏库或受档案限制的媒体不进入最近记录(visibility leak fix)。
|
||||
if !visibility.Allows(media) {
|
||||
continue
|
||||
}
|
||||
entry["media"] = media
|
||||
} else if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
|
||||
// 尝试从 Emby 远端补全媒体详情,与 historyContinueHandler 保持相同策略。
|
||||
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
|
||||
mount, acct, resolveErr := svc.EmbyRemote.ResolveMount(ctx, mountID)
|
||||
if resolveErr == nil && mount != nil && acct != nil {
|
||||
remoteMedia, detailErr := svc.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID)
|
||||
if detailErr == nil && remoteMedia != nil {
|
||||
if !visibility.Allows(remoteMedia) {
|
||||
continue
|
||||
}
|
||||
entry["media"] = *remoteMedia
|
||||
} else {
|
||||
// 无法获取 Emby 媒体详情,跳过此条记录。
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// 挂载不可用,跳过。
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// 媒体记录不存在且无法 Emby 补全,跳过。
|
||||
continue
|
||||
}
|
||||
recent = append(recent, entry)
|
||||
}
|
||||
|
||||
return daily, byType, recent
|
||||
}
|
||||
|
||||
// historyContinueHandler returns "Continue Watching" rows: incomplete
|
||||
// items, most recent first.
|
||||
func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
userID := toString(uid)
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 10
|
||||
}
|
||||
var rows []model.PlaybackHistory
|
||||
if err := svc.Repo.DB.
|
||||
Where("user_id = ? AND completed = ?", toString(uid), false).
|
||||
Where("user_id = ? AND completed = ?", userID, false).
|
||||
Order("watched_at desc").
|
||||
Limit(limit).
|
||||
Find(&rows).Error; err != nil {
|
||||
@@ -116,61 +305,52 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
mIdx := make(map[string]model.Media, len(media))
|
||||
for _, m := range media {
|
||||
if !mediaVisibleForRequest(c, svc, &m) {
|
||||
continue
|
||||
}
|
||||
mIdx[m.ID] = m
|
||||
}
|
||||
out := make([]gin.H, 0, len(rows))
|
||||
staleIDs := make([]string, 0)
|
||||
for _, r := range rows {
|
||||
m, ok := mIdx[r.MediaID]
|
||||
if !ok {
|
||||
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
|
||||
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
|
||||
if mount, acct, _ := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID); mount != nil && acct != nil {
|
||||
if rm, err := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID); err == nil && rm != nil {
|
||||
if mediaVisibleForRequest(c, svc, rm) {
|
||||
out = append(out, gin.H{
|
||||
"history": r,
|
||||
"media": *rm,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
fallback := fallbackHistoryMedia(r.MediaID)
|
||||
if fallback != nil {
|
||||
if ok {
|
||||
if mediaVisibleForRequest(c, svc, &m) {
|
||||
out = append(out, gin.H{
|
||||
"history": r,
|
||||
"media": *fallback,
|
||||
"media": m,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"history": r,
|
||||
"media": m,
|
||||
})
|
||||
|
||||
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
|
||||
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
|
||||
mount, acct, resolveErr := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID)
|
||||
if resolveErr == nil && mount != nil && acct != nil {
|
||||
remoteMedia, detailErr := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID)
|
||||
if detailErr == nil && remoteMedia != nil {
|
||||
if mediaVisibleForRequest(c, svc, remoteMedia) {
|
||||
out = append(out, gin.H{
|
||||
"history": r,
|
||||
"media": *remoteMedia,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 媒体记录已不存在。继续返回占位卡只会让用户点击后遇到 404,
|
||||
// 因此清理这条失效播放记录,不再占用继续观看列表。
|
||||
staleIDs = append(staleIDs, r.MediaID)
|
||||
}
|
||||
if len(staleIDs) > 0 {
|
||||
_ = svc.Repo.DB.WithContext(c.Request.Context()).Unscoped().
|
||||
Where("user_id = ? AND media_id IN ?", userID, staleIDs).
|
||||
Delete(&model.PlaybackHistory{}).Error
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackHistoryMedia(mediaID string) *model.Media {
|
||||
if mediaID == "" {
|
||||
return nil
|
||||
}
|
||||
title := "媒体"
|
||||
if service.IsEmbyRemoteID(mediaID) {
|
||||
title = "远程媒体"
|
||||
}
|
||||
return &model.Media{
|
||||
Base: model.Base{ID: mediaID},
|
||||
Title: title,
|
||||
}
|
||||
}
|
||||
|
||||
// historyDeleteHandler removes one or all history rows for the caller.
|
||||
//
|
||||
// DELETE /api/watch-history?media_id=xxx → delete just that media's row
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func newHistoryStatsEnv(t *testing.T) (*gin.Engine, *service.Container, string) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.PlaybackHistory{}, &model.UserPermission{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
|
||||
svc.Permissions = service.NewPermissionService(zap.NewNop(), repos)
|
||||
|
||||
const userID = "user-1"
|
||||
if err := repos.User.Create(context.Background(), &model.User{
|
||||
Base: model.Base{ID: userID}, Username: "tester", PasswordHash: "x", Role: "user", IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
authed := router.Group("/api", func(c *gin.Context) {
|
||||
c.Set(middleware.CtxUserID, userID)
|
||||
c.Next()
|
||||
})
|
||||
authed.GET("/watch-history/stats", historyStatsHandler(svc))
|
||||
return router, svc, userID
|
||||
}
|
||||
|
||||
type historyStatsPayload struct {
|
||||
Total int64 `json:"total"`
|
||||
Completed int64 `json:"completed"`
|
||||
InProgress int64 `json:"in_progress"`
|
||||
WatchedMs int64 `json:"watched_ms"`
|
||||
WatchedHours float64 `json:"watched_hours"`
|
||||
Daily []struct {
|
||||
Day string `json:"day"`
|
||||
WatchMs int64 `json:"watch_ms"`
|
||||
Plays int64 `json:"plays"`
|
||||
} `json:"daily"`
|
||||
ByLibraryType []struct {
|
||||
Type string `json:"type"`
|
||||
WatchMs int64 `json:"watch_ms"`
|
||||
Count int64 `json:"count"`
|
||||
} `json:"by_library_type"`
|
||||
Recent []struct {
|
||||
Media *model.Media `json:"media"`
|
||||
} `json:"recent"`
|
||||
}
|
||||
|
||||
func fetchHistoryStats(t *testing.T, router *gin.Engine) historyStatsPayload {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/watch-history/stats", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var payload historyStatsPayload
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode %s: %v", w.Body.String(), err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// 新字段必须提供每日聚合、库类型分布与在看数量,供个人统计页绘图。
|
||||
func TestHistoryStatsIncludesDailyAndTypes(t *testing.T) {
|
||||
router, svc, userID := newHistoryStatsEnv(t)
|
||||
ctx := context.Background()
|
||||
|
||||
movieLib := &model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
|
||||
if err := svc.Repo.Library.Create(ctx, movieLib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tvLib := &model.Library{Name: "剧集", Path: "/media/tv", Type: "tv", Enabled: true}
|
||||
if err := svc.Repo.Library.Create(ctx, tvLib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
yesterday := time.Now().Add(-24 * time.Hour)
|
||||
today := time.Now().Add(-time.Hour)
|
||||
|
||||
rows := []struct {
|
||||
media *model.Media
|
||||
watchedAt time.Time
|
||||
position int64
|
||||
completed bool
|
||||
}{
|
||||
{
|
||||
media: &model.Media{LibraryID: movieLib.ID, Title: "电影A", Path: "/media/movies/a.mkv"},
|
||||
watchedAt: yesterday, position: 60000, completed: true,
|
||||
},
|
||||
{
|
||||
media: &model.Media{LibraryID: tvLib.ID, Title: "剧B", Path: "/media/tv/b.mkv"},
|
||||
watchedAt: today, position: 30000, completed: false,
|
||||
},
|
||||
}
|
||||
for _, row := range rows {
|
||||
if err := svc.Repo.DB.Create(row.media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &model.PlaybackHistory{
|
||||
UserID: userID, MediaID: row.media.ID, PositionMs: row.position,
|
||||
DurationMs: 120000, WatchedAt: row.watchedAt, Completed: row.completed,
|
||||
}
|
||||
if err := svc.Repo.DB.Create(h).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
payload := fetchHistoryStats(t, router)
|
||||
|
||||
if payload.Total != 2 {
|
||||
t.Fatalf("total = %d, want 2", payload.Total)
|
||||
}
|
||||
if payload.Completed != 1 {
|
||||
t.Fatalf("completed = %d, want 1", payload.Completed)
|
||||
}
|
||||
if payload.InProgress != 1 {
|
||||
t.Fatalf("in_progress = %d, want 1", payload.InProgress)
|
||||
}
|
||||
if payload.WatchedMs != 90000 {
|
||||
t.Fatalf("watched_ms = %d, want 90000", payload.WatchedMs)
|
||||
}
|
||||
if len(payload.Daily) != 2 {
|
||||
t.Fatalf("daily = %+v, want 2 days", payload.Daily)
|
||||
}
|
||||
if len(payload.ByLibraryType) != 2 {
|
||||
t.Fatalf("by_library_type = %+v, want 2 entries", payload.ByLibraryType)
|
||||
}
|
||||
if len(payload.Recent) != 2 {
|
||||
t.Fatalf("recent = %d entries, want 2", len(payload.Recent))
|
||||
}
|
||||
if payload.Recent[0].Media == nil || payload.Recent[0].Media.Title != "剧B" {
|
||||
t.Fatalf("recent[0] = %+v, want the most recent entry (剧B)", payload.Recent[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 没有任何播放记录时,新字段要返回空数组而不是 null,前端无需额外判空。
|
||||
func TestHistoryStatsEmptyProvidesEmptyArrays(t *testing.T) {
|
||||
router, _, _ := newHistoryStatsEnv(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/watch-history/stats", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
for _, field := range []string{`"daily":[]`, `"by_library_type":[]`, `"recent":[]`} {
|
||||
if !containsSubstring(body, field) {
|
||||
t.Fatalf("body = %s, want %s", body, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsSubstring(haystack, needle string) bool {
|
||||
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||||
if haystack[i:i+len(needle)] == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestHistoryStatsBreakdownsVisibilityFilter validates that historyStatsBreakdowns
|
||||
// respects the caller's MediaVisibility: media in a hidden library must be absent
|
||||
// from both the recent list and the by_library_type buckets.
|
||||
func TestHistoryStatsBreakdownsVisibilityFilter(t *testing.T) {
|
||||
_, svc, userID := newHistoryStatsEnv(t)
|
||||
ctx := context.Background()
|
||||
|
||||
allowedLib := &model.Library{Name: "允许库", Path: "/media/allowed", Type: "movie", Enabled: true}
|
||||
hiddenLib := &model.Library{Name: "隐藏库", Path: "/media/hidden", Type: "tv", Enabled: true}
|
||||
if err := svc.Repo.Library.Create(ctx, allowedLib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.Repo.Library.Create(ctx, hiddenLib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
allowedMedia := &model.Media{LibraryID: allowedLib.ID, Title: "允许媒体", Path: "/media/allowed/a.mkv"}
|
||||
hiddenMedia := &model.Media{LibraryID: hiddenLib.ID, Title: "隐藏媒体", Path: "/media/hidden/b.mkv"}
|
||||
if err := svc.Repo.DB.Create(allowedMedia).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.Repo.DB.Create(hiddenMedia).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, mid := range []string{allowedMedia.ID, hiddenMedia.ID} {
|
||||
h := &model.PlaybackHistory{
|
||||
UserID: userID, MediaID: mid, PositionMs: 10000,
|
||||
DurationMs: 100000, WatchedAt: now, Completed: false,
|
||||
}
|
||||
if err := svc.Repo.DB.Create(h).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// visibility that hides hiddenLib
|
||||
vis := service.MediaVisibility{
|
||||
HiddenLibraryIDs: []string{hiddenLib.ID},
|
||||
}
|
||||
|
||||
_, byType, recent := historyStatsBreakdowns(ctx, svc, userID, vis)
|
||||
|
||||
// recent must contain only the allowed media
|
||||
for _, entry := range recent {
|
||||
m, ok := entry["media"]
|
||||
if !ok {
|
||||
t.Fatal("recent entry missing media field")
|
||||
}
|
||||
switch med := m.(type) {
|
||||
case *model.Media:
|
||||
if med.LibraryID == hiddenLib.ID {
|
||||
t.Fatalf("hidden media appeared in recent: %s", med.Title)
|
||||
}
|
||||
case model.Media:
|
||||
if med.LibraryID == hiddenLib.ID {
|
||||
t.Fatalf("hidden media appeared in recent: %s", med.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(recent) != 1 {
|
||||
t.Fatalf("recent length = %d, want 1 (hidden entry must be excluded)", len(recent))
|
||||
}
|
||||
|
||||
// by_library_type must not contain the hidden library's type ("tv")
|
||||
for _, bt := range byType {
|
||||
if bt.Type == "tv" {
|
||||
t.Fatalf("hidden library type 'tv' appeared in by_library_type (count=%d)", bt.Count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryStatsBreakdownsNilMediaCountsAsOther confirms that a history row
|
||||
// whose media has been deleted (nil lookup) is counted under the "other" type
|
||||
// bucket rather than silently dropped.
|
||||
func TestHistoryStatsBreakdownsNilMediaCountsAsOther(t *testing.T) {
|
||||
_, svc, userID := newHistoryStatsEnv(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Insert a history row whose media_id does not correspond to any Media row.
|
||||
ghost := &model.PlaybackHistory{
|
||||
UserID: userID,
|
||||
MediaID: "ghost-media-id",
|
||||
PositionMs: 5000,
|
||||
DurationMs: 50000,
|
||||
WatchedAt: time.Now(),
|
||||
Completed: false,
|
||||
}
|
||||
if err := svc.Repo.DB.Create(ghost).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
vis := service.MediaVisibility{} // unrestricted
|
||||
_, byType, _ := historyStatsBreakdowns(ctx, svc, userID, vis)
|
||||
|
||||
var otherEntry *historyTypeStat
|
||||
for i := range byType {
|
||||
if byType[i].Type == "other" {
|
||||
otherEntry = &byType[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if otherEntry == nil {
|
||||
t.Fatalf("expected 'other' bucket for nil-media history row, got %+v", byType)
|
||||
}
|
||||
if otherEntry.Count != 1 {
|
||||
t.Fatalf("other.Count = %d, want 1", otherEntry.Count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryStatsPermissionDeny checks that a user without can_view_history
|
||||
// receives HTTP 403 from the gated route.
|
||||
func TestHistoryStatsPermissionDeny(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&model.User{}, &model.Library{}, &model.Media{},
|
||||
&model.PlaybackHistory{}, &model.UserPermission{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
|
||||
svc.Permissions = service.NewPermissionService(zap.NewNop(), repos)
|
||||
|
||||
const userID = "user-noperm"
|
||||
if err := repos.User.Create(context.Background(), &model.User{
|
||||
Base: model.Base{ID: userID}, Username: "noperm", PasswordHash: "x", Role: "user", IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Explicitly deny can_view_history for this user.
|
||||
// First seed defaults (Effective will create the row with defaults), then
|
||||
// update to deny via Save which uses an explicit map update path in the repo.
|
||||
if _, err := svc.Permissions.Effective(context.Background(), userID); err != nil {
|
||||
t.Fatalf("seed permissions: %v", err)
|
||||
}
|
||||
denyPerm := &model.UserPermission{UserID: userID, CanViewHistory: false}
|
||||
if err := svc.Permissions.Save(context.Background(), userID, denyPerm); err != nil {
|
||||
t.Fatalf("save permission: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
authed := router.Group("/api", func(c *gin.Context) {
|
||||
c.Set(middleware.CtxUserID, userID)
|
||||
c.Set(middleware.CtxUserRole, "user")
|
||||
c.Next()
|
||||
})
|
||||
authed.GET("/watch-history/stats", requirePermission(svc, "can_view_history"), historyStatsHandler(svc))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/watch-history/stats", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403 for user without can_view_history", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func TestHistoryContinueRemovesMissingMedia(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.PlaybackHistory{}, &model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
valid := model.Media{
|
||||
Base: model.Base{ID: "media-valid"},
|
||||
Title: "有效影片",
|
||||
Path: "/media/valid.mkv",
|
||||
}
|
||||
if err := db.Create(&valid).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := db.Create(&model.PlaybackHistory{
|
||||
Base: model.Base{ID: "history-valid"},
|
||||
UserID: "user-1",
|
||||
MediaID: valid.ID,
|
||||
PositionMs: 30_000,
|
||||
DurationMs: 120_000,
|
||||
WatchedAt: now.Add(-time.Minute),
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.PlaybackHistory{
|
||||
Base: model.Base{ID: "history-stale"},
|
||||
UserID: "user-1",
|
||||
MediaID: "media-deleted",
|
||||
PositionMs: 60_000,
|
||||
DurationMs: 120_000,
|
||||
WatchedAt: now,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{
|
||||
Log: zap.NewNop(),
|
||||
Repo: repos,
|
||||
Playback: service.NewPlaybackService(zap.NewNop(), repos),
|
||||
}
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(middleware.CtxUserID, "user-1")
|
||||
c.Next()
|
||||
})
|
||||
router.GET("/watch-history/continue", historyContinueHandler(svc))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/watch-history/continue?limit=10", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var body []struct {
|
||||
History model.PlaybackHistory `json:"history"`
|
||||
Media model.Media `json:"media"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(body) != 1 {
|
||||
t.Fatalf("continue watching rows = %d, want 1: %s", len(body), rec.Body.String())
|
||||
}
|
||||
if body[0].Media.ID != valid.ID || body[0].History.ID != "history-valid" {
|
||||
t.Fatalf("unexpected row: %#v", body[0])
|
||||
}
|
||||
|
||||
var staleCount int64
|
||||
if err := db.Unscoped().Model(&model.PlaybackHistory{}).
|
||||
Where("user_id = ? AND media_id = ?", "user-1", "media-deleted").
|
||||
Count(&staleCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if staleCount != 0 {
|
||||
t.Fatalf("stale history rows remaining = %d, want 0", staleCount)
|
||||
}
|
||||
}
|
||||