Compare commits
174 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e496b35f1 | |||
| 91eb0dadf6 | |||
| 3d7d661199 | |||
| 8686d8cae6 | |||
| 43cfeabea1 | |||
| d112e8e4db | |||
| 6604691c19 | |||
| 23937d8137 | |||
| 6864a9c7f1 | |||
| 079b3ff063 | |||
| 3c30c64a39 | |||
| 6186c8b848 | |||
| 0db8698fce | |||
| 42703c6760 | |||
| 4ed2edab5b | |||
| 374ee28f01 | |||
| 3a6b8db0b3 | |||
| b50cb7c31e | |||
| 5f4b613bde | |||
| 67ef9c95be | |||
| 2bf1b27e23 | |||
| 48a1c36a7a | |||
| d7665e4ef0 | |||
| 99916c0b97 | |||
| 6adc3cb3b2 | |||
| 2b2dc697ce | |||
| 8bfdd75d47 | |||
| 1a3248f4bb | |||
| 0be7f8b97e | |||
| 6984158f24 | |||
| d545ff1a7b | |||
| 227e0a9197 | |||
| e85df5fb6b | |||
| 6656f9c398 | |||
| dacfec7492 | |||
| b0f1ab39b9 | |||
| 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 | |||
| b7f1cd85f8 | |||
| be9d3a6d9e | |||
| b8e48a1f45 | |||
| 670dd90dbe | |||
| 9a68ef701f | |||
| 386fc358d1 | |||
| 2989649445 | |||
| 5ddca9a1f6 | |||
| e337d9932b | |||
| df654ea47b | |||
| 41fe75e136 | |||
| e65accf2cd | |||
| f54ef2228c | |||
| 3c2bfb14ca | |||
| 8f64f961ac | |||
| 6797394c5b | |||
| e71d9d61d1 | |||
| 1ff60d5641 | |||
| 3f361631d0 | |||
| cfe0c0cf28 | |||
| d6355d5582 | |||
| 0f06bdf929 | |||
| e840cbd2c9 | |||
| 2bb3610da7 | |||
| 9ca1b28cb4 | |||
| 9effb1422b | |||
| b855e00345 | |||
| 8f2551a1b6 | |||
| 18e6cb40fd | |||
| 92e693ccac | |||
| 62206aa05a | |||
| f9dd082159 | |||
| d489c1608e | |||
| 1ea29cca9e | |||
| ec5f81e6cb | |||
| 8eb017fedc | |||
| fb4e762026 | |||
| 4239474100 | |||
| 8225b4e319 | |||
| c086b01381 | |||
| f196e5621f |
@@ -14,6 +14,7 @@ verify-cache/
|
||||
verify-media/
|
||||
verify-downloads/
|
||||
.codex-*
|
||||
.codex/
|
||||
.tmp/
|
||||
.tmp_*
|
||||
.tmp-deploy-*
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ config.yaml
|
||||
.tmp-live-backups/
|
||||
.tmp-*
|
||||
.codex-*
|
||||
.codex/
|
||||
downloads/
|
||||
media/
|
||||
*.pid
|
||||
@@ -79,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
|
||||
@@ -84,12 +84,15 @@ ENV MEBOX_APP_PORT=8080 \
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD busybox wget -q --spider http://127.0.0.1:8080/api/health || exit 1
|
||||
# 探测协议不能写死:开启 HTTPS 后 8080 只接受 TLS。脚本按数据目录里的
|
||||
# .http_scheme 选择协议并在失败时回退,无需用户改配置。
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD ["/usr/local/bin/mebox-healthcheck"]
|
||||
|
||||
# Tiny entrypoint that lets us run as a NAS host UID/GID via PUID/PGID without
|
||||
# rewriting /etc/passwd or /etc/group on every container start.
|
||||
COPY docker-entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
COPY docker-healthcheck.sh /usr/local/bin/mebox-healthcheck
|
||||
RUN chmod +x /entrypoint.sh /usr/local/bin/mebox-healthcheck
|
||||
|
||||
CMD ["/entrypoint.sh"]
|
||||
|
||||
@@ -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,206 @@
|
||||
// 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"
|
||||
"path/filepath"
|
||||
"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("未从输入中识别到书源")
|
||||
}
|
||||
|
||||
// 书源文件缓存根目录:java.downloadFile / cacheFile 需要它才能落盘,
|
||||
// 否则「拷贝轻小说」这类把整卷文本缓存到本地再读的源会被判为不支持。
|
||||
// 用固定子目录而不是每次 MkdirTemp,避免反复运行在临时目录里留一堆垃圾。
|
||||
cfg := &config.Config{}
|
||||
cacheDir := filepath.Join(os.TempDir(), "reader-smoke-cache")
|
||||
if err := os.MkdirAll(cacheDir, 0o750); err == nil {
|
||||
cfg.Cache.CacheDir = cacheDir
|
||||
}
|
||||
svc := reader.NewReaderService(cfg, 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.AutoMigrateWithConfig(db, a.cfg, a.logger); 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)
|
||||
}
|
||||
@@ -98,3 +98,41 @@ func logFilePaths(cfg *config.Config) (string, string, string) {
|
||||
}
|
||||
return filepath.Join(out, "app.log"), filepath.Join(out, "warn.log"), filepath.Join(out, "error.log")
|
||||
}
|
||||
|
||||
// newEmbyCompatLogger 构建只写入 Emby 兼容日志文件的独立 Zap 实例。
|
||||
// 它不参与 app.log 的日志级别过滤,始终记录 INFO 及以上,确保成功请求也能
|
||||
// 用于还原客户端的接口调用顺序;轮转参数沿用 logging 配置。
|
||||
func newEmbyCompatLogger(cfg *config.Config) (*zap.Logger, func(), error) {
|
||||
encoderCfg := zap.NewProductionEncoderConfig()
|
||||
encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||
var encoder zapcore.Encoder
|
||||
if strings.EqualFold(strings.TrimSpace(cfg.Logging.Format), "console") {
|
||||
encoder = zapcore.NewConsoleEncoder(encoderCfg)
|
||||
} else {
|
||||
encoder = zapcore.NewJSONEncoder(encoderCfg)
|
||||
}
|
||||
|
||||
writer, err := newRotatingFileWriter(embyCompatLogPath(cfg), cfg.Logging)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
log := zap.New(
|
||||
zapcore.NewCore(encoder, writer, zap.InfoLevel),
|
||||
zap.AddCaller(),
|
||||
zap.AddStacktrace(zapcore.ErrorLevel),
|
||||
zap.ErrorOutput(zapcore.Lock(os.Stderr)),
|
||||
)
|
||||
return log, func() { _ = writer.Close() }, nil
|
||||
}
|
||||
|
||||
func embyCompatLogPath(cfg *config.Config) string {
|
||||
out := strings.TrimSpace(cfg.Logging.OutputPath)
|
||||
if out == "" || strings.EqualFold(out, "stdout") || strings.EqualFold(out, "stderr") {
|
||||
return filepath.Join(cfg.App.DataDir, "logs", "emby-compat.log")
|
||||
}
|
||||
if ext := filepath.Ext(out); ext != "" {
|
||||
base := strings.TrimSuffix(out, ext)
|
||||
return base + ".emby-compat" + ext
|
||||
}
|
||||
return filepath.Join(out, "emby-compat.log")
|
||||
}
|
||||
|
||||
@@ -116,3 +116,34 @@ func TestRotatingFileWriterCapsFileSize(t *testing.T) {
|
||||
}
|
||||
_ = writer.Sync()
|
||||
}
|
||||
|
||||
func TestEmbyCompatLoggerWritesDedicatedFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{}
|
||||
cfg.App.DataDir = dir
|
||||
cfg.Logging.Format = "json"
|
||||
cfg.Logging.OutputPath = filepath.Join(dir, "logs")
|
||||
cfg.Logging.EnableRotation = true
|
||||
cfg.Logging.MaxSizeMB = 1
|
||||
cfg.Logging.MaxBackups = 2
|
||||
|
||||
log, closeFn, err := newEmbyCompatLogger(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
log.Info("emby request", zap.String("client", "Infuse"))
|
||||
_ = log.Sync()
|
||||
closeFn()
|
||||
|
||||
path := filepath.Join(dir, "logs", "emby-compat.log")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "emby request") || !strings.Contains(string(data), "Infuse") {
|
||||
t.Fatalf("dedicated Emby log missing request data: %s", data)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "logs", "app.log")); !os.IsNotExist(err) {
|
||||
t.Fatalf("Emby logger must not write app.log, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,95 +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() }()
|
||||
|
||||
appVersion := effectiveVersion(version)
|
||||
logger.Info("starting MeBox",
|
||||
zap.String("version", appVersion),
|
||||
zap.Int("port", cfg.App.Port),
|
||||
zap.String("data_dir", cfg.App.DataDir),
|
||||
)
|
||||
|
||||
// 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, 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()
|
||||
}
|
||||
|
||||
@@ -76,6 +76,9 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
|
||||
if err := os.MkdirAll(filepath.Join(webDir, "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(webDir, "fonts"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(webDir, "brand"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -85,6 +88,9 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(webDir, "assets", "app.js"), []byte("console.log('ok')"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webDir, "fonts", "geist-400.woff2"), []byte("wOF2-test-font"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(webDir, "brand", "mebox-logo.svg"), []byte("<svg></svg>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -105,6 +111,29 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
|
||||
t.Fatalf("asset Cache-Control = %q, want immutable", got)
|
||||
}
|
||||
|
||||
fontReq := httptest.NewRequest(http.MethodGet, "/fonts/geist-400.woff2", nil)
|
||||
fontResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(fontResp, fontReq)
|
||||
if fontResp.Code != http.StatusOK {
|
||||
t.Fatalf("font status = %d, want 200", fontResp.Code)
|
||||
}
|
||||
if got := fontResp.Header().Get("Cache-Control"); !strings.Contains(got, "max-age=86400") {
|
||||
t.Fatalf("font Cache-Control = %q, want max-age=86400", got)
|
||||
}
|
||||
if got := fontResp.Body.String(); got != "wOF2-test-font" {
|
||||
t.Fatalf("font body = %q, want wOF2-test-font", got)
|
||||
}
|
||||
|
||||
missingFontReq := httptest.NewRequest(http.MethodGet, "/fonts/missing.woff2", nil)
|
||||
missingFontResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(missingFontResp, missingFontReq)
|
||||
if missingFontResp.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing font status = %d, want 404", missingFontResp.Code)
|
||||
}
|
||||
if strings.Contains(missingFontResp.Body.String(), "index") {
|
||||
t.Fatalf("missing font should not serve SPA index: %q", missingFontResp.Body.String())
|
||||
}
|
||||
|
||||
brandReq := httptest.NewRequest(http.MethodGet, "/brand/mebox-logo.svg", nil)
|
||||
brandResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(brandResp, brandReq)
|
||||
|
||||
@@ -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,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 开启 HTTPS 后,同一个端口会同时面对两类连接:
|
||||
//
|
||||
// - TLS 客户端(浏览器、播放器)——正常提供 HTTPS 服务;
|
||||
// - 明确访问 http:// 的客户端——以前只会撞出一句
|
||||
// "Client sent an HTTP request to an HTTPS server.",用户看不懂。
|
||||
//
|
||||
// 一个端口上只有 TLS 监听时无法区分两者,所以在 Accept 之后先偷看第一个字节:
|
||||
// TLS 握手的记录类型固定是 0x16(handshake),而 HTTP 请求行必然是可见字符。
|
||||
// 据此把连接分流成两路,明文那一路交给只做跳转的 http.Server,于是 http:// 会被
|
||||
// 自动重定向到 https://(见 server_manager.go 的 httpsRedirectHandler)。
|
||||
const tlsRecordHandshake = 0x16
|
||||
|
||||
// defaultPeekTimeout 限制"偷看首字节"阶段的等待,避免只连不发的半开连接长期占住
|
||||
// goroutine。分流器按实例持有该值,便于测试用更短的超时。
|
||||
const defaultPeekTimeout = 10 * time.Second
|
||||
|
||||
// splitListener 是监听分流器:Accept 到底层连接后按首字节投递给对应的一路。
|
||||
type splitListener struct {
|
||||
ln net.Listener
|
||||
tlsCh chan net.Conn
|
||||
plainCh chan net.Conn
|
||||
quit chan struct{}
|
||||
once sync.Once
|
||||
peek time.Duration
|
||||
}
|
||||
|
||||
func newSplitListener(ln net.Listener) *splitListener {
|
||||
return newSplitListenerWithTimeout(ln, defaultPeekTimeout)
|
||||
}
|
||||
|
||||
func newSplitListenerWithTimeout(ln net.Listener, peek time.Duration) *splitListener {
|
||||
s := &splitListener{
|
||||
ln: ln,
|
||||
tlsCh: make(chan net.Conn),
|
||||
plainCh: make(chan net.Conn),
|
||||
quit: make(chan struct{}),
|
||||
peek: peek,
|
||||
}
|
||||
go s.acceptLoop()
|
||||
return s
|
||||
}
|
||||
|
||||
// tlsListener 返回只收到 TLS 连接的 listener。
|
||||
func (s *splitListener) tlsListener() net.Listener {
|
||||
return &channelListener{addr: s.ln.Addr(), conns: s.tlsCh, quit: s.quit}
|
||||
}
|
||||
|
||||
// plainListener 返回只收到明文 HTTP 连接的 listener。
|
||||
func (s *splitListener) plainListener() net.Listener {
|
||||
return &channelListener{addr: s.ln.Addr(), conns: s.plainCh, quit: s.quit}
|
||||
}
|
||||
|
||||
func (s *splitListener) acceptLoop() {
|
||||
for {
|
||||
conn, err := s.ln.Accept()
|
||||
if err != nil {
|
||||
// 底层监听关闭(换证、关停)后两路 Accept 一起返回 net.ErrClosed,
|
||||
// 由持有它们的 http.Server 自行收尾。
|
||||
s.stop()
|
||||
return
|
||||
}
|
||||
go s.dispatch(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *splitListener) dispatch(conn net.Conn) {
|
||||
br := bufio.NewReader(conn)
|
||||
_ = conn.SetReadDeadline(time.Now().Add(s.peek))
|
||||
first, err := br.Peek(1)
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
peeked := &peekedConn{Conn: conn, r: br}
|
||||
ch := s.plainCh
|
||||
if first[0] == tlsRecordHandshake {
|
||||
ch = s.tlsCh
|
||||
}
|
||||
select {
|
||||
case ch <- peeked: // 交给对应的 http.Server,由它负责读写与关闭
|
||||
case <-s.quit:
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *splitListener) stop() {
|
||||
s.once.Do(func() { close(s.quit) })
|
||||
}
|
||||
|
||||
// channelListener 把一路 channel 包装成 net.Listener,供 http.Server.Serve 使用。
|
||||
type channelListener struct {
|
||||
addr net.Addr
|
||||
conns <-chan net.Conn
|
||||
quit <-chan struct{}
|
||||
}
|
||||
|
||||
func (l *channelListener) Accept() (net.Conn, error) {
|
||||
select {
|
||||
case conn := <-l.conns:
|
||||
return conn, nil
|
||||
case <-l.quit:
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
}
|
||||
|
||||
// Close 是空实现:真正要释放的端口在 splitListener 的底层监听上,它一旦关闭,
|
||||
// 这里的 Accept 就会返回 net.ErrClosed,http.Server 随之退出。
|
||||
func (l *channelListener) Close() error { return nil }
|
||||
|
||||
func (l *channelListener) Addr() net.Addr { return l.addr }
|
||||
|
||||
// peekedConn 把已经读进 bufio 缓冲的首字节还给上层读取者。
|
||||
type peekedConn struct {
|
||||
net.Conn
|
||||
r *bufio.Reader
|
||||
}
|
||||
|
||||
func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
|
||||
@@ -0,0 +1,140 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSplitListenerRoutesByFirstByte(t *testing.T) {
|
||||
raw, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
split := newSplitListener(raw)
|
||||
addr := raw.Addr().String()
|
||||
|
||||
// TLS:首字节 0x16。分流器偷看过的字节必须还能被上层读到,否则握手会失败。
|
||||
handshake := []byte{0x16, 0x03, 0x01, 0x00, 0x2c}
|
||||
tlsClient, err := net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = tlsClient.Close() }()
|
||||
if _, err := tlsClient.Write(handshake); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tlsConn := <-split.tlsCh
|
||||
defer func() { _ = tlsConn.Close() }()
|
||||
got := make([]byte, len(handshake))
|
||||
if _, err := io.ReadFull(tlsConn, got); err != nil {
|
||||
t.Fatalf("read peeked tls bytes: %v", err)
|
||||
}
|
||||
if string(got) != string(handshake) {
|
||||
t.Fatalf("tls bytes = %v, want %v", got, handshake)
|
||||
}
|
||||
|
||||
// 明文 HTTP:请求行走另一路,同样要读到偷看过的首字节。
|
||||
request := "GET /api/health HTTP/1.1\r\nHost: example.com\r\n\r\n"
|
||||
plainClient, err := net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = plainClient.Close() }()
|
||||
if _, err := plainClient.Write([]byte(request)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plainConn := <-split.plainCh
|
||||
defer func() { _ = plainConn.Close() }()
|
||||
head := make([]byte, len("GET /"))
|
||||
if _, err := io.ReadFull(plainConn, head); err != nil {
|
||||
t.Fatalf("read peeked plain bytes: %v", err)
|
||||
}
|
||||
if string(head) != request[:len(head)] {
|
||||
t.Fatalf("plain bytes = %q, want prefix of %q", head, request)
|
||||
}
|
||||
|
||||
// 底层监听关闭后两路 Accept 都要返回 net.ErrClosed,http.Server 才能收尾。
|
||||
_ = raw.Close()
|
||||
for _, ln := range []net.Listener{split.tlsListener(), split.plainListener()} {
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for {
|
||||
_, err := ln.Accept()
|
||||
if err == net.ErrClosed {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("Accept after close = %v, want net.ErrClosed", err)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只连不发的半开连接不应被分给任何一路,而应在偷看超时后被丢弃。
|
||||
func TestSplitListenerDropsIdleConnection(t *testing.T) {
|
||||
raw, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = raw.Close() }()
|
||||
split := newSplitListenerWithTimeout(raw, 200*time.Millisecond)
|
||||
|
||||
conn, err := net.Dial("tcp", raw.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
select {
|
||||
case c := <-split.plainCh:
|
||||
_ = c.Close()
|
||||
t.Fatal("idle connection should not be dispatched")
|
||||
case c := <-split.tlsCh:
|
||||
_ = c.Close()
|
||||
t.Fatal("idle connection should not be dispatched")
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSRedirectTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
host string
|
||||
rawURL string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "keeps host port path and query", host: "10.0.0.2:8080", rawURL: "/library?tab=1&q=a%20b", want: "https://10.0.0.2:8080/library?tab=1&q=a%20b"},
|
||||
{name: "root path", host: "nas.local:18080", rawURL: "/", want: "https://nas.local:18080/"},
|
||||
{name: "keeps escaping of user input", host: "10.0.0.2", rawURL: "/a%2Fb", want: "https://10.0.0.2/a%2Fb"},
|
||||
{name: "absolute form request line", host: "10.0.0.2:8080", rawURL: "http://10.0.0.2:8080/api/x?y=1", want: "https://10.0.0.2:8080/api/x?y=1"},
|
||||
{name: "rejects empty host", host: "", rawURL: "/", wantErr: true},
|
||||
{name: "rejects host with slash", host: "evil.com/path", rawURL: "/", wantErr: true},
|
||||
{name: "rejects userinfo host", host: "user@evil.com", rawURL: "/", wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, tc.rawURL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Host = tc.host
|
||||
|
||||
if host := redirectHostHeader(req); host == "" {
|
||||
if !tc.wantErr {
|
||||
t.Fatalf("redirectHostHeader(%q) rejected, want accepted", tc.host)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tc.wantErr {
|
||||
t.Fatalf("redirectHostHeader(%q) accepted, want rejected", tc.host)
|
||||
}
|
||||
if got := httpsRedirectURL(tc.host, req); got != tc.want {
|
||||
t.Fatalf("redirect target = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -19,13 +19,16 @@ import (
|
||||
"github.com/truewhile/MeBox/web"
|
||||
)
|
||||
|
||||
func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) *gin.Engine {
|
||||
func buildRouter(cfg *config.Config, logger *zap.Logger, embyCompatLogger *zap.Logger, svc *service.Container) *gin.Engine {
|
||||
if !cfg.App.Debug {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(middleware.RequestLogger(logger))
|
||||
r.Use(middleware.EmbyCompatLogger(embyCompatLogger, func(path string) bool {
|
||||
return !isFrontendLibraryRoute(path) && handler.IsEmbyPath(path)
|
||||
}))
|
||||
if !cfg.App.Debug && len(cfg.App.CORSOrigins) == 0 {
|
||||
logger.Warn("CORS: no origins configured in production — CORS headers will be omitted (same-origin enforced). Set app.cors_origins for cross-origin access.")
|
||||
}
|
||||
@@ -59,6 +62,13 @@ func serveSPA(r *gin.Engine, root fs.FS) {
|
||||
c.Next()
|
||||
})
|
||||
assets.GET("/*filepath", serveFSDir(root, "assets"))
|
||||
fonts := r.Group("/fonts")
|
||||
fonts.Use(middleware.GzipStatic())
|
||||
fonts.Use(func(c *gin.Context) {
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.Next()
|
||||
})
|
||||
fonts.GET("/*filepath", serveFSDir(root, "fonts"))
|
||||
brand := r.Group("/brand")
|
||||
brand.Use(func(c *gin.Context) {
|
||||
setNoCacheHeaders(c)
|
||||
@@ -70,11 +80,11 @@ func serveSPA(r *gin.Engine, root fs.FS) {
|
||||
r.GET(rootFile, serveFSFile(root, name))
|
||||
r.HEAD(rootFile, serveFSFile(root, name))
|
||||
}
|
||||
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
|
||||
if handler.TryHandleEmbyNormalizedRoute(c, r) {
|
||||
return
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
|
||||
if handler.TryHandleEmbyNormalizedRoute(c, r) {
|
||||
return
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
if shouldBypassSPAFallback(path) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -17,6 +19,14 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// httpSchemeFileName 是数据目录下记录当前监听协议的小文件(内容为 http 或 https)。
|
||||
//
|
||||
// 容器健康检查无法读取后台保存的 HTTPS 设置(它存在数据库里),而同一端口在开启
|
||||
// HTTPS 后只接受 TLS。若健康检查固定用 http:// 探测,每轮都会撞出
|
||||
// "client sent an HTTP request to an HTTPS server",既刷日志又让容器永远 unhealthy。
|
||||
// 因此由服务进程在每次成功绑定监听后写下真实协议,供 docker-healthcheck.sh 选择。
|
||||
const httpSchemeFileName = ".http_scheme"
|
||||
|
||||
// tlsPair 记录当前正在服务的证书,用于判断是否需要重新绑定监听。
|
||||
type tlsPair struct {
|
||||
cert tls.Certificate
|
||||
@@ -35,6 +45,9 @@ type tlsPair struct {
|
||||
// - 旧服务器随后优雅退出,正在进行的播放/请求不会被立刻掐断。
|
||||
//
|
||||
// 任何校验失败都会中止切换并保留旧监听,保证用户不会被锁在服务外面。
|
||||
//
|
||||
// 开启 HTTPS 时端口上会同时接两类连接:TLS 走主服务器,明文 HTTP 走
|
||||
// redirectSrv 并跳转到 HTTPS(见 protocol_mux.go)。
|
||||
type serverManager struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
@@ -43,6 +56,7 @@ type serverManager struct {
|
||||
|
||||
mu sync.Mutex
|
||||
srv *http.Server
|
||||
redirectSrv *http.Server
|
||||
ln net.Listener
|
||||
pair *tlsPair
|
||||
stopCh chan struct{}
|
||||
@@ -92,11 +106,11 @@ func (m *serverManager) Reload() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
oldSrv, oldLn, oldPair := m.srv, m.ln, m.pair
|
||||
oldSrv, oldRedirect, oldLn, oldPair := m.srv, m.redirectSrv, m.ln, m.pair
|
||||
if oldLn != nil {
|
||||
_ = oldLn.Close() // 释放端口后再绑定新监听
|
||||
}
|
||||
m.srv, m.ln, m.pair = nil, nil, nil
|
||||
m.srv, m.redirectSrv, m.ln, m.pair = nil, nil, nil, nil
|
||||
|
||||
firstErr := m.bind(pair)
|
||||
if firstErr != nil {
|
||||
@@ -107,6 +121,7 @@ func (m *serverManager) Reload() error {
|
||||
}
|
||||
// 新监听已就绪,让旧服务器在新连接切换到新监听后优雅退出。
|
||||
m.drain(oldSrv)
|
||||
m.drain(oldRedirect)
|
||||
m.logServerReady()
|
||||
m.maybeStartAutoReloadLocked()
|
||||
return firstErr
|
||||
@@ -120,11 +135,19 @@ func (m *serverManager) Shutdown(ctx context.Context) error {
|
||||
close(m.stopCh)
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.srv == nil {
|
||||
return nil
|
||||
targets := []*http.Server{m.srv, m.redirectSrv}
|
||||
m.mu.Unlock()
|
||||
|
||||
var firstErr error
|
||||
for _, srv := range targets {
|
||||
if srv == nil {
|
||||
continue
|
||||
}
|
||||
if err := srv.Shutdown(ctx); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return m.srv.Shutdown(ctx)
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// desiredPair 根据当前配置计算目标监听形态:nil 表示明文 HTTP,非 nil 表示 TLS。
|
||||
@@ -217,25 +240,107 @@ func (m *serverManager) pairEquals(pair *tlsPair) bool {
|
||||
return pair.version == m.pair.version
|
||||
}
|
||||
|
||||
// bind 创建并按需启用 TLS 的监听,异步开始服务。
|
||||
// bind 创建监听,异步开始服务。
|
||||
//
|
||||
// 未开启 HTTPS 时端口只服务明文 HTTP;开启后同一个端口两种流量都要接:TLS 连接
|
||||
// 交给主服务器,明文 HTTP 连接交给只做跳转的 redirect 服务器,让 http:// 自动
|
||||
// 跳到 https://,而不是甩给用户一句 "Client sent an HTTP request to an HTTPS
|
||||
// server."。
|
||||
func (m *serverManager) bind(pair *tlsPair) error {
|
||||
ln, err := net.Listen("tcp", m.addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen %s: %w", m.addr, err)
|
||||
}
|
||||
srv := &http.Server{
|
||||
Handler: m.handler,
|
||||
|
||||
if pair == nil {
|
||||
srv := m.newHTTPServer(m.handler)
|
||||
m.srv, m.redirectSrv, m.ln, m.pair = srv, nil, ln, nil
|
||||
m.writeSchemeState(nil)
|
||||
go m.serve(srv, ln)
|
||||
return nil
|
||||
}
|
||||
|
||||
split := newSplitListener(ln)
|
||||
srv := m.newHTTPServer(m.handler)
|
||||
tlsLn := tls.NewListener(split.tlsListener(), &tls.Config{
|
||||
Certificates: []tls.Certificate{pair.cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
redirect := m.newHTTPServer(m.httpsRedirectHandler())
|
||||
|
||||
m.srv, m.redirectSrv, m.ln, m.pair = srv, redirect, ln, pair
|
||||
m.writeSchemeState(pair)
|
||||
go m.serve(srv, tlsLn)
|
||||
go m.serve(redirect, split.plainListener())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *serverManager) newHTTPServer(handler http.Handler) *http.Server {
|
||||
return &http.Server{
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 15 * time.Second,
|
||||
}
|
||||
if pair != nil {
|
||||
ln = tls.NewListener(ln, &tls.Config{
|
||||
Certificates: []tls.Certificate{pair.cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
}
|
||||
|
||||
// httpsRedirectHandler 处理"开着 HTTPS 却用 http:// 访问"的请求:跳转到同端口、
|
||||
// 同路径的 https://。
|
||||
//
|
||||
// 用 302/307 而不是 301/308:HTTPS 开关可以在后台随时改,永久跳转会被浏览器长期
|
||||
// 缓存,等用户关掉 HTTPS 之后 http 访问会被带到已经不提供 TLS 的端口上打不开。
|
||||
func (m *serverManager) httpsRedirectHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host := redirectHostHeader(r)
|
||||
if host == "" {
|
||||
// 没有可用的 Host(HTTP/1.0 或畸形请求头)时宁可明说,也不跳到错的地方。
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "HTTPS is enabled; open https://<host>:%d instead.\n", m.cfg.App.Port)
|
||||
return
|
||||
}
|
||||
status := http.StatusFound
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
status = http.StatusTemporaryRedirect // 保住方法与请求体
|
||||
}
|
||||
w.Header().Set("Location", httpsRedirectURL(host, r))
|
||||
w.WriteHeader(status)
|
||||
})
|
||||
}
|
||||
|
||||
// httpsRedirectURL 拼出同端口同路径的 HTTPS 地址。
|
||||
func httpsRedirectURL(host string, r *http.Request) string {
|
||||
target := "https://" + host + r.URL.EscapedPath()
|
||||
if r.URL.RawQuery != "" {
|
||||
target += "?" + r.URL.RawQuery
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// redirectHostHeader 取重定向目标主机。HTTP/1.1 里 Host 是唯一的主机来源(端口映射
|
||||
// 到容器 8080 时它同时也是 HTTPS 端口),这里只做字符过滤,避免把请求头里的怪字符
|
||||
// 带进 Location 造成任意跳转。
|
||||
func redirectHostHeader(r *http.Request) string {
|
||||
host := strings.TrimSpace(r.Host)
|
||||
if host == "" || strings.ContainsAny(host, " \t\r\n/\\@?#") {
|
||||
return ""
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// writeSchemeState 把当前实际监听的协议写入数据目录,供容器健康检查按协议探测。
|
||||
// 写失败只降级为告警:健康检查脚本在状态文件缺失时会回退试另一种协议。
|
||||
func (m *serverManager) writeSchemeState(pair *tlsPair) {
|
||||
dataDir := strings.TrimSpace(m.cfg.App.DataDir)
|
||||
if dataDir == "" {
|
||||
return
|
||||
}
|
||||
scheme := "http"
|
||||
if pair != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
path := filepath.Join(dataDir, httpSchemeFileName)
|
||||
if err := os.WriteFile(path, []byte(scheme+"\n"), 0o644); err != nil { // #nosec G304 -- path is DataDir + fixed filename.
|
||||
m.log.Warn("write http scheme state failed", zap.String("path", path), zap.Error(err))
|
||||
}
|
||||
m.srv, m.ln, m.pair = srv, ln, pair
|
||||
go m.serve(srv, ln)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *serverManager) serve(s *http.Server, ln net.Listener) {
|
||||
@@ -271,7 +376,7 @@ func (m *serverManager) logServerReady() {
|
||||
zap.String("listen", m.addr),
|
||||
)
|
||||
if m.pair != nil {
|
||||
m.log.Info("HTTPS is enabled; plain HTTP is no longer served on this port",
|
||||
m.log.Info("HTTPS is enabled; plain HTTP requests on this port are redirected to HTTPS",
|
||||
zap.String("addr", m.addr),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -32,6 +36,8 @@ func makeTestPairPEM(t *testing.T) (certPEM, keyPEM string) {
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
DNSNames: []string{"localhost"},
|
||||
// 带上回环 IP,HTTPS 客户端就能在校验证书的前提下连 127.0.0.1:port。
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
@@ -105,3 +111,136 @@ func TestDesiredPairModes(t *testing.T) {
|
||||
t.Fatal("version should change after files replaced")
|
||||
}
|
||||
}
|
||||
|
||||
// 健康检查依赖该文件判断该用 http 还是 https 探测,内容与当前监听协议必须一致,
|
||||
// 否则容器会一直显示 unhealthy 并刷 TLS handshake error 日志。
|
||||
func TestWriteSchemeState(t *testing.T) {
|
||||
m := newTestServerManager(t)
|
||||
m.cfg.App.DataDir = t.TempDir()
|
||||
path := filepath.Join(m.cfg.App.DataDir, httpSchemeFileName)
|
||||
|
||||
m.writeSchemeState(nil)
|
||||
if data, err := os.ReadFile(path); err != nil || strings.TrimSpace(string(data)) != "http" {
|
||||
t.Fatalf("plain HTTP state: data=%q err=%v", data, err)
|
||||
}
|
||||
|
||||
m.writeSchemeState(&tlsPair{})
|
||||
if data, err := os.ReadFile(path); err != nil || strings.TrimSpace(string(data)) != "https" {
|
||||
t.Fatalf("HTTPS state: data=%q err=%v", data, err)
|
||||
}
|
||||
|
||||
// DataDir 为空时不应写到工作目录(测试/裸跑二进制时会污染仓库)。
|
||||
m.cfg.App.DataDir = ""
|
||||
_ = os.Remove(path)
|
||||
m.writeSchemeState(nil)
|
||||
if _, err := os.Stat(httpSchemeFileName); !os.IsNotExist(err) {
|
||||
t.Fatalf("empty DataDir should not create %s in cwd: %v", httpSchemeFileName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 开启 HTTPS 后同一个端口要同时接 TLS 与明文 HTTP:明文请求跳到 HTTPS,而不是
|
||||
// 给用户看 "Client sent an HTTP request to an HTTPS server."。
|
||||
func TestServerManagerRedirectsPlainHTTPToHTTPS(t *testing.T) {
|
||||
certPEM, keyPEM := makeTestPairPEM(t)
|
||||
cfg := &config.Config{}
|
||||
cfg.App.Port = 8080
|
||||
cfg.App.DataDir = t.TempDir()
|
||||
cfg.App.HTTPSEnabled = true
|
||||
cfg.App.SSLCert, cfg.App.SSLKey = certPEM, keyPEM
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, "secure:"+r.URL.Path)
|
||||
})
|
||||
m := newServerManager(cfg, zap.NewNop(), handler)
|
||||
m.addr = "127.0.0.1:0" // 端口 0 交给内核分配,测试之间不会撞端口
|
||||
if err := m.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = m.Shutdown(context.Background()) })
|
||||
|
||||
addr := m.ln.Addr().String()
|
||||
// 端口 0 只在第一次分配时用;热切换要绑回同一个端口(生产里端口本来就是固定的)。
|
||||
m.addr = addr
|
||||
schemeFile := filepath.Join(cfg.App.DataDir, httpSchemeFileName)
|
||||
if data, err := os.ReadFile(schemeFile); err != nil || strings.TrimSpace(string(data)) != "https" {
|
||||
t.Fatalf("scheme state = %q err=%v, want https", data, err)
|
||||
}
|
||||
|
||||
plain := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
}
|
||||
|
||||
resp, err := plain.Get("http://" + addr + "/library?tab=1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("plain GET status = %d, want %d", resp.StatusCode, http.StatusFound)
|
||||
}
|
||||
if got, want := resp.Header.Get("Location"), "https://"+addr+"/library?tab=1"; got != want {
|
||||
t.Fatalf("Location = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
postResp, err := plain.Post("http://"+addr+"/api/me/login", "application/json", strings.NewReader("{}"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = postResp.Body.Close() }()
|
||||
if postResp.StatusCode != http.StatusTemporaryRedirect {
|
||||
t.Fatalf("plain POST status = %d, want %d", postResp.StatusCode, http.StatusTemporaryRedirect)
|
||||
}
|
||||
if got, want := postResp.Header.Get("Location"), "https://"+addr+"/api/me/login"; got != want {
|
||||
t.Fatalf("POST Location = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// 同一端口上的 TLS 连接照常拿到正常响应(校验证书,不做跳过校验)。
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM([]byte(certPEM)) {
|
||||
t.Fatal("append test certificate to pool failed")
|
||||
}
|
||||
secure := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
|
||||
},
|
||||
}
|
||||
secureResp, err := secure.Get("https://" + addr + "/library")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = secureResp.Body.Close() }()
|
||||
body, err := io.ReadAll(secureResp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if secureResp.StatusCode != http.StatusOK || string(body) != "secure:/library" {
|
||||
t.Fatalf("https response = %d %q, want 200 %q", secureResp.StatusCode, body, "secure:/library")
|
||||
}
|
||||
|
||||
// 关掉 HTTPS 后同一端口回到纯明文服务,不再有多余的跳转服务器。
|
||||
cfg.App.HTTPSEnabled = false
|
||||
if err := m.Reload(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.redirectSrv != nil {
|
||||
t.Fatal("redirect server should be released when HTTPS is off")
|
||||
}
|
||||
if data, err := os.ReadFile(schemeFile); err != nil || strings.TrimSpace(string(data)) != "http" {
|
||||
t.Fatalf("scheme state = %q err=%v, want http", data, err)
|
||||
}
|
||||
plainResp, err := plain.Get("http://" + addr + "/library")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = plainResp.Body.Close() }()
|
||||
plainBody, err := io.ReadAll(plainResp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plainResp.StatusCode != http.StatusOK || string(plainBody) != "secure:/library" {
|
||||
t.Fatalf("plain response after disabling https = %d %q", plainResp.StatusCode, plainBody)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,218 @@
|
||||
# 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:
|
||||
# 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
|
||||
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
|
||||
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
|
||||
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
|
||||
@@ -115,7 +115,9 @@ services:
|
||||
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
|
||||
# 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
|
||||
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
|
||||
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
@@ -124,8 +126,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 +149,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 +174,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 +198,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:
|
||||
@@ -73,7 +73,9 @@ services:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
|
||||
# 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
|
||||
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
|
||||
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
@@ -82,5 +84,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
|
||||
@@ -104,7 +87,9 @@ services:
|
||||
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
|
||||
# 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
|
||||
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
|
||||
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
@@ -113,8 +98,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 +121,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 +146,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。
|
||||
@@ -131,7 +131,9 @@ services:
|
||||
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
|
||||
# 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
|
||||
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
|
||||
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
@@ -141,8 +143,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 +168,5 @@ services:
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "10"
|
||||
max-size: "20m"
|
||||
max-file: "3"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/bin/sh
|
||||
# MeBox 容器健康检查。
|
||||
#
|
||||
# 背景:MeBox 只监听一个端口,且开启 HTTPS 后该端口只接受 TLS(明文 HTTP 不再
|
||||
# 服务)。健康检查若固定用 http:// 探测,每轮都会被 TLS 监听拒绝并在服务日志里
|
||||
# 留下一条 "client sent an HTTP request to an HTTPS server",同时容器状态永远
|
||||
# 是 unhealthy。反过来固定用 https:// 探测,则在未开启 HTTPS 的部署上产生
|
||||
# "first record does not look like a TLS handshake"。
|
||||
#
|
||||
# 当前协议由服务进程写入 $MEBOX_APP_DATA_DIR/.http_scheme(见
|
||||
# cmd/server/server_manager.go 的 writeSchemeState)。脚本优先按该文件选择协议,
|
||||
# 文件缺失或该协议探测失败时再试另一种,避免状态文件过期导致误报不健康。
|
||||
set -u
|
||||
|
||||
port="${MEBOX_APP_PORT:-8080}"
|
||||
data_dir="${MEBOX_APP_DATA_DIR:-/data}"
|
||||
|
||||
scheme_file="$data_dir/.http_scheme"
|
||||
scheme=""
|
||||
if [ -r "$scheme_file" ]; then
|
||||
scheme="$(tr -d '[:space:]' <"$scheme_file")"
|
||||
fi
|
||||
if [ "$scheme" = "https" ]; then
|
||||
first="https"
|
||||
second="http"
|
||||
else
|
||||
first="http"
|
||||
second="https"
|
||||
fi
|
||||
|
||||
for proto in "$first" "$second"; do
|
||||
# --no-check-certificate:用户常用自签证书或 IP 访问,校验域名必然会失败。
|
||||
if busybox wget -q --spider -T 3 --no-check-certificate \
|
||||
"$proto://127.0.0.1:$port/api/health" >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
exit 1
|
||||
@@ -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,167 @@
|
||||
# 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`(影视侧 JSON/对象缓存)、`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,正文去标签与实体;移出书架同步删落盘文件;同名重复导入覆盖更新并尽量保留进度 | 本地书可上传、可读、可删 |
|
||||
| P4.10 书源按用户独立 ✅ | 书源从全局唯一改为按用户独立:`ReaderBookSource` 增加 `user_id`,唯一键改为 `(user_id, source_url)`;导入/列表/启停/删除/搜索全部按当前用户限定;老库迁移自动删旧唯一索引、按「谁书架上有这本书」回填归属并给多引用用户复制副本、清理软删历史行;删除改为物理删除(软删会占住唯一键导致「删掉再导入」失败);正文缓存键随之改为「书源行 ID + 书本地址」,不同用户改过规则的同名书源不再共享缓存 | 用户的启停/规则/变量/删除互不影响 |
|
||||
| P4.7 正文持久缓存 ✅ | 服务端正文持久缓存(`reader_content_cache.go`:书源侧产物 + 索引落库 + 内容落盘 `cache_dir/reader-content/`、TTL/容量 LRU、目录刷新 remap、换源/移出书架清理、读穿透 singleflight)+ 批量取正文接口 `POST /api/reader/books/:id/content-batch`(对应 CacheBook 协议)+ 前端窗口预取(默认 3 章) | 慢源连续翻章不再每章等待;重复阅读零网络 |
|
||||
| P4.8 图片/字体兼容 ✅ | `coverDecodeJs`(封面解密代理,含搜索结果未入库场景)/ `ruleContent.imageDecode`(正文图片解密)+ 图片 URL 尾部 `,{headers}` options 逐图应用(options 优先于书源 header 与 Cookie)+ `java.queryTTF` / `queryBase64TTF` / `replaceFont`(sfnt cmap 0/4/6、loca 短/长、glyf 简单+复合字形) | 加密图与字体混淆正文可读 |
|
||||
| P4.9 规则稳定性 ✅ | `@put` 回写书源变量(修复跨请求丢失);`book.getVariable/putVariable` 真正注入书籍上下文并落库(对应 Book.upVariable);全部书源 `regexp2` 统一 3s 匹配超时;解压输出 32MB 上限;非 UTF-8 TXT 托管副本按 UTF-8 落盘(外部引用读时解码);换源单事务;JS `cache` 按书源命名空间隔离并落盘;HLS 双端识别(服务端 `#EXTM3U` 嗅探 + `hls` 标志下发) | 与阅读 App 行为对齐,坏源不再拖死服务 |
|
||||
| P5 体验完善 | 换源(ChangeBookSourceDialog 四档排序)、追更(定时刷新目录 + 缓存清理)、发现页(exploreUrl 标签条)、阅读器高级设置(页眉页脚提示、点击区域自定义)、书源编辑器六 Tab、备份导出 | 完整体验 |
|
||||
|
||||
### 实施记录:书源会话状态仍是跨用户共享(下一步)
|
||||
|
||||
书源本身已按用户独立,但 `ReaderSourceState`(Cookie / 登录信息 / loginHeader / 源变量)
|
||||
仍以 `source_url` 为唯一键、跨用户共享一份。两个用户导入同一书源时:
|
||||
|
||||
- 一方登录后,另一方复用同一份 Cookie;一方的登出会清掉另一方的登录态。
|
||||
|
||||
彻底隔离需要给状态表也加 `user_id`(或按书源行 ID 组织),涉及登录面板与变量读写
|
||||
的一批调用点,留作单独一轮处理。
|
||||
|
||||
### 实施记录:关于「宽松 @put 触发 panic」的复核
|
||||
|
||||
前期评估曾报告 `sourcerule.go` 的宽松 `@put` 回退正则含 RE2 不支持的反向引用、可能 panic。
|
||||
逐行复核后该结论**不成立**:`splitPutRule` 的下标全部由固定捕获分组数保证。
|
||||
真实缺陷是另外两个并已修复:
|
||||
|
||||
- `AnalyzeRule.Put` 没有 `sourcePutter` 分支,`@put` 只写进一次性的 `bookVars`,跨请求丢失;
|
||||
- `applyBookContext` 的三处调用点都在传 `book=nil`,`book.getVariable/putVariable` 与 `book.name` 长期是死代码。
|
||||
|
||||
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,260 @@
|
||||
# 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 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载管理界面(服务端批量缓存接口与窗口预取已可用,见下)。
|
||||
- 章节预取:阅读页对当前章之后默认 3 章走 `POST /reader/books/:id/content-batch` 批量抓取(服务端并发 + 持久缓存),翻到后续章直接命中;单章串行预取在高延迟书源上赶不上连续翻章。
|
||||
- 书源按用户独立:书源管理(列表/导入/启停/删除)与搜索都只作用于当前用户自己的书源。同一份书源不同用户可以各导入一份,各自启停、排序、改规则与变量,互不可见;Web 端无需额外交互,接口按登录用户自动隔离。
|
||||
|
||||
**书架分组(`/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,原生纵向滚动照常。
|
||||
- 手机横向滑动翻页(`hooks/useHorizontalSwipe.ts`):翻页模式(文本/漫画)在三分区覆盖层上再接一层触摸手势——**从左往右滑=上一页、从右往左滑=下一页**,并且**跟手**:位移超过 12px 且横向明显大于纵向(1.2 倍)之后,内容就跟着手指走,松手再按「位移 ≥45px」决定翻页还是回弹(跟手手势不再看手势时长,慢慢拖过去同样是拖动)。跟手手势一律吞掉随之而来的 click,否则短距离拖动会「回弹 + 再翻一页」;没走跟手的手势仍按原来的「≥45px + 时长 ≤800ms」判定一次滑动,短距离当点击交给分区按钮。`consumeSwipe()` 让三个分区按钮的 `onClick` 先判断这次 click 是否已被手势吃掉,标记在每次 `touchstart` 重置。
|
||||
- 跟手拖拽平移的是两个不同的元素。文本:正文列外面单挂一层「跟手层」(`pageDragRef`),分页平移由 `applyTextPageTransform` 命令式写在里层(`contentRef`),跟手层只管手指位移;松手时两层同时动(跟手位移归零 + 页码变化),合起来就是一次连续滑动。正文用 `ReaderTextPageBody` memo,避免长章每次 `setPage` 重渲染整棵段落树(手机会先卡一下再翻页)。漫画:平移 `ReaderComic` 的舞台,舞台左右各摆一页(见下一条)。
|
||||
- 漫画单页翻页的跟手轨道与预取(`ReaderComic` 的 `draggable` 分支):翻页模式且没有并排铺开两页时,当前页左右各摆一页,手指横滑整条舞台跟着走。单元格按图片序号做 key、位置用 `left` 百分比铺开(不是固定三个槽位换内容),所以翻页时变的只是 `left`,`<img>` 的 src 不变,浏览器不会重新解码、也不会先闪一下转圈。松手要翻页时先把新页瞬移到手指离开的位置(这一步必须关掉过渡,否则会先反着滑一段),读一次布局把这一帧定成过渡起点,再打开过渡滑回 0;同时页码 +1/-1,单元格的 `left` 在同一次提交里跟着换,两边相抵,屏幕上看不出切换。**两侧那两页同时就是下一页/上一页的预取**(带 `eager`:它们被 `overflow-hidden` 裁在屏幕外,懒加载会认为「离视口还远」而不去取,横滑过去就会看到转圈)——这正是「手机上翻一页要等一秒、电脑上(双页开着会顺带往后探测 8 张)不卡」的根因。
|
||||
- 滚动模式的滚轮(`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+滚轮` 保留浏览器缩放。
|
||||
- **浏览器全屏(沉浸式)**:阅读页本身已经是 `fixed inset-0` 的独立布局(`/reader/*` 不套影视 Layout),所以屏幕上的留白只剩浏览器自带的地址栏/工具栏。底部动作行加「全屏 / 还原」,对 `readerRef` 调标准 Fullscreen API(`requestFullscreen` / `exitFullscreen`),进全屏前先收起菜单。按钮只在 `document.fullscreenEnabled === true` 时出现——**iOS 上的 Safari 至今不支持元素全屏**(只有 `<video>` 能全屏),所以那里不显示按钮,而不是显示一个点了没反应的死按钮;iPhone 想彻底去掉浏览器界面只能「添加到主屏幕」。离开阅读页时卸载 effect 主动 `exitFullscreen`,否则返回书架后浏览器仍停在全屏、整站都被罩住。全屏状态由 `fullscreenchange` 事件回写,不做乐观更新(用户按 Esc / 系统手势退出时也要跟着变)。
|
||||
- **手机端双击中间区域进出全屏**(`hooks/useDoubleTap.ts` + `hooks/useIsTouchDevice.ts`):中间区域单击本来就是呼出菜单,现在双击再切一次全屏,两个动作在同一块屏幕上,得先等一小会儿看有没有第二下。`useDoubleTap` 把这段窗口做成纯函数 `decideDoubleTap`(第一下进 `tap` 窗口等第二下;第二下到达取消单击、改判双击),**双击回调在第二次点击的事件里同步执行**——浏览器只认用户手势上下文里的 `requestFullscreen`,挪进定时器再调会被直接拒绝。只在「触摸设备(`(hover: none)`)+ 支持元素全屏」时启用:桌面端根本不装这套,单击立刻生效,点中间呼出菜单的手感与以前完全一致;iOS 也不装(它不支持元素全屏)。三条中间点击路径(翻页模式的三分区覆盖层、文本滚动模式的 `handleZoneTap`、漫画滚动模式的 `onZone`)统一走 `centerTap()`,左右分区点击会 `cancel()` 掉待判定的单击,免得点完翻页又弹出菜单。菜单打开时整屏遮罩先吃掉那次点击,遮罩在中间区域关闭菜单的同时 `arm()` 一个双击窗口,「双击中间切全屏」在菜单开着时也成立。手机端再给阅读根节点挂 `touch-action: manipulation`,关掉浏览器自带的双击缩放(纵向滚动与双指缩放不受影响),避免同一手势被浏览器抢着缩一次。
|
||||
- 目录:整屏面板(顶部返回 + 书名 + 章数,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 四档排序。
|
||||
- 搜索分页:滚到底自动取下一页(`POST /reader/search` 的 `page`,从 1 开始,对应书源 `searchUrl` 的 `{{page}}`),逐页增量合并(跨页按「书名+作者」去重、并书源,见 `utils/searchBooks.ts`)。收口比 legado 严一档:**新一页没带来新书就停止翻页**——不支持分页的源每页都重复返回首页结果,照 legado「任一源有结果即可翻页」会一直转圈加载;单次搜索最多 20 页。
|
||||
- 书源/替换编辑器按第 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
|
||||
@@ -11,42 +12,61 @@ require (
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
github.com/metatube-community/metatube-sdk-go v1.4.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.48.0
|
||||
golang.org/x/sys v0.41.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.5.7
|
||||
gorm.io/gorm v1.30.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
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
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
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/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
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/goccy/go-json v0.10.5 // 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-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgx/v5 v5.4.3 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
@@ -58,11 +78,12 @@ require (
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
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
|
||||
@@ -79,14 +100,14 @@ require (
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
golang.org/x/arch v0.25.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // 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.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.47.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
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=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
@@ -20,22 +35,32 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
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=
|
||||
github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47/go.mod h1:bIH3W2QoHvchgAPl6DUwIUguIHJwvMS6sePQIvhxPTc=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/gzip v1.2.6 h1:OtN8DplD5DNZCSLAnQ5HxRkD2qZ5VU+JhOrcfJrcRvg=
|
||||
github.com/gin-contrib/gzip v1.2.6/go.mod h1:BQy8/+JApnRjAVUplSGZiVtD2k8GmIE2e9rYu/hLzzU=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||
@@ -48,30 +73,43 @@ 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/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
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=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
|
||||
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
|
||||
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.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=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -92,6 +130,8 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/metatube-community/metatube-sdk-go v1.4.0 h1:NBbjxU5SuzgMABM58/f/tmbuTg+hn+Nv1PnlF+gihGI=
|
||||
github.com/metatube-community/metatube-sdk-go v1.4.0/go.mod h1:FRgPioGhlkXgD6RmOKQDWITwxlsl0K9cXONFz06jymw=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -99,6 +139,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -108,15 +150,14 @@ 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/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
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.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
@@ -159,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=
|
||||
@@ -173,28 +215,109 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
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.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
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.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/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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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/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.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.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.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -203,15 +326,35 @@ gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM=
|
||||
gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
|
||||
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
|
||||
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -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 |
@@ -46,6 +46,9 @@ func TestLoadDefaults(t *testing.T) {
|
||||
if cfg.Cache.MediaTTLSeconds != 90 {
|
||||
t.Fatalf("expected default media cache ttl 90, got %d", cfg.Cache.MediaTTLSeconds)
|
||||
}
|
||||
if cfg.Cache.MemoryMaxSizeMB != DefaultCacheMemoryMaxSizeMB {
|
||||
t.Fatalf("expected default runtime cache memory limit %d, got %d", DefaultCacheMemoryMaxSizeMB, cfg.Cache.MemoryMaxSizeMB)
|
||||
}
|
||||
if cfg.Search.Index != "mebox_media" {
|
||||
t.Fatalf("expected default search index, got %q", cfg.Search.Index)
|
||||
}
|
||||
@@ -88,6 +91,7 @@ func TestEnvOverride(t *testing.T) {
|
||||
t.Setenv("MEBOX_DATABASE_DSN", "postgres://mebox:secret@postgres:5432/mebox?sslmode=disable")
|
||||
t.Setenv("MEBOX_CACHE_REDIS_URL", "redis://redis:6379/0")
|
||||
t.Setenv("MEBOX_CACHE_MEDIA_TTL_SECONDS", "30")
|
||||
t.Setenv("MEBOX_CACHE_MEMORY_MAX_SIZE_MB", "48")
|
||||
t.Setenv("MEBOX_SEARCH_BACKEND", "opensearch")
|
||||
t.Setenv("MEBOX_SEARCH_OPENSEARCH_URL", "http://opensearch:9200")
|
||||
t.Setenv("MEBOX_LICENSE_SERVER_URL", "https://license.example.com")
|
||||
@@ -103,8 +107,8 @@ func TestEnvOverride(t *testing.T) {
|
||||
if cfg.Database.Type != "postgres" || cfg.Database.DSN == "" {
|
||||
t.Fatalf("expected postgres database config from env, got type=%q dsn=%q", cfg.Database.Type, cfg.Database.DSN)
|
||||
}
|
||||
if cfg.Cache.RedisURL != "redis://redis:6379/0" || cfg.Cache.MediaTTLSeconds != 30 {
|
||||
t.Fatalf("expected redis cache config from env, got url=%q ttl=%d", cfg.Cache.RedisURL, cfg.Cache.MediaTTLSeconds)
|
||||
if cfg.Cache.RedisURL != "redis://redis:6379/0" || cfg.Cache.MediaTTLSeconds != 30 || cfg.Cache.MemoryMaxSizeMB != 48 {
|
||||
t.Fatalf("expected redis cache config from env, got url=%q ttl=%d memory=%d", cfg.Cache.RedisURL, cfg.Cache.MediaTTLSeconds, cfg.Cache.MemoryMaxSizeMB)
|
||||
}
|
||||
if cfg.Search.Backend != "opensearch" || cfg.Search.OpenSearchURL != "http://opensearch:9200" {
|
||||
t.Fatalf("expected opensearch config from env, got backend=%q url=%q", cfg.Search.Backend, cfg.Search.OpenSearchURL)
|
||||
|
||||
@@ -7,6 +7,7 @@ const (
|
||||
defaultDatabaseMaxIdleConns = 4
|
||||
defaultLicenseServerURL = "https://mgosever.3jzs.com"
|
||||
defaultLicensePublicKey = "MCowBQYDK2VwAyEABRXnXy+urjrbKit6Yu/HiezWgP0NdsZW3tsegJWRrtI="
|
||||
DefaultCacheMemoryMaxSizeMB = 128
|
||||
)
|
||||
|
||||
func setDefaults(v *viper.Viper) {
|
||||
@@ -32,6 +33,7 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("database.cache_size", -40000)
|
||||
v.SetDefault("database.max_open_conns", defaultDatabaseMaxOpenConns)
|
||||
v.SetDefault("database.max_idle_conns", defaultDatabaseMaxIdleConns)
|
||||
v.SetDefault("database.postgres_trgm_index", true)
|
||||
|
||||
v.SetDefault("secrets.jwt_secret", "")
|
||||
|
||||
@@ -43,11 +45,17 @@ 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.cleanup_interval_min", 60)
|
||||
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.redis_url", "")
|
||||
v.SetDefault("cache.redis_prefix", "mebox")
|
||||
v.SetDefault("cache.media_ttl_seconds", 90)
|
||||
v.SetDefault("cache.emby_latest_ttl_seconds", 300)
|
||||
v.SetDefault("cache.reader_content_max_size_mb", 1024)
|
||||
v.SetDefault("cache.reader_content_ttl_hours", 168)
|
||||
v.SetDefault("cache.reader_files_ttl_hours", 720)
|
||||
|
||||
v.SetDefault("search.backend", "")
|
||||
v.SetDefault("search.opensearch_url", "")
|
||||
|
||||
@@ -47,12 +47,30 @@ 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
|
||||
}
|
||||
if c.Cache.RedisPrefix == "" {
|
||||
c.Cache.RedisPrefix = "mebox"
|
||||
}
|
||||
if c.Cache.MediaTTLSeconds < 1 {
|
||||
c.Cache.MediaTTLSeconds = 90
|
||||
}
|
||||
if c.Cache.ReaderContentMaxSizeMB < 0 {
|
||||
c.Cache.ReaderContentMaxSizeMB = 0
|
||||
}
|
||||
if c.Cache.ReaderContentTTLHours < 0 {
|
||||
c.Cache.ReaderContentTTLHours = 0
|
||||
}
|
||||
if c.Cache.ReaderFilesTTLHours < 0 {
|
||||
c.Cache.ReaderFilesTTLHours = 0
|
||||
}
|
||||
c.Search.Backend = strings.ToLower(strings.TrimSpace(c.Search.Backend))
|
||||
if c.Search.Index == "" {
|
||||
c.Search.Index = "mebox_media"
|
||||
|
||||
@@ -50,7 +50,8 @@ type AppConfig struct {
|
||||
DataDir string `mapstructure:"data_dir"`
|
||||
WebDir string `mapstructure:"web_dir"`
|
||||
// HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置
|
||||
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。
|
||||
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS;
|
||||
// 同一端口上的明文 HTTP 请求会被跳转到 HTTPS,而不是直接拒绝。
|
||||
HTTPSEnabled bool `mapstructure:"https_enabled"`
|
||||
// SSLCert 是 PEM 编码的 SSL 证书内容。
|
||||
SSLCert string `mapstructure:"ssl_cert"`
|
||||
@@ -86,6 +87,10 @@ type DatabaseConfig struct {
|
||||
CacheSize int `mapstructure:"cache_size"`
|
||||
MaxOpenConns int `mapstructure:"max_open_conns"`
|
||||
MaxIdleConns int `mapstructure:"max_idle_conns"`
|
||||
// PostgresTrgmIndex 决定是否用 pg_trgm 的 GIN 三元组索引替代 SQLite 的
|
||||
// FTS5 全文索引。开启后 ILIKE '%term%' 搜索能走索引而不是整表扫描,代价是
|
||||
// media 表写入时要多维护 4 个 GIN 索引。仅对 PostgreSQL 生效。
|
||||
PostgresTrgmIndex bool `mapstructure:"postgres_trgm_index"`
|
||||
}
|
||||
|
||||
// SecretsConfig 保存 JWT / 第三方 API 密钥(不要提交值)。
|
||||
@@ -113,17 +118,33 @@ type LoggingConfig struct {
|
||||
MaxBackups int `mapstructure:"max_backups"`
|
||||
}
|
||||
|
||||
// CacheConfig 控制磁盘转码/刮削缓存。
|
||||
// CacheConfig 控制磁盘转写缓存和进程内热缓存。
|
||||
type CacheConfig struct {
|
||||
CacheDir string `mapstructure:"cache_dir"`
|
||||
ImagesMaxSizeMB int `mapstructure:"images_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"`
|
||||
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"`
|
||||
RedisURL string `mapstructure:"redis_url"`
|
||||
RedisPrefix string `mapstructure:"redis_prefix"`
|
||||
MediaTTLSeconds int `mapstructure:"media_ttl_seconds"`
|
||||
// EmbyLatestTTLSeconds 是 Emby「最新添加」(Items/Latest) 的缓存时长。
|
||||
// 客户端刷新首页时会并发请求全部媒体库的 Latest(生产环境观察到 73 个
|
||||
// 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机
|
||||
// 上造成秒级延迟。默认 300 秒,新入库内容最迟 5 分钟后出现在最新列表。
|
||||
EmbyLatestTTLSeconds int `mapstructure:"emby_latest_ttl_seconds"`
|
||||
// ReaderContentMaxSizeMB 阅读正文持久缓存的总容量(MB),0 表示不限制。
|
||||
ReaderContentMaxSizeMB int `mapstructure:"reader_content_max_size_mb"`
|
||||
// ReaderContentTTLHours 阅读正文缓存的保留时长(小时),0 表示不过期。
|
||||
ReaderContentTTLHours int `mapstructure:"reader_content_ttl_hours"`
|
||||
// ReaderFilesTTLHours 书源文件缓存(java.cacheFile / downloadFile)的保留时长(小时)。
|
||||
// 0 表示使用内置兜底值(30 天)。
|
||||
ReaderFilesTTLHours int `mapstructure:"reader_files_ttl_hours"`
|
||||
}
|
||||
|
||||
type SearchConfig struct {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func MigrateCurrentToPostgres(src *gorm.DB, targetDSN string, batchSize int, log
|
||||
}
|
||||
|
||||
// 1. 初始化目标库 Schema、类型与索引
|
||||
if err := AutoMigrate(targetDB); err != nil {
|
||||
if err := AutoMigrateWithConfig(targetDB, nil, log); err != nil {
|
||||
return nil, fmt.Errorf("auto migrate target PostgreSQL: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// legacyReaderSource 旧版书源结构(全局唯一,没有 user_id)。
|
||||
type legacyReaderSource struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
Name string
|
||||
SourceURL string `gorm:"type:varchar(512);uniqueIndex"`
|
||||
RawJSON string
|
||||
Enabled bool
|
||||
EnabledExplore bool
|
||||
Variables string
|
||||
Header string
|
||||
}
|
||||
|
||||
func (legacyReaderSource) TableName() string { return "reader_book_sources" }
|
||||
|
||||
// 迁移路径:老库(书源全局唯一、无 user_id)升级后
|
||||
// 1. 旧唯一索引被删除,两个用户可以各持一份同 URL 的书源副本;
|
||||
// 2. 书源按「谁书架上有这本书」回填归属,多个引用者各得一份副本;
|
||||
// 3. 软删的历史书源行被清理,删除后重新导入不再撞唯一键。
|
||||
func TestReaderSourceMigrationBackfill(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "legacy.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Windows 上 sqlite 文件句柄会阻塞 TempDir 清理,测试结束前先关连接。
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
// 造老结构:只有书源表(带旧唯一索引),书籍表用当前模型建。
|
||||
if err := db.AutoMigrate(&legacyReaderSource{}, &model.ReaderBook{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shared := &legacyReaderSource{ID: "legacy-1", Name: "共享源", SourceURL: "https://shared.example.com", Enabled: true}
|
||||
if err := db.Create(shared).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
softDeleted := &legacyReaderSource{ID: "legacy-2", Name: "已删源", SourceURL: "https://gone.example.com"}
|
||||
if err := db.Create(softDeleted).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Delete(softDeleted).Error; err != nil { // 旧版 DeleteSource 的软删
|
||||
t.Fatal(err)
|
||||
}
|
||||
// u1 两本、u2 一本都引用共享源;孤儿源无人引用。
|
||||
books := []model.ReaderBook{
|
||||
{UserID: "u1", Origin: "https://shared.example.com", BookURL: "https://shared.example.com/b/1", Name: "书1"},
|
||||
{UserID: "u1", Origin: "https://shared.example.com", BookURL: "https://shared.example.com/b/2", Name: "书2"},
|
||||
{UserID: "u2", Origin: "https://shared.example.com", BookURL: "https://shared.example.com/b/1", Name: "书1"},
|
||||
{UserID: "u2", Origin: "https://orphan.example.com", BookURL: "https://orphan.example.com/b/1", Name: "孤儿"},
|
||||
}
|
||||
for i := range books {
|
||||
if err := db.Create(&books[i]).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移。
|
||||
if err := dropLegacyReaderSourceUniqueIndex(db); err != nil {
|
||||
t.Fatalf("删旧索引失败: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("AutoMigrate 失败: %v", err)
|
||||
}
|
||||
if err := backfillReaderSourceOwnership(db); err != nil {
|
||||
t.Fatalf("回填失败: %v", err)
|
||||
}
|
||||
|
||||
// 1. 旧唯一索引已删除:两个用户同 URL 各自一份。
|
||||
var rows []model.ReaderBookSource
|
||||
if err := db.Order("user_id ASC").Find(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
byUser := map[string]model.ReaderBookSource{}
|
||||
for _, r := range rows {
|
||||
if r.SourceURL == "https://shared.example.com" {
|
||||
byUser[r.UserID] = r
|
||||
}
|
||||
}
|
||||
if len(byUser) != 2 {
|
||||
t.Fatalf("共享源应有两份副本,实际 %d 份: %+v", len(byUser), byUser)
|
||||
}
|
||||
if byUser["u1"].ID == "" || byUser["u2"].ID == "" || byUser["u1"].ID == byUser["u2"].ID {
|
||||
t.Fatalf("两份副本应有不同行 ID: u1=%q u2=%q", byUser["u1"].ID, byUser["u2"].ID)
|
||||
}
|
||||
if byUser["u1"].RawJSON != shared.RawJSON || byUser["u1"].Name != "共享源" {
|
||||
t.Fatal("副本应保留原书源内容")
|
||||
}
|
||||
// 引用最多的用户保留原行(u1 有两本书)。
|
||||
if byUser["u1"].ID != "legacy-1" {
|
||||
t.Fatalf("原行应归引用最多的用户,实际 %q", byUser["u1"].ID)
|
||||
}
|
||||
|
||||
// 2. 软删的历史行被清理。
|
||||
var softLeft int64
|
||||
if err := db.Unscoped().Model(&model.ReaderBookSource{}).
|
||||
Where("source_url = ?", "https://gone.example.com").Count(&softLeft).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if softLeft != 0 {
|
||||
t.Fatalf("软删的历史书源应被清理,剩余 %d 行", softLeft)
|
||||
}
|
||||
|
||||
// 3. 无人引用的书源保持无归属(不会凭空分配给谁)。
|
||||
var orphans int64
|
||||
if err := db.Model(&model.ReaderBookSource{}).
|
||||
Where("source_url = ? AND user_id <> ''", "https://orphan.example.com").Count(&orphans).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if orphans != 0 {
|
||||
t.Fatal("无人引用的书源不应被分配归属")
|
||||
}
|
||||
|
||||
// 4. 回填是幂等的(再次执行不产生重复副本)。
|
||||
if err := backfillReaderSourceOwnership(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var total int64
|
||||
if err := db.Model(&model.ReaderBookSource{}).Where("source_url = ?", "https://shared.example.com").Count(&total).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Fatalf("重复回填不应新增副本,实际 %d 份", total)
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,52 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// AutoMigrate creates tables for every model registered in the model package.
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
return AutoMigrateWithConfig(db, nil, nil)
|
||||
}
|
||||
|
||||
// AutoMigrateWithConfig 与 AutoMigrate 相同,另外接受配置与日志:
|
||||
// 配置用来决定可选的 PostgreSQL 搜索索引,日志用来记录耗时步骤
|
||||
// (首次在已有大库上重建索引可能需要数十秒)。两者都可以为 nil。
|
||||
func AutoMigrateWithConfig(db *gorm.DB, cfg *config.Config, log *zap.Logger) error {
|
||||
// 必须先于 AutoMigrate:旧库中可能已有重复的 (user_id, media_id) 历史行,
|
||||
// 不去重会导致唯一索引 uniq_user_history 创建失败。
|
||||
if err := dedupePlaybackHistories(db); err != nil {
|
||||
return err
|
||||
}
|
||||
// 书源从「全局唯一」改成「按用户独立」:先删掉 source_url 上的旧唯一索引。
|
||||
// AutoMigrate 只会按索引名创建新索引,不会删除已改名的旧唯一索引,留着它
|
||||
// 第二个用户导入同一书源就会撞唯一约束。
|
||||
if err := dropLegacyReaderSourceUniqueIndex(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
return err
|
||||
}
|
||||
// 回填书源归属:老库的书源没有 user_id,按「谁书架上有这本书」分配或复制副本。
|
||||
if err := backfillReaderSourceOwnership(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensurePostgresColumnCompatibility(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensurePerformanceIndexes(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureMediaPathIndex(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureLibraryRootsCompatibility(db); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -34,6 +59,111 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
}
|
||||
return ensureSQLiteQueryOptimizer(db)
|
||||
}
|
||||
return ensurePostgresSearchIndexes(db, cfg, log)
|
||||
}
|
||||
|
||||
// ensureMediaPathIndex 建立 media.path 的唯一约束与等值查找索引。
|
||||
//
|
||||
// 模型上不再用 gorm:"uniqueIndex"(见 model.Media.Path 的注释):PostgreSQL 的
|
||||
// btree 单条索引项上限是 2704 字节,varchar(1024) 在中文路径下可达 3072 字节,
|
||||
// 对整列建唯一索引会让这类行插入时报
|
||||
//
|
||||
// index row size ... exceeds btree version 4 maximum 2704
|
||||
//
|
||||
// 所以 PostgreSQL 改用 md5(path) 的表达式唯一索引(定长 32 字节,永远塞得下),
|
||||
// 同时保留一条普通索引 idx_media_path 供 path = ? 等值查找(扫描与 upsert 的
|
||||
// 热路径)使用。SQLite 没有这个长度限制,沿用整列唯一索引。
|
||||
func ensureMediaPathIndex(db *gorm.DB) error {
|
||||
if db == nil || !db.Migrator().HasTable("media") {
|
||||
return nil
|
||||
}
|
||||
if !isPostgres(db) {
|
||||
return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_media_path ON media(path)`).Error
|
||||
}
|
||||
// 先建哈希唯一索引:此时旧索引还在,唯一性不会出现空窗。
|
||||
if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_media_path_hash ON media(md5(path))`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
unique, found, err := postgresIndexIsUnique(db, "idx_media_path")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found && unique {
|
||||
if err := db.Exec(`DROP INDEX IF EXISTS idx_media_path`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Exec(`CREATE INDEX IF NOT EXISTS idx_media_path ON media(path)`).Error
|
||||
}
|
||||
|
||||
// postgresIndexIsUnique 报告当前 schema 下是否存在该索引、以及它是否唯一。
|
||||
func postgresIndexIsUnique(db *gorm.DB, indexName string) (unique bool, found bool, err error) {
|
||||
// 用指针接 bool:没有匹配行时 Scan 不报错,nil 即代表索引不存在。
|
||||
var row struct {
|
||||
IsUnique *bool
|
||||
}
|
||||
if err := db.Raw(`
|
||||
SELECT i.indisunique AS is_unique
|
||||
FROM pg_class c
|
||||
JOIN pg_index i ON i.indexrelid = c.oid
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = current_schema() AND c.relname = ?
|
||||
`, indexName).Scan(&row).Error; err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
if row.IsUnique == nil {
|
||||
return false, false, nil
|
||||
}
|
||||
return *row.IsUnique, true, nil
|
||||
}
|
||||
|
||||
// ensurePostgresSearchIndexes 给 PostgreSQL 补上 SQLite FTS5 的替代品。
|
||||
//
|
||||
// 全文检索只在 SQLite 下存在(FTS5 虚表 + 触发器),PostgreSQL 下
|
||||
// media_search_repository 会退回 title/original_name/path/genres 的 ILIKE
|
||||
// 全表扫描。pg_trgm 的 GIN 三元组索引能让这类 '%term%' 模式(≥3 字符)走索引,
|
||||
// 把搜索从整表扫描降到索引扫描。
|
||||
//
|
||||
// 之所以可关闭:media 表写入非常频繁(每次扫描批量入库),多 4 个 GIN 索引会
|
||||
// 明显放大写入成本。默认开启以对齐 SQLite 的搜索能力,写入压力大的部署可以关掉。
|
||||
//
|
||||
// 整个过程是尽力而为:没有建扩展的权限、Postgres 版本不支持等情况下只记一条
|
||||
// 警告并继续启动,绝不让一个搜索加速项把服务挡在门外。
|
||||
func ensurePostgresSearchIndexes(db *gorm.DB, cfg *config.Config, log *zap.Logger) error {
|
||||
if !isPostgres(db) {
|
||||
return nil
|
||||
}
|
||||
if cfg != nil && !cfg.Database.PostgresTrgmIndex {
|
||||
if log != nil {
|
||||
log.Info("postgres trigram search index disabled; media search falls back to sequential scan")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := db.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`).Error; err != nil {
|
||||
if log != nil {
|
||||
log.Warn("create pg_trgm extension failed; media search falls back to sequential scan", zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 首次在已有大库上执行会重建索引(服务尚未开始接受请求),之后每次启动都因
|
||||
// IF NOT EXISTS 直接跳过。
|
||||
if log != nil {
|
||||
log.Info("ensuring postgres trigram search indexes")
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_title_trgm ON media USING gin (title gin_trgm_ops)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_original_name_trgm ON media USING gin (original_name gin_trgm_ops)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_path_trgm ON media USING gin (path gin_trgm_ops)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_genres_trgm ON media USING gin (genres gin_trgm_ops)`,
|
||||
} {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
if log != nil {
|
||||
log.Warn("create trigram index failed; media search may fall back to sequential scan",
|
||||
zap.String("statement", stmt), zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -42,6 +172,105 @@ func ensureSQLiteQueryOptimizer(db *gorm.DB) error {
|
||||
return db.Exec("ANALYZE").Error
|
||||
}
|
||||
|
||||
// dropLegacyReaderSourceUniqueIndex 删除书源表上旧版 source_url 唯一索引。
|
||||
//
|
||||
// 书源改为按用户独立后唯一键是 (user_id, source_url);SQLite 的 RenameTable 与
|
||||
// AutoMigrate 都不会删除按旧名创建的索引(实测旧唯一索引在改名后依然生效),
|
||||
// 必须显式删除,否则第二个用户导入同一书源会撞唯一约束。
|
||||
func dropLegacyReaderSourceUniqueIndex(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&model.ReaderBookSource{}) {
|
||||
return nil
|
||||
}
|
||||
for _, name := range []string{"idx_reader_book_sources_source_url", "reader_book_sources_source_url_key"} {
|
||||
if db.Migrator().HasIndex(&model.ReaderBookSource{}, name) {
|
||||
if err := db.Migrator().DropIndex(&model.ReaderBookSource{}, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
// 旧版 DeleteSource 是软删:这些历史行会继续占用 (user_id, source_url) 唯一键,
|
||||
// 让「删掉再导入同一书源」失败。书源是可重新导入的数据,这里一次性清掉。
|
||||
return db.Unscoped().Where("deleted_at IS NOT NULL").Delete(&model.ReaderBookSource{}).Error
|
||||
}
|
||||
|
||||
// backfillReaderSourceOwnership 给老库的书源补上归属用户。
|
||||
//
|
||||
// 书源原先全局唯一,现在按用户独立:按「谁的书架上有这本书」把书源分配给该用户;
|
||||
// 同一书源被多个用户引用时,给每个用户复制一份副本(各自启停/排序/改规则),
|
||||
// 这样谁都不会因为别人删源而失去书源。
|
||||
//
|
||||
// 没有 user_id 且无人引用的书源保持原样(user_id 为空)——它不再出现在任何人的
|
||||
// 列表里,但数据仍在,需要时可以人工认领。
|
||||
func backfillReaderSourceOwnership(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable(&model.ReaderBookSource{}) {
|
||||
return nil
|
||||
}
|
||||
var pending []model.ReaderBookSource
|
||||
if err := db.Where("user_id = '' OR user_id IS NULL").Find(&pending).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range pending {
|
||||
src := pending[i]
|
||||
if err := assignOrCloneReaderSource(db, &src); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assignOrCloneReaderSource(db *gorm.DB, src *model.ReaderBookSource) error {
|
||||
if src == nil || src.SourceURL == "" {
|
||||
return nil
|
||||
}
|
||||
// 引用该书源的书籍分布(去重到用户)。
|
||||
type ownerCount struct {
|
||||
UserID string
|
||||
N int64
|
||||
}
|
||||
var owners []ownerCount
|
||||
if err := db.Model(&model.ReaderBook{}).
|
||||
Select("user_id, COUNT(*) AS n").
|
||||
Where("origin = ?", src.SourceURL).
|
||||
Group("user_id").Order("n DESC").Scan(&owners).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(owners) == 0 {
|
||||
return nil // 无人引用:保持无归属
|
||||
}
|
||||
// 第一个用户(引用最多)直接接手这一行。
|
||||
owner := owners[0].UserID
|
||||
if owner == "" {
|
||||
return nil
|
||||
}
|
||||
if err := db.Model(&model.ReaderBookSource{}).
|
||||
Where("id = ?", src.ID).
|
||||
Updates(map[string]any{"user_id": owner}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 其余用户各复制一份副本(新行 ID,其余字段全量拷贝)。
|
||||
for _, o := range owners[1:] {
|
||||
if o.UserID == "" || o.UserID == owner {
|
||||
continue
|
||||
}
|
||||
var exists int64
|
||||
if err := db.Model(&model.ReaderBookSource{}).
|
||||
Where("user_id = ? AND source_url = ?", o.UserID, src.SourceURL).
|
||||
Count(&exists).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if exists > 0 {
|
||||
continue
|
||||
}
|
||||
clone := *src
|
||||
clone.ID = ""
|
||||
clone.UserID = o.UserID
|
||||
if err := db.Create(&clone).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dedupePlaybackHistories removes duplicate (user_id, media_id) rows left by
|
||||
// the former read-then-write upsert, so the uniq_user_history composite unique
|
||||
// index can be created on existing databases. Keeps the most recent row per
|
||||
@@ -67,16 +296,35 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error {
|
||||
if !isPostgres(db) {
|
||||
return nil
|
||||
}
|
||||
statements := []string{
|
||||
`ALTER TABLE media ALTER COLUMN container TYPE varchar(128)`,
|
||||
`ALTER TABLE media ALTER COLUMN genres TYPE text`,
|
||||
`ALTER TABLE media ALTER COLUMN series_id TYPE varchar(128)`,
|
||||
`ALTER TABLE media ALTER COLUMN duplicate_of TYPE varchar(128)`,
|
||||
`ALTER TABLE playback_histories ALTER COLUMN media_id TYPE varchar(128)`,
|
||||
`ALTER TABLE favorites ALTER COLUMN media_id TYPE varchar(128)`,
|
||||
`ALTER TABLE playlist_items ALTER COLUMN media_id TYPE varchar(128)`,
|
||||
// 只在类型确实不匹配时才 ALTER。
|
||||
//
|
||||
// PostgreSQL 的 ALTER COLUMN ... TYPE 只要类型声明与目标不同就会整表重写并
|
||||
// 重建相关索引,执行期间持有 ACCESS EXCLUSIVE 锁。此前这里每次启动都无条件
|
||||
// 发这 7 条语句:类型其实早已一致,但几十万行的 media 表仍然被反复重写,
|
||||
// 启动会卡住数分钟,且期间这张表连读都被阻塞。
|
||||
changes := []struct {
|
||||
table string
|
||||
column string
|
||||
target string
|
||||
}{
|
||||
{"media", "container", "varchar(128)"},
|
||||
{"media", "genres", "text"},
|
||||
{"media", "series_id", "varchar(128)"},
|
||||
{"media", "duplicate_of", "varchar(128)"},
|
||||
{"playback_histories", "media_id", "varchar(128)"},
|
||||
{"favorites", "media_id", "varchar(128)"},
|
||||
{"playlist_items", "media_id", "varchar(128)"},
|
||||
}
|
||||
for _, stmt := range statements {
|
||||
for _, change := range changes {
|
||||
current, found, err := postgresColumnType(db, change.table, change.column)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || current == change.target {
|
||||
continue
|
||||
}
|
||||
stmt := "ALTER TABLE " + quoteIdent(change.table) +
|
||||
" ALTER COLUMN " + quoteIdent(change.column) + " TYPE " + change.target
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -84,6 +332,35 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// postgresColumnType 返回列在当前 schema 下的规范化类型描述,例如
|
||||
// "varchar(128)" / "text"。列不存在时 found 为 false。
|
||||
func postgresColumnType(db *gorm.DB, table, column string) (value string, found bool, err error) {
|
||||
var row struct {
|
||||
DataType string
|
||||
MaxLength *int64
|
||||
}
|
||||
if err := db.Raw(`
|
||||
SELECT data_type AS data_type, character_maximum_length AS max_length
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?
|
||||
`, table, column).Scan(&row).Error; err != nil {
|
||||
return "", false, fmt.Errorf("inspect postgres column %s.%s: %w", table, column, err)
|
||||
}
|
||||
dataType := strings.ToLower(strings.TrimSpace(row.DataType))
|
||||
if dataType == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
// information_schema 用的是 SQL 标准名,映射回 GORM 标签里的写法再比较。
|
||||
switch dataType {
|
||||
case "character varying", "varchar":
|
||||
dataType = "varchar"
|
||||
}
|
||||
if row.MaxLength != nil && *row.MaxLength > 0 {
|
||||
dataType = fmt.Sprintf("%s(%d)", dataType, *row.MaxLength)
|
||||
}
|
||||
return dataType, true, nil
|
||||
}
|
||||
|
||||
func ensurePerformanceIndexes(db *gorm.DB) error {
|
||||
statements := []string{
|
||||
// 完整多级排序索引:媒体库分页与首页预览的 ORDER BY
|
||||
@@ -99,6 +376,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`,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestAutoMigrateCreatesMediaPathUniqueIndex 守住 media.path 的唯一约束。
|
||||
//
|
||||
// 模型上不再声明 gorm:"uniqueIndex"(PostgreSQL 的 btree 单条索引项上限是
|
||||
// 2704 字节,而 varchar(1024) 在中文路径下可达 3072 字节,对整列建唯一索引会让
|
||||
// 这类行插入直接失败),改由 AutoMigrate 里的 ensureMediaPathIndex 按方言显式
|
||||
// 建立。一旦这个调用被漏掉,扫描会静默产生重复的媒体行(同一文件被入库多次)。
|
||||
func TestAutoMigrateCreatesMediaPathUniqueIndex(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "mebox.db")), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 必须在 TempDir 清理之前关掉连接,否则 Windows 上删除库文件会失败。
|
||||
// t.Cleanup 是后进先出:这里注册的关闭早于 t.TempDir 注册的删除。
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
}
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("AutoMigrate: %v", err)
|
||||
}
|
||||
|
||||
var definition string
|
||||
if err := db.Raw(`SELECT COALESCE(sql, '') FROM sqlite_master WHERE type = 'index' AND name = 'idx_media_path'`).Scan(&definition).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if definition == "" {
|
||||
t.Fatal("AutoMigrate did not create idx_media_path")
|
||||
}
|
||||
if !strings.Contains(strings.ToUpper(definition), "UNIQUE") {
|
||||
t.Fatalf("idx_media_path is not unique: %q", definition)
|
||||
}
|
||||
|
||||
if err := db.Create(&model.Media{Base: model.Base{ID: "m-1"}, Path: "/media/a.mkv"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.Media{Base: model.Base{ID: "m-2"}, Path: "/media/a.mkv"}).Error; err == nil {
|
||||
t.Fatal("a duplicate media path was accepted; the unique constraint is not enforced")
|
||||
}
|
||||
}
|
||||
@@ -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,13 @@ 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)
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// 云 HLS 分片代理的错误映射:
|
||||
// - 会话不存在/过期要给前端可判断的 code,前端据此重拉 master.m3u8 建会话续播,
|
||||
// 而不是当成播放失败直接退回本地转码;
|
||||
// - 客户端主动断开(seek、切清晰度、关页面取消在途请求)不是网关故障,记 499,
|
||||
// 与 nginx 的口径一致。以前统一记 502,既污染监控又会让前端拿到误导性错误。
|
||||
func newCloud115HLSSessionTestRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := &service.Container{
|
||||
Cloud115: service.NewCloud115PlaybackService(&config.Config{}, zap.NewNop(), nil, nil),
|
||||
}
|
||||
router := gin.New()
|
||||
router.GET("/api/cloud115/hls/:session/:key", cloud115HLSSessionHandler(svc))
|
||||
return router
|
||||
}
|
||||
|
||||
func TestCloud115HLSSessionUnknownSessionReturnsCodedNotFound(t *testing.T) {
|
||||
router := newCloud115HLSSessionTestRouter()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/unknown-session/abc?media_id=media-1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "hls_session_not_found") {
|
||||
t.Fatalf("expected machine-readable code in body, got %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloud115HLSSessionClientAbortReturns499(t *testing.T) {
|
||||
router := newCloud115HLSSessionTestRouter()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/unknown-session/abc?media_id=media-1", nil).
|
||||
WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != 499 {
|
||||
t.Fatalf("status = %d, want 499 (client closed request); body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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
|
||||
}
|
||||
// 客户端主动断开(播放器 seek、切切清晰度、关页面时取消在途请求)会让
|
||||
// 上游请求以 context.Canceled 结束。这是正常现象,不是网关故障;以前
|
||||
// 统一记成 502,既污染监控又和 nginx 的 499 对不上,这里按 499 记。
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) ||
|
||||
c.Request.Context().Err() != nil {
|
||||
c.AbortWithStatus(499)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, service.ErrCloud115HLSSessionNotFound):
|
||||
// code 供前端判断「会话没了,需要重新拉一次 master.m3u8 建会话」,
|
||||
// 而不是直接放弃云端播放退回本地转码。
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": "hls_session_not_found", "error": "hls session not found"})
|
||||
case errors.Is(err, service.ErrCloud115HLSUpstreamExpired):
|
||||
c.JSON(http.StatusGone, gin.H{"code": "hls_session_expired", "error": "hls upstream expired"})
|
||||
default:
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -14,7 +16,11 @@ import (
|
||||
// specific danmaku library chosen by the user after a disambiguation.
|
||||
func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
res, err := svc.Danmaku.Fetch(c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"))
|
||||
uid := currentUserID(c)
|
||||
// 按用户读取弹幕源、凭据、合并偏好和渲染参数。
|
||||
opts := service.DanmakuFetchOptions{UserID: uid}
|
||||
res, err := svc.Danmaku.FetchWithOptions(
|
||||
c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"), opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -23,11 +29,36 @@ func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// getDanmakuConfigHandler exposes the danmaku renderer knobs (opacity, font
|
||||
// size, area, enabled) so the player can initialize its control panel without
|
||||
// admin privileges.
|
||||
// getDanmakuConfigHandler exposes the current user's player volume and danmaku
|
||||
// preferences so the player can initialize without admin privileges.
|
||||
func getDanmakuConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, svc.Danmaku.Config(c.Request.Context()))
|
||||
c.JSON(http.StatusOK, svc.Danmaku.ConfigForUser(c.Request.Context(), currentUserID(c)))
|
||||
}
|
||||
}
|
||||
|
||||
// updateDanmakuSettingsHandler 持久化当前用户的播放器音量与弹幕偏好。
|
||||
func updateDanmakuSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := currentUserID(c)
|
||||
if strings.TrimSpace(uid) == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||
return
|
||||
}
|
||||
var req service.DanmakuSettingsPatch
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
cfg, err := svc.Danmaku.UpdateUserSettings(c.Request.Context(), uid, req)
|
||||
if errors.Is(err, service.ErrNoDanmakuSettings) || errors.Is(err, service.ErrInvalidDanmakuSettings) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"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"
|
||||
)
|
||||
|
||||
// newDanmakuSettingsContext 构造带登录用户的最小 gin 上下文。
|
||||
func newDanmakuSettingsContext(t *testing.T, svc *service.Container, method, path, body string, userID string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
if userID != "" {
|
||||
c.Set(middleware.CtxUserID, userID)
|
||||
}
|
||||
return c, w
|
||||
}
|
||||
|
||||
func newDanmakuSettingsService(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.Setting{}, &model.Media{}, &model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
user := model.User{Username: "settings-user", PasswordHash: "x", Role: "user", IsActive: true}
|
||||
user.ID = "user-1"
|
||||
if err := repos.User.Create(t.Context(), &user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
return &service.Container{
|
||||
Repo: repos,
|
||||
Danmaku: service.NewDanmakuService(zap.NewNop(), repos),
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsPersistsMergeSources(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"merge_sources":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 resp struct {
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !resp.MergeSources {
|
||||
t.Fatal("response should echo merge_sources=true")
|
||||
}
|
||||
// 落库校验:重新读取应为 true。
|
||||
if !svc.Danmaku.MergeSourcesEnabled(t.Context(), "user-1") {
|
||||
t.Fatal("merge preference was not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsRejectsMissingField(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", `{}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsRequiresAuthentication(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"merge_sources":true}`, "")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// config 接口应把当前用户的合并偏好带出去,供面板初始化。
|
||||
func TestGetDanmakuConfigIncludesPerUserMergePreference(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", w.Code)
|
||||
}
|
||||
var cfg struct {
|
||||
MergeSources bool `json:"merge_sources"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if cfg.MergeSources {
|
||||
t.Fatal("default merge preference should be false")
|
||||
}
|
||||
|
||||
if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
c2, w2 := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1")
|
||||
getDanmakuConfigHandler(svc)(c2)
|
||||
if err := json.Unmarshal(w2.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !cfg.MergeSources {
|
||||
t.Fatal("config should reflect the persisted merge preference")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
|
||||
svc := newDanmakuSettingsService(t)
|
||||
|
||||
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", body, "user-1")
|
||||
updateDanmakuSettingsHandler(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("my-app-secret")) {
|
||||
t.Fatal("response must never expose the application secret")
|
||||
}
|
||||
var cfg service.DanmakuRenderConfig
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if cfg.Enabled || cfg.Opacity != "0.6" || cfg.FontSize != "32" || cfg.Area != "0.7" {
|
||||
t.Fatalf("unexpected render config: %+v", cfg)
|
||||
}
|
||||
if !cfg.MergeSources || cfg.Volume != 0.35 {
|
||||
t.Fatalf("unexpected user preferences: %+v", cfg)
|
||||
}
|
||||
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured {
|
||||
t.Fatalf("unexpected service config: %+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.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.DanmakuSource != "https://dm.example/base" {
|
||||
t.Fatalf("preferences not persisted: %+v", user)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
|
||||
`{"source":"ftp://dm.example.com"}`, "user-1")
|
||||
updateDanmakuSettingsHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.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.DanmakuSource != "" {
|
||||
t.Fatalf("invalid source was persisted: %q", user.DanmakuSource)
|
||||
}
|
||||
}
|
||||
@@ -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,121 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"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/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// newEmbyCompatTestRouter 构造一个挂载了完整 Emby 路由表的测试引擎。
|
||||
func newEmbyCompatTestRouter(t *testing.T, secret string) *gin.Engine {
|
||||
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.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", 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.Fatalf("create user: %v", err)
|
||||
}
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||||
})
|
||||
return router
|
||||
}
|
||||
|
||||
func TestEmbyAdditionalPartsReturnsEmptyArray(t *testing.T) {
|
||||
const secret = "test-secret"
|
||||
router := newEmbyCompatTestRouter(t, secret)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Videos/msgo-series-abc/AdditionalParts", nil)
|
||||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
// 必须命中 AdditionalParts 静态路由并返回空数组,而不是被 /Videos/:id/:seg
|
||||
// 的 HLS 兜底路由接走返回空 404。
|
||||
var payload []any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
if len(payload) != 0 {
|
||||
t.Fatalf("expected an empty array, got %v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyItemImagesWithoutTypeReturnsArray(t *testing.T) {
|
||||
router := newEmbyCompatTestRouter(t, "test-secret")
|
||||
|
||||
// 不带 Type 的图片清单接口是公开路由,不要求 token,与带 Type 的
|
||||
// 图片字节流一致(客户端缓存 URL 时会丢 token)。
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Items/unknown-item/Images", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct == "" || ct[:16] != "application/json" {
|
||||
t.Fatalf("content type = %q, want application/json", ct)
|
||||
}
|
||||
var payload []any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyItemImagesLowerCaseRouteIsRegistered(t *testing.T) {
|
||||
router := newEmbyCompatTestRouter(t, "test-secret")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/items/unknown-item/images", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyUserImageWithoutAvatarReturnsCacheableNotFound(t *testing.T) {
|
||||
router := newEmbyCompatTestRouter(t, "test-secret")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Users/user-1/Images/Primary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// 用户没有头像时 Emby 同样返回 404,但响应必须可缓存,否则客户端会
|
||||
// 在每次进入设置页时重复请求(线上曾观察到每分钟一次的重试)。
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if cc := w.Header().Get("Cache-Control"); cc != "public, max-age=86400" {
|
||||
t.Fatalf("Cache-Control = %q, want the cacheable directive", cc)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -29,27 +29,87 @@ var embyPlaceholderPNG = []byte{
|
||||
// /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。
|
||||
func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
clearEmbyImageNoStoreHeaders(c)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
req := c.Request.WithContext(ctx)
|
||||
id := c.Param("id")
|
||||
imgType := strings.ToLower(c.Param("type"))
|
||||
raw, err := svc.Emby.ImageURL(ctx, id, imgType)
|
||||
if err != nil || raw == "" {
|
||||
embyServePlaceholderImage(c)
|
||||
embyServeImage(c, svc, c.Param("id"), c.Param("type"), c.Query("tag"))
|
||||
}
|
||||
}
|
||||
|
||||
// embyPersonImageHandler 兼容 Emby 官方的 /Persons/{Name}/Images/{Type}。
|
||||
// Name 可能是伪装后的远程人物 ID,也可能是电影详情 People 中的显示名称。
|
||||
func embyPersonImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
embyServeImage(c, svc, c.Param("name"), c.Param("type"), c.Query("tag"))
|
||||
}
|
||||
}
|
||||
|
||||
func embyServeImage(c *gin.Context, svc *service.Container, id, imageType, tag string) {
|
||||
clearEmbyImageNoStoreHeaders(c)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
req := c.Request.WithContext(ctx)
|
||||
if svc == nil || svc.Emby == nil {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
// PersonImageURL handles TMDb/remote people first and falls back to regular
|
||||
// media/library artwork, so the official /Items/{personId}/Images route
|
||||
// works for synthetic person IDs as well as normal item IDs.
|
||||
raw, err := svc.Emby.PersonImageURL(ctx, id, imageType, tag)
|
||||
if err != nil || raw == "" {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
if svc.ImageProxy == nil {
|
||||
embyServePlaceholderImage(c)
|
||||
return
|
||||
}
|
||||
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
|
||||
embyServePlaceholderImage(c)
|
||||
}
|
||||
}
|
||||
|
||||
// embyItemImagesHandler 处理不带 Type 的 GET /Items/{Id}/Images,返回图片
|
||||
// 清单(Emby 的 ImageInfo 数组)。客户端据此决定详情页加载哪些图。
|
||||
func embyItemImagesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if svc == nil || svc.Emby == nil || id == "" {
|
||||
c.JSON(http.StatusOK, []any{})
|
||||
return
|
||||
}
|
||||
if svc.ImageProxy == nil {
|
||||
embyServePlaceholderImage(c)
|
||||
infos := svc.Emby.ImageInfos(c.Request.Context(), id)
|
||||
if infos == nil {
|
||||
infos = []map[string]any{}
|
||||
}
|
||||
c.JSON(http.StatusOK, infos)
|
||||
}
|
||||
}
|
||||
|
||||
// embyUserImageHandler 处理 /Users/{UserId}/Images/{Type}。Emby 对未设置
|
||||
// 头像的用户同样返回 404,但响应必须带缓存头,否则客户端每次进入设置页
|
||||
// 都会重复请求同一个空头像(日志中曾观察到每分钟重试)。
|
||||
func embyUserImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := strings.TrimSpace(c.Param("userId"))
|
||||
raw := ""
|
||||
if svc != nil && svc.Emby != nil && uid != "" {
|
||||
raw = svc.Emby.UserAvatarURL(c.Request.Context(), uid)
|
||||
}
|
||||
if raw == "" || svc == nil || svc.ImageProxy == nil {
|
||||
embyMissingAvatar(c)
|
||||
return
|
||||
}
|
||||
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
|
||||
embyServePlaceholderImage(c)
|
||||
if err := svc.ImageProxy.Serve(c.Request.Context(), c.Writer, c.Request, raw); err != nil {
|
||||
embyMissingAvatar(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// embyMissingAvatar 以 Emby 语义返回"该用户没有头像",并允许客户端长期缓存。
|
||||
func embyMissingAvatar(c *gin.Context) {
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
|
||||
func clearEmbyImageNoStoreHeaders(c *gin.Context) {
|
||||
c.Writer.Header().Del("Cache-Control")
|
||||
c.Writer.Header().Del("Pragma")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
|
||||
// 1. 测试上报进度:客户端使用小写 query 参数 itemId / positionTicks
|
||||
progressReq := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000",
|
||||
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000&playSessionId=remote-mount-1-2000000000000",
|
||||
nil,
|
||||
)
|
||||
wProgress := httptest.NewRecorder()
|
||||
@@ -85,6 +85,9 @@ func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
|
||||
if hist.PositionMs != 30000 {
|
||||
t.Fatalf("expected position_ms = 30000, got %d", hist.PositionMs)
|
||||
}
|
||||
if hist.SessionID != "remote-mount-1-2000000000000" || hist.SessionStartedAtMs != 2000000000000 {
|
||||
t.Fatalf("unexpected playback session metadata: %#v", hist)
|
||||
}
|
||||
|
||||
// 2. 测试 Filters=IsResumable 能够包含该远程条目
|
||||
resumableReq := httptest.NewRequest(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsEmbyPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{path: "/emby", want: true},
|
||||
{path: "/emby/System/Info/Public", want: true},
|
||||
{path: "//emby//Items//1", want: true},
|
||||
{path: "/System/Info/Public", want: true},
|
||||
{path: "/Search/Hints", want: true},
|
||||
{path: "/Playback/BitrateTest", want: true},
|
||||
{path: "/api/unknown", want: false},
|
||||
{path: "/library/123e4567-e89b-12d3-a456-426614174000", want: true},
|
||||
{path: "/random/path", want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := IsEmbyPath(tt.path); got != tt.want {
|
||||
t.Errorf("IsEmbyPath(%q) = %v, want %v", tt.path, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeEmbyPrefixedInternalAPIPath(t *testing.T) {
|
||||
got, changed := NormalizeEmbyPath("/emby/api/Stream/123")
|
||||
if !changed || got != "/emby/api/stream/123" {
|
||||
t.Fatalf("NormalizeEmbyPath() = (%q, %v), want (/emby/api/stream/123, true)", got, changed)
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,92 @@ var embyStaticSegments = map[string]struct{}{
|
||||
"embywebsocket": {},
|
||||
}
|
||||
|
||||
var embyRootSegments = map[string]struct{}{
|
||||
"albums": {},
|
||||
"artists": {},
|
||||
"audio": {},
|
||||
"audiocodecs": {},
|
||||
"auth": {},
|
||||
"branding": {},
|
||||
"channels": {},
|
||||
"collections": {},
|
||||
"connect": {},
|
||||
"containers": {},
|
||||
"devices": {},
|
||||
"displaypreferences": {},
|
||||
"dlna": {},
|
||||
"encoding": {},
|
||||
"environment": {},
|
||||
"gamegenres": {},
|
||||
"games": {},
|
||||
"genres": {},
|
||||
"images": {},
|
||||
"items": {},
|
||||
"libraries": {},
|
||||
"library": {},
|
||||
"livestreams": {},
|
||||
"livetv": {},
|
||||
"localization": {},
|
||||
"movies": {},
|
||||
"musicgenres": {},
|
||||
"news": {},
|
||||
"notification": {},
|
||||
"notifications": {},
|
||||
"officialratings": {},
|
||||
"packages": {},
|
||||
"persons": {},
|
||||
"playback": {},
|
||||
"playlists": {},
|
||||
"plugins": {},
|
||||
"providers": {},
|
||||
"reports": {},
|
||||
"scheduledtasks": {},
|
||||
"search": {},
|
||||
"sessions": {},
|
||||
"shows": {},
|
||||
"songs": {},
|
||||
"studios": {},
|
||||
"subtitlecodecs": {},
|
||||
"sync": {},
|
||||
"system": {},
|
||||
"tags": {},
|
||||
"trailers": {},
|
||||
"user_usage_stats": {},
|
||||
"users": {},
|
||||
"videocodecs": {},
|
||||
"videos": {},
|
||||
"years": {},
|
||||
}
|
||||
|
||||
func isEmbyRootSegment(segment string) bool {
|
||||
if _, ok := embyRootSegments[segment]; ok {
|
||||
return true
|
||||
}
|
||||
_, ok := embyStaticSegments[segment]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsEmbyPath 判断路径是否属于 Emby/Jellyfin 兼容面的命名空间。
|
||||
// 除显式 /emby 前缀外,Emby 客户端也会直接请求客户端协议的根路径,
|
||||
// 例如 /System/Info/Public、/Users/Public、/Sessions。
|
||||
func IsEmbyPath(path string) bool {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
path = multipleSlashesRE.ReplaceAllString(path, "/")
|
||||
lower := strings.ToLower(path)
|
||||
if lower == "/emby" || strings.HasPrefix(lower, "/emby/") {
|
||||
return true
|
||||
}
|
||||
|
||||
segments := strings.Split(strings.Trim(path, "/"), "/")
|
||||
if len(segments) == 0 || segments[0] == "" {
|
||||
return false
|
||||
}
|
||||
return isEmbyRootSegment(strings.ToLower(segments[0]))
|
||||
}
|
||||
|
||||
// NormalizeEmbyPath 规范化 Emby 请求路径:
|
||||
// 1. 折叠重复斜杠(如 //emby/ -> /emby/);
|
||||
// 2. 折叠重复前缀(如 /emby/emby/System/Info -> /emby/System/Info);
|
||||
@@ -132,7 +218,7 @@ func NormalizeEmbyPath(p string) (string, bool) {
|
||||
|
||||
// 检查第一段是否为 Emby 根路由关键字
|
||||
firstLower := strings.ToLower(segments[0])
|
||||
if _, ok := embyStaticSegments[firstLower]; !ok && firstLower != "api" {
|
||||
if !isEmbyRootSegment(firstLower) && firstLower != "api" {
|
||||
// 不是 Emby 相关路径,保持原样
|
||||
return original, false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func TestEmbyItemImageRouteResolvesTMDbPersonID(t *testing.T) {
|
||||
imageData, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl9sAAAAASUVORK5CYII=")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
imageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/t/p/w300/actor.jpg" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(imageData)
|
||||
}))
|
||||
defer imageServer.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
Cache: config.CacheConfig{CacheDir: t.TempDir()},
|
||||
Secrets: config.SecretsConfig{TMDbImageProxy: imageServer.URL + "/t/p"},
|
||||
}
|
||||
tmdb := service.NewTMDbProvider(cfg, zap.NewNop(), nil)
|
||||
emby := service.NewEmbyService(cfg, zap.NewNop(), nil).SetTMDbProvider(tmdb)
|
||||
proxy := service.NewImageProxy(cfg, zap.NewNop())
|
||||
imageURL, _ := url.Parse(imageServer.URL)
|
||||
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{imageURL.Host} })
|
||||
svc := &service.Container{Emby: emby, ImageProxy: proxy}
|
||||
|
||||
raw, err := emby.PersonImageURL(t.Context(), "person~tmdb~101", "Primary", "tmdb:/actor.jpg?p2")
|
||||
if err != nil || raw != imageServer.URL+"/t/p/w300/actor.jpg" {
|
||||
t.Fatalf("resolved raw=%q err=%v", raw, err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{
|
||||
{Key: "id", Value: "person~tmdb~101"},
|
||||
{Key: "type", Value: "Primary"},
|
||||
}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/emby/Items/person~tmdb~101/Images/Primary?tag=tmdb%3A%2Factor.jpg%3Fp2", nil)
|
||||
embyItemImageHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := len(w.Body.Bytes()); got != len(imageData) {
|
||||
t.Fatalf("image bytes=%d want %d", got, len(imageData))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -19,6 +19,8 @@ type embyPlayingReq struct {
|
||||
PositionLower int64 `json:"positionTicks"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks"`
|
||||
RunTimeLower int64 `json:"runTimeTicks"`
|
||||
PlaySessionID string `json:"PlaySessionId"`
|
||||
PlaySession string `json:"playSessionId"`
|
||||
}
|
||||
|
||||
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
@@ -48,6 +50,11 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if runTime == 0 {
|
||||
runTime, _ = strconv.ParseInt(firstQueryValue(c, "RunTimeTicks", "runTimeTicks"), 10, 64)
|
||||
}
|
||||
playSessionID := embyFirstNonEmptyString(
|
||||
req.PlaySessionID,
|
||||
req.PlaySession,
|
||||
firstQueryValue(c, "PlaySessionId", "playSessionId"),
|
||||
)
|
||||
if itemID == "" {
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
@@ -57,7 +64,7 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err := svc.Emby.RecordProgress(c.Request.Context(), uid, itemID, pos, runTime); err != nil {
|
||||
if err := svc.Emby.RecordProgressWithSession(c.Request.Context(), uid, itemID, pos, runTime, playSessionID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -85,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)
|
||||
@@ -109,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)
|
||||
|
||||
@@ -115,12 +115,31 @@ func registerEmbyPublicClientRoutes(grp *gin.RouterGroup, jwtSecret string, svc
|
||||
|
||||
func registerEmbyPublicImageRoutes(grp *gin.RouterGroup, svc *service.Container) {
|
||||
// 图片公开(Infuse 缓存 URL 时会丢 token)
|
||||
//
|
||||
// 不带 Type 的 /Items/{Id}/Images 返回图片清单(ImageInfo 数组),与下面
|
||||
// 带 Type 的图片字节流是不同接口,必须单独注册,否则会落到 NoRoute 并
|
||||
// 返回 text/plain 的 404。
|
||||
grp.GET("/Items/:id/Images", embyItemImagesHandler(svc))
|
||||
grp.HEAD("/Items/:id/Images", embyItemImagesHandler(svc))
|
||||
grp.GET("/items/:id/images", embyItemImagesHandler(svc))
|
||||
grp.GET("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/Items/:id/Images/:type/:index", embyItemImageHandler(svc))
|
||||
grp.HEAD("/Items/:id/Images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/items/:id/images/:type", embyItemImageHandler(svc))
|
||||
grp.GET("/items/:id/images/:type/:index", embyItemImageHandler(svc))
|
||||
grp.HEAD("/items/:id/images/:type", embyItemImageHandler(svc))
|
||||
// 官方 Emby 客户端也可能使用 /Persons/{Name}/Images/{Type} 获取演职人员头像。
|
||||
grp.GET("/Persons/:name/Images/:type", embyPersonImageHandler(svc))
|
||||
grp.GET("/Persons/:name/Images/:type/:index", embyPersonImageHandler(svc))
|
||||
grp.HEAD("/Persons/:name/Images/:type", embyPersonImageHandler(svc))
|
||||
grp.GET("/persons/:name/images/:type", embyPersonImageHandler(svc))
|
||||
grp.GET("/persons/:name/images/:type/:index", embyPersonImageHandler(svc))
|
||||
grp.HEAD("/persons/:name/images/:type", embyPersonImageHandler(svc))
|
||||
// 用户头像。没有头像时返回带缓存头的 404,避免客户端反复重试。
|
||||
grp.GET("/Users/:userId/Images/:type", embyUserImageHandler(svc))
|
||||
grp.HEAD("/Users/:userId/Images/:type", embyUserImageHandler(svc))
|
||||
grp.GET("/users/:userId/images/:type", embyUserImageHandler(svc))
|
||||
grp.HEAD("/users/:userId/images/:type", embyUserImageHandler(svc))
|
||||
}
|
||||
|
||||
func registerEmbyGetRoutes(grp *gin.RouterGroup, svc *service.Container, paths []string, factory embyRouteHandlerFactory) {
|
||||
@@ -177,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))
|
||||
@@ -202,6 +226,12 @@ func registerEmbyAuthenticatedPlaybackRoutes(auth *gin.RouterGroup, prefix strin
|
||||
auth.POST("/Users/:userId/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
|
||||
|
||||
registerEmbyVideoStreamRoutes(auth, svc, "/Videos")
|
||||
// Emby 官方接口:附加片段清单。MeBox 不提供附加片段,但必须返回空数组
|
||||
// 而不是 404 —— 部分客户端(RodelPlayer)在详情页无条件请求它,404 会
|
||||
// 让它们把条目判定为不完整。必须注册成静态段,否则会被
|
||||
// /Videos/:id/:seg 的 HLS 兜底路由抢先匹配并返回空 404。
|
||||
auth.GET("/Videos/:id/AdditionalParts", embyEmptyArrayHandler(svc))
|
||||
auth.HEAD("/Videos/:id/AdditionalParts", embyEmptyArrayHandler(svc))
|
||||
auth.GET("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
||||
auth.HEAD("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
|
||||
auth.GET("/Users/:userId/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(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,11 +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,10 +4,12 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -32,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 过滤直属条目,
|
||||
@@ -60,37 +62,37 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
role, _ := c.Get(middleware.CtxUserRole)
|
||||
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1")
|
||||
if !includeHidden {
|
||||
libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs)
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
filtered := libs[:0]
|
||||
for _, lib := range libs {
|
||||
if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) {
|
||||
filtered = append(filtered, lib)
|
||||
}
|
||||
}
|
||||
libs = filtered
|
||||
}
|
||||
rawIDs := strings.TrimSpace(c.Query("ids"))
|
||||
var targetSet map[string]struct{}
|
||||
if rawIDs != "" {
|
||||
targetSet = make(map[string]struct{})
|
||||
for _, id := range strings.Split(rawIDs, ",") {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
targetSet[id] = struct{}{}
|
||||
}
|
||||
if !includeHidden {
|
||||
libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs)
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
filtered := libs[:0]
|
||||
for _, lib := range libs {
|
||||
if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) {
|
||||
filtered = append(filtered, lib)
|
||||
}
|
||||
}
|
||||
if len(targetSet) > 0 {
|
||||
filtered := libs[:0]
|
||||
for _, lib := range libs {
|
||||
if _, ok := targetSet[lib.ID]; ok {
|
||||
filtered = append(filtered, lib)
|
||||
}
|
||||
libs = filtered
|
||||
}
|
||||
rawIDs := strings.TrimSpace(c.Query("ids"))
|
||||
var targetSet map[string]struct{}
|
||||
if rawIDs != "" {
|
||||
targetSet = make(map[string]struct{})
|
||||
for _, id := range strings.Split(rawIDs, ",") {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
targetSet[id] = struct{}{}
|
||||
}
|
||||
libs = filtered
|
||||
}
|
||||
}
|
||||
if len(targetSet) > 0 {
|
||||
filtered := libs[:0]
|
||||
for _, lib := range libs {
|
||||
if _, ok := targetSet[lib.ID]; ok {
|
||||
filtered = append(filtered, lib)
|
||||
}
|
||||
}
|
||||
libs = filtered
|
||||
}
|
||||
withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true"
|
||||
limit := 10
|
||||
if withPreview {
|
||||
@@ -103,49 +105,72 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
out := make([]webLibraryPayload, 0, len(libs)+8)
|
||||
if withPreview {
|
||||
previews, err := svc.Media.ListLibrariesWithPreview(ctx, libs, mediaVisibilityForRequest(c, svc), limit)
|
||||
var previews []service.LibraryPreviewItem
|
||||
if c.Query("include_total") == "0" {
|
||||
previews, err = svc.Media.ListLibraryPreviews(ctx, libs, mediaVisibilityForRequest(c, svc), limit)
|
||||
} else {
|
||||
previews, err = svc.Media.ListLibrariesWithPreview(ctx, libs, mediaVisibilityForRequest(c, svc), limit)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
for _, p := range previews {
|
||||
out = append(out, webLibraryPayload{Library: p.Library, Total: p.Total, Cards: p.Cards})
|
||||
}
|
||||
} else {
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
libIDs := make([]string, len(libs))
|
||||
for i, l := range libs {
|
||||
libIDs[i] = l.ID
|
||||
}
|
||||
counts, _ := svc.Repo.Media.CountByLibraries(ctx, libIDs, repository.MediaQueryFilter{
|
||||
IncludeNSFW: visibility.IncludeNSFW,
|
||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||
out = append(out, webLibraryPayload{
|
||||
Library: p.Library,
|
||||
Total: p.Total,
|
||||
Cards: service.NewSeriesCardViews(p.Cards),
|
||||
})
|
||||
for _, l := range libs {
|
||||
var total int64
|
||||
if counts != nil {
|
||||
total = counts[l.ID]
|
||||
}
|
||||
out = append(out, webLibraryPayload{Library: l, Total: total})
|
||||
}
|
||||
} else {
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
libIDs := make([]string, len(libs))
|
||||
for i, l := range libs {
|
||||
libIDs[i] = l.ID
|
||||
}
|
||||
counts, _ := svc.Media.CountLibrariesCached(ctx, libIDs, repository.MediaQueryFilter{
|
||||
IncludeNSFW: visibility.IncludeNSFW,
|
||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||
})
|
||||
for _, l := range libs {
|
||||
var total int64
|
||||
if counts != nil {
|
||||
total = counts[l.ID]
|
||||
}
|
||||
out = append(out, webLibraryPayload{Library: l, Total: total})
|
||||
}
|
||||
}
|
||||
// 精确指定目标库时,如果目标全是本地库,就不必枚举远程挂载。
|
||||
// 首页预览会拆成多个小批次请求,跳过无关远程调用可以明显缩短
|
||||
// 每批次的尾延迟;未指定 ids 的完整库列表仍保持原行为。
|
||||
includeRemote := true
|
||||
if len(targetSet) > 0 {
|
||||
includeRemote = false
|
||||
for id := range targetSet {
|
||||
if service.IsEmbyRemoteID(id) {
|
||||
includeRemote = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 远程 Emby 挂载库追加在本地库之后(非管理员视图仍受 allowed_library_ids 约束)。
|
||||
if svc.EmbyRemote != nil {
|
||||
if includeRemote && svc.EmbyRemote != nil {
|
||||
if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil {
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
allowedViews := make([]service.RemoteLibraryView, 0, len(views))
|
||||
for _, v := range views {
|
||||
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) {
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
allowedViews := make([]service.RemoteLibraryView, 0, len(views))
|
||||
for _, v := range views {
|
||||
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) {
|
||||
continue
|
||||
}
|
||||
if len(targetSet) > 0 {
|
||||
if _, ok := targetSet[v.Library.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
if len(targetSet) > 0 {
|
||||
if _, ok := targetSet[v.Library.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
allowedViews = append(allowedViews, v)
|
||||
}
|
||||
allowedViews = append(allowedViews, v)
|
||||
}
|
||||
remotePayloads := make([]webLibraryPayload, len(allowedViews))
|
||||
for i, v := range allowedViews {
|
||||
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
|
||||
@@ -170,19 +195,19 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if acct == nil {
|
||||
return
|
||||
}
|
||||
tmpMount := &model.EmbyMount{
|
||||
Base: model.Base{ID: v.MountID},
|
||||
AccountID: v.AccountID,
|
||||
RemoteViewID: v.RemoteID,
|
||||
CollectionType: v.CollectionType,
|
||||
Name: v.Library.Name,
|
||||
}
|
||||
tmpMount := &model.EmbyMount{
|
||||
Base: model.Base{ID: v.MountID},
|
||||
AccountID: v.AccountID,
|
||||
RemoteViewID: v.RemoteID,
|
||||
CollectionType: v.CollectionType,
|
||||
Name: v.Library.Name,
|
||||
}
|
||||
itemTypes := remoteLibraryItemTypes(v.CollectionType)
|
||||
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}()
|
||||
@@ -383,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")
|
||||
@@ -421,9 +532,15 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
})
|
||||
return
|
||||
}
|
||||
sortSpec := parseMediaSort(c)
|
||||
var history map[string]time.Time
|
||||
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(c.Request.Context(), 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
|
||||
@@ -439,17 +556,19 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
})
|
||||
return
|
||||
}
|
||||
items, total, err := svc.Media.ListMediaVisibleGrouped(c.Request.Context(), id, page, size, mediaVisibilityForRequest(c, svc))
|
||||
grouped, err := svc.Media.GroupedMediaVisibleFiltered(ctx, id, mediaVisibilityForRequest(c, svc), filters)
|
||||
if err != nil {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
}
|
||||
grouped = service.SortMediaItems(grouped, sortSpec.Field, sortSpec.Order, history)
|
||||
items := service.PaginateMediaItems(grouped, page, size)
|
||||
if items == nil {
|
||||
items = []service.MediaItem{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"total": total,
|
||||
"total": len(grouped),
|
||||
"page": page,
|
||||
"page_size": size,
|
||||
})
|
||||
@@ -563,6 +682,39 @@ func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return remoteItems
|
||||
}
|
||||
|
||||
if c.DefaultQuery("group_series", "0") != "0" {
|
||||
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
remoteItems := fetchRemote(50000)
|
||||
all := service.GroupMediaSeriesItems(append(localItems, remoteItems...))
|
||||
|
||||
if c.Query("page") != "" || c.Query("page_size") != "" {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
paged := paginateSlice(all, page, size)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": paged,
|
||||
"total": len(all),
|
||||
"page": page,
|
||||
"page_size": size,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if len(all) > limit {
|
||||
all = all[:limit]
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": all})
|
||||
return
|
||||
}
|
||||
|
||||
if c.Query("page") != "" || c.Query("page_size") != "" {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
@@ -658,7 +810,23 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if mount.ProxyPlay {
|
||||
// 远程条目可能是「整部剧/季/目录」:这类条目自身没有媒体源,远程 Emby 对它
|
||||
// 们的 /Videos/{id}/stream 会直接回 500(Unable to cast object of type
|
||||
// 'Series' to type 'IHasMediaSources')。网页端、外部播放器、第三方客户端都
|
||||
// 可能把这种 ID 送进来,这里统一改投第一集:302 让客户端自己跟过去,播放器
|
||||
// 原有的 token / proxy 查询参数一并保留。
|
||||
if playable := svc.EmbyRemote.PlayableRemoteID(ctx, acct, remoteID); playable != "" && playable != remoteID {
|
||||
target := "/api/stream/" + service.EncodeEmbyRemoteID(mountID, playable)
|
||||
if rawQuery := c.Request.URL.RawQuery; rawQuery != "" {
|
||||
target += "?" + rawQuery
|
||||
}
|
||||
setRedirectNoStoreHeaders(c)
|
||||
c.Redirect(http.StatusFound, target)
|
||||
return
|
||||
}
|
||||
// 远程 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()})
|
||||
@@ -666,25 +834,25 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
return
|
||||
}
|
||||
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 现代浏览器在 HTTPS 页面中请求不安全源(HTTP 视频流)会直接报 Mixed Content 拦截导致播放失败。
|
||||
// 仅当当前前端请求为 HTTPS 且远程直连目标为 HTTP 时,自动降级通过本机反向代理传输流,避免播放被浏览器阻断;
|
||||
// 其它场景(HTTP 页面访问 HTTP/HTTPS,或 HTTPS 访问 HTTPS)继续 302 直连,最大化节省服务器带宽与流量。
|
||||
if requestIsHTTPS(c) && strings.HasPrefix(strings.ToLower(target), "http://") {
|
||||
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()})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
setRedirectNoStoreHeaders(c)
|
||||
c.Redirect(http.StatusFound, target)
|
||||
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 现代浏览器在 HTTPS 页面中请求不安全源(HTTP 视频流)会直接报 Mixed Content 拦截导致播放失败。
|
||||
// 仅当当前前端请求为 HTTPS 且远程直连目标为 HTTP 时,自动降级通过本机反向代理传输流,避免播放被浏览器阻断;
|
||||
// 其它场景(HTTP 页面访问 HTTP/HTTPS,或 HTTPS 访问 HTTPS)继续 302 直连,最大化节省服务器带宽与流量。
|
||||
if requestIsHTTPS(c) && strings.HasPrefix(strings.ToLower(target), "http://") {
|
||||
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()})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
setRedirectNoStoreHeaders(c)
|
||||
c.Redirect(http.StatusFound, target)
|
||||
return
|
||||
}
|
||||
m, err := svc.Media.GetMedia(ctx, id)
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
@@ -694,6 +862,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"})
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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/config"
|
||||
"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 TestSearchMediaSeriesKeepsRemoteLibrariesSeparate(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"TotalRecordCount": 1,
|
||||
"Items": []map[string]any{{
|
||||
"Id": "same-series", "Name": "龙珠改", "Type": "Series",
|
||||
"ProviderIds": map[string]string{"Tmdb": "61709"},
|
||||
}},
|
||||
})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.StrmAccount{}, &model.EmbyMount{}, &model.Setting{}, &model.User{}, &model.PlayProfile{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "本地番剧", Path: "/media/anime", Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := []model.Media{
|
||||
{Base: model.Base{ID: "local-1"}, LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep1.mkv", SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709},
|
||||
{Base: model.Base{ID: "local-2"}, LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep2.mkv", SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709},
|
||||
}
|
||||
if err := db.Create(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawConfig, _ := json.Marshal(map[string]string{"url": upstream.URL, "token": "fake-token"})
|
||||
acct := model.StrmAccount{Base: model.Base{ID: "account"}, Name: "Emby", Provider: model.StrmProviderEmbyRemote, Config: string(rawConfig), Enabled: true}
|
||||
if err := repos.StrmAccount.Create(t.Context(), &acct); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedIDs := map[string]string{lib.ID: "local-1"}
|
||||
for _, suffix := range []string{"a", "b"} {
|
||||
mount := model.EmbyMount{Base: model.Base{ID: "mount-" + suffix}, AccountID: acct.ID, RemoteViewID: "view-" + suffix, RemoteViewName: "番剧" + suffix, CollectionType: "tvshows", Enabled: true}
|
||||
if err := repos.EmbyMount.Create(t.Context(), &mount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedIDs[service.EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)] = service.EncodeEmbyRemoteID(mount.ID, "same-series")
|
||||
}
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
|
||||
EmbyRemote: service.NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())),
|
||||
}
|
||||
|
||||
request := func(params string) ([]model.Media, int) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set(middleware.CtxUserID, "admin")
|
||||
c.Set(middleware.CtxUserRole, "admin")
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=龙珠改&group_series=1&"+params, nil)
|
||||
searchMediaHandler(svc)(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Items []model.Media `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return response.Items, response.Total
|
||||
}
|
||||
|
||||
items, _ := request("limit=8")
|
||||
if len(items) != len(expectedIDs) {
|
||||
t.Fatalf("got %d results, want one local series and two remote series: %#v", len(items), items)
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for page := 1; page <= len(expectedIDs); page++ {
|
||||
items, total := request(fmt.Sprintf("page=%d&page_size=1", page))
|
||||
if total != len(expectedIDs) || len(items) != 1 {
|
||||
t.Fatalf("page %d: total=%d items=%#v, want total=3 and one item", page, total, items)
|
||||
}
|
||||
item := items[0]
|
||||
if expectedIDs[item.DisplayLibraryID] != item.ID || seen[item.DisplayLibraryID] || item.DisplayLibraryName == "" {
|
||||
t.Fatalf("page %d lost or repeated a library's series identity: %#v", page, item)
|
||||
}
|
||||
seen[item.DisplayLibraryID] = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func parseMediaSort(c *gin.Context) service.MediaSortSpec {
|
||||
if c == nil {
|
||||
return service.NormalizeMediaSort("", "")
|
||||
}
|
||||
return service.NormalizeMediaSort(c.Query("sort"), c.Query("order"))
|
||||
}
|
||||
|
||||
// mediaHistoryMap loads the current user's latest playback time per media ID.
|
||||
// It is only used for the explicit last_played sort so normal pagination keeps
|
||||
// its single-query path.
|
||||
func mediaHistoryMap(c *gin.Context, svc *service.Container) map[string]time.Time {
|
||||
if c == nil || svc == nil || svc.Repo == nil || svc.Repo.DB == nil {
|
||||
return nil
|
||||
}
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
userID := strings.TrimSpace(toString(uid))
|
||||
if userID == "" {
|
||||
return nil
|
||||
}
|
||||
var rows []struct {
|
||||
MediaID string `gorm:"column:media_id"`
|
||||
WatchedAt time.Time `gorm:"column:watched_at"`
|
||||
}
|
||||
if err := svc.Repo.DB.WithContext(c.Request.Context()).
|
||||
Model(&model.PlaybackHistory{}).
|
||||
Select("media_id, MAX(watched_at) AS watched_at").
|
||||
Where("user_id = ?", userID).
|
||||
Group("media_id").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]time.Time, len(rows))
|
||||
for _, row := range rows {
|
||||
out[row.MediaID] = row.WatchedAt
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -447,6 +447,89 @@ func TestEmptyLibraryListsReturnEmptyArraysNotNull(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMediaGroupsSeriesBeforeLimit(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.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &model.PlayProfile{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "动漫", Path: "/media/anime", Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
rows := []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "dbkai-ep-1", CreatedAt: now.Add(-2 * time.Minute), UpdatedAt: now.Add(-2 * time.Minute)},
|
||||
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E01.mkv",
|
||||
SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "dbkai-ep-2", CreatedAt: now.Add(-time.Minute), UpdatedAt: now.Add(-time.Minute)},
|
||||
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E02.mkv",
|
||||
SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "dbkai-ep-3", CreatedAt: now, UpdatedAt: now},
|
||||
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E03.mkv",
|
||||
SeasonNum: 1, EpisodeNum: 3, TMDbID: 61709,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "db-movie", CreatedAt: now.Add(-3 * time.Minute), UpdatedAt: now.Add(-3 * time.Minute)},
|
||||
LibraryID: lib.ID, Title: "龙珠超:布罗利", Path: "/media/anime/龙珠超:布罗利 (2018)/龙珠超:布罗利.mkv",
|
||||
TMDbID: 503314,
|
||||
},
|
||||
}
|
||||
if err := db.Create(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set(middleware.CtxUserID, "user-1")
|
||||
c.Set(middleware.CtxUserRole, "user")
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=龙珠&limit=2&group_series=1", nil)
|
||||
searchMediaHandler(svc)(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var res struct {
|
||||
Items []model.Media `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Items) != 2 {
|
||||
t.Fatalf("expected one representative per series after limit, got %d: %#v", len(res.Items), res.Items)
|
||||
}
|
||||
seenSeries := false
|
||||
seenMovie := false
|
||||
for _, item := range res.Items {
|
||||
switch item.TMDbID {
|
||||
case 61709:
|
||||
seenSeries = true
|
||||
if item.EpisodeNum != 1 {
|
||||
t.Fatalf("series representative episode=%d, want first episode", item.EpisodeNum)
|
||||
}
|
||||
case 503314:
|
||||
seenMovie = true
|
||||
}
|
||||
}
|
||||
if !seenSeries || !seenMovie {
|
||||
t.Fatalf("expected one Dragon Ball series and one movie, got %#v", res.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMediaHandlerIncludesEmbyRemote(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -15,9 +15,12 @@ import (
|
||||
// ─── History ────────────────────────────────────────────────────────────────
|
||||
|
||||
type progressReq struct {
|
||||
MediaID string `json:"media_id" binding:"required"`
|
||||
PositionMs int64 `json:"position_ms"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
MediaID string `json:"media_id" binding:"required"`
|
||||
PositionMs int64 `json:"position_ms"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
SessionID string `json:"session_id"`
|
||||
SessionStartedAtMs int64 `json:"session_started_at_ms"`
|
||||
Sequence int64 `json:"sequence"`
|
||||
}
|
||||
|
||||
func recordProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
@@ -28,9 +31,15 @@ func recordProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
if err := svc.Playback.RecordProgress(
|
||||
c.Request.Context(), uid.(string), req.MediaID, req.PositionMs, req.DurationMs,
|
||||
); err != nil {
|
||||
if err := svc.Playback.RecordProgressUpdate(c.Request.Context(), service.ProgressUpdate{
|
||||
UserID: toString(uid),
|
||||
MediaID: req.MediaID,
|
||||
PositionMs: req.PositionMs,
|
||||
DurationMs: req.DurationMs,
|
||||
SessionID: req.SessionID,
|
||||
SessionStartedAtMs: req.SessionStartedAtMs,
|
||||
Sequence: req.Sequence,
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -76,6 +85,18 @@ func toggleFavouriteHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func listFavouritesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, _ := c.Get(middleware.CtxUserID)
|
||||
if c.Query("ids") == "1" {
|
||||
ids, err := svc.Playback.ListFavouriteIDs(c.Request.Context(), uid.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if ids == nil {
|
||||
ids = []string{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ids": ids})
|
||||
return
|
||||
}
|
||||
items, err := svc.Playback.ListFavourites(c.Request.Context(), uid.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||