mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb4e762026 | |||
| 4239474100 | |||
| 8225b4e319 | |||
| c086b01381 | |||
| f196e5621f | |||
| 13d60b3bd9 | |||
| 3d30f6f27c | |||
| bd02e194ff | |||
| 1c9b742c8d | |||
| 20e7d7fa99 | |||
| 394ea0382e | |||
| 4d698d2757 | |||
| fdfb2ee987 | |||
| b23b1d728e | |||
| ad427fc0d2 | |||
| c489374b61 | |||
| 5a47e6f52d | |||
| c4b8114115 | |||
| 0e592105c4 | |||
| f20c9c3234 | |||
| 38b85bb487 | |||
| 6bf8a6878a | |||
| 2e636ce43f | |||
| 28aa466107 | |||
| f1b8010496 | |||
| 394d1a3cda | |||
| 465e98a792 | |||
| 292ae22dcd | |||
| bc7e5fc79d | |||
| f7fec93d44 | |||
| b4a1dc38bb | |||
| 3150971f10 | |||
| a71a18ce82 | |||
| 5a189a44fc | |||
| fb84c62e9a | |||
| 355fd06036 | |||
| 4173caac5d | |||
| 2aeedcc182 | |||
| d96782622d | |||
| ebe425036b | |||
| edbaa1c84b | |||
| 0179332013 | |||
| 7fb3db0f4c | |||
| e4a101b502 | |||
| 28551c7883 | |||
| a826ed9613 | |||
| 030ed5f325 |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"ssh": {
|
||||
"command": "cmd.exe",
|
||||
"args": [
|
||||
"/c",
|
||||
"npx",
|
||||
"-y",
|
||||
"@aiondadotcom/mcp-ssh"
|
||||
],
|
||||
"env": {
|
||||
"ProgramData": "C:\\ProgramData"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,122 +1,78 @@
|
||||
name: AuTo Docker Image
|
||||
|
||||
name: Build & Publish
|
||||
|
||||
# 版本策略(version 分支托管,main 零污染):
|
||||
# - VERSION 文件单独存放在 version 分支,CI 构建时读取并自增写回 version 分支,
|
||||
# main 分支不再出现任何 CI 提交,本地推送永不与远程冲突。
|
||||
# - push 到 main:版本号自动 patch+1,发布 latest + 版本镜像、GitHub Release、
|
||||
# 多平台单文件二进制,并部署服务器。
|
||||
# - push tag v*:正式发版,版本号取 tag 名(不 bump version 分支),其余同上。
|
||||
# - 手动触发:版本号在 version 分支当前值上自增,等同 push main 全量发布。
|
||||
# 查看当前版本号:git show origin/version:VERSION
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
|
||||
# 保留手动触发作为备选
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_type:
|
||||
description: '版本递增类型'
|
||||
required: true
|
||||
default: 'patch'
|
||||
type: choice
|
||||
options:
|
||||
- patch # 0.0.x
|
||||
- minor # 0.x.0
|
||||
- major # x.0.0
|
||||
|
||||
permissions:
|
||||
contents: write # 需要写入权限来更新版本文件
|
||||
contents: write # 读写 version 分支、发布 Release 与上传二进制需要
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
version-and-publish:
|
||||
build-image:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
new_version: ${{ steps.bump_version.outputs.new_version }}
|
||||
tag: ${{ steps.bump_version.outputs.tag }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
release_tag: ${{ steps.version.outputs.release_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # 获取完整历史以便版本计算
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# 1. 获取或初始化版本号
|
||||
- name: Get current version
|
||||
id: get_version
|
||||
# 1. 解析版本号:tag 触发取 tag 名(去掉 v 前缀);其余场景读 version 分支并 patch+1
|
||||
- name: Resolve version
|
||||
id: version
|
||||
run: |
|
||||
# 从文件读取版本号,或使用默认值
|
||||
if [ -f VERSION ]; then
|
||||
CURRENT_VERSION=$(cat VERSION)
|
||||
if [ "${{ github.ref_type }}" = "tag" ]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
else
|
||||
CURRENT_VERSION="0.0.0"
|
||||
echo $CURRENT_VERSION > VERSION
|
||||
git fetch origin version
|
||||
BASE=$(git show FETCH_HEAD:VERSION 2>/dev/null || echo "0.0.0")
|
||||
MAJOR=$(echo "$BASE" | cut -d. -f1)
|
||||
MINOR=$(echo "$BASE" | cut -d. -f2)
|
||||
PATCH=$(echo "$BASE" | cut -d. -f3)
|
||||
VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||
fi
|
||||
echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# 分离版本组成部分
|
||||
MAJOR=$(echo $CURRENT_VERSION | cut -d. -f1)
|
||||
MINOR=$(echo $CURRENT_VERSION | cut -d. -f2)
|
||||
PATCH=$(echo $CURRENT_VERSION | cut -d. -f3)
|
||||
echo "major=$MAJOR" >> $GITHUB_OUTPUT
|
||||
echo "minor=$MINOR" >> $GITHUB_OUTPUT
|
||||
echo "patch=$PATCH" >> $GITHUB_OUTPUT
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=mebox-v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "new_version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# 2. 计算新版本号
|
||||
- name: Bump version
|
||||
id: bump_version
|
||||
run: |
|
||||
MAJOR=${{ steps.get_version.outputs.major }}
|
||||
MINOR=${{ steps.get_version.outputs.minor }}
|
||||
PATCH=${{ steps.get_version.outputs.patch }}
|
||||
|
||||
# 手动触发时根据选择递增
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
TYPE="${{ github.event.inputs.version_type }}"
|
||||
if [ "$TYPE" = "major" ]; then
|
||||
MAJOR=$((MAJOR + 1))
|
||||
MINOR=0
|
||||
PATCH=0
|
||||
elif [ "$TYPE" = "minor" ]; then
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
else # patch
|
||||
PATCH=$((PATCH + 1))
|
||||
fi
|
||||
else
|
||||
# 自动触发时默认 patch 递增
|
||||
PATCH=$((PATCH + 1))
|
||||
fi
|
||||
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=MeBox-v${NEW_VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "tag=mebox-v${NEW_VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
# 3. 更新 VERSION 文件
|
||||
- name: Update version file
|
||||
run: |
|
||||
echo "${{ steps.bump_version.outputs.new_version }}" > VERSION
|
||||
|
||||
# 如果存在 go.mod,也更新其中的版本(可选)
|
||||
# if [ -f go.mod ]; then
|
||||
# sed -i "s/^version .*/version ${{ steps.bump_version.outputs.new_version }}/" go.mod
|
||||
# fi
|
||||
|
||||
# 4. 提交版本变更
|
||||
- name: Commit version bump
|
||||
# 2. 把新版本号写回 version 分支(clone 单分支写入,冲突时 rebase 重试)
|
||||
# tag 触发的正式发版版本号来自 tag 本身,跳过自增。
|
||||
- name: Bump version branch
|
||||
if: github.ref_type != 'tag'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
REPO="https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git"
|
||||
git clone --depth 1 --branch version "$REPO" "$RUNNER_TEMP/version-branch"
|
||||
cd "$RUNNER_TEMP/version-branch"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add VERSION
|
||||
git commit -m "chore: bump version to ${{ steps.bump_version.outputs.new_version }} [skip ci]"
|
||||
git push
|
||||
echo "${{ steps.version.outputs.new_version }}" > VERSION
|
||||
git commit -am "chore: bump version to ${{ steps.version.outputs.new_version }}"
|
||||
ok=0
|
||||
for i in 1 2 3 4 5; do
|
||||
if git push origin version; then ok=1; break; fi
|
||||
git pull --rebase origin version || true
|
||||
sleep 5
|
||||
done
|
||||
[ "$ok" = "1" ] || { echo "::error::version 分支推送冲突,重试 5 次仍失败"; exit 1; }
|
||||
|
||||
# 5. 创建 Git Tag
|
||||
- name: Create and push tag
|
||||
run: |
|
||||
TAG="${{ steps.bump_version.outputs.tag }}"
|
||||
git tag $TAG
|
||||
git push origin $TAG
|
||||
|
||||
# 6. 设置 Docker QEMU 和 Buildx
|
||||
# 3. 设置 Docker QEMU 和 Buildx
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
# 7. 登录 GHCR
|
||||
# 3. 登录 GHCR
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -124,7 +80,7 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# 8. 提取镜像元数据
|
||||
# 4. 提取镜像元数据
|
||||
- name: Extract image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -132,10 +88,9 @@ jobs:
|
||||
images: ghcr.io/${{ github.repository_owner }}/mebox
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=raw,value=${{ steps.bump_version.outputs.tag }}
|
||||
type=raw,value=${{ steps.bump_version.outputs.new_version }}
|
||||
type=raw,value=${{ steps.version.outputs.version }}
|
||||
|
||||
# 9. 构建并推送
|
||||
# 5. 构建并推送
|
||||
- name: Build & push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
@@ -147,13 +102,14 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.bump_version.outputs.new_version }}
|
||||
VERSION=${{ steps.version.outputs.release_tag }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# 单文件可执行构建:把前端打包进二进制(go:embed),交叉编译 Windows /
|
||||
# Linux / macOS 的 amd64 / arm64 产物,作为 GitHub Release 附件发布。
|
||||
build-frontend:
|
||||
needs: [build-image]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -178,7 +134,7 @@ jobs:
|
||||
# 先创建(幂等)空的 GitHub Release,供后续 build-binaries 并行上传附件,
|
||||
# 也避免矩阵各 job 并发 upload 时 release 尚不存在而互相竞争。
|
||||
publish-create-release:
|
||||
needs: [version-and-publish]
|
||||
needs: [build-image]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -187,17 +143,21 @@ jobs:
|
||||
- name: Create release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ needs.version-and-publish.outputs.tag }}
|
||||
RELEASE_TAG: ${{ needs.build-image.outputs.release_tag }}
|
||||
run: |
|
||||
set -eux
|
||||
# tag 已由 version-and-publish 推送;若 release 已存在则忽略(--verify-tag 幂等)
|
||||
# tag push 时 tag 已存在;手动触发时基于当前 main 创建 tag(幂等)
|
||||
if ! git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
git tag "$RELEASE_TAG"
|
||||
git push origin "$RELEASE_TAG"
|
||||
fi
|
||||
gh release create "$RELEASE_TAG" \
|
||||
--title "MeBox ${{ needs.version-and-publish.outputs.new_version }}" \
|
||||
--notes "自动化发布 ${{ needs.version-and-publish.outputs.new_version }}" \
|
||||
--title "MeBox ${{ needs.build-image.outputs.version }}" \
|
||||
--notes "自动化发布 ${{ needs.build-image.outputs.version }}" \
|
||||
--verify-tag --latest || true
|
||||
|
||||
build-binaries:
|
||||
needs: [version-and-publish, build-frontend, publish-create-release]
|
||||
needs: [build-image, build-frontend, publish-create-release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -237,7 +197,7 @@ jobs:
|
||||
- name: Build binary
|
||||
run: |
|
||||
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${{ needs.version-and-publish.outputs.tag }}" \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${{ needs.build-image.outputs.release_tag }}" \
|
||||
-o "dist/mebox-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server
|
||||
- name: Package
|
||||
run: |
|
||||
@@ -252,7 +212,7 @@ jobs:
|
||||
- name: Upload to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ needs.version-and-publish.outputs.tag }}
|
||||
RELEASE_TAG: ${{ needs.build-image.outputs.release_tag }}
|
||||
run: |
|
||||
set -eux
|
||||
PKG="mebox_${{ matrix.goos }}_${{ matrix.goarch }}.zip"
|
||||
@@ -267,7 +227,7 @@ jobs:
|
||||
|
||||
deploy:
|
||||
name: Deploy to Server
|
||||
needs: [version-and-publish]
|
||||
needs: [build-image]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy via SSH
|
||||
@@ -302,4 +262,3 @@ jobs:
|
||||
docker image prune -f
|
||||
|
||||
echo "==== 部署完成并已启动 ===="
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
| **Emby/Jellyfin 客户端兼容** | 内置完整 Emby 服务端协议实现:Infuse、SenPlayer、Fileball、Emby/Jellyfin 官方客户端等可直接把本服务当作 Emby 服务器添加,使用 MeBox 账号登录,海报墙、进度同步、多用户无缝衔接 |
|
||||
| **远程 Emby 挂载** | 将远程 Emby 媒体库挂载到本地界面统一浏览(无需单独开 Emby 客户端) |
|
||||
| **网盘与 STRM** | OpenList、CloudDrive2、115、WebDAV 等;STRM 同步、上传/下载队列、直链/302 播放 |
|
||||
| **下载与整理** | qBittorrent 接入、站点搜索与订阅、下载后自动整理、文件管理器(复制/移动/硬链/软链) |
|
||||
| **下载与整理** | 下载目录定时自动整理(智能分类、自动注册媒体库)、文件管理器(复制/移动/硬链/软链) |
|
||||
| **用户与权限** | 管理员/普通用户、有效期、成人内容开关、播放配置 PIN、细粒度操作权限 |
|
||||
| **运维能力** | 统一任务队列、存储统计、DLNA 投屏、系统设置与日志 |
|
||||
|
||||
@@ -182,7 +182,7 @@ environment:
|
||||
|
||||
1. **创建媒体库** → 填写 `/media/...` → 执行扫库
|
||||
2. **配置元数据源** → 系统设置中添加 TMDb、Bangumi 等 API
|
||||
3. **(可选)连接 qBittorrent** → 下载客户端设置,宿主机可用 `http://host.docker.internal:8085`
|
||||
3. **(可选)配置下载目录自动整理** → 文件管理中将下载目录设为整理源,下载完成后自动分类入库
|
||||
4. **(可选)配置网盘账号** → STRM 管理中添加 OpenList / 115 / WebDAV 等
|
||||
5. **第三方播放器** → 以 Emby 服务器添加 `http://服务器IP:18080`,使用 MeBox 账号登录
|
||||
|
||||
@@ -193,8 +193,8 @@ environment:
|
||||
**扫库或入库很慢?**
|
||||
先确认路径映射与数据库档位。网盘扫描还受接口限速与目录规模影响;大库可考虑第二档 Redis 或第三档 OpenSearch。
|
||||
|
||||
**qBittorrent 下载后无法整理?**
|
||||
确认下载目录已通过 `volumes` 挂进容器,且 `MEBOX_DOWNLOAD_*` 环境变量对应正确。
|
||||
**下载目录文件没有被自动整理?**
|
||||
确认下载目录已通过 `volumes` 挂进容器,且 `MEBOX_DOWNLOAD_*` 环境变量对应正确。MeBox 负责目录整理入库,qBittorrent 等下载器按普通软件自行部署即可。
|
||||
|
||||
**硬链接失败(cross-device link)?**
|
||||
硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 btrfs 子卷或网盘挂载时请改用复制或软链接。
|
||||
|
||||
+4
-4
@@ -49,7 +49,7 @@ In practice, MeBox gives you:
|
||||
| **Emby/Jellyfin client compatible** | Full Emby server protocol implementation: Infuse, SenPlayer, Fileball, and official Emby/Jellyfin clients can add MeBox as an Emby server and sign in with MeBox accounts — poster walls, watch progress, and multi-user work out of the box |
|
||||
| **Remote Emby mounts** | Browse remote Emby libraries inside MeBox without a separate Emby client |
|
||||
| **Cloud & STRM** | OpenList, CloudDrive2, 115, WebDAV; STRM sync; upload/download queues; direct or 302 playback |
|
||||
| **Downloads & organize** | qBittorrent, site search/subscriptions, post-download organization, file manager |
|
||||
| **Downloads & organize** | Scheduled download-folder organization (smart classification, auto library registration), file manager (copy/move/hardlink/symlink) |
|
||||
| **Users & permissions** | Admin/regular users, expiry, NSFW toggle, play-profile PIN, granular permissions |
|
||||
| **Operations** | Unified task queue, storage stats, DLNA casting, settings and logs |
|
||||
|
||||
@@ -162,7 +162,7 @@ environment:
|
||||
|
||||
1. Create a library with a container path such as `/media/Movies`, then scan
|
||||
2. Add metadata providers (TMDb, Bangumi, etc.) in system settings
|
||||
3. Optionally connect qBittorrent (`http://host.docker.internal:8085` when qB runs on the host)
|
||||
3. Optionally set up download-folder auto-organization under file management so finished downloads land in the right library
|
||||
4. Optionally configure cloud accounts under STRM management
|
||||
5. Add the server in Emby-compatible players at `http://SERVER_IP:18080` using MeBox credentials
|
||||
|
||||
@@ -173,8 +173,8 @@ environment:
|
||||
**Library scan is slow**
|
||||
Check path mapping and DB tier. Cloud scans also depend on API limits and folder size.
|
||||
|
||||
**qBittorrent downloads are not organized**
|
||||
Ensure the download directory is mounted into the container and env vars match.
|
||||
**Downloaded files are not organized**
|
||||
Ensure the download directory is mounted into the container and env vars match. MeBox handles folder organization; run qBittorrent or any downloader yourself as a regular app.
|
||||
|
||||
**Hardlink fails with cross-device link**
|
||||
Hardlinks require the same filesystem/subvolume; use copy or symlink across disks or cloud mounts.
|
||||
|
||||
@@ -70,8 +70,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) {
|
||||
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
|
||||
|
||||
@@ -55,7 +55,7 @@ services:
|
||||
bind:
|
||||
create_host_path: false
|
||||
|
||||
# 下载目录。需要和 qBittorrent 保存路径保持一致。
|
||||
# 下载目录。需要和下载器保存路径保持一致。
|
||||
# NAS 示例:source: /vol1/1000/Downloads
|
||||
# Windows Docker Desktop 示例:source: D:/Downloads
|
||||
- type: bind
|
||||
|
||||
@@ -69,7 +69,7 @@ services:
|
||||
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
||||
|
||||
extra_hosts:
|
||||
# 容器访问宿主机服务用,例如 qBittorrent: http://host.docker.internal:8085
|
||||
# 容器访问宿主机服务(如下载器等)用: http://host.docker.internal:8085
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
healthcheck:
|
||||
|
||||
@@ -50,7 +50,7 @@ services:
|
||||
bind:
|
||||
create_host_path: false
|
||||
|
||||
# 下载目录。需要和 qBittorrent 保存路径保持一致。
|
||||
# 下载目录。需要和下载器保存路径保持一致。
|
||||
# NAS 示例:source: /vol1/1000/Downloads
|
||||
# Windows Docker Desktop 示例:source: D:/Downloads
|
||||
- type: bind
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ services:
|
||||
ports:
|
||||
- "18080:8080"
|
||||
|
||||
# 让容器可以访问宿主机上的 qBittorrent。
|
||||
# 让容器可以访问宿主机上的服务(如下载器)。
|
||||
# qB 地址通常可填:http://host.docker.internal:8085
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
+2
-2
@@ -90,7 +90,7 @@ GitHub:https://github.com/truewhile/MeBox
|
||||
|
||||

|
||||
|
||||
接入 qBittorrent,下载目录定时自动整理入媒体库:智能分类子库、自动注册目的地媒体库、复制/移动/硬链/软链多种整理方式,命名规则可配。
|
||||
配合任意下载器(qBittorrent、Transmission 等下载到本地目录即可),MeBox 定时自动整理入媒体库:智能分类子库、自动注册目的地媒体库、复制/移动/硬链/软链多种整理方式,命名规则可配。
|
||||
|
||||
**10. 多用户与权限**
|
||||
|
||||
@@ -144,7 +144,7 @@ docker compose up -d
|
||||
- **网盘用户**:STRM 管理 → 添加网盘账号(115 可扫码)→ 添加同步目录 → 生成 STRM 后直链播放;
|
||||
- **已有 Emby**:Emby 挂载 → 添加 Emby 账号 → 勾选要挂载的媒体库;
|
||||
- **第三方播放器(Emby 客户端全兼容)**:Infuse / SenPlayer / Fileball / Emby、Jellyfin 官方客户端,按「添加 Emby 服务器」填 `http://服务器IP:18080`,用 MeBox 账号登录即可,原有使用习惯完全不变;
|
||||
- **下载党**:设置里接入 qBittorrent,配置下载目录自动整理,下完即入库。
|
||||
- **下载党**:qBittorrent 等任意下载器把视频下到下载目录,在文件管理里把它设为整理源,下完自动分类入库。
|
||||
|
||||
### 路径映射小抄(Docker 最常见坑)
|
||||
|
||||
|
||||
@@ -11,42 +11,46 @@ 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/metatube-community/metatube-sdk-go v1.4.0
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
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
|
||||
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.49.0
|
||||
golang.org/x/sys v0.42.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/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/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/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // 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.8.0 // 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,6 +62,7 @@ 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
|
||||
@@ -79,14 +84,16 @@ 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/arch v0.25.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/image v0.37.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // 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
|
||||
)
|
||||
|
||||
@@ -4,8 +4,8 @@ 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 +20,26 @@ 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/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,8 +52,8 @@ 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/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/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
@@ -58,20 +62,25 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
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.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
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 +101,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 +110,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=
|
||||
@@ -112,11 +125,10 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA
|
||||
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/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=
|
||||
@@ -173,28 +185,38 @@ 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.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
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/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/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 +225,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=
|
||||
|
||||
@@ -120,6 +120,25 @@ func changePasswordHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func temporaryPasswordHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, ok := c.Get(middleware.CtxUserID)
|
||||
if !ok || uid == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
code, expireSec, err := svc.Auth.CreateTemporaryPassword(c.Request.Context(), uid.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": code,
|
||||
"expires_in": expireSec,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +257,10 @@ func embyClientInfoFromRequest(c *gin.Context) embyClientInfo {
|
||||
firstHeaderValue(c, "X-Emby-Client", "X-MediaBrowser-Client"),
|
||||
c.Query("Client"),
|
||||
c.Query("client"),
|
||||
c.Query("X-Emby-Client"),
|
||||
c.Query("X-MediaBrowser-Client"),
|
||||
auth["Client"],
|
||||
auth["client"],
|
||||
),
|
||||
}
|
||||
ua := strings.TrimSpace(c.GetHeader("User-Agent"))
|
||||
@@ -315,10 +318,36 @@ func embyClientFromUserAgent(ua string) string {
|
||||
return "Emby"
|
||||
case strings.Contains(lower, "jellyfin"):
|
||||
return "Jellyfin"
|
||||
case strings.Contains(lower, "capyplayer") || strings.Contains(lower, "capy player") || strings.Contains(lower, "卡皮巴拉"):
|
||||
return "CapyPlayer"
|
||||
case strings.Contains(lower, "senplayer") || strings.Contains(lower, "sen player") || strings.Contains(lower, "森播"):
|
||||
return "SenPlayer"
|
||||
case strings.Contains(lower, "yamby"):
|
||||
return "Yamby"
|
||||
case strings.Contains(lower, "vidhub"):
|
||||
return "VidHub"
|
||||
case strings.Contains(lower, "fileball"):
|
||||
return "Fileball"
|
||||
case strings.Contains(lower, "hamhub"):
|
||||
return "HamHub"
|
||||
case strings.Contains(lower, "afusekt") || strings.Contains(lower, "afuse"):
|
||||
return "AfuseKt"
|
||||
case strings.Contains(lower, "cony"):
|
||||
return "Cony"
|
||||
case strings.Contains(lower, "kodi"):
|
||||
return "Kodi"
|
||||
case strings.Contains(lower, "mrmc"):
|
||||
return "MrMC"
|
||||
case strings.Contains(lower, "forward"):
|
||||
return "Forward"
|
||||
case strings.Contains(lower, "alpha"):
|
||||
return "Alpha"
|
||||
case strings.Contains(lower, "dandanplay") || strings.Contains(lower, "弹弹play"):
|
||||
return "DanDanPlay"
|
||||
case strings.Contains(lower, "potplayer"):
|
||||
return "PotPlayer"
|
||||
case strings.Contains(lower, "vlc"):
|
||||
return "VLC"
|
||||
case strings.Contains(lower, "hills"):
|
||||
return "Hills"
|
||||
default:
|
||||
@@ -343,7 +372,7 @@ func embyDeviceFromUserAgent(ua string) string {
|
||||
return "Mac"
|
||||
case strings.Contains(lower, "linux"):
|
||||
return "Linux PC"
|
||||
case strings.Contains(lower, "appletv") || strings.Contains(lower, "apple tv"):
|
||||
case strings.Contains(lower, "appletv") || strings.Contains(lower, "apple tv") || strings.Contains(lower, "appletvos"):
|
||||
return "Apple TV"
|
||||
default:
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
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/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func TestNormalizeEmbyPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantPath string
|
||||
changed bool
|
||||
}{
|
||||
{
|
||||
input: "/emby/System/Info",
|
||||
wantPath: "/emby/system/info",
|
||||
changed: true,
|
||||
},
|
||||
{
|
||||
input: "/emby/emby/System/Info",
|
||||
wantPath: "/emby/system/info",
|
||||
changed: true,
|
||||
},
|
||||
{
|
||||
input: "/emby/emby/emby/items/123/playbackInfo",
|
||||
wantPath: "/emby/items/123/playbackinfo",
|
||||
changed: true,
|
||||
},
|
||||
{
|
||||
input: "//emby//System//Info//Public",
|
||||
wantPath: "/emby/system/info/public",
|
||||
changed: true,
|
||||
},
|
||||
{
|
||||
input: "/Items/msgo-series-1/PlaybackInfo",
|
||||
wantPath: "/items/msgo-series-1/playbackinfo",
|
||||
changed: true,
|
||||
},
|
||||
{
|
||||
input: "/Videos/m-123/Master.m3u8",
|
||||
wantPath: "/videos/m-123/master.m3u8",
|
||||
changed: true,
|
||||
},
|
||||
{
|
||||
input: "/api/unknown/other",
|
||||
wantPath: "/api/unknown/other",
|
||||
changed: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
gotPath, changed := NormalizeEmbyPath(tt.input)
|
||||
if gotPath != tt.wantPath || changed != tt.changed {
|
||||
t.Errorf("NormalizeEmbyPath(%q) = (%q, %v), want (%q, %v)", tt.input, gotPath, changed, tt.wantPath, tt.changed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyDuplicatePrefixHandling(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(nil, nil, repos),
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "secret", svc)
|
||||
|
||||
// 模拟重复拼接前缀的客户端请求: /emby/emby/System/Info/Public
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/emby/System/Info/Public", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for /emby/emby/System/Info/Public, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "ServerName") {
|
||||
t.Fatalf("expected server info body, got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyMixedCaseHandling(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(nil, nil, repos),
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "secret", svc)
|
||||
|
||||
// 混合大小写驼峰: /emby/system/Info/Public
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/system/Info/Public", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for /emby/system/Info/Public, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyClientIdentification(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ua string
|
||||
query string
|
||||
headerAuth string
|
||||
wantClient string
|
||||
}{
|
||||
{
|
||||
name: "CapyPlayer via UA",
|
||||
ua: "CapyPlayer/1.2.0 (iOS)",
|
||||
wantClient: "CapyPlayer",
|
||||
},
|
||||
{
|
||||
name: "SenPlayer via UA",
|
||||
ua: "SenPlayer/2.1",
|
||||
wantClient: "SenPlayer",
|
||||
},
|
||||
{
|
||||
name: "Fileball via UA",
|
||||
ua: "Fileball/1.0.0",
|
||||
wantClient: "Fileball",
|
||||
},
|
||||
{
|
||||
name: "Kodi via UA",
|
||||
ua: "Kodi/20.2",
|
||||
wantClient: "Kodi",
|
||||
},
|
||||
{
|
||||
name: "Client in query",
|
||||
ua: "CustomApp/1.0",
|
||||
query: "?X-Emby-Client=CapyPlayer",
|
||||
wantClient: "CapyPlayer",
|
||||
},
|
||||
{
|
||||
name: "Client in auth header",
|
||||
ua: "Custom/1.0",
|
||||
headerAuth: `MediaBrowser Client="SenPlayer", Device="AppleTV", DeviceId="abc"`,
|
||||
wantClient: "SenPlayer",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
url := "/test"
|
||||
if tt.query != "" {
|
||||
url += tt.query
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
if tt.ua != "" {
|
||||
req.Header.Set("User-Agent", tt.ua)
|
||||
}
|
||||
if tt.headerAuth != "" {
|
||||
req.Header.Set("X-Emby-Authorization", tt.headerAuth)
|
||||
}
|
||||
c.Request = req
|
||||
|
||||
info := embyClientInfoFromRequest(c)
|
||||
if info.Client != tt.wantClient {
|
||||
t.Fatalf("embyClientInfoFromRequest Client = %q, want %q", info.Client, tt.wantClient)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyAdaptivePrefixPlaybackInfo(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(nil, nil, repos),
|
||||
}
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, svc)
|
||||
|
||||
token := signedTestToken(t, secret)
|
||||
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)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "m-adaptive-1"},
|
||||
Title: "测试媒体",
|
||||
Path: "D:\\media\\test.mkv",
|
||||
LibraryID: "lib-1",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
// 1. 从 /emby 前缀请求 PlaybackInfo
|
||||
req1 := httptest.NewRequest(http.MethodGet, "/emby/Items/m-adaptive-1/PlaybackInfo", nil)
|
||||
req1.Header.Set("X-Emby-Token", token)
|
||||
w1 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w1, req1)
|
||||
|
||||
if w1.Code != http.StatusOK {
|
||||
t.Fatalf("playbackinfo /emby code = %d: %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
var res1 struct {
|
||||
MediaSources []struct {
|
||||
DirectStreamURL string `json:"DirectStreamUrl"`
|
||||
} `json:"MediaSources"`
|
||||
}
|
||||
if err := json.Unmarshal(w1.Body.Bytes(), &res1); err != nil || len(res1.MediaSources) == 0 {
|
||||
t.Fatalf("unmarshal /emby response: %v, body: %s", err, w1.Body.String())
|
||||
}
|
||||
if !strings.Contains(res1.MediaSources[0].DirectStreamURL, "/Videos/m-adaptive-1/stream") {
|
||||
t.Fatalf("DirectStreamUrl should point to video stream endpoint, got: %s", res1.MediaSources[0].DirectStreamURL)
|
||||
}
|
||||
if !strings.Contains(res1.MediaSources[0].DirectStreamURL, "api_key="+token) {
|
||||
t.Fatalf("DirectStreamUrl should carry api_key token, got: %s", res1.MediaSources[0].DirectStreamURL)
|
||||
}
|
||||
|
||||
// 2. 从重复前缀 /emby/emby 请求 PlaybackInfo (模拟客户端再次追加 BaseUrl 场景)
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/emby/emby/Items/m-adaptive-1/PlaybackInfo", nil)
|
||||
req2.Header.Set("X-Emby-Token", token)
|
||||
w2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w2, req2)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("playbackinfo /emby/emby code = %d: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
var res2 struct {
|
||||
MediaSources []struct {
|
||||
DirectStreamURL string `json:"DirectStreamUrl"`
|
||||
} `json:"MediaSources"`
|
||||
}
|
||||
if err := json.Unmarshal(w2.Body.Bytes(), &res2); err != nil || len(res2.MediaSources) == 0 {
|
||||
t.Fatalf("unmarshal /emby/emby response: %v, body: %s", err, w2.Body.String())
|
||||
}
|
||||
|
||||
// 3. 从根路径 /Items 请求 PlaybackInfo
|
||||
req3 := httptest.NewRequest(http.MethodGet, "/Items/m-adaptive-1/PlaybackInfo", nil)
|
||||
req3.Header.Set("X-Emby-Token", token)
|
||||
w3 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w3, req3)
|
||||
|
||||
if w3.Code != http.StatusOK {
|
||||
t.Fatalf("playbackinfo root code = %d: %s", w3.Code, w3.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyImageClearNoStore(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(nil, nil, repos),
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "secret", svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Items/non-existent-item/Images/Primary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("placeholder image should return 200, got %d", w.Code)
|
||||
}
|
||||
cacheControl := w.Header().Get("Cache-Control")
|
||||
if strings.Contains(cacheControl, "no-store") {
|
||||
t.Fatalf("image response should not have no-store, got: %s", cacheControl)
|
||||
}
|
||||
if !strings.Contains(cacheControl, "public") {
|
||||
t.Fatalf("image response should have public cache-control, got: %s", cacheControl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyTemporaryPasswordLogin(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-jwt-secret-very-secure-key-12345"
|
||||
log := zap.NewNop()
|
||||
permissions := service.NewPermissionService(log, repos)
|
||||
tokenSvc := service.NewTokenService(cfg, log, repos)
|
||||
authSvc := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
|
||||
embySvc := service.NewEmbyService(nil, nil, repos)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Auth: authSvc,
|
||||
Token: tokenSvc,
|
||||
Emby: embySvc,
|
||||
}
|
||||
|
||||
user, _, err := authSvc.Register(t.Context(), "tvuser", "strongpassword123")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
// 1. 生成 6 位纯数字临时密码 (OTP)
|
||||
code, expireSec, err := authSvc.CreateTemporaryPassword(t.Context(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create temp password: %v", err)
|
||||
}
|
||||
if len(code) != 6 || expireSec <= 0 {
|
||||
t.Fatalf("invalid temp password format: %s, expire: %d", code, expireSec)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "test-jwt-secret-very-secure-key-12345", svc)
|
||||
|
||||
// 2. 使用临时密码在 Emby 接口登录
|
||||
body := `{"Username":"tvuser","Pw":"` + code + `"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("login with temp password code = %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var loginResp struct {
|
||||
AccessToken string `json:"AccessToken"`
|
||||
User struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
} `json:"User"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &loginResp); err != nil {
|
||||
t.Fatalf("unmarshal login resp: %v", err)
|
||||
}
|
||||
if loginResp.AccessToken == "" || loginResp.User.ID != user.ID {
|
||||
t.Fatalf("unexpected login payload: %#v", loginResp)
|
||||
}
|
||||
|
||||
// 3. 验证阅后即焚:第二次使用同一临时密码应登录失败 (401)
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(body))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
w2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w2, req2)
|
||||
|
||||
if w2.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("second login with consumed temp password should fail, got %d", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbySeriesArtworkInheritanceAndRunTimeTicksFallback(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret-compat"
|
||||
log := zap.NewNop()
|
||||
permissions := service.NewPermissionService(log, repos)
|
||||
tokenSvc := service.NewTokenService(cfg, log, repos)
|
||||
authSvc := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
|
||||
embySvc := service.NewEmbyService(nil, nil, repos)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Auth: authSvc,
|
||||
Token: tokenSvc,
|
||||
Emby: embySvc,
|
||||
}
|
||||
|
||||
user, _, err := authSvc.Register(t.Context(), "artworkuser", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
token, err := authSvc.IssueEmbyToken(user)
|
||||
if err != nil {
|
||||
t.Fatalf("issue token: %v", err)
|
||||
}
|
||||
|
||||
// 创建 TV Library
|
||||
lib := &model.Library{
|
||||
Name: "电视剧",
|
||||
Path: "/media/电视剧",
|
||||
Type: "tv",
|
||||
}
|
||||
lib.ID = "lib-tv-1"
|
||||
if err := db.Create(lib).Error; err != nil {
|
||||
t.Fatalf("create lib: %v", err)
|
||||
}
|
||||
|
||||
// 创建 Series
|
||||
series := &model.Series{
|
||||
LibraryID: "lib-tv-1",
|
||||
Title: "Test Drama",
|
||||
PosterURL: "https://example.com/series_poster.jpg",
|
||||
BackdropURL: "https://example.com/series_backdrop.jpg",
|
||||
}
|
||||
series.ID = "s-test-1"
|
||||
if err := db.Create(series).Error; err != nil {
|
||||
t.Fatalf("create series: %v", err)
|
||||
}
|
||||
|
||||
// 创建单集 Episode(DurationSec 为 0,但有播放进度 posMs,用于测试 RunTimeTicks 兜底)
|
||||
ep := &model.Media{
|
||||
LibraryID: "lib-tv-1",
|
||||
SeriesID: "s-test-1",
|
||||
Title: "Test Episode 1",
|
||||
Path: "/media/电视剧/Test Drama/Season 1/S01E01.mp4",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
DurationSec: 0, // 未知时长
|
||||
PosterURL: "https://example.com/ep1_still.jpg",
|
||||
}
|
||||
ep.ID = "ep-test-1"
|
||||
if err := db.Create(ep).Error; err != nil {
|
||||
t.Fatalf("create ep: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, svc)
|
||||
|
||||
// 1. 获取 Series 详情
|
||||
reqSeries := httptest.NewRequest(http.MethodGet, "/emby/Items/s-test-1", nil)
|
||||
reqSeries.Header.Set("X-Emby-Token", token)
|
||||
wSeries := httptest.NewRecorder()
|
||||
router.ServeHTTP(wSeries, reqSeries)
|
||||
if wSeries.Code != http.StatusOK {
|
||||
t.Fatalf("get series code = %d: %s", wSeries.Code, wSeries.Body.String())
|
||||
}
|
||||
var seriesPayload map[string]any
|
||||
_ = json.Unmarshal(wSeries.Body.Bytes(), &seriesPayload)
|
||||
if seriesPayload["PrimaryImageTag"] != "s-test-1" {
|
||||
t.Fatalf("series PrimaryImageTag should match series ID, got %v", seriesPayload["PrimaryImageTag"])
|
||||
}
|
||||
if _, ok := seriesPayload["People"]; !ok {
|
||||
t.Fatalf("series payload should include People array")
|
||||
}
|
||||
|
||||
// 2. 获取 Episode 详情,验证继承 SeriesPrimaryImageTag 和 ParentBackdropItemId
|
||||
// 添加一条播放进度记录 (posMs = 60000)
|
||||
hist := &model.PlaybackHistory{
|
||||
UserID: user.ID,
|
||||
MediaID: ep.ID,
|
||||
PositionMs: 60000,
|
||||
}
|
||||
_ = db.Create(hist).Error
|
||||
|
||||
reqEp := httptest.NewRequest(http.MethodGet, "/emby/Users/"+user.ID+"/Items/ep-test-1", nil)
|
||||
reqEp.Header.Set("X-Emby-Token", token)
|
||||
wEp := httptest.NewRecorder()
|
||||
router.ServeHTTP(wEp, reqEp)
|
||||
if wEp.Code != http.StatusOK {
|
||||
t.Fatalf("get ep code = %d: %s", wEp.Code, wEp.Body.String())
|
||||
}
|
||||
var epPayload map[string]any
|
||||
_ = json.Unmarshal(wEp.Body.Bytes(), &epPayload)
|
||||
t.Logf("epPayload: %#v", epPayload)
|
||||
|
||||
// 验证图片继承
|
||||
if epPayload["SeriesPrimaryImageTag"] != "s-test-1" {
|
||||
t.Fatalf("ep SeriesPrimaryImageTag should inherit series ID, got %v", epPayload["SeriesPrimaryImageTag"])
|
||||
}
|
||||
if epPayload["ParentBackdropItemId"] != "s-test-1" {
|
||||
t.Fatalf("ep ParentBackdropItemId should inherit series ID, got %v", epPayload["ParentBackdropItemId"])
|
||||
}
|
||||
if _, ok := epPayload["People"]; !ok {
|
||||
t.Fatalf("ep payload should include People array")
|
||||
}
|
||||
|
||||
// 验证 RunTimeTicks 兜底
|
||||
runTimeTicks, _ := epPayload["RunTimeTicks"].(float64)
|
||||
if runTimeTicks <= 0 {
|
||||
t.Fatalf("ep RunTimeTicks should be safely fallback to positive value, got %v", runTimeTicks)
|
||||
}
|
||||
userData, _ := epPayload["UserData"].(map[string]any)
|
||||
playedPct, _ := userData["PlayedPercentage"].(float64)
|
||||
if playedPct <= 0 {
|
||||
t.Fatalf("ep PlayedPercentage should be > 0, got %v", playedPct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeTemporaryPasswordEndpoint(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret-temp"
|
||||
log := zap.NewNop()
|
||||
permissions := service.NewPermissionService(log, repos)
|
||||
tokenSvc := service.NewTokenService(cfg, log, repos)
|
||||
authSvc := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Auth: authSvc,
|
||||
Token: tokenSvc,
|
||||
}
|
||||
|
||||
user, tokens, err := authSvc.Register(t.Context(), "optuser", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
api := router.Group("/api")
|
||||
authed := api.Group("")
|
||||
authed.Use(func(c *gin.Context) {
|
||||
c.Set(middleware.CtxUserID, user.ID)
|
||||
c.Next()
|
||||
})
|
||||
registerAuthedUserAndLicenseRoutes(authed, svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/me/temporary-password", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("generate temp password code = %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Code string `json:"code"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal resp: %v", err)
|
||||
}
|
||||
if len(resp.Code) != 6 || resp.ExpiresIn <= 0 {
|
||||
t.Fatalf("invalid temp password resp: %#v", resp)
|
||||
}
|
||||
|
||||
// 验证使用生成的临时密码能登录
|
||||
loginResp, err := authSvc.LoginWithTemporaryPassword(t.Context(), "optuser", resp.Code)
|
||||
if err != nil || loginResp == nil || loginResp.User.ID != user.ID {
|
||||
t.Fatalf("login with temp pass failed: %v", err)
|
||||
}
|
||||
_ = tokens
|
||||
}
|
||||
@@ -51,13 +51,14 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
func clearEmbyImageNoStoreHeaders(c *gin.Context) {
|
||||
c.Writer.Header().Del("Cache-Control")
|
||||
c.Writer.Header().Del("Pragma")
|
||||
c.Writer.Header().Del("Expires")
|
||||
}
|
||||
|
||||
func embyServePlaceholderImage(c *gin.Context) {
|
||||
c.Header("Content-Type", "image/png")
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.Header("Content-Length", strconv.Itoa(len(embyPlaceholderPNG)))
|
||||
if c.Request.Method == http.MethodHead {
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
multipleSlashesRE = regexp.MustCompile(`/{2,}`)
|
||||
)
|
||||
|
||||
// embyStaticSegments 包含 Emby API 中已知的保留静态路由分段(全部小写),
|
||||
// 用于在遇到客户端混合大小写(如 /System/info, /items/:id/playbackInfo)时,
|
||||
// 将静态段规范化为对应的小写形式,而保留动态参数段(:id, :userId 等)的原大小写。
|
||||
var embyStaticSegments = map[string]struct{}{
|
||||
"system": {},
|
||||
"info": {},
|
||||
"public": {},
|
||||
"endpoint": {},
|
||||
"configuration": {},
|
||||
"ping": {},
|
||||
"users": {},
|
||||
"useritems": {},
|
||||
"me": {},
|
||||
"authenticatebyname": {},
|
||||
"items": {},
|
||||
"counts": {},
|
||||
"latest": {},
|
||||
"resume": {},
|
||||
"playbackinfo": {},
|
||||
"shows": {},
|
||||
"seasons": {},
|
||||
"episodes": {},
|
||||
"nextup": {},
|
||||
"upcoming": {},
|
||||
"similar": {},
|
||||
"thumbnailset": {},
|
||||
"thememedia": {},
|
||||
"specialfeatures": {},
|
||||
"intros": {},
|
||||
"videos": {},
|
||||
"stream": {},
|
||||
"subtitles": {},
|
||||
"master.m3u8": {},
|
||||
"main.m3u8": {},
|
||||
"sessions": {},
|
||||
"playing": {},
|
||||
"progress": {},
|
||||
"stopped": {},
|
||||
"capabilities": {},
|
||||
"full": {},
|
||||
"logout": {},
|
||||
"views": {},
|
||||
"library": {},
|
||||
"mediafolders": {},
|
||||
"virtualfolders": {},
|
||||
"selectablemediafolders": {},
|
||||
"branding": {},
|
||||
"css": {},
|
||||
"localization": {},
|
||||
"options": {},
|
||||
"cultures": {},
|
||||
"customcssjs": {},
|
||||
"scripts": {},
|
||||
"displaypreferences": {},
|
||||
"quickconnect": {},
|
||||
"enabled": {},
|
||||
"startup": {},
|
||||
"complete": {},
|
||||
"favoriteitems": {},
|
||||
"playeditems": {},
|
||||
"images": {},
|
||||
"primary": {},
|
||||
"backdrop": {},
|
||||
"banner": {},
|
||||
"thumb": {},
|
||||
"logo": {},
|
||||
"serverdomains": {},
|
||||
"ext": {},
|
||||
"danmu": {},
|
||||
"raw": {},
|
||||
"mediasegments": {},
|
||||
"artists": {},
|
||||
"persons": {},
|
||||
"genres": {},
|
||||
"embywebsocket": {},
|
||||
}
|
||||
|
||||
// NormalizeEmbyPath 规范化 Emby 请求路径:
|
||||
// 1. 折叠重复斜杠(如 //emby/ -> /emby/);
|
||||
// 2. 折叠重复前缀(如 /emby/emby/System/Info -> /emby/System/Info);
|
||||
// 3. 将静态关键字段归一化为小写,同时保留动态 ID/参数的原有大小写。
|
||||
func NormalizeEmbyPath(p string) (string, bool) {
|
||||
if p == "" {
|
||||
return "", false
|
||||
}
|
||||
original := p
|
||||
|
||||
// 1. 折叠多余斜杠
|
||||
p = multipleSlashesRE.ReplaceAllString(p, "/")
|
||||
|
||||
// 2. 折叠重复的 /emby 前缀
|
||||
for {
|
||||
lower := strings.ToLower(p)
|
||||
if strings.HasPrefix(lower, "/emby/emby/") {
|
||||
p = "/emby/" + p[len("/emby/emby/"):]
|
||||
continue
|
||||
}
|
||||
if lower == "/emby/emby" {
|
||||
p = "/emby"
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// 3. 分析是否具有 Emby 路由特征
|
||||
hasEmbyPrefix := false
|
||||
workPath := p
|
||||
if strings.HasPrefix(strings.ToLower(workPath), "/emby/") {
|
||||
hasEmbyPrefix = true
|
||||
workPath = workPath[len("/emby"):]
|
||||
} else if strings.EqualFold(workPath, "/emby") {
|
||||
return "/emby", original != "/emby"
|
||||
}
|
||||
|
||||
segments := strings.Split(strings.Trim(workPath, "/"), "/")
|
||||
if len(segments) == 0 || segments[0] == "" {
|
||||
return p, p != original
|
||||
}
|
||||
|
||||
// 检查第一段是否为 Emby 根路由关键字
|
||||
firstLower := strings.ToLower(segments[0])
|
||||
if _, ok := embyStaticSegments[firstLower]; !ok && firstLower != "api" {
|
||||
// 不是 Emby 相关路径,保持原样
|
||||
return original, false
|
||||
}
|
||||
|
||||
// 4. 将已知静态段转为小写,动态段保留原样
|
||||
for i, seg := range segments {
|
||||
segLower := strings.ToLower(seg)
|
||||
if _, isStatic := embyStaticSegments[segLower]; isStatic {
|
||||
if seg != segLower {
|
||||
segments[i] = segLower
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
if hasEmbyPrefix {
|
||||
builder.WriteString("/emby")
|
||||
}
|
||||
for _, seg := range segments {
|
||||
builder.WriteString("/")
|
||||
builder.WriteString(seg)
|
||||
}
|
||||
if strings.HasSuffix(original, "/") && !strings.HasSuffix(builder.String(), "/") {
|
||||
builder.WriteString("/")
|
||||
}
|
||||
|
||||
normalized := builder.String()
|
||||
return normalized, normalized != original
|
||||
}
|
||||
|
||||
const embyNormalizedCtxKey = "emby_normalized_path"
|
||||
|
||||
// TryHandleEmbyNormalizedRoute 尝试在 404 NoRoute 阶段对 Emby 路径做前缀与大小写纠偏并重定向分发。
|
||||
// 若成功分发并处理,返回 true;否则返回 false。
|
||||
func TryHandleEmbyNormalizedRoute(c *gin.Context, r *gin.Engine) bool {
|
||||
if c == nil || r == nil {
|
||||
return false
|
||||
}
|
||||
if c.GetBool(embyNormalizedCtxKey) {
|
||||
return false
|
||||
}
|
||||
normalized, changed := NormalizeEmbyPath(c.Request.URL.Path)
|
||||
if !changed {
|
||||
return false
|
||||
}
|
||||
|
||||
c.Set(embyNormalizedCtxKey, true)
|
||||
c.Request.URL.Path = normalized
|
||||
|
||||
// 重置 context 状态并由 engine 重新查找路由树
|
||||
c.Params = nil
|
||||
c.Writer.Header().Del("Content-Type")
|
||||
r.HandleContext(c)
|
||||
return true
|
||||
}
|
||||
@@ -23,6 +23,14 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container)
|
||||
auth := grp.Group("", embyAuthRequiredWithSessionFallback(jwtSecret), activeEmbyUserRequired(svc), embyRealtimeSessionActivity(svc))
|
||||
registerEmbyAuthenticatedRoutes(auth, prefix, svc)
|
||||
}
|
||||
|
||||
// 为没有设置 NoRoute 的 Engine 提供 Emby 路径前缀与大小写纠偏(如单测或独立挂载环境)
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
if TryHandleEmbyNormalizedRoute(c, r) {
|
||||
return
|
||||
}
|
||||
c.Status(404)
|
||||
})
|
||||
}
|
||||
|
||||
type embyRouteHandlerFactory func(*service.Container) gin.HandlerFunc
|
||||
|
||||
@@ -34,11 +34,18 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
|
||||
embyError(c, http.StatusBadRequest, "missing username or password")
|
||||
return
|
||||
}
|
||||
resp, err := svc.Auth.Login(c.Request.Context(), req.Username, password)
|
||||
if err != nil {
|
||||
embyError(c, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
resp, err := svc.Auth.Login(c.Request.Context(), req.Username, password)
|
||||
if err != nil {
|
||||
// 支持电视端/客户端一次性 6 位临时密码登录 (OTP)
|
||||
if tempResp, tempErr := svc.Auth.LoginWithTemporaryPassword(c.Request.Context(), req.Username, password); tempErr == nil {
|
||||
resp = tempResp
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
embyError(c, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
// 记录登录设备会话并执行防共享检测(登录客户端数 / 设备指纹)。
|
||||
clientInfo := embyClientInfoFromRequest(c)
|
||||
if svc.Sessions != nil {
|
||||
|
||||
+27
-10
@@ -170,7 +170,13 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if acct == nil {
|
||||
return
|
||||
}
|
||||
tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}}
|
||||
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
|
||||
@@ -482,7 +488,7 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusOK, m)
|
||||
return
|
||||
}
|
||||
m, err := svc.Media.GetMedia(ctx, id)
|
||||
m, err := svc.Media.GetMediaItem(ctx, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -491,7 +497,7 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if !mediaVisibleForRequest(c, svc, m) {
|
||||
if !mediaVisibleForRequest(c, svc, &m.Media) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
@@ -660,14 +666,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()})
|
||||
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
|
||||
}
|
||||
setRedirectNoStoreHeaders(c)
|
||||
c.Redirect(http.StatusFound, target)
|
||||
return
|
||||
}
|
||||
m, err := svc.Media.GetMedia(ctx, id)
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
|
||||
@@ -45,8 +45,8 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
token := externalPlaybackToken(c, svc, m.ID, m.DurationSec)
|
||||
profileQuery := externalProfileQuery(c)
|
||||
hlsURL := "/api/hls/" + m.ID + "/index.m3u8?token=" + url.QueryEscape(token) + profileQuery
|
||||
if service.IsEmbyRemoteID(m.ID) || service.IsStrmMediaRow(m) {
|
||||
// Emby 远程挂载与 STRM 媒体一样,默认直连播放,不提供转码地址
|
||||
if service.IsEmbyRemoteID(m.ID) {
|
||||
// 远程 Emby 挂载没有本地文件,不能提供转码地址。STRM 默认直连,直连失败时可走 HLS。
|
||||
hlsURL = ""
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
||||
@@ -413,7 +413,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
|
||||
return router, svc, cfg.Secrets.JWTSecret
|
||||
}
|
||||
|
||||
func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
|
||||
func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
|
||||
router, _, secret := newPlaybackScopeTestRouter(t)
|
||||
loginToken := signedTestToken(t, secret)
|
||||
|
||||
@@ -435,8 +435,8 @@ func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
|
||||
if payload.StreamURL == "" {
|
||||
t.Fatalf("expected non-empty stream_url")
|
||||
}
|
||||
if payload.HlsURL != "" {
|
||||
t.Fatalf("expected empty hls_url for STRM media, got %q", payload.HlsURL)
|
||||
if payload.HlsURL == "" || !strings.Contains(payload.HlsURL, "/api/hls/media-1/") {
|
||||
t.Fatalf("expected strm hls_url, got %q", payload.HlsURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.DELETE("/strm/accounts/:id", deleteStrmAccountHandler(svc))
|
||||
admin.POST("/strm/accounts/:id/test", testStrmAccountHandler(svc))
|
||||
admin.GET("/strm/accounts/:id/list", listStrmRemoteDirHandler(svc))
|
||||
admin.GET("/strm/accounts/:id/resolve", resolveStrmRemoteDirHandler(svc))
|
||||
admin.GET("/strm/115/sources", listStrm115SourcesHandler(svc))
|
||||
admin.POST("/strm/accounts/:id/oauth/start", startStrm115OAuthHandler(svc))
|
||||
admin.POST("/strm/accounts/:id/oauth/poll", pollStrm115OAuthHandler(svc))
|
||||
|
||||
@@ -14,6 +14,8 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co
|
||||
authed.PUT("/me/pinned-libraries", setPinnedLibrariesHandler(svc))
|
||||
authed.POST("/me/password", changePasswordHandler(svc))
|
||||
authed.POST("/me/logout", logoutHandler(svc))
|
||||
authed.GET("/me/temporary-password", temporaryPasswordHandler(svc))
|
||||
authed.POST("/me/temporary-password", temporaryPasswordHandler(svc))
|
||||
|
||||
authed.GET("/auth/permissions", getMyPermissionsHandler(svc))
|
||||
}
|
||||
|
||||
@@ -195,7 +195,8 @@ func listLibrarySeriesEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
|
||||
grouped := service.GroupEpisodeVersionsForDisplay(items)
|
||||
c.JSON(http.StatusOK, gin.H{"items": grouped, "total": len(grouped)})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +236,7 @@ func listMediaEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
|
||||
grouped := service.GroupEpisodeVersionsForDisplay(items)
|
||||
c.JSON(http.StatusOK, gin.H{"items": grouped, "total": len(grouped)})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
// 远程 Emby 挂载媒体与 STRM 一样,默认直连播放,不进行转码。
|
||||
// 远程 Emby 挂载没有本地文件,不能转码。STRM 允许直连失败后走 HLS。
|
||||
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
|
||||
return
|
||||
|
||||
+64
-36
@@ -3,6 +3,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -172,6 +173,19 @@ func listStrmRemoteDirHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveStrmRemoteDirHandler 按远端目录引用(115 为目录 ID)反查完整展示路径。
|
||||
func resolveStrmRemoteDirHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
dir := strings.TrimSpace(c.Query("dir"))
|
||||
path, err := svc.Strm.ResolveRemoteDirPath(c.Request.Context(), c.Param("id"), dir)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"path": path})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 全局设置 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func getStrmSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
@@ -203,24 +217,26 @@ func updateStrmSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// ─── 同步目录 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type strmSyncPathReq struct {
|
||||
Name string `json:"name"`
|
||||
AccountID string `json:"account_id"`
|
||||
Provider string `json:"provider"`
|
||||
RemotePath string `json:"remote_path"`
|
||||
LocalPath string `json:"local_path"`
|
||||
StrmBaseURL string `json:"strm_base_url"`
|
||||
VideoExt string `json:"video_ext"`
|
||||
MetaExt string `json:"meta_ext"`
|
||||
ExcludeName string `json:"exclude_name"`
|
||||
MinVideoSizeMB int64 `json:"min_video_size_mb"`
|
||||
AddPath int `json:"add_path"`
|
||||
DownloadMeta *bool `json:"download_meta"`
|
||||
UploadMeta *bool `json:"upload_meta"`
|
||||
DeleteDir *bool `json:"delete_dir"`
|
||||
Cron string `json:"cron"`
|
||||
EnableCron *bool `json:"enable_cron"`
|
||||
SyncMode string `json:"sync_mode"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name string `json:"name"`
|
||||
AccountID string `json:"account_id"`
|
||||
Provider string `json:"provider"`
|
||||
RemotePath string `json:"remote_path"`
|
||||
RemoteDisplayPath string `json:"remote_display_path"`
|
||||
LocalPath string `json:"local_path"`
|
||||
StrmBaseURL string `json:"strm_base_url"`
|
||||
VideoExt string `json:"video_ext"`
|
||||
MetaExt string `json:"meta_ext"`
|
||||
ExcludeName string `json:"exclude_name"`
|
||||
MinVideoSizeMB int64 `json:"min_video_size_mb"`
|
||||
AddPath int `json:"add_path"`
|
||||
DownloadMeta *bool `json:"download_meta"`
|
||||
UploadMeta *bool `json:"upload_meta"`
|
||||
DeleteDir *bool `json:"delete_dir"`
|
||||
KeepExt *bool `json:"keep_ext"`
|
||||
Cron string `json:"cron"`
|
||||
EnableCron *bool `json:"enable_cron"`
|
||||
SyncMode string `json:"sync_mode"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type strmSyncPathView struct {
|
||||
@@ -240,6 +256,16 @@ func strmSyncPathViews(svc *service.Container, c *gin.Context, paths []model.Str
|
||||
view.AccountEnabled = acct.Enabled
|
||||
}
|
||||
}
|
||||
// 历史 115 数据若尚未记录展示路径,尝试反查一次并回写数据库自愈
|
||||
if p.Provider == model.StrmProvider115 && strings.TrimSpace(p.RemoteDisplayPath) == "" && strings.TrimSpace(p.RemotePath) != "" && p.AccountID != "" {
|
||||
resolveCtx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||
if fullPath, err := svc.Strm.ResolveRemoteDirPath(resolveCtx, p.AccountID, p.RemotePath); err == nil && fullPath != "" {
|
||||
view.RemoteDisplayPath = fullPath
|
||||
p.RemoteDisplayPath = fullPath
|
||||
_ = svc.Repo.StrmSyncPath.Update(context.Background(), &p)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
out = append(out, view)
|
||||
}
|
||||
return out
|
||||
@@ -663,24 +689,26 @@ func strmPlayHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// strmSyncPathFromReq 组装同步目录模型(缺省值交给服务层处理)。
|
||||
func strmSyncPathFromReq(req strmSyncPathReq) *model.StrmSyncPath {
|
||||
return &model.StrmSyncPath{
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
AccountID: strings.TrimSpace(req.AccountID),
|
||||
Provider: strings.TrimSpace(req.Provider),
|
||||
RemotePath: strings.TrimSpace(req.RemotePath),
|
||||
LocalPath: strings.TrimSpace(req.LocalPath),
|
||||
StrmBaseURL: strings.TrimSpace(req.StrmBaseURL),
|
||||
VideoExt: req.VideoExt,
|
||||
MetaExt: req.MetaExt,
|
||||
ExcludeName: req.ExcludeName,
|
||||
MinVideoSizeMB: req.MinVideoSizeMB,
|
||||
AddPath: req.AddPath,
|
||||
DownloadMeta: boolValue(req.DownloadMeta, true),
|
||||
UploadMeta: boolValue(req.UploadMeta, false),
|
||||
DeleteDir: boolValue(req.DeleteDir, false),
|
||||
Cron: strings.TrimSpace(req.Cron),
|
||||
EnableCron: boolValue(req.EnableCron, false),
|
||||
SyncMode: strings.TrimSpace(req.SyncMode),
|
||||
Enabled: boolValue(req.Enabled, true),
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
AccountID: strings.TrimSpace(req.AccountID),
|
||||
Provider: strings.TrimSpace(req.Provider),
|
||||
RemotePath: strings.TrimSpace(req.RemotePath),
|
||||
RemoteDisplayPath: strings.TrimSpace(req.RemoteDisplayPath),
|
||||
LocalPath: strings.TrimSpace(req.LocalPath),
|
||||
StrmBaseURL: strings.TrimSpace(req.StrmBaseURL),
|
||||
VideoExt: req.VideoExt,
|
||||
MetaExt: req.MetaExt,
|
||||
ExcludeName: req.ExcludeName,
|
||||
MinVideoSizeMB: req.MinVideoSizeMB,
|
||||
AddPath: req.AddPath,
|
||||
DownloadMeta: boolValue(req.DownloadMeta, true),
|
||||
UploadMeta: boolValue(req.UploadMeta, false),
|
||||
DeleteDir: boolValue(req.DeleteDir, false),
|
||||
KeepExt: boolValue(req.KeepExt, false),
|
||||
Cron: strings.TrimSpace(req.Cron),
|
||||
EnableCron: boolValue(req.EnableCron, false),
|
||||
SyncMode: strings.TrimSpace(req.SyncMode),
|
||||
Enabled: boolValue(req.Enabled, true),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,16 +116,6 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
|
||||
{"key": "adult.pin", "type": "text"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "qbittorrent",
|
||||
"label": "qBittorrent",
|
||||
"items": []gin.H{
|
||||
{"key": "qbittorrent.url", "type": "text"},
|
||||
{"key": "qbittorrent.username", "type": "text"},
|
||||
{"key": "qbittorrent.password", "type": "text"},
|
||||
{"key": "qbittorrent.savepath", "type": "text"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "system-update",
|
||||
"label": "系统更新",
|
||||
|
||||
@@ -36,7 +36,11 @@ type StrmSyncPath struct {
|
||||
AccountID string `gorm:"size:36;index" json:"account_id"` // StrmAccount.ID;local 为空
|
||||
Provider string `gorm:"size:32" json:"provider"` // StrmProvider*(冗余,便于列表展示)
|
||||
RemotePath string `gorm:"size:1024" json:"remote_path"` // 远端目录:115=目录ID,OpenList/CD2=路径,local=源目录
|
||||
LocalPath string `gorm:"size:1024" json:"local_path"` // STRM/元数据本地输出目录
|
||||
// RemoteDisplayPath 是远端目录的完整展示路径(如 /电影/剧集)。115 的
|
||||
// RemotePath 是目录 ID,用户无法辨认,浏览选择或按 ID 反查时把人类可读
|
||||
// 路径存到这里;路径型网盘(CD2/OpenList)与 local 留空(RemotePath 即路径)。
|
||||
RemoteDisplayPath string `gorm:"size:1024" json:"remote_display_path"`
|
||||
LocalPath string `gorm:"size:1024" json:"local_path"` // STRM/元数据本地输出目录
|
||||
// STRM 链接配置(空值继承全局 strm.* 设置)
|
||||
StrmBaseURL string `gorm:"size:512" json:"strm_base_url"` // 覆盖 strm.base_url
|
||||
VideoExt string `gorm:"size:512" json:"video_ext"` // 逗号分隔,覆盖 strm.video_ext
|
||||
@@ -47,6 +51,9 @@ type StrmSyncPath struct {
|
||||
DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕)
|
||||
UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端
|
||||
DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录
|
||||
// KeepExt=true 时为每个视频生成 name.mkv.strm / name.mp4.strm(保留全部版本);
|
||||
// false(默认)时同名不同扩展只择优生成一条 name.strm,避免互相覆盖与来回抖动。
|
||||
KeepExt bool `json:"keep_ext"`
|
||||
Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选)
|
||||
EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步
|
||||
SyncMode string `gorm:"size:32;default:'incremental'" json:"sync_mode"` // 默认同步模式:incremental / full
|
||||
@@ -124,7 +131,7 @@ type StrmUploadTask struct {
|
||||
FileName string `gorm:"size:512" json:"file_name"`
|
||||
LocalPath string `gorm:"size:1024" json:"local_path"` // 本地源文件
|
||||
RemotePath string `gorm:"size:1024" json:"remote_path"` // 远端目标路径
|
||||
RemoteRef string `gorm:"size:1024" json:"remote_ref"` // 远端同名旧文件引用(115 文件 ID;上传覆盖前先删除旧文件,WebDAV/OpenList 直接覆盖无需删除)
|
||||
RemoteRef string `gorm:"size:1024" json:"remote_ref"` // 上传前:远端同名旧文件 ID(逗号分隔,覆盖前先删);上传成功后:新文件 ID
|
||||
Size int64 `json:"size"`
|
||||
Status string `gorm:"size:16;index" json:"status"`
|
||||
Error string `gorm:"size:1024" json:"error"`
|
||||
|
||||
@@ -95,28 +95,30 @@ func (r *StrmSyncPathRepository) List(ctx context.Context) ([]model.StrmSyncPath
|
||||
func (r *StrmSyncPathRepository) Update(ctx context.Context, p *model.StrmSyncPath) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Model(&model.StrmSyncPath{}).Where("id = ?", p.ID).Updates(map[string]any{
|
||||
"name": p.Name,
|
||||
"account_id": p.AccountID,
|
||||
"provider": p.Provider,
|
||||
"remote_path": p.RemotePath,
|
||||
"local_path": p.LocalPath,
|
||||
"strm_base_url": p.StrmBaseURL,
|
||||
"video_ext": p.VideoExt,
|
||||
"meta_ext": p.MetaExt,
|
||||
"exclude_name": p.ExcludeName,
|
||||
"min_video_size_mb": p.MinVideoSizeMB,
|
||||
"add_path": p.AddPath,
|
||||
"download_meta": p.DownloadMeta,
|
||||
"upload_meta": p.UploadMeta,
|
||||
"delete_dir": p.DeleteDir,
|
||||
"cron": p.Cron,
|
||||
"enable_cron": p.EnableCron,
|
||||
"sync_mode": p.SyncMode,
|
||||
"enabled": p.Enabled,
|
||||
"last_sync_at": p.LastSyncAt,
|
||||
"last_sync_status": p.LastSyncStatus,
|
||||
"last_sync_message": p.LastSyncMessage,
|
||||
"updated_at": time.Now(),
|
||||
"name": p.Name,
|
||||
"account_id": p.AccountID,
|
||||
"provider": p.Provider,
|
||||
"remote_path": p.RemotePath,
|
||||
"remote_display_path": p.RemoteDisplayPath,
|
||||
"local_path": p.LocalPath,
|
||||
"strm_base_url": p.StrmBaseURL,
|
||||
"video_ext": p.VideoExt,
|
||||
"meta_ext": p.MetaExt,
|
||||
"exclude_name": p.ExcludeName,
|
||||
"min_video_size_mb": p.MinVideoSizeMB,
|
||||
"add_path": p.AddPath,
|
||||
"download_meta": p.DownloadMeta,
|
||||
"upload_meta": p.UploadMeta,
|
||||
"delete_dir": p.DeleteDir,
|
||||
"keep_ext": p.KeepExt,
|
||||
"cron": p.Cron,
|
||||
"enable_cron": p.EnableCron,
|
||||
"sync_mode": p.SyncMode,
|
||||
"enabled": p.Enabled,
|
||||
"last_sync_at": p.LastSyncAt,
|
||||
"last_sync_status": p.LastSyncStatus,
|
||||
"last_sync_message": p.LastSyncMessage,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
@@ -860,6 +862,34 @@ func (r *StrmUploadTaskRepository) GetActiveLocalPathMap(ctx context.Context, sy
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetRecentDoneUploadSizeMap 返回近期已成功上传的 local_path → size。
|
||||
// 用于缩短「上传已 done 但 115 列表尚未反映」窗口内的重复入队:同路径且大小未变则跳过。
|
||||
// 同一路径存在多条 done 时取最新一条(finished_at 降序)。
|
||||
func (r *StrmUploadTaskRepository) GetRecentDoneUploadSizeMap(ctx context.Context, syncPathID string, since time.Time) (map[string]int64, error) {
|
||||
var rows []model.StrmUploadTask
|
||||
err := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
|
||||
Select("local_path", "size", "finished_at").
|
||||
Where("sync_path_id = ? AND status = ? AND finished_at IS NOT NULL AND finished_at >= ?",
|
||||
syncPathID, model.StrmTaskDone, since).
|
||||
Order("finished_at DESC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]int64, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.LocalPath == "" {
|
||||
continue
|
||||
}
|
||||
// 已按 finished_at DESC;先写入的是最新,后续同路径跳过
|
||||
if _, exists := out[row.LocalPath]; exists {
|
||||
continue
|
||||
}
|
||||
out[row.LocalPath] = row.Size
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *StrmUploadTaskRepository) DeleteFinishedOlderThan(ctx context.Context, before time.Time) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Unscoped().Where("status IN ? AND finished_at < ?",
|
||||
|
||||
@@ -7,8 +7,16 @@ import (
|
||||
"image/jpeg"
|
||||
_ "image/png" // register PNG decoder
|
||||
"strings"
|
||||
|
||||
"github.com/metatube-community/metatube-sdk-go/detector"
|
||||
)
|
||||
|
||||
const adultPosterRatio = 2.0 / 3.0
|
||||
|
||||
var findPrimaryFaceAxisRatio = func(img image.Image, ratio float64, advanced bool) (float64, bool) {
|
||||
return detector.FindPrimaryFaceAxisRatio(img, ratio, advanced)
|
||||
}
|
||||
|
||||
// IsAdultMediaPathOrMetadata reports whether media is adult based on path, mediaType, nsfw flag, or adult code.
|
||||
func IsAdultMediaPathOrMetadata(path, mediaType string, nsfw bool) bool {
|
||||
if nsfw {
|
||||
@@ -38,7 +46,8 @@ func IsAdultArtworkURL(raw string) bool {
|
||||
}
|
||||
|
||||
// CropAdultCoverPoster checks if the image data is a wide full-jacket DVD cover (width > height * 1.15).
|
||||
// If so, it crops the right portion (the front cover poster) and returns the encoded JPEG bytes.
|
||||
// If so, it uses MetaTube's Pigo face detector to position a 2:3 poster crop.
|
||||
// When no face is detected, it falls back to the conventional right-side front cover.
|
||||
// If the image is already portrait or cannot be decoded, it safely returns the original data.
|
||||
func CropAdultCoverPoster(data []byte) ([]byte, string, error) {
|
||||
if len(data) == 0 {
|
||||
@@ -59,32 +68,11 @@ func CropAdultCoverPoster(data []byte) ([]byte, string, error) {
|
||||
return data, format, nil
|
||||
}
|
||||
|
||||
// Front cover width is typically ~0.70-0.72 of the height (or right ~50-52% of total width)
|
||||
cropWidth := int(float64(height) * 0.71)
|
||||
maxCropWidth := int(float64(width) * 0.53)
|
||||
if cropWidth > maxCropWidth {
|
||||
cropWidth = maxCropWidth
|
||||
}
|
||||
if cropWidth <= 0 {
|
||||
cropWidth = width / 2
|
||||
}
|
||||
minX := bounds.Max.X - cropWidth
|
||||
if minX < bounds.Min.X {
|
||||
minX = bounds.Min.X
|
||||
}
|
||||
|
||||
cropRect := image.Rect(minX, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)
|
||||
type subImager interface {
|
||||
SubImage(r image.Rectangle) image.Image
|
||||
}
|
||||
var cropped image.Image
|
||||
if si, ok := src.(subImager); ok {
|
||||
cropped = si.SubImage(cropRect)
|
||||
} else {
|
||||
dst := image.NewRGBA(image.Rect(0, 0, cropRect.Dx(), cropRect.Dy()))
|
||||
draw.Draw(dst, dst.Bounds(), src, cropRect.Min, draw.Src)
|
||||
cropped = dst
|
||||
position := 1.0
|
||||
if detected, ok := findPrimaryFaceAxisRatio(src, adultPosterRatio, true); ok {
|
||||
position = detected
|
||||
}
|
||||
cropped := cropImageAtPosition(src, adultPosterRatio, position)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, cropped, &jpeg.Options{Quality: 92}); err != nil {
|
||||
@@ -92,3 +80,29 @@ func CropAdultCoverPoster(data []byte) ([]byte, string, error) {
|
||||
}
|
||||
return buf.Bytes(), "image/jpeg", nil
|
||||
}
|
||||
|
||||
func cropImageAtPosition(src image.Image, ratio, position float64) image.Image {
|
||||
bounds := src.Bounds()
|
||||
width := bounds.Dx()
|
||||
height := bounds.Dy()
|
||||
cropWidth, cropHeight := width, height
|
||||
x, y := 0, 0
|
||||
if candidate := int(float64(height) * ratio); candidate < width {
|
||||
cropWidth = candidate
|
||||
x = max(min(int(float64(width)*position)-cropWidth/2, width-cropWidth), 0)
|
||||
} else if candidate := int(float64(width) / ratio); candidate < height {
|
||||
cropHeight = candidate
|
||||
y = max(min(int(float64(height)*position)-cropHeight/2, height-cropHeight), 0)
|
||||
}
|
||||
cropRect := image.Rect(0, 0, cropWidth, cropHeight).
|
||||
Add(image.Pt(x, y)).
|
||||
Add(bounds.Min)
|
||||
if sub, ok := src.(interface {
|
||||
SubImage(image.Rectangle) image.Image
|
||||
}); ok {
|
||||
return sub.SubImage(cropRect)
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, cropRect.Dx(), cropRect.Dy()))
|
||||
draw.Draw(dst, dst.Bounds(), src, cropRect.Min, draw.Src)
|
||||
return dst
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ func TestCropAdultCoverPosterWideLandscape(t *testing.T) {
|
||||
if croppedWidth <= 0 || croppedWidth >= 800 {
|
||||
t.Fatalf("unexpected cropped width: %d", croppedWidth)
|
||||
}
|
||||
// Verify aspect ratio is roughly standard portrait (~0.68 - 0.73)
|
||||
// MetaTube's default primary-image ratio is 2:3.
|
||||
ratio := float64(croppedWidth) / float64(croppedHeight)
|
||||
if ratio < 0.65 || ratio > 0.75 {
|
||||
t.Fatalf("expected portrait ratio ~0.71, got %f (%dx%d)", ratio, croppedWidth, croppedHeight)
|
||||
if ratio < 0.65 || ratio > 0.68 {
|
||||
t.Fatalf("expected portrait ratio ~0.667, got %f (%dx%d)", ratio, croppedWidth, croppedHeight)
|
||||
}
|
||||
|
||||
// Verify the cropped image contains the right side color (blue), not the left side color (red)
|
||||
@@ -58,6 +58,30 @@ func TestCropAdultCoverPosterWideLandscape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCropAdultCoverPosterCentersDetectedFace(t *testing.T) {
|
||||
originalDetector := findPrimaryFaceAxisRatio
|
||||
findPrimaryFaceAxisRatio = func(image.Image, float64, bool) (float64, bool) {
|
||||
return 0.25, true
|
||||
}
|
||||
defer func() {
|
||||
findPrimaryFaceAxisRatio = originalDetector
|
||||
}()
|
||||
|
||||
origBytes := createTestImage(900, 600, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
|
||||
croppedBytes, _, err := CropAdultCoverPoster(origBytes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
croppedImg, _, err := image.Decode(bytes.NewReader(croppedBytes))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _, b, _ := croppedImg.At(croppedImg.Bounds().Dx()/2, croppedImg.Bounds().Dy()/2).RGBA()
|
||||
if r <= b {
|
||||
t.Fatalf("face-positioned crop did not follow detected left-side axis: r=%d b=%d", r, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCropAdultCoverPosterKeepsPortrait(t *testing.T) {
|
||||
// Create an already vertical portrait image (500x700)
|
||||
origBytes := createTestImage(500, 700, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255})
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -180,6 +181,7 @@ func (p *AdultProvider) SearchCandidates(ctx context.Context, query string) ([]*
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matches = p.enrichMetaTubeCandidates(ctx, mtCfg, code, matches)
|
||||
for _, m := range matches {
|
||||
m.OriginalName = code
|
||||
m.Title = FormatAdultTitle(code, m.Title)
|
||||
@@ -194,6 +196,7 @@ func (p *AdultProvider) SearchCandidates(ctx context.Context, query string) ([]*
|
||||
if mtCfg.ServerURL != "" {
|
||||
matches, err := p.metatube.Search(ctx, mtCfg, code)
|
||||
if err == nil && len(matches) > 0 {
|
||||
matches = p.enrichMetaTubeCandidates(ctx, mtCfg, code, matches)
|
||||
for _, m := range matches {
|
||||
m.OriginalName = code
|
||||
m.Title = FormatAdultTitle(code, m.Title)
|
||||
@@ -217,6 +220,50 @@ func (p *AdultProvider) SearchCandidates(ctx context.Context, query string) ([]*
|
||||
}
|
||||
}
|
||||
|
||||
// GetMetaTubeCandidate fetches the selected provider result instead of
|
||||
// re-running a search that may choose a different provider.
|
||||
func (p *AdultProvider) GetMetaTubeCandidate(ctx context.Context, provider, id string) (*Match, error) {
|
||||
if p == nil || p.metatube == nil {
|
||||
return nil, nil
|
||||
}
|
||||
provider = strings.TrimSpace(provider)
|
||||
id = strings.TrimSpace(id)
|
||||
if provider == "" || id == "" {
|
||||
return nil, nil
|
||||
}
|
||||
engine := strings.ToLower(p.getSetting(ctx, "adult.scraper.engine", "builtin"))
|
||||
if engine != "metatube" && engine != "auto" {
|
||||
return nil, nil
|
||||
}
|
||||
cfg := p.ResolveMetaTubeConfig(ctx)
|
||||
if cfg.ServerURL == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return p.metatube.GetMovie(ctx, cfg, provider, id)
|
||||
}
|
||||
|
||||
func (p *AdultProvider) enrichMetaTubeCandidates(ctx context.Context, cfg MetaTubeConfig, code string, matches []*Match) []*Match {
|
||||
var wg sync.WaitGroup
|
||||
for i, candidate := range matches {
|
||||
if candidate == nil ||
|
||||
normalizeAdultCode(candidate.OriginalName) != code ||
|
||||
strings.TrimSpace(candidate.DoubanID) == "" ||
|
||||
strings.TrimSpace(candidate.TheTVDBID) == "" {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(index int, current *Match) {
|
||||
defer wg.Done()
|
||||
detailed, err := p.metatube.GetMovie(ctx, cfg, current.TheTVDBID, current.DoubanID)
|
||||
if err == nil && detailed != nil {
|
||||
matches[index] = detailed
|
||||
}
|
||||
}(i, candidate)
|
||||
}
|
||||
wg.Wait()
|
||||
return matches
|
||||
}
|
||||
|
||||
func (p *AdultProvider) searchBuiltin(ctx context.Context, code string) (*Match, error) {
|
||||
bases := p.resolveBases(ctx)
|
||||
if len(bases) == 0 {
|
||||
@@ -243,12 +290,46 @@ func (p *AdultProvider) searchBuiltin(ctx context.Context, code string) (*Match,
|
||||
match.OriginalName = code
|
||||
match.Title = FormatAdultTitle(code, match.Title)
|
||||
match.NSFW = true
|
||||
p.applyMetaTubeFaceAwareArtwork(ctx, code, match)
|
||||
return match, nil
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (p *AdultProvider) applyMetaTubeFaceAwareArtwork(ctx context.Context, code string, match *Match) {
|
||||
if p == nil || p.metatube == nil || match == nil || strings.TrimSpace(match.PosterURL) == "" {
|
||||
return
|
||||
}
|
||||
// In explicit built-in mode MetaTube remains useful as an image processor:
|
||||
// its primary endpoint performs the same automatic face-aware crop used by
|
||||
// the official Jellyfin plugin. Auto mode already tried MetaTube metadata,
|
||||
// so avoid repeating the same failed request during fallback.
|
||||
if strings.ToLower(p.getSetting(ctx, "adult.scraper.engine", "builtin")) != "builtin" {
|
||||
return
|
||||
}
|
||||
cfg := p.ResolveMetaTubeConfig(ctx)
|
||||
if cfg.ServerURL == "" || !cfg.CropCover {
|
||||
return
|
||||
}
|
||||
candidates, err := p.metatube.Search(ctx, cfg, code)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate == nil || normalizeAdultCode(candidate.OriginalName) != code {
|
||||
continue
|
||||
}
|
||||
if candidate.PosterURL != "" {
|
||||
match.PosterURL = candidate.PosterURL
|
||||
}
|
||||
if candidate.BackdropURL != "" {
|
||||
match.BackdropURL = candidate.BackdropURL
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdultProvider) resolveBases(ctx context.Context) []string {
|
||||
customJavDB := p.getSetting(ctx, "adult.scraper.builtin_javdb_url", "")
|
||||
customJavBus := p.getSetting(ctx, "adult.scraper.builtin_javbus_url", "")
|
||||
|
||||
@@ -25,7 +25,8 @@ func TestAdultProviderRouting(t *testing.T) {
|
||||
repos := repository.New(db)
|
||||
|
||||
mtServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/movies/search" {
|
||||
switch r.URL.Path {
|
||||
case "/v1/movies/search":
|
||||
q := r.URL.Query().Get("q")
|
||||
if q == "SSIS-001" {
|
||||
results := struct {
|
||||
@@ -46,6 +47,21 @@ func TestAdultProviderRouting(t *testing.T) {
|
||||
_ = json.NewEncoder(w).Encode(results)
|
||||
return
|
||||
}
|
||||
case "/v1/movies/javdb/999":
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Data MetaTubeMovieInfo `json:"data"`
|
||||
}{
|
||||
Data: MetaTubeMovieInfo{
|
||||
ID: "999",
|
||||
Number: "SSIS-001",
|
||||
Title: "河北彩花 専属デビュー",
|
||||
Provider: "javdb",
|
||||
CoverURL: "https://example.com/poster.jpg",
|
||||
PreviewImages: []string{"https://example.com/backdrop.jpg"},
|
||||
ReleaseDate: "2021-06-19",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
@@ -80,6 +96,11 @@ func TestAdultProviderRouting(t *testing.T) {
|
||||
if len(candidates) != 1 {
|
||||
t.Fatalf("expected 1 candidate, got %d", len(candidates))
|
||||
}
|
||||
wantPoster := mtServer.URL + "/v1/images/primary/javdb/999?auto=true&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fposter.jpg"
|
||||
wantBackdrop := mtServer.URL + "/v1/images/backdrop/javdb/999?quality=90"
|
||||
if candidates[0].PosterURL != wantPoster || candidates[0].BackdropURL != wantBackdrop {
|
||||
t.Fatalf("candidate artwork was not enriched: %#v", candidates[0])
|
||||
}
|
||||
|
||||
// 2. Test auto mode with failing metatube query
|
||||
_ = repos.Setting.Set(context.Background(), "adult.scraper.engine", "auto")
|
||||
@@ -89,3 +110,68 @@ func TestAdultProviderRouting(t *testing.T) {
|
||||
t.Errorf("expected nil for nonexistent in auto mode when sources unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinAdultScrapeUsesMetaTubeFaceAwareArtwork(t *testing.T) {
|
||||
builtinServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/search":
|
||||
_, _ = w.Write([]byte(`<a class="box" href="/v/local"><strong>SSIS-001 本地候选</strong></a>`))
|
||||
case "/v/local":
|
||||
_, _ = w.Write([]byte(`<h2>SSIS-001 本地标题</h2><img class="video-cover" src="/wide-cover.jpg">`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer builtinServer.Close()
|
||||
|
||||
metaTubeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/movies/search" || r.URL.Query().Get("q") != "SSIS-001" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Data []MetaTubeSearchResult `json:"data"`
|
||||
}{
|
||||
Data: []MetaTubeSearchResult{{
|
||||
ID: "999",
|
||||
Number: "SSIS-001",
|
||||
Title: "MetaTube candidate",
|
||||
Provider: "AVE",
|
||||
CoverURL: "https://example.com/wide-cover.jpg",
|
||||
}},
|
||||
})
|
||||
}))
|
||||
defer metaTubeServer.Close()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Setting{}, &model.APIConfig{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
for key, value := range map[string]string{
|
||||
"adult.scraper.engine": "builtin",
|
||||
"adult.scraper.builtin_javdb_url": builtinServer.URL,
|
||||
"adult.scraper.metatube_server": metaTubeServer.URL,
|
||||
"adult.scraper.crop_cover": "true",
|
||||
} {
|
||||
if err := repos.Setting.Set(t.Context(), key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
provider := NewAdultProvider(zap.NewNop(), nil, repos)
|
||||
match, err := provider.Search(t.Context(), "SSIS-001")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if match == nil {
|
||||
t.Fatal("expected built-in match")
|
||||
}
|
||||
wantPoster := metaTubeServer.URL + "/v1/images/primary/AVE/999?auto=true&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fwide-cover.jpg"
|
||||
if match.PosterURL != wantPoster {
|
||||
t.Fatalf("built-in poster = %q, want face-aware URL %q", match.PosterURL, wantPoster)
|
||||
}
|
||||
}
|
||||
|
||||
+115
-1
@@ -3,10 +3,13 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -18,6 +21,12 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
type temporaryPasswordEntry struct {
|
||||
userID string
|
||||
username string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// AuthService handles registration, login, and JWT issuance.
|
||||
type AuthService struct {
|
||||
cfg *config.Config
|
||||
@@ -25,11 +34,21 @@ type AuthService struct {
|
||||
repo *repository.Container
|
||||
tokenSvc *TokenService
|
||||
permissionSvc *PermissionService
|
||||
|
||||
tempPassMu sync.RWMutex
|
||||
tempPasswords map[string]temporaryPasswordEntry
|
||||
}
|
||||
|
||||
// NewAuthService is the constructor.
|
||||
func NewAuthService(cfg *config.Config, log *zap.Logger, repo *repository.Container, tokenSvc *TokenService, permissionSvc *PermissionService) *AuthService {
|
||||
return &AuthService{cfg: cfg, log: log, repo: repo, tokenSvc: tokenSvc, permissionSvc: permissionSvc}
|
||||
return &AuthService{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
repo: repo,
|
||||
tokenSvc: tokenSvc,
|
||||
permissionSvc: permissionSvc,
|
||||
tempPasswords: make(map[string]temporaryPasswordEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// Common service-level errors.
|
||||
@@ -254,3 +273,98 @@ func hashPassword(p string) (string, error) {
|
||||
}
|
||||
return string(h), nil
|
||||
}
|
||||
|
||||
const temporaryPasswordTTL = 5 * time.Minute
|
||||
|
||||
// CreateTemporaryPassword 为指定用户生成一个 6 位数字的临时登录密码(有效期 5 分钟),
|
||||
// 供 Emby 电视端/客户端进行无键盘或快速输入登录。
|
||||
func (s *AuthService) CreateTemporaryPassword(ctx context.Context, userID string) (string, int, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return "", 0, errors.New("auth service unavailable")
|
||||
}
|
||||
user, err := s.repo.User.FindByID(ctx, userID)
|
||||
if err != nil || user == nil {
|
||||
return "", 0, ErrInvalidCredentials
|
||||
}
|
||||
if !user.IsActive {
|
||||
return "", 0, ErrUserInactive
|
||||
}
|
||||
if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) {
|
||||
return "", 0, ErrUserExpired
|
||||
}
|
||||
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(900000))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
code := fmt.Sprintf("%06d", n.Int64()+100000)
|
||||
|
||||
s.tempPassMu.Lock()
|
||||
defer s.tempPassMu.Unlock()
|
||||
now := time.Now()
|
||||
for k, v := range s.tempPasswords {
|
||||
if now.After(v.expiresAt) {
|
||||
delete(s.tempPasswords, k)
|
||||
}
|
||||
}
|
||||
s.tempPasswords[code] = temporaryPasswordEntry{
|
||||
userID: user.ID,
|
||||
username: user.Username,
|
||||
expiresAt: now.Add(temporaryPasswordTTL),
|
||||
}
|
||||
|
||||
return code, int(temporaryPasswordTTL.Seconds()), nil
|
||||
}
|
||||
|
||||
// VerifyAndConsumeTemporaryPassword 校验并消费临时登录密码(阅后即焚)。
|
||||
func (s *AuthService) VerifyAndConsumeTemporaryPassword(ctx context.Context, username, code string) (*model.User, bool) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, false
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if len(code) != 6 {
|
||||
return nil, false
|
||||
}
|
||||
s.tempPassMu.Lock()
|
||||
entry, ok := s.tempPasswords[code]
|
||||
if ok {
|
||||
delete(s.tempPasswords, code)
|
||||
}
|
||||
s.tempPassMu.Unlock()
|
||||
|
||||
if !ok || time.Now().After(entry.expiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if strings.TrimSpace(username) != "" && !strings.EqualFold(strings.TrimSpace(username), entry.username) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
user, err := s.repo.User.FindByID(ctx, entry.userID)
|
||||
if err != nil || user == nil || !user.IsActive {
|
||||
return nil, false
|
||||
}
|
||||
if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) {
|
||||
return nil, false
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
|
||||
// LoginWithTemporaryPassword 尝试使用 6 位数字临时登录密码 (OTP) 进行登录。
|
||||
func (s *AuthService) LoginWithTemporaryPassword(ctx context.Context, username, code string) (*LoginResponse, error) {
|
||||
user, ok := s.VerifyAndConsumeTemporaryPassword(ctx, username, code)
|
||||
if !ok || user == nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if s.tokenSvc == nil {
|
||||
return nil, errors.New("token service unavailable")
|
||||
}
|
||||
tokens, err := s.tokenSvc.IssuePair(ctx, user.ID, user.Role, user.Tier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LoginResponse{
|
||||
User: user,
|
||||
Tokens: tokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -87,15 +87,15 @@ func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*Dir
|
||||
if ref == "/" {
|
||||
return nil, fmt.Errorf("%s: file reference required", p.name)
|
||||
}
|
||||
if p.typ == TypeOpenList && isCloudVideoPlaybackCandidate(ref) {
|
||||
if p.apiBase == nil {
|
||||
return nil, fmt.Errorf("%s: pure 302 playback requires an OpenList API server address; configure server/api_url so /api/fs/get can return raw_url", p.name)
|
||||
}
|
||||
if p.typ == TypeOpenList && p.apiBase != nil {
|
||||
link, err := p.resolveOpenListAPIDirect(ctx, ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: pure 302 playback requires OpenList raw_url for %s: %w", p.name, ref, err)
|
||||
if err == nil {
|
||||
return link, nil
|
||||
}
|
||||
// API 获取直链失败:非视频文件(元数据)回退到 WebDAV;视频文件报错
|
||||
if isCloudVideoPlaybackCandidate(ref) {
|
||||
return nil, fmt.Errorf("%s: resolve download URL for %s via API failed: %w", p.name, ref, err)
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
if p.typ == TypeCloudDrive2 && isCloudVideoPlaybackCandidate(ref) {
|
||||
link, err := p.resolveCloudDAVRedirectDirect(ctx, ref)
|
||||
|
||||
@@ -61,9 +61,10 @@ func TestOpenListWebDAVListAndResolve(t *testing.T) {
|
||||
if len(entries) != 1 || entries[0].ID != "/Cloud/Movie.mkv" || entries[0].Size != 1024 {
|
||||
t.Fatalf("entries = %#v", entries)
|
||||
}
|
||||
// Video file: API fails → error (no WebDAV fallback for video)
|
||||
_, err = p.Resolve(context.Background(), entries[0].ID)
|
||||
if err == nil || !strings.Contains(err.Error(), "pure 302 playback requires OpenList raw_url") {
|
||||
t.Fatalf("openlist video resolve should require raw_url instead of WebDAV proxy fallback, err=%v", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "resolve download URL") || !strings.Contains(err.Error(), "via API failed") {
|
||||
t.Fatalf("openlist video resolve should error on API failure, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -195,10 +195,43 @@ func TestOpenListResolveDoesNotFallbackToWebDAVWhenAPIRawURLFails(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = p.Resolve(context.Background(), "/Cloud/Movie.mkv")
|
||||
if err == nil || !strings.Contains(err.Error(), "pure 302 playback requires OpenList raw_url") {
|
||||
t.Fatalf("resolve error = %v, want raw_url requirement", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "resolve download URL") || !strings.Contains(err.Error(), "via API failed") {
|
||||
t.Fatalf("resolve error = %v, want API resolve failure", err)
|
||||
}
|
||||
if davSeen {
|
||||
t.Fatal("openlist video resolve fell back to WebDAV after raw_url failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenListResolveMetadataUsesAPIInsteadOfWebDAV(t *testing.T) {
|
||||
var gotPath, gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/fs/get" {
|
||||
t.Fatalf("unexpected request %s %s; metadata should use API, not WebDAV", r.Method, r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"https://cdn.example.test/poster.jpg?sign=1"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// .nfo metadata file should use API, not WebDAV
|
||||
link, err := p.Resolve(context.Background(), "/Cloud/Movie/Movie.nfo")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if gotPath != "/api/fs/get" {
|
||||
t.Fatalf("api path = %q, want /api/fs/get (metadata should not use WebDAV)", gotPath)
|
||||
}
|
||||
if gotAuth != "alist-token" {
|
||||
t.Fatalf("Authorization = %q, want token", gotAuth)
|
||||
}
|
||||
if link.URL != "https://cdn.example.test/poster.jpg?sign=1" {
|
||||
t.Fatalf("url = %q", link.URL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,35 +123,41 @@ func (p *openAPI115Provider) ResolveBatch(ctx context.Context, fileRefs []string
|
||||
// OpenClient 暴露底层客户端(token 刷新用)。
|
||||
func (p *openAPI115Provider) OpenClient() *cloud115.OpenClient { return p.c }
|
||||
|
||||
// PutLocalFile 直接上传本地文件,避免通过 io.Reader 复制临时文件产生的磁盘开销与并发重命名碰撞。
|
||||
func (p *openAPI115Provider) PutLocalFile(ctx context.Context, parentCID, localPath string) error {
|
||||
_, err := p.c.Upload(ctx, localPath, parentCID, "", "")
|
||||
return err
|
||||
}
|
||||
|
||||
// PutFileNamed 把本地元数据上传到 115 指定父目录(parentCID 为父目录 cid)。
|
||||
// io.Reader 无法携带文件名,因此走独立的 named 上传接口。将内容落为临时文件后
|
||||
// 重命名为目标文件名,再交给 115 上传(/open/upload/init 的 file_name 取真实文件名)。
|
||||
// 为防止多并发上传线程在同一临时目录下发生同名文件(如 poster.jpg)碰撞覆盖与误删,
|
||||
// 为每个上传任务分配专属临时子目录。
|
||||
func (p *openAPI115Provider) PutFileNamed(ctx context.Context, parentCID, fileName string, r io.Reader) error {
|
||||
tmp, err := os.CreateTemp("", "mebox-upload-*")
|
||||
tmpDir, err := os.MkdirTemp("", "mebox-upload-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("115: 创建临时目录失败:%w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
}()
|
||||
|
||||
safeName := filepath.Base(fileName)
|
||||
if safeName == "" || safeName == "." {
|
||||
safeName = "file"
|
||||
}
|
||||
tmpPath := filepath.Join(tmpDir, safeName)
|
||||
dst, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("115: 创建临时文件失败:%w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer func() {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
}()
|
||||
if _, err := io.Copy(tmp, r); err != nil {
|
||||
if _, err := io.Copy(dst, r); err != nil {
|
||||
_ = dst.Close()
|
||||
return fmt.Errorf("115: 写入临时文件失败:%w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
if err := dst.Close(); err != nil {
|
||||
return fmt.Errorf("115: 关闭临时文件失败:%w", err)
|
||||
}
|
||||
// 重命名为目标文件名,保证上传到 115 后保留原始文件名。
|
||||
// 重命名失败必须 fail fast:静默用随机临时名上传会导致 115 上的文件名
|
||||
// 变成 mebox-upload-xxx,破坏元数据文件名契约。
|
||||
if fileName != "" && fileName != filepath.Base(tmpPath) {
|
||||
namedPath := filepath.Join(filepath.Dir(tmpPath), fileName)
|
||||
if err := os.Rename(tmpPath, namedPath); err != nil {
|
||||
return fmt.Errorf("115: 重命名临时文件为 %s 失败:%w", fileName, err)
|
||||
}
|
||||
tmpPath = namedPath
|
||||
}
|
||||
|
||||
_, err = p.c.Upload(ctx, tmpPath, parentCID, "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -150,6 +150,7 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attemptedAccess := strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ")
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
@@ -218,7 +219,7 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
|
||||
// refresh_token 刷新后重试一次。刷新失败或重试后仍失败才返回,
|
||||
// 避免长时间同步因 token 过期而整体失败。
|
||||
if isTokenCode(base.Code) {
|
||||
if access && c.tryRefreshTokenLocked(ctx) {
|
||||
if access && c.tryRefreshTokenLocked(ctx, attemptedAccess) {
|
||||
continue
|
||||
}
|
||||
if access {
|
||||
@@ -305,21 +306,23 @@ func (c *OpenClient) doAuthJSONWithUA(ctx context.Context, method, rawURL string
|
||||
// tryRefreshTokenLocked 并发安全地刷新 access_token;成功返回 true(调用方
|
||||
// 应使用内存中的新 token 重试原请求)。
|
||||
//
|
||||
// 拿到写锁后在锁内读取 oldAccess,与持锁期间的当前值对比:若已被其他
|
||||
// goroutine 刷新过则直接复用新 token,避免并发请求连环轮转消耗 115 的
|
||||
// 一次性 refresh_token。全程持写锁读写 token 字段,无 TOCTOU 窗口。
|
||||
// failedAccess 是失败请求实际携带的 token。拿到写锁后与当前 token 对比:
|
||||
// 若已被其他 goroutine 刷新过则直接复用,避免并发请求连环轮转消耗 115
|
||||
// 的一次性 refresh_token。全程持写锁读写 token 字段,无 TOCTOU 窗口。
|
||||
//
|
||||
// 对"refresh_token 本身已失效/被吊销"(IsRefreshTokenDead,如 40140114/116/119/120)
|
||||
// 这类不可恢复的错误直接放弃并清空内存 token(提示需重新授权)。
|
||||
// 对其它失败(网络瞬时抖动、刷新接口可重试错误码等)做指数退避重试几次再放弃,
|
||||
// 避免同步长任务中途 token 到期时恰好撞上一个短暂的刷新失败就整体失败。
|
||||
func (c *OpenClient) tryRefreshTokenLocked(ctx context.Context) bool {
|
||||
func (c *OpenClient) tryRefreshTokenLocked(ctx context.Context, failedAccess string) bool {
|
||||
c.tokenMu.Lock()
|
||||
// 在已持有写锁内读取当前 token 作为"刷新前快照",消除双重加锁窗口:
|
||||
// 若在拿锁期间已有其他 goroutine 完成刷新,refreshTokenWhileLocked
|
||||
// 内的 c.AccessToken != oldAccess 判断会立即命中并返回复用。
|
||||
oldAccess := c.AccessToken
|
||||
newToken, ok := c.refreshTokenWhileLocked(ctx, oldAccess)
|
||||
// 请求发出后若其他 goroutine 已经刷新完成,直接复用新 token 重试;
|
||||
// 不能再次轮换一次性的 refresh_token。
|
||||
if failedAccess != "" && c.AccessToken != failedAccess {
|
||||
c.tokenMu.Unlock()
|
||||
return true
|
||||
}
|
||||
newToken, ok := c.refreshTokenWhileLocked(ctx, failedAccess)
|
||||
c.tokenMu.Unlock()
|
||||
// 回调必须在 tokenMu 释放后调用,避免上层在回调内访问客户端时死锁
|
||||
if ok && newToken != nil && c.OnTokenRefreshed != nil {
|
||||
|
||||
@@ -140,6 +140,47 @@ func TestRefreshTokenDead(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentTokenFailuresShareOneRefresh(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
refreshCalls := 0
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/open/refreshToken" {
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
mu.Lock()
|
||||
refreshCalls++
|
||||
mu.Unlock()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(`{"state":true,"data":{"access_token":"at-new","refresh_token":"rt-new","expires_in":7200}}`))
|
||||
})
|
||||
|
||||
client := NewOpenClient("100195129", "at-old", "rt-old")
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan bool, 2)
|
||||
for range 2 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
results <- client.tryRefreshTokenLocked(context.Background(), "at-old")
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
for ok := range results {
|
||||
if !ok {
|
||||
t.Fatal("concurrent refresh should reuse the refreshed token")
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if refreshCalls != 1 {
|
||||
t.Fatalf("refresh calls = %d, want 1", refreshCalls)
|
||||
}
|
||||
if client.CurrentAccessToken() != "at-new" {
|
||||
t.Fatalf("access token = %q, want at-new", client.CurrentAccessToken())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFsListAndDownload(t *testing.T) {
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
|
||||
@@ -119,6 +119,60 @@ func (c *OpenClient) GetFsListFlat(ctx context.Context, cid string, offset, limi
|
||||
return files, resp.Count, nil
|
||||
}
|
||||
|
||||
// FindNamedContentInParent 在父目录下查找与本地内容一致的同名文件。
|
||||
// 匹配条件:文件名完全一致、大小一致,且 SHA1 为空或与 expectedSHA1 大小写不敏感相等。
|
||||
// 同时返回该目录下全部同名文件(含未匹配的脏副本),便于上传前清理。
|
||||
// 目录过大时分页扫描,最多拉取 maxListPages 页(每页 pageSize 条)。
|
||||
func (c *OpenClient) FindNamedContentInParent(ctx context.Context, parentCID, fileName, expectedSHA1 string, expectedSize int64) (matched *RemoteFile, sameName []RemoteFile, err error) {
|
||||
fileName = strings.TrimSpace(fileName)
|
||||
if fileName == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
const pageSize = 200
|
||||
const maxListPages = 20 // 最多扫描 4000 项,元数据父目录通常远小于此
|
||||
expectedSHA1 = strings.TrimSpace(expectedSHA1)
|
||||
offset := 0
|
||||
for page := 0; page < maxListPages; page++ {
|
||||
files, _, listErr := c.GetFsList(ctx, parentCID, offset, pageSize)
|
||||
if listErr != nil {
|
||||
return nil, sameName, listErr
|
||||
}
|
||||
if len(files) == 0 {
|
||||
break
|
||||
}
|
||||
for i := range files {
|
||||
f := files[i]
|
||||
if f.Category == TypeDir {
|
||||
continue
|
||||
}
|
||||
// fta=0/2 表示未上传完成,不可作为已存在副本
|
||||
if f.Fta == "0" || f.Fta == "2" {
|
||||
continue
|
||||
}
|
||||
if f.FileName != fileName {
|
||||
continue
|
||||
}
|
||||
sameName = append(sameName, f)
|
||||
if matched != nil {
|
||||
continue
|
||||
}
|
||||
if f.FileSize != expectedSize {
|
||||
continue
|
||||
}
|
||||
remoteSha := strings.TrimSpace(f.Sha1)
|
||||
if remoteSha == "" || remoteSha == "-" || strings.EqualFold(remoteSha, expectedSHA1) {
|
||||
cp := f
|
||||
matched = &cp
|
||||
}
|
||||
}
|
||||
if len(files) < pageSize {
|
||||
break
|
||||
}
|
||||
offset += len(files)
|
||||
}
|
||||
return matched, sameName, nil
|
||||
}
|
||||
|
||||
// GetFsDetailByCid 查询文件(夹)详情。
|
||||
func (c *OpenClient) GetFsDetailByCid(ctx context.Context, fileId string) (*RemoteFileDetail, error) {
|
||||
params := map[string]string{"file_id": fileId}
|
||||
@@ -423,24 +477,32 @@ func (c *OpenClient) GetToken(qrCode *QrCodeDataReturn) (*TokenData, error) {
|
||||
|
||||
// RefreshToken 刷新访问令牌。
|
||||
func (c *OpenClient) RefreshToken(refreshToken string) (*TokenData, error) {
|
||||
c.tokenMu.Lock()
|
||||
if refreshToken == "" {
|
||||
refreshToken = c.currentRefreshToken()
|
||||
refreshToken = c.RefreshTokenStr
|
||||
}
|
||||
if refreshToken == "" {
|
||||
c.tokenMu.Unlock()
|
||||
return nil, fmt.Errorf("没有可用的 refresh_token")
|
||||
}
|
||||
token, err := c.doRefreshToken(refreshToken)
|
||||
if err != nil {
|
||||
// refresh_token 已失效时清空内存令牌(提示需重新授权)
|
||||
if IsRefreshTokenDead(err) {
|
||||
c.SetAuthToken("", "")
|
||||
c.setAuthTokenLocked("", "")
|
||||
}
|
||||
c.tokenMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
if token.AccessToken == "" || token.RefreshToken == "" {
|
||||
c.tokenMu.Unlock()
|
||||
return nil, fmt.Errorf("115: 刷新返回空凭证(access_token/refresh_token 缺失)")
|
||||
}
|
||||
c.SetAuthToken(token.AccessToken, token.RefreshToken)
|
||||
c.setAuthTokenLocked(token.AccessToken, token.RefreshToken)
|
||||
c.tokenMu.Unlock()
|
||||
if c.OnTokenRefreshed != nil {
|
||||
c.OnTokenRefreshed(token.AccessToken, token.RefreshToken)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package cloud115
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindNamedContentInParentMatchAndSameName(t *testing.T) {
|
||||
sha := "AABBCCDDEEFF00112233445566778899AABBCCDD"
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/open/ufile/files" {
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
w.Write([]byte(fmt.Sprintf(`{"state":true,"data":[
|
||||
{"fid":"dir-1","fc":"0","fn":"sub","fs":0},
|
||||
{"fid":"keep","fc":"1","fn":"a.nfo","fs":10,"sha1":%q,"fta":"1"},
|
||||
{"fid":"dirty","fc":"1","fn":"a.nfo","fs":10,"sha1":"OTHER","fta":"1"},
|
||||
{"fid":"other","fc":"1","fn":"b.nfo","fs":10,"sha1":%q,"fta":"1"},
|
||||
{"fid":"incomplete","fc":"1","fn":"a.nfo","fs":10,"sha1":%q,"fta":"0"}
|
||||
]}`, sha, sha, sha)))
|
||||
})
|
||||
c := NewOpenClient("100195125", "at1", "rt1")
|
||||
matched, sameName, err := c.FindNamedContentInParent(context.Background(), "parent", "a.nfo", sha, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if matched == nil || matched.FileId != "keep" {
|
||||
t.Fatalf("matched = %+v, want keep", matched)
|
||||
}
|
||||
if len(sameName) != 2 { // keep + dirty;incomplete 被 fta 过滤
|
||||
t.Fatalf("sameName len=%d, want 2 (incomplete excluded)", len(sameName))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindNamedContentInParentNoMatch(t *testing.T) {
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"state":true,"data":[
|
||||
{"fid":"x","fc":"1","fn":"a.nfo","fs":9,"sha1":"OTHER","fta":"1"}
|
||||
]}`))
|
||||
})
|
||||
c := NewOpenClient("100195125", "at1", "rt1")
|
||||
matched, sameName, err := c.FindNamedContentInParent(context.Background(), "parent", "a.nfo", "WANT", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if matched != nil {
|
||||
t.Fatalf("expected no match, got %+v", matched)
|
||||
}
|
||||
if len(sameName) != 1 {
|
||||
t.Fatalf("sameName should still list name hits, got %d", len(sameName))
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package service — AES-GCM crypto helper for at-rest secrets.
|
||||
//
|
||||
// Sensitive fields (third-party API keys, qBittorrent passwords, …) are
|
||||
// Sensitive fields (third-party API keys, service passwords, …) are
|
||||
// stored in SQLite. We encrypt them with AES-256-GCM keyed off the JWT
|
||||
// secret so a stolen DB file alone is not enough to recover the
|
||||
// plaintext credentials.
|
||||
|
||||
@@ -369,7 +369,10 @@ func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danma
|
||||
} else if name := strings.TrimSpace(m.Title); name != "" {
|
||||
term.name = name
|
||||
} else {
|
||||
term.name = strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))
|
||||
term.name = mediaSidecarBase(m.Path)
|
||||
if term.name == "" {
|
||||
term.name = strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))
|
||||
}
|
||||
}
|
||||
if m.EpisodeNum > 0 {
|
||||
term.episode = strconv.Itoa(m.EpisodeNum)
|
||||
@@ -527,23 +530,7 @@ func (s *DanmakuService) hashCachePut(stamp, hash string) {
|
||||
// ("xxx.mkv.strm") — so a second strip removes a real video extension only
|
||||
// (filepath.Ext would misread names like "xxx.第01话" as having an extension).
|
||||
func danmakuMatchFileName(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
clean := strings.ReplaceAll(path, "\\", "/")
|
||||
if idx := strings.LastIndex(clean, "/"); idx >= 0 {
|
||||
clean = clean[idx+1:]
|
||||
}
|
||||
base := filepath.Base(clean)
|
||||
if ext := filepath.Ext(base); ext != "" {
|
||||
base = strings.TrimSuffix(base, ext)
|
||||
}
|
||||
if second := strings.ToLower(filepath.Ext(base)); second != "" {
|
||||
if _, ok := videoExtensions[second]; ok && second != ".strm" {
|
||||
base = strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(base)
|
||||
return mediaSidecarBase(path)
|
||||
}
|
||||
|
||||
// mediaHash returns the dandanplay match hash (MD5 of the first 16MB of the
|
||||
|
||||
@@ -2,7 +2,11 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -455,6 +459,13 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
|
||||
|
||||
runTimeTicks := int64(m.DurationSec) * 10_000_000
|
||||
durationMs := int64(m.DurationSec) * 1000
|
||||
if durationMs <= 0 && posMs > 0 {
|
||||
durationMs = posMs * 2
|
||||
if durationMs < 30*60*1000 {
|
||||
durationMs = 30 * 60 * 1000
|
||||
}
|
||||
runTimeTicks = durationMs * 10_000
|
||||
}
|
||||
played := posMs > 0 && durationMs > 0 && posMs >= durationMs*9/10
|
||||
pct := 0.0
|
||||
if durationMs > 0 {
|
||||
@@ -488,6 +499,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
|
||||
"ImageTags": imageTags,
|
||||
"BackdropImageTags": backdropTags,
|
||||
"Genres": splitCSV(m.Genres),
|
||||
"People": e.resolveMediaPeople(ctx, m),
|
||||
"ProviderIds": map[string]string{
|
||||
"Tmdb": intToStr(m.TMDbID),
|
||||
"Bangumi": intToStr(m.BangumiID),
|
||||
@@ -501,8 +513,106 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
|
||||
},
|
||||
"MediaSources": e.mediaSourcesForItem(ctx, m, true, false),
|
||||
}
|
||||
if primaryArtwork != "" {
|
||||
item["PrimaryImageTag"] = m.ID
|
||||
}
|
||||
if seriesID != "" {
|
||||
if sEntry, ok, _ := e.payloadSeriesEntry(ctx, seriesID); ok {
|
||||
if sEntry.posterURL != "" {
|
||||
item["SeriesPrimaryImageTag"] = seriesID
|
||||
}
|
||||
if len(backdropTags) == 0 && sEntry.backdropURL != "" {
|
||||
item["ParentBackdropItemId"] = seriesID
|
||||
item["ParentBackdropImageTags"] = []string{seriesID + "-bd"}
|
||||
}
|
||||
}
|
||||
}
|
||||
if premiered, ok := embyPremiereDate(m.ReleaseDate); ok {
|
||||
item["PremiereDate"] = premiered
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []map[string]any {
|
||||
if m == nil || strings.TrimSpace(m.Path) == "" {
|
||||
return []map[string]any{}
|
||||
}
|
||||
dir := filepath.Dir(m.Path)
|
||||
candidates := make([]string, 0, 6)
|
||||
seenPath := map[string]struct{}{}
|
||||
add := func(path string) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(filepath.Clean(path))
|
||||
if _, ok := seenPath[key]; ok {
|
||||
return
|
||||
}
|
||||
seenPath[key] = struct{}{}
|
||||
candidates = append(candidates, path)
|
||||
}
|
||||
// 共享词干优先(竞女01.mkv.strm → 竞女01.nfo),并兼容旧的单层剥扩展命名。
|
||||
add(nfoPath(m.Path))
|
||||
for _, base := range mediaSidecarBaseVariants(m.Path) {
|
||||
add(filepath.Join(dir, base+".nfo"))
|
||||
}
|
||||
add(filepath.Join(dir, "movie.nfo"))
|
||||
add(filepath.Join(dir, "tvshow.nfo"))
|
||||
|
||||
people := make([]map[string]any, 0)
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, p := range candidates {
|
||||
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
|
||||
doc, _, err := decodeNFOFile(p)
|
||||
if err == nil && doc != nil {
|
||||
for _, d := range doc.Directors {
|
||||
name := strings.TrimSpace(d)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
personID := embyPersonID(name, "Director")
|
||||
if seen[personID] {
|
||||
continue
|
||||
}
|
||||
seen[personID] = true
|
||||
people = append(people, map[string]any{
|
||||
"Id": personID,
|
||||
"Name": name,
|
||||
"Type": "Director",
|
||||
"Role": "Director",
|
||||
})
|
||||
}
|
||||
for _, a := range doc.Actors {
|
||||
name := strings.TrimSpace(a.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
personID := embyPersonID(name, "Actor")
|
||||
if seen[personID] {
|
||||
continue
|
||||
}
|
||||
seen[personID] = true
|
||||
role := strings.TrimSpace(a.Role)
|
||||
if role == "" {
|
||||
role = "Actor"
|
||||
}
|
||||
people = append(people, map[string]any{
|
||||
"Id": personID,
|
||||
"Name": name,
|
||||
"Type": "Actor",
|
||||
"Role": role,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return people
|
||||
}
|
||||
|
||||
func embyPersonID(name, roleType string) string {
|
||||
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(name)) + ":" + strings.ToLower(strings.TrimSpace(roleType))))
|
||||
return "person-" + hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
@@ -23,10 +23,17 @@ type embyLibraryTypeEntry struct {
|
||||
found bool // 库不存在时 found=false,调用方可退回计数启发式
|
||||
}
|
||||
|
||||
type embyPayloadSeriesEntry struct {
|
||||
title string
|
||||
posterURL string
|
||||
backdropURL string
|
||||
found bool
|
||||
}
|
||||
|
||||
type embyPayloadCache struct {
|
||||
mu sync.Mutex
|
||||
libTypes map[string]embyLibraryTypeEntry
|
||||
series map[string]string // series_id -> title("" 表示不存在/无标题)
|
||||
series map[string]embyPayloadSeriesEntry
|
||||
}
|
||||
|
||||
func (c *embyPayloadCache) libraryType(id string) (embyLibraryTypeEntry, bool) {
|
||||
@@ -45,14 +52,33 @@ func (c *embyPayloadCache) setLibraryType(id string, entry embyLibraryTypeEntry)
|
||||
func (c *embyPayloadCache) seriesTitle(id string) (string, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
title, ok := c.series[id]
|
||||
return title, ok
|
||||
entry, ok := c.series[id]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return entry.title, true
|
||||
}
|
||||
|
||||
func (c *embyPayloadCache) seriesEntry(id string) (embyPayloadSeriesEntry, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
entry, ok := c.series[id]
|
||||
return entry, ok
|
||||
}
|
||||
|
||||
func (c *embyPayloadCache) setSeriesEntry(id string, entry embyPayloadSeriesEntry) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.series[id] = entry
|
||||
}
|
||||
|
||||
func (c *embyPayloadCache) setSeriesTitle(id, title string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.series[id] = title
|
||||
entry := c.series[id]
|
||||
entry.title = title
|
||||
entry.found = true
|
||||
c.series[id] = entry
|
||||
}
|
||||
|
||||
// withPayloadCache attaches a fresh request-scoped cache if none exists yet.
|
||||
@@ -65,7 +91,7 @@ func (e *EmbyService) withPayloadCache(ctx context.Context) context.Context {
|
||||
}
|
||||
return context.WithValue(ctx, embyPayloadCacheKey{}, &embyPayloadCache{
|
||||
libTypes: map[string]embyLibraryTypeEntry{},
|
||||
series: map[string]string{},
|
||||
series: map[string]embyPayloadSeriesEntry{},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -113,17 +139,22 @@ func (e *EmbyService) prefetchPayloadCache(ctx context.Context, rows []model.Med
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(seriesIDs) > 0 {
|
||||
var series []model.Series
|
||||
if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id IN ?", seriesIDs).Find(&series).Error; err == nil {
|
||||
for _, s := range series {
|
||||
cache.setSeriesTitle(s.ID, s.Title)
|
||||
if len(seriesIDs) > 0 {
|
||||
var series []model.Series
|
||||
if err := e.repo.DB.WithContext(ctx).Select("id, title, poster_url, backdrop_url").Where("id IN ?", seriesIDs).Find(&series).Error; err == nil {
|
||||
for _, s := range series {
|
||||
cache.setSeriesEntry(s.ID, embyPayloadSeriesEntry{
|
||||
title: s.Title,
|
||||
posterURL: s.PosterURL,
|
||||
backdropURL: s.BackdropURL,
|
||||
found: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// payloadLibraryType resolves a library type through the request cache,
|
||||
// payloadLibraryType resolves a library type through the request cache,
|
||||
// falling back to a direct lookup when no cache is attached. found=false
|
||||
// means the library row does not exist (soft-deleted or orphaned id).
|
||||
func (e *EmbyService) payloadLibraryType(ctx context.Context, libraryID string) (typ string, found bool, err error) {
|
||||
@@ -152,24 +183,50 @@ func (e *EmbyService) payloadLibraryType(ctx context.Context, libraryID string)
|
||||
// payloadSeriesTitle resolves a series title through the request cache,
|
||||
// falling back to a direct lookup when no cache is attached.
|
||||
func (e *EmbyService) payloadSeriesTitle(ctx context.Context, seriesID string) (string, bool, error) {
|
||||
if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok {
|
||||
if title, hit := cache.seriesTitle(seriesID); hit {
|
||||
return title, true, nil
|
||||
}
|
||||
var s model.Series
|
||||
if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id = ?", seriesID).First(&s).Error; err != nil {
|
||||
cache.setSeriesTitle(seriesID, "")
|
||||
return "", true, nil
|
||||
}
|
||||
cache.setSeriesTitle(s.ID, s.Title)
|
||||
return s.Title, true, nil
|
||||
}
|
||||
series, err := e.repo.Series.FindByID(ctx, seriesID)
|
||||
if err != nil {
|
||||
entry, ok, err := e.payloadSeriesEntry(ctx, seriesID)
|
||||
if err != nil || !ok {
|
||||
return "", false, err
|
||||
}
|
||||
if series == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
return series.Title, true, nil
|
||||
return entry.title, true, nil
|
||||
}
|
||||
|
||||
// payloadSeriesEntry resolves a series entry through the request cache,
|
||||
// falling back to a direct lookup when no cache is attached.
|
||||
func (e *EmbyService) payloadSeriesEntry(ctx context.Context, seriesID string) (embyPayloadSeriesEntry, bool, error) {
|
||||
if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok {
|
||||
if entry, hit := cache.seriesEntry(seriesID); hit {
|
||||
return entry, entry.found, nil
|
||||
}
|
||||
if e.repo != nil && e.repo.Series != nil {
|
||||
s, err := e.repo.Series.FindByID(ctx, seriesID)
|
||||
if err != nil || s == nil {
|
||||
cache.setSeriesEntry(seriesID, embyPayloadSeriesEntry{})
|
||||
return embyPayloadSeriesEntry{}, false, err
|
||||
}
|
||||
entry := embyPayloadSeriesEntry{
|
||||
title: s.Title,
|
||||
posterURL: s.PosterURL,
|
||||
backdropURL: s.BackdropURL,
|
||||
found: true,
|
||||
}
|
||||
cache.setSeriesEntry(seriesID, entry)
|
||||
return entry, true, nil
|
||||
}
|
||||
}
|
||||
if e.repo != nil && e.repo.Series != nil {
|
||||
series, err := e.repo.Series.FindByID(ctx, seriesID)
|
||||
if err != nil {
|
||||
return embyPayloadSeriesEntry{}, false, err
|
||||
}
|
||||
if series == nil {
|
||||
return embyPayloadSeriesEntry{}, false, nil
|
||||
}
|
||||
return embyPayloadSeriesEntry{
|
||||
title: series.Title,
|
||||
posterURL: series.PosterURL,
|
||||
backdropURL: series.BackdropURL,
|
||||
found: true,
|
||||
}, true, nil
|
||||
}
|
||||
return embyPayloadSeriesEntry{}, false, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func TestResolveMediaPeopleUsesSharedStemBesideKeepExtStrm(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
dir := t.TempDir()
|
||||
mediaPath := filepath.Join(dir, "竞女01.mkv.strm")
|
||||
nfoPath := filepath.Join(dir, "竞女01.nfo")
|
||||
if err := os.WriteFile(mediaPath, []byte("http://example/play"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nfo := `<movie>
|
||||
<title>竞女01</title>
|
||||
<director>导演甲</director>
|
||||
<actor><name>演员乙</name><role>主角</role></actor>
|
||||
</movie>`
|
||||
if err := os.WriteFile(nfoPath, []byte(nfo), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
people := svc.resolveMediaPeople(t.Context(), &model.Media{Path: mediaPath, Title: "竞女01"})
|
||||
if len(people) != 2 {
|
||||
t.Fatalf("people=%d want 2: %#v", len(people), people)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, p := range people {
|
||||
got[p["Name"].(string)] = p["Type"].(string)
|
||||
}
|
||||
if got["导演甲"] != "Director" || got["演员乙"] != "Actor" {
|
||||
t.Fatalf("unexpected people: %#v", people)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMediaPeopleStillReadsLegacyKeepExtNFO(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
dir := t.TempDir()
|
||||
mediaPath := filepath.Join(dir, "竞女01.mkv.strm")
|
||||
legacyNFO := filepath.Join(dir, "竞女01.mkv.nfo")
|
||||
if err := os.WriteFile(mediaPath, []byte("http://example/play"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(legacyNFO, []byte(`<movie><director>旧导演</director></movie>`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
people := svc.resolveMediaPeople(t.Context(), &model.Media{Path: mediaPath})
|
||||
if len(people) != 1 || people[0]["Name"] != "旧导演" {
|
||||
t.Fatalf("expected legacy keep_ext nfo people, got %#v", people)
|
||||
}
|
||||
}
|
||||
@@ -321,7 +321,7 @@ func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedde
|
||||
func (e *EmbyService) baseMediaSource(ctx context.Context, m *model.Media, container string, isCloud bool, playURL string, directOnly bool) map[string]any {
|
||||
return map[string]any{
|
||||
"Id": m.ID,
|
||||
"Name": m.Title,
|
||||
"Name": MediaVersionLabel(*m),
|
||||
"Path": embyMediaSourcePath(m),
|
||||
"Container": container,
|
||||
"Size": m.SizeBytes,
|
||||
|
||||
@@ -139,6 +139,37 @@ func (r *EmbyRemoteService) ListAccounts(ctx context.Context) ([]model.StrmAccou
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ConfiguredRemoteHosts 返回所有已配置的远程 Emby 线路的主机名/IP(去重、不含端口)。
|
||||
func (r *EmbyRemoteService) ConfiguredRemoteHosts(ctx context.Context) []string {
|
||||
if r == nil || r.repo == nil || r.repo.StrmAccount == nil {
|
||||
return nil
|
||||
}
|
||||
accounts, err := r.ListAccounts(ctx)
|
||||
if err != nil || len(accounts) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
var hosts []string
|
||||
for _, acct := range accounts {
|
||||
lines, _, err := r.LinesOf(&acct)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range lines {
|
||||
u, err := url.Parse(line.URL)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
continue
|
||||
}
|
||||
h := strings.ToLower(u.Hostname())
|
||||
if !seen[h] {
|
||||
seen[h] = true
|
||||
hosts = append(hosts, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
// AccountByID 按 ID 查找远程 Emby 挂载账号(不存在或类型不符返回 nil)。
|
||||
func (r *EmbyRemoteService) AccountByID(ctx context.Context, id string) *model.StrmAccount {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
@@ -789,6 +820,7 @@ func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyM
|
||||
if parentID != "" {
|
||||
q.Set("ParentId", parentID)
|
||||
}
|
||||
q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
|
||||
path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/Latest"
|
||||
var out []map[string]any
|
||||
if err := r.doGet(ctx, acct, cfg, path, q, &out); err != nil {
|
||||
|
||||
@@ -180,21 +180,31 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
|
||||
if _, rid, ok := DecodeEmbyRemoteID(seriesID); ok {
|
||||
seriesID = rid
|
||||
}
|
||||
rating := remoteItemFloat(item, "CommunityRating")
|
||||
if rating == 0 {
|
||||
rating = remoteItemFloat(item, "CriticRating")
|
||||
}
|
||||
media := model.Media{
|
||||
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
|
||||
Title: remoteItemString(item, "Name"),
|
||||
OriginalName: remoteItemString(item, "OriginalTitle"),
|
||||
Overview: remoteItemString(item, "Overview"),
|
||||
Year: remoteItemInt(item, "ProductionYear"),
|
||||
Rating: float32(rating),
|
||||
Path: remoteItemString(item, "Path"),
|
||||
Genres: remoteItemGenres(item),
|
||||
ScrapeStatus: "done",
|
||||
}
|
||||
rating := remoteItemFloat(item, "CommunityRating")
|
||||
if rating == 0 {
|
||||
rating = remoteItemFloat(item, "CriticRating")
|
||||
}
|
||||
year := remoteItemInt(item, "ProductionYear")
|
||||
if year == 0 {
|
||||
year = remoteItemInt(item, "Year")
|
||||
}
|
||||
if year == 0 {
|
||||
year = remoteItemInt(item, "SeriesProductionYear")
|
||||
}
|
||||
if year == 0 {
|
||||
year = remoteItemInt(item, "SeriesYear")
|
||||
}
|
||||
media := model.Media{
|
||||
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
|
||||
Title: remoteItemString(item, "Name"),
|
||||
OriginalName: remoteItemString(item, "OriginalTitle"),
|
||||
Overview: remoteItemString(item, "Overview"),
|
||||
Year: year,
|
||||
Rating: float32(rating),
|
||||
Path: remoteItemString(item, "Path"),
|
||||
Genres: remoteItemGenres(item),
|
||||
ScrapeStatus: "done",
|
||||
}
|
||||
if date, ok := parseEmbyRemoteDate(remoteItemString(item, "DateCreated")); ok {
|
||||
media.CreatedAt = date
|
||||
media.UpdatedAt = date
|
||||
@@ -561,6 +571,46 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
|
||||
if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// 剧集类媒体库:直接拉取最新入库/更新的 Series 剧集本身(按上次添加集日期倒序)。
|
||||
// 避免 Emby /Items/Latest 默认返回无年份/无系列海报的单集(Episode)。
|
||||
if mount != nil && (mount.CollectionType == "tvshows" || mount.CollectionType == "tv") {
|
||||
q := url.Values{}
|
||||
q.Set("ParentId", remoteViewID)
|
||||
q.Set("IncludeItemTypes", "Series")
|
||||
q.Set("Recursive", "false")
|
||||
q.Set("SortBy", "DateLastContentAdded")
|
||||
q.Set("SortOrder", "Descending")
|
||||
q.Set("Limit", strconv.Itoa(limit))
|
||||
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
|
||||
var body struct {
|
||||
Items []map[string]any `json:"Items"`
|
||||
}
|
||||
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err == nil && len(body.Items) > 0 {
|
||||
cards := make([]SeriesCard, 0, len(body.Items))
|
||||
for _, it := range body.Items {
|
||||
RewriteEmbyRemoteIDs(it, mount.ID)
|
||||
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
|
||||
count := remoteItemInt(it, "RecursiveItemCount")
|
||||
if count == 0 {
|
||||
count = remoteItemInt(it, "ChildCount")
|
||||
}
|
||||
if count == 0 {
|
||||
count = 1
|
||||
}
|
||||
var lastAdded *time.Time
|
||||
if date, ok := parseEmbyRemoteDate(remoteItemString(it, "DateLastMediaAdded")); ok {
|
||||
lastAdded = &date
|
||||
}
|
||||
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
|
||||
}
|
||||
if r.cache != nil {
|
||||
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
}
|
||||
|
||||
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -576,7 +626,11 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
|
||||
t := m.CreatedAt
|
||||
lastAdded = &t
|
||||
}
|
||||
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: 0, LastAddedAt: lastAdded})
|
||||
count := remoteItemInt(it, "RecursiveItemCount")
|
||||
if count == 0 {
|
||||
count = remoteItemInt(it, "ChildCount")
|
||||
}
|
||||
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
|
||||
}
|
||||
if r.cache != nil {
|
||||
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
|
||||
|
||||
@@ -400,3 +400,130 @@ func TestRemoteSearchMedia(t *testing.T) {
|
||||
t.Fatalf("expected 0 items due to HiddenLibraryIDs, got %d", len(hiddenFiltered))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteLatestCardsTvShowsYearAndPoster(t *testing.T) {
|
||||
var requestedFields string
|
||||
var requestedIncludeItemTypes string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedFields = r.URL.Query().Get("Fields")
|
||||
requestedIncludeItemTypes = r.URL.Query().Get("IncludeItemTypes")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"TotalRecordCount": 1,
|
||||
"Items": []map[string]any{
|
||||
{
|
||||
"Id": "series-100",
|
||||
"Name": "炒翻天",
|
||||
"Type": "Series",
|
||||
"ProductionYear": 2024,
|
||||
"ImageTags": map[string]any{
|
||||
"Primary": "tag123",
|
||||
},
|
||||
"RecursiveItemCount": 12,
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": server.URL,
|
||||
"token": "fake-token",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-tv"},
|
||||
Name: "tv-emby",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
_ = repos.StrmAccount.Create(t.Context(), acct)
|
||||
mount := &model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-tv"},
|
||||
AccountID: acct.ID,
|
||||
RemoteViewID: "view-tv",
|
||||
RemoteViewName: "新番连载",
|
||||
CollectionType: "tvshows",
|
||||
Enabled: true,
|
||||
}
|
||||
_ = repos.EmbyMount.Create(t.Context(), mount)
|
||||
|
||||
cards, err := svc.RemoteLatestCards(t.Context(), mount, acct, "view-tv", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteLatestCards failed: %v", err)
|
||||
}
|
||||
if len(cards) != 1 {
|
||||
t.Fatalf("expected 1 card, got %d", len(cards))
|
||||
}
|
||||
if cards[0].Rep.Year != 2024 {
|
||||
t.Fatalf("expected Year 2024, got %d", cards[0].Rep.Year)
|
||||
}
|
||||
if cards[0].Count != 12 {
|
||||
t.Fatalf("expected Count 12, got %d", cards[0].Count)
|
||||
}
|
||||
if cards[0].Rep.PosterURL == "" {
|
||||
t.Fatalf("expected PosterURL not empty")
|
||||
}
|
||||
if requestedIncludeItemTypes != "Series" {
|
||||
t.Fatalf("expected IncludeItemTypes=Series, got %q", requestedIncludeItemTypes)
|
||||
}
|
||||
if !strings.Contains(requestedFields, "ProductionYear") {
|
||||
t.Fatalf("expected Fields to contain ProductionYear, got %q", requestedFields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteLatestFields(t *testing.T) {
|
||||
var requestedFields string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedFields = r.URL.Query().Get("Fields")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode([]map[string]any{
|
||||
{
|
||||
"Id": "movie-100",
|
||||
"Name": "测试电影",
|
||||
"Type": "Movie",
|
||||
"ProductionYear": 2023,
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": server.URL,
|
||||
"token": "fake-token",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-movie"},
|
||||
Name: "movie-emby",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
_ = repos.StrmAccount.Create(t.Context(), acct)
|
||||
mount := &model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-movie"},
|
||||
AccountID: acct.ID,
|
||||
RemoteViewID: "view-movie",
|
||||
CollectionType: "movies",
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
items, err := svc.RemoteLatest(t.Context(), mount, acct, "view-movie", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteLatest failed: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(items))
|
||||
}
|
||||
if !strings.Contains(requestedFields, "ProductionYear") {
|
||||
t.Fatalf("expected Fields to contain ProductionYear, got %q", requestedFields)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,16 +27,20 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any {
|
||||
"CommunityRating": group.Rating,
|
||||
"RecursiveItemCount": len(group.Episodes),
|
||||
"ChildCount": len(e.seasonsForSeries(group)),
|
||||
"DateCreated": group.CreatedAt,
|
||||
"DateLastMediaAdded": lastMediaAdded,
|
||||
"ImageTags": imageTags,
|
||||
"BackdropImageTags": backdropTags,
|
||||
"DateCreated": group.CreatedAt,
|
||||
"DateLastMediaAdded": lastMediaAdded,
|
||||
"ImageTags": imageTags,
|
||||
"BackdropImageTags": backdropTags,
|
||||
"People": []map[string]any{},
|
||||
"ProviderIds": map[string]string{
|
||||
"Tmdb": intToStr(group.TMDbID),
|
||||
"Bangumi": intToStr(group.BangumiID),
|
||||
},
|
||||
"UserData": emptyUserData(),
|
||||
}
|
||||
if group.PosterURL != "" {
|
||||
item["PrimaryImageTag"] = group.ID
|
||||
}
|
||||
if premiered, ok := embyPremiereDate(group.ReleaseDate); ok {
|
||||
item["PremiereDate"] = premiered
|
||||
}
|
||||
@@ -53,7 +57,7 @@ func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any {
|
||||
if season.Series.BackdropURL != "" {
|
||||
backdropTags = append(backdropTags, season.ID+"-bd")
|
||||
}
|
||||
return map[string]any{
|
||||
item := map[string]any{
|
||||
"Id": season.ID,
|
||||
"Name": season.Name,
|
||||
"ServerId": embyServerID,
|
||||
@@ -67,6 +71,16 @@ func (e *EmbyService) seasonPayload(season embySeasonGroup) map[string]any {
|
||||
"ChildCount": len(season.Episodes),
|
||||
"ImageTags": imageTags,
|
||||
"BackdropImageTags": backdropTags,
|
||||
"People": []map[string]any{},
|
||||
"UserData": emptyUserData(),
|
||||
}
|
||||
if season.Series.PosterURL != "" {
|
||||
item["PrimaryImageTag"] = season.ID
|
||||
item["SeriesPrimaryImageTag"] = season.Series.ID
|
||||
}
|
||||
if season.Series.BackdropURL != "" {
|
||||
item["ParentBackdropItemId"] = season.Series.ID
|
||||
item["ParentBackdropImageTags"] = []string{season.Series.ID + "-bd"}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ import (
|
||||
|
||||
// SystemInfo returns the full Emby identity payload.
|
||||
func (e *EmbyService) SystemInfo() map[string]any {
|
||||
port := 8096
|
||||
if e != nil && e.cfg != nil {
|
||||
port = e.cfg.App.Port
|
||||
}
|
||||
return map[string]any{
|
||||
"Id": embyServerID,
|
||||
"ServerId": embyServerID,
|
||||
@@ -27,10 +31,10 @@ func (e *EmbyService) SystemInfo() map[string]any {
|
||||
"SupportsLibraryMonitor": true,
|
||||
"SupportsHttps": false,
|
||||
"SupportsAutoDiscovery": true,
|
||||
"HttpServerPortNumber": e.cfg.App.Port,
|
||||
"HttpServerPortNumber": port,
|
||||
"HttpsPortNumber": 0,
|
||||
"PublishedServerUrl": "",
|
||||
"WebSocketPortNumber": e.cfg.App.Port,
|
||||
"WebSocketPortNumber": port,
|
||||
"CompletedInstallations": []any{},
|
||||
"CanSelfRestart": false,
|
||||
"CanLaunchWebBrowser": false,
|
||||
@@ -40,6 +44,10 @@ func (e *EmbyService) SystemInfo() map[string]any {
|
||||
|
||||
// SystemInfoPublic 是不需要认证的精简版(Emby Web 客户端登陆前会拉)。
|
||||
func (e *EmbyService) SystemInfoPublic() map[string]any {
|
||||
port := 8096
|
||||
if e != nil && e.cfg != nil {
|
||||
port = e.cfg.App.Port
|
||||
}
|
||||
return map[string]any{
|
||||
"Id": embyServerID,
|
||||
"ServerId": embyServerID,
|
||||
@@ -50,7 +58,7 @@ func (e *EmbyService) SystemInfoPublic() map[string]any {
|
||||
"OperatingSystem": "Windows",
|
||||
"LocalAddress": "",
|
||||
"WanAddress": "",
|
||||
"HttpServerPortNumber": e.cfg.App.Port,
|
||||
"HttpServerPortNumber": port,
|
||||
"HttpsPortNumber": 0,
|
||||
"SupportsHttps": false,
|
||||
"SupportsAutoDiscovery": true,
|
||||
|
||||
@@ -22,18 +22,19 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
||||
patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
|
||||
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
|
||||
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
||||
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
||||
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
||||
patCNRange = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[-~–—]\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
||||
patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`)
|
||||
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`)
|
||||
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`)
|
||||
patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`)
|
||||
patSpecialSeason = regexp.MustCompile(`(?i)^(?:s0+|season[\s._-]*0+|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|番外篇?|特别篇|特別篇|特典|外传|外傳|总集篇|總集篇)$`)
|
||||
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
||||
patSEnERange = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})\s*[-~–—]\s*(?:s(\d{1,2}))?e?(\d{1,3})(?:[^0-9]|$)`)
|
||||
patDanglingSE = regexp.MustCompile(`(?i)(?:^|[\s._-])s\d{1,2}e(?:[\s._-]|$)`)
|
||||
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
|
||||
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
|
||||
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
||||
patCNRange = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[-~–—]\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
||||
patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`)
|
||||
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`)
|
||||
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`)
|
||||
patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`)
|
||||
patSpecialSeason = regexp.MustCompile(`(?i)^(?:s0+|season[\s._-]*0+|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|番外篇?|特别篇|特別篇|特典|外传|外傳|总集篇|總集篇)$`)
|
||||
patSeasonEpisodeZero = regexp.MustCompile(`(?i)s0*([1-9]\d?)e0+(?:[^0-9]|$)`)
|
||||
// patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
|
||||
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
|
||||
)
|
||||
@@ -41,7 +42,10 @@ var (
|
||||
// ParseEpisode tries to extract (season, episode) from an arbitrary filename.
|
||||
// Returns (0, 0) when nothing recognisable is found.
|
||||
func ParseEpisode(path string) (season, episode int) {
|
||||
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
name := mediaSidecarBase(path)
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
}
|
||||
|
||||
if m := patSEnE.FindStringSubmatch(name); len(m) == 3 {
|
||||
season = mustAtoi(m[1])
|
||||
@@ -90,13 +94,37 @@ func ParseEpisode(path string) (season, episode int) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// onlineEpisodeIdentityFromPath converts the common anime SxxE00 convention
|
||||
// to provider-style specials. For example, S01E00 becomes S00E01 and S02E00
|
||||
// becomes S00E02. Normal episodes retain their parsed identity.
|
||||
func onlineEpisodeIdentityFromPath(path string) (season, episode int) {
|
||||
season, episode = ParseEpisode(path)
|
||||
if episode != 0 {
|
||||
return season, episode
|
||||
}
|
||||
name := mediaSidecarBase(path)
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
}
|
||||
if m := patSeasonEpisodeZero.FindStringSubmatch(name); len(m) == 2 {
|
||||
specialEpisode := mustAtoi(m[1])
|
||||
if specialEpisode > 0 {
|
||||
return 0, specialEpisode
|
||||
}
|
||||
}
|
||||
return season, episode
|
||||
}
|
||||
|
||||
type episodeRef struct {
|
||||
Season int
|
||||
Episode int
|
||||
}
|
||||
|
||||
func episodeRefsFromTitle(path string) []episodeRef {
|
||||
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
name := mediaSidecarBase(path)
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
}
|
||||
if refs := parseSEpisodeRange(name); len(refs) > 0 {
|
||||
return refs
|
||||
}
|
||||
|
||||
@@ -68,3 +68,23 @@ func TestEpisodeRefsFromTitleParsesRanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlineEpisodeIdentityFromPathMapsAnimeEpisodeZeroToSpecials(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
wantSeason int
|
||||
wantEpisode int
|
||||
}{
|
||||
{`动漫/路人女主/Season 1/S01E00 - 爱与青春的杀必死回.mkv`, 0, 1},
|
||||
{`动漫/路人女主/Season 2/S02E00 - 恋爱与纯情的杀必死回.mkv`, 0, 2},
|
||||
{`动漫/路人女主/Season 2/S02E03 - 初稿与二稿.mkv`, 2, 3},
|
||||
{`动漫/路人女主/Specials/S00E04.mkv`, 0, 4},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
season, episode := onlineEpisodeIdentityFromPath(tc.path)
|
||||
if season != tc.wantSeason || episode != tc.wantEpisode {
|
||||
t.Errorf("onlineEpisodeIdentityFromPath(%q) = (%d, %d), want (%d, %d)",
|
||||
tc.path, season, episode, tc.wantSeason, tc.wantEpisode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,11 @@ func (s *FileManagerService) allowedRoots() (map[string]string, error) {
|
||||
}
|
||||
addSetting("organize-source", "organize.source_dir")
|
||||
addSetting("organize-target", "organize.target_dir")
|
||||
addSetting("qb-savepath", "qbittorrent.savepath")
|
||||
addSetting("downloader-savepath", "downloader.savepath")
|
||||
// 兼容历史键名 qbittorrent.savepath:旧版本把下载器保存目录存在该键下
|
||||
if value, err := s.repo.Setting.Get(context.Background(), "downloader.savepath"); err != nil || strings.TrimSpace(value) == "" {
|
||||
addSetting("downloader-savepath", "qbittorrent.savepath")
|
||||
}
|
||||
}
|
||||
if s.repo != nil && s.repo.Library != nil {
|
||||
libs, err := s.repo.Library.List(context.Background())
|
||||
|
||||
@@ -196,9 +196,10 @@ func TestFileManagerIncludesConfiguredOrganizeRoots(t *testing.T) {
|
||||
got[root.Label] = root.Path
|
||||
}
|
||||
for label, want := range map[string]string{
|
||||
"organize-source": filepath.Clean(sourceDir),
|
||||
"organize-target": filepath.Clean(targetDir),
|
||||
"qb-savepath": filepath.Clean(qbDir),
|
||||
"organize-source": filepath.Clean(sourceDir),
|
||||
"organize-target": filepath.Clean(targetDir),
|
||||
// 旧键 qbittorrent.savepath 写入应经兼容回退落在 downloader-savepath 下
|
||||
"downloader-savepath": filepath.Clean(qbDir),
|
||||
} {
|
||||
if got[label] != want {
|
||||
t.Fatalf("root %s = %q, want %q; roots=%#v", label, got[label], want, listing.Roots)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -42,6 +43,13 @@ type ImageProxy struct {
|
||||
libRootsMu sync.Mutex
|
||||
libRootsCache []string
|
||||
libRootsAt time.Time
|
||||
|
||||
// allowedRemoteHostsFn returns hostnames or IPs of explicitly configured
|
||||
// upstream services (e.g. remote Emby mounts) that should bypass SSRF private IP checks.
|
||||
allowedRemoteHostsFn func() []string
|
||||
allowedHostsMu sync.Mutex
|
||||
allowedHostsCache map[string]bool
|
||||
allowedHostsAt time.Time
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -51,6 +59,12 @@ const (
|
||||
|
||||
// NewImageProxy is the constructor.
|
||||
func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
||||
proxy := &ImageProxy{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
cacheDir: filepath.Join(cfg.Cache.CacheDir, "images"),
|
||||
}
|
||||
|
||||
// Honor HTTP(S)_PROXY env vars so deployments behind GFW can pull
|
||||
// from image.tmdb.org via their HTTP proxy without extra config. On
|
||||
// Windows we also honor the current user's system proxy settings.
|
||||
@@ -63,6 +77,7 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
||||
// 仅 URL 解析层的 isPrivateHost 可被十进制/十六进制 IP、解析到
|
||||
// 私网的域名与 DNS rebinding 绕过;在拨号层对最终连接 IP 做二次
|
||||
// 校验(含重定向后的每条连接)堵住该旁路。
|
||||
// 用户明确配置的远程挂载源(如内网 Emby)豁免该私网限制。
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 15 * time.Second,
|
||||
Control: func(_, address string, _ syscall.RawConn) error {
|
||||
@@ -70,6 +85,9 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if proxy.isAllowedRemoteHost(host) {
|
||||
return nil
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return errors.New("image proxy: refusing non-IP dial target")
|
||||
@@ -82,12 +100,9 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
||||
}
|
||||
transport.DialContext = dialer.DialContext
|
||||
}
|
||||
return &ImageProxy{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
cacheDir: filepath.Join(cfg.Cache.CacheDir, "images"),
|
||||
client: &http.Client{Timeout: 30 * time.Second, Transport: transport},
|
||||
}
|
||||
|
||||
proxy.client = &http.Client{Timeout: 30 * time.Second, Transport: transport}
|
||||
return proxy
|
||||
}
|
||||
|
||||
// proxyConfiguredForImageFetch 探测环境变量或系统代理是否会影响图片抓取。
|
||||
@@ -125,6 +140,47 @@ func (p *ImageProxy) libraryRoots() []string {
|
||||
return p.libRootsCache
|
||||
}
|
||||
|
||||
// SetAllowedRemoteHostsProvider injects a callback that returns hostnames or IPs
|
||||
// of explicitly configured remote services (e.g. remote Emby mounts). Requests to
|
||||
// these hosts bypass SSRF private-IP restrictions.
|
||||
func (p *ImageProxy) SetAllowedRemoteHostsProvider(fn func() []string) {
|
||||
p.allowedRemoteHostsFn = fn
|
||||
}
|
||||
|
||||
func (p *ImageProxy) isAllowedRemoteHost(host string) bool {
|
||||
if p == nil || p.allowedRemoteHostsFn == nil {
|
||||
return false
|
||||
}
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
// Strip port if present
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = strings.ToLower(strings.TrimSpace(h))
|
||||
}
|
||||
|
||||
p.allowedHostsMu.Lock()
|
||||
defer p.allowedHostsMu.Unlock()
|
||||
if p.allowedHostsCache == nil || time.Since(p.allowedHostsAt) >= 30*time.Second {
|
||||
rawList := p.allowedRemoteHostsFn()
|
||||
cache := make(map[string]bool, len(rawList))
|
||||
for _, item := range rawList {
|
||||
item = strings.ToLower(strings.TrimSpace(item))
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(item); err == nil {
|
||||
item = strings.ToLower(strings.TrimSpace(h))
|
||||
}
|
||||
cache[item] = true
|
||||
}
|
||||
p.allowedHostsCache = cache
|
||||
p.allowedHostsAt = time.Now()
|
||||
}
|
||||
return p.allowedHostsCache[host]
|
||||
}
|
||||
|
||||
// Prune removes oldest cached images until disk usage is within the configured limit.
|
||||
func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
|
||||
if p.cfg == nil || p.cfg.Cache.ImagesMaxSizeMB <= 0 {
|
||||
|
||||
@@ -22,7 +22,7 @@ func (p *ImageProxy) validateURL(raw string) (*url.URL, error) {
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return nil, errors.New("unsupported scheme")
|
||||
}
|
||||
if isPrivateHost(u.Hostname()) {
|
||||
if !p.isAllowedRemoteHost(u.Hostname()) && isPrivateHost(u.Hostname()) {
|
||||
return nil, errors.New("requests to private/internal hosts are not allowed")
|
||||
}
|
||||
return u, nil
|
||||
|
||||
@@ -51,7 +51,7 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
|
||||
p.log.Warn("imageproxy: build request failed", zap.String("url", raw), zap.Error(err))
|
||||
return nil, "", "", errImageProxyRequestSetup
|
||||
}
|
||||
applyRemoteImageHeaders(req, host)
|
||||
applyRemoteImageHeaders(req, host, raw)
|
||||
|
||||
resp, err := candidate.client.Do(req)
|
||||
if err != nil {
|
||||
@@ -79,7 +79,7 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
|
||||
return data, ctype, resp.Header.Get("Content-Length"), nil
|
||||
}
|
||||
|
||||
func applyRemoteImageHeaders(req *http.Request, host string) {
|
||||
func applyRemoteImageHeaders(req *http.Request, host, raw string) {
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
|
||||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,ja;q=0.8,en;q=0.7")
|
||||
@@ -88,7 +88,7 @@ func applyRemoteImageHeaders(req *http.Request, host string) {
|
||||
if cookie := remoteImageCookie(host); cookie != "" {
|
||||
req.Header.Set("Cookie", cookie)
|
||||
}
|
||||
if referer := remoteImageReferer(host); referer != "" {
|
||||
if referer := remoteImageReferer(host, raw); referer != "" {
|
||||
req.Header.Set("Referer", referer)
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,7 @@ func remoteImageCookie(host string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func remoteImageReferer(host string) string {
|
||||
func remoteImageReferer(host, raw string) string {
|
||||
h := strings.ToLower(strings.TrimSpace(host))
|
||||
switch {
|
||||
case strings.Contains(h, "doubanio.com"):
|
||||
@@ -125,7 +125,11 @@ func remoteImageReferer(host string) string {
|
||||
case strings.Contains(h, "fc2.com"):
|
||||
return "https://adult.contents.fc2.com/"
|
||||
case h != "":
|
||||
return "https://" + h + "/"
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(raw)), "http://") {
|
||||
scheme = "http"
|
||||
}
|
||||
return scheme + "://" + h + "/"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -158,9 +162,9 @@ func fetchRemoteImageWithCurl(ctx context.Context, raw, host string) ([]byte, st
|
||||
"--header", "Cache-Control: no-cache",
|
||||
"--header", "Pragma: no-cache",
|
||||
}
|
||||
if referer := remoteImageReferer(host); referer != "" {
|
||||
args = append(args, "--referer", referer)
|
||||
}
|
||||
if referer := remoteImageReferer(host, raw); referer != "" {
|
||||
args = append(args, "--referer", referer)
|
||||
}
|
||||
if cookie := remoteImageCookie(host); cookie != "" {
|
||||
args = append(args, "--cookie", cookie)
|
||||
}
|
||||
|
||||
@@ -317,9 +317,14 @@ func TestRemoteImageRefererForAdultHosts(t *testing.T) {
|
||||
{"example.com", "https://example.com/"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := remoteImageReferer(tt.host); got != tt.want {
|
||||
t.Errorf("remoteImageReferer(%q) = %q, want %q", tt.host, got, tt.want)
|
||||
for _, tt := range tests {
|
||||
if got := remoteImageReferer(tt.host, "https://"+tt.host+"/img.jpg"); got != tt.want {
|
||||
t.Errorf("remoteImageReferer(%q) = %q, want %q", tt.host, got, tt.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Also verify HTTP protocol preservation for generic hosts
|
||||
if got := remoteImageReferer("192.168.1.100", "http://192.168.1.100:8096/image"); got != "http://192.168.1.100/" {
|
||||
t.Errorf("remoteImageReferer for HTTP host = %q, want http://192.168.1.100/", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,9 +117,32 @@ func TestIsPrivateHost(t *testing.T) {
|
||||
// Hostnames must NOT be blocked even though GFW DNS poisoning may resolve
|
||||
// them to private/loopback IPs — blocking them broke legitimate posters.
|
||||
allowed := []string{"image.tmdb.org", "lain.bgm.tv", "example.com", "8.8.8.8"}
|
||||
for _, h := range allowed {
|
||||
if isPrivateHost(h) {
|
||||
t.Errorf("isPrivateHost(%q) = true, want false", h)
|
||||
for _, h := range allowed {
|
||||
if isPrivateHost(h) {
|
||||
t.Errorf("isPrivateHost(%q) = true, want false", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageProxyAllowedRemoteHostBypassesPrivateCheck(t *testing.T) {
|
||||
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
||||
|
||||
rawURL := "http://192.168.1.100:8096/emby/Items/123/Images/Primary"
|
||||
// Before setting allowed remote hosts, private host is rejected by validateURL
|
||||
if _, err := proxy.validateURL(rawURL); err == nil {
|
||||
t.Fatal("expected validateURL to reject private IP before whitelist")
|
||||
}
|
||||
|
||||
// After configuring whitelist with the Emby host
|
||||
proxy.SetAllowedRemoteHostsProvider(func() []string {
|
||||
return []string{"192.168.1.100:8096"}
|
||||
})
|
||||
|
||||
u, err := proxy.validateURL(rawURL)
|
||||
if err != nil {
|
||||
t.Fatalf("expected validateURL to allow whitelisted host, got: %v", err)
|
||||
}
|
||||
if u.Hostname() != "192.168.1.100" {
|
||||
t.Fatalf("hostname = %s, want 192.168.1.100", u.Hostname())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,39 +54,59 @@ func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
|
||||
}
|
||||
|
||||
func localPosterCandidates(mediaPath string) []string {
|
||||
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
|
||||
names := []string{
|
||||
base + "-poster",
|
||||
base + ".poster",
|
||||
"poster",
|
||||
"folder",
|
||||
"cover",
|
||||
"movie",
|
||||
"show",
|
||||
base + "-cover",
|
||||
base + ".cover",
|
||||
base,
|
||||
base + "-thumb",
|
||||
base + ".thumb",
|
||||
"thumb",
|
||||
names := make([]string, 0, 24)
|
||||
seen := map[string]struct{}{}
|
||||
add := func(name string) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
names = append(names, name)
|
||||
}
|
||||
for _, base := range mediaSidecarBaseVariants(mediaPath) {
|
||||
add(base + "-poster")
|
||||
add(base + ".poster")
|
||||
add(base + "-cover")
|
||||
add(base + ".cover")
|
||||
add(base)
|
||||
add(base + "-thumb")
|
||||
add(base + ".thumb")
|
||||
}
|
||||
for _, name := range []string{"poster", "folder", "cover", "movie", "show", "thumb"} {
|
||||
add(name)
|
||||
}
|
||||
return append(adultArtworkNameCandidates(mediaPath, "poster"), names...)
|
||||
}
|
||||
|
||||
func localBackdropCandidates(mediaPath string) []string {
|
||||
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
|
||||
names := []string{
|
||||
base + "-fanart",
|
||||
base + ".fanart",
|
||||
base + "-backdrop",
|
||||
base + ".backdrop",
|
||||
base + "-background",
|
||||
"fanart",
|
||||
"backdrop",
|
||||
"background",
|
||||
"landscape",
|
||||
"banner",
|
||||
"clearart",
|
||||
names := make([]string, 0, 24)
|
||||
seen := map[string]struct{}{}
|
||||
add := func(name string) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
names = append(names, name)
|
||||
}
|
||||
for _, base := range mediaSidecarBaseVariants(mediaPath) {
|
||||
add(base + "-fanart")
|
||||
add(base + ".fanart")
|
||||
add(base + "-backdrop")
|
||||
add(base + ".backdrop")
|
||||
add(base + "-background")
|
||||
}
|
||||
for _, name := range []string{"fanart", "backdrop", "background", "landscape", "banner", "clearart"} {
|
||||
add(name)
|
||||
}
|
||||
return append(adultArtworkNameCandidates(mediaPath, "backdrop"), names...)
|
||||
}
|
||||
|
||||
@@ -28,13 +28,12 @@ func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMe
|
||||
|
||||
func findMovieNFO(mediaPath, libraryRoot string) (*nfoDocument, string, error) {
|
||||
mediaDir := filepath.Dir(mediaPath)
|
||||
base := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
|
||||
adultCode := AdultCodeFromMediaPath(mediaPath)
|
||||
names := []string{
|
||||
base + ".nfo",
|
||||
"movie.nfo",
|
||||
filepath.Base(mediaDir) + ".nfo",
|
||||
names := make([]string, 0, 8)
|
||||
for _, base := range mediaSidecarBaseVariants(mediaPath) {
|
||||
names = append(names, base+".nfo")
|
||||
}
|
||||
names = append(names, "movie.nfo", filepath.Base(mediaDir)+".nfo")
|
||||
if adultCode != "" {
|
||||
names = append([]string{adultCode + ".nfo", strings.ReplaceAll(adultCode, "-", "") + ".nfo"}, names...)
|
||||
}
|
||||
|
||||
@@ -39,10 +39,19 @@ func (r ManualScrapeRequest) EpisodeArtworkOption() *bool {
|
||||
}
|
||||
|
||||
func (s *ScraperService) ApplyManualMatch(ctx context.Context, mediaID string, req ManualScrapeRequest) (*model.Media, error) {
|
||||
return s.ApplyManualMatchWithOptions(ctx, mediaID, req, ScrapeOptions{EpisodeArtwork: req.EpisodeArtworkOption()})
|
||||
return s.ApplyManualMatchWithOptions(ctx, mediaID, req, ScrapeOptions{
|
||||
EpisodeArtwork: req.EpisodeArtworkOption(),
|
||||
ForceRematch: true,
|
||||
RebuildIdentity: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ScraperService) ApplyManualMatchWithOptions(ctx context.Context, mediaID string, req ManualScrapeRequest, options ScrapeOptions) (*model.Media, error) {
|
||||
// A user-triggered match is authoritative. Do not let metadata written by a
|
||||
// previous scrape influence the new result.
|
||||
options.ForceRematch = true
|
||||
options.RebuildIdentity = true
|
||||
|
||||
media, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil || media == nil {
|
||||
return nil, errors.New("media not found")
|
||||
@@ -55,12 +64,32 @@ func (s *ScraperService) ApplyManualMatchWithOptions(ctx context.Context, mediaI
|
||||
if strings.TrimSpace(match.Title) == "" {
|
||||
return nil, errors.New("manual match title required")
|
||||
}
|
||||
rebuildManualScrapeIdentity(media, lib, match)
|
||||
if err := s.applyProviderMatchWithOptions(ctx, media, lib, match, options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.Media.FindByID(ctx, mediaID)
|
||||
}
|
||||
|
||||
func rebuildManualScrapeIdentity(media *model.Media, lib *model.Library, match *Match) {
|
||||
if media == nil || match == nil {
|
||||
return
|
||||
}
|
||||
mediaType := normalizeOrganizeMediaType(match.MediaType)
|
||||
if mediaType == "" && librarySupportsSeasons(lib) {
|
||||
mediaType = "tv"
|
||||
}
|
||||
switch mediaType {
|
||||
case "tv", "anime", "variety":
|
||||
media.SeasonNum, media.EpisodeNum = onlineEpisodeIdentityFromPath(media.Path)
|
||||
media.EpisodeTitle = ""
|
||||
case "movie", "adult":
|
||||
media.SeasonNum = 0
|
||||
media.EpisodeNum = 0
|
||||
media.EpisodeTitle = ""
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScraperService) manualRequestMatch(ctx context.Context, req ManualScrapeRequest) (*Match, error) {
|
||||
source := strings.ToLower(strings.TrimSpace(req.Source))
|
||||
mediaType := normalizeMediaType(req.MediaType, req.Title, "")
|
||||
@@ -98,7 +127,22 @@ func (s *ScraperService) manualRequestMatch(ctx context.Context, req ManualScrap
|
||||
}
|
||||
}
|
||||
case source == "adult":
|
||||
if match := s.manualAdultMatch(ctx, firstText(req.OriginalName, req.Title)); match != nil {
|
||||
var match *Match
|
||||
if s.adult != nil {
|
||||
match, _ = s.adult.GetMetaTubeCandidate(ctx, req.TheTVDBID, req.DoubanID)
|
||||
}
|
||||
if match == nil {
|
||||
match = s.manualAdultMatch(ctx, firstText(req.OriginalName, req.Title))
|
||||
}
|
||||
if match != nil {
|
||||
// Older search responses used the portrait poster as a backdrop
|
||||
// fallback. Do not let that placeholder replace a real preview
|
||||
// image or processed primary image fetched from MetaTube.
|
||||
if strings.TrimSpace(match.BackdropURL) != "" &&
|
||||
strings.TrimSpace(req.BackdropURL) == strings.TrimSpace(req.PosterURL) {
|
||||
req.BackdropURL = ""
|
||||
req.PosterURL = ""
|
||||
}
|
||||
return mergeManualRequestIntoMatch(match, req), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -34,6 +35,74 @@ func TestManualRequestMatchFallsBackToCandidatePayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualAdultMatchUsesSelectedMetaTubeDetailsAndRealBackdrop(t *testing.T) {
|
||||
var detailCalls atomic.Int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/movies/AVE/94600" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
detailCalls.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(struct {
|
||||
Data MetaTubeMovieInfo `json:"data"`
|
||||
}{
|
||||
Data: MetaTubeMovieInfo{
|
||||
ID: "94600",
|
||||
Number: "CWPBD-138",
|
||||
Title: "Selected title",
|
||||
Provider: "AVE",
|
||||
CoverURL: "https://example.com/poster.jpg",
|
||||
PreviewImages: []string{"https://example.com/backdrop.jpg"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Setting{}, &model.APIConfig{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := repos.Setting.Set(t.Context(), "adult.scraper.engine", "metatube"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Setting.Set(t.Context(), "adult.scraper.metatube_server", upstream.URL); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adult := NewAdultProvider(zap.NewNop(), nil, repos)
|
||||
scraper := &ScraperService{adult: adult}
|
||||
|
||||
match, err := scraper.manualRequestMatch(t.Context(), ManualScrapeRequest{
|
||||
Source: "adult",
|
||||
MediaType: "adult",
|
||||
Title: "CWPBD-138 Selected title",
|
||||
OriginalName: "CWPBD-138",
|
||||
PosterURL: "https://example.com/search-cover.jpg",
|
||||
BackdropURL: "https://example.com/search-cover.jpg",
|
||||
DoubanID: "94600",
|
||||
TheTVDBID: "AVE",
|
||||
NSFW: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detailCalls.Load() != 1 {
|
||||
t.Fatalf("selected MetaTube detail calls = %d, want 1", detailCalls.Load())
|
||||
}
|
||||
wantPoster := upstream.URL + "/v1/images/primary/AVE/94600?auto=true&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fposter.jpg"
|
||||
if match.PosterURL != wantPoster {
|
||||
t.Fatalf("selected poster = %q, want %q", match.PosterURL, wantPoster)
|
||||
}
|
||||
wantBackdrop := upstream.URL + "/v1/images/backdrop/AVE/94600?quality=90"
|
||||
if match.BackdropURL != wantBackdrop {
|
||||
t.Fatalf("backdrop = %q, want %q", match.BackdropURL, wantBackdrop)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePositiveIDStringAcceptsProviderPrefixes(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"12345": "12345",
|
||||
@@ -627,3 +696,90 @@ func TestApplyManualMovieMatchClearsEpisodeMarkers(t *testing.T) {
|
||||
t.Fatalf("stale external IDs were not cleared for manual movie fallback: tmdb=%d thetvdb=%q", got.TMDbID, got.TheTVDBID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyManualTVMatchRebuildsEpisodeIdentityFromPath(t *testing.T) {
|
||||
var requestedEpisodePath string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/tv/69367":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": 69367,
|
||||
"name": "路人女主的养成方法",
|
||||
"original_name": "冴えない彼女の育てかた",
|
||||
"overview": "整剧简介",
|
||||
"poster_path": "/show.jpg",
|
||||
"backdrop_path": "/show-backdrop.jpg",
|
||||
"first_air_date": "2015-01-09",
|
||||
"vote_average": 7.0,
|
||||
})
|
||||
case "/tv/69367/season/0/episode/1":
|
||||
requestedEpisodePath = r.URL.Path
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"name": "爱与青春的杀必死回",
|
||||
"overview": "特别篇简介",
|
||||
"still_path": "/special.jpg",
|
||||
"air_date": "2015-01-07",
|
||||
"vote_average": 7.5,
|
||||
"runtime": 24,
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.TMDbAPIKey = "test-key"
|
||||
cfg.Secrets.TMDbAPIProxy = upstream.URL
|
||||
log := zap.NewNop()
|
||||
scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
|
||||
|
||||
root := t.TempDir()
|
||||
lib := model.Library{Name: "动漫", Path: root, Type: "anime", Enabled: true}
|
||||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{
|
||||
LibraryID: lib.ID,
|
||||
Title: "旧标题",
|
||||
Path: filepath.Join(root, "路人女主的养成方法 (2015)", "Season 1", "S01E00 - 爱与青春的杀必死回.strm"),
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
EpisodeTitle: "错误百出的序曲",
|
||||
SeriesID: "stale-series",
|
||||
TMDbID: 111,
|
||||
BangumiID: 222,
|
||||
ScrapeStatus: "matched",
|
||||
}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := scraper.ApplyManualMatch(t.Context(), media.ID, ManualScrapeRequest{
|
||||
Source: "tmdb",
|
||||
MediaType: "tv",
|
||||
Title: "路人女主的养成方法",
|
||||
TMDbID: 69367,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if requestedEpisodePath != "/tv/69367/season/0/episode/1" {
|
||||
t.Fatalf("episode details path = %q, want season-zero special", requestedEpisodePath)
|
||||
}
|
||||
if got.SeasonNum != 0 || got.EpisodeNum != 1 || got.EpisodeTitle != "爱与青春的杀必死回" {
|
||||
t.Fatalf("rebuilt episode identity = S%02dE%02d %q", got.SeasonNum, got.EpisodeNum, got.EpisodeTitle)
|
||||
}
|
||||
if got.SeriesID != "" || got.TMDbID != 69367 || got.BangumiID != 0 {
|
||||
t.Fatalf("stale scrape identity survived manual rematch: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -111,3 +113,63 @@ func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, e
|
||||
*media = items[0]
|
||||
return media, nil
|
||||
}
|
||||
|
||||
// GetMediaItem 返回媒体详情,并附带同片多版本列表(用于详情页/播放器切换)。
|
||||
func (s *MediaService) GetMediaItem(ctx context.Context, id string) (*MediaItem, error) {
|
||||
media, err := s.GetMedia(ctx, id)
|
||||
if err != nil || media == nil {
|
||||
return nil, err
|
||||
}
|
||||
versions, err := s.listVersionSiblings(ctx, media)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := &MediaItem{Media: *media}
|
||||
if len(versions) > 1 {
|
||||
item.Versions = versions
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// listVersionSiblings 查找与当前条目同属一个版本组的全部媒体(含自身)。
|
||||
func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Media) ([]model.Media, error) {
|
||||
if media == nil || strings.TrimSpace(media.ID) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
key := mediaVersionGroupKey(*media)
|
||||
if key == "" {
|
||||
return []model.Media{*media}, nil
|
||||
}
|
||||
libraryIDs, err := MergedLibraryIDsForLibrary(ctx, s.repo, media.LibraryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(libraryIDs) == 0 {
|
||||
libraryIDs = []string{media.LibraryID}
|
||||
}
|
||||
filter := repository.MediaQueryFilter{IncludeNSFW: true}
|
||||
candidates, err := s.repo.Media.ListByLibrariesFilteredNoCount(ctx, libraryIDs, 0, 5000, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.attachLibraryMetadata(ctx, candidates)
|
||||
matched := make([]model.Media, 0, 4)
|
||||
for _, row := range candidates {
|
||||
if mediaVersionGroupKey(row) == key {
|
||||
matched = append(matched, row)
|
||||
}
|
||||
}
|
||||
if len(matched) == 0 {
|
||||
return []model.Media{*media}, nil
|
||||
}
|
||||
sort.SliceStable(matched, func(i, j int) bool {
|
||||
if matched[i].ID == media.ID {
|
||||
return true
|
||||
}
|
||||
if matched[j].ID == media.ID {
|
||||
return false
|
||||
}
|
||||
return betterMediaVersion(matched[i], matched[j])
|
||||
})
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// mediaSidecarBase 返回媒体文件用于配对 NFO/海报/字幕等元数据的共享基名。
|
||||
// 同片多版本(含 keep_ext 的 name.mkv.strm / name.mp4.strm)应落到同一词干,
|
||||
// 从而忽略中间的视频扩展,按「同一影片」匹配边车文件。
|
||||
//
|
||||
// movie.mkv → movie
|
||||
// movie.strm → movie
|
||||
// movie.mkv.strm → movie
|
||||
// Show.S01E01.mp4.strm → Show.S01E01
|
||||
func mediaSidecarBase(mediaPath string) string {
|
||||
clean := strings.ReplaceAll(strings.TrimSpace(mediaPath), "\\", "/")
|
||||
if clean == "" {
|
||||
return ""
|
||||
}
|
||||
return mediaFileStem(filepath.Base(clean))
|
||||
}
|
||||
|
||||
// mediaFileStem 去掉最终扩展名;若为 .strm 且前一层是视频扩展,再剥一层。
|
||||
func mediaFileStem(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || name == "." {
|
||||
return ""
|
||||
}
|
||||
ext := filepath.Ext(name)
|
||||
stem := strings.TrimSuffix(name, ext)
|
||||
if strings.EqualFold(ext, ".strm") {
|
||||
if second := strings.ToLower(filepath.Ext(stem)); second != "" {
|
||||
if _, ok := videoExtensions[second]; ok && second != ".strm" {
|
||||
stem = strings.TrimSuffix(stem, filepath.Ext(stem))
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(stem)
|
||||
}
|
||||
|
||||
// mediaSidecarBaseVariants 返回匹配用的基名候选:共享词干优先,其次保留单层剥扩展
|
||||
// (兼容历史上写成 name.mkv.nfo / name.mkv-poster.jpg 的边车)。
|
||||
func mediaSidecarBaseVariants(mediaPath string) []string {
|
||||
stem := mediaSidecarBase(mediaPath)
|
||||
single := strings.TrimSuffix(filepath.Base(strings.TrimSpace(mediaPath)), filepath.Ext(mediaPath))
|
||||
single = strings.TrimSpace(single)
|
||||
out := make([]string, 0, 2)
|
||||
seen := map[string]struct{}{}
|
||||
for _, item := range []string{stem, single} {
|
||||
if item == "" || item == "." {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(item)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestMediaSidecarBaseIgnoresKeepExt(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/media/竞女01.mkv.strm": "竞女01",
|
||||
"/media/竞女01.mp4.strm": "竞女01",
|
||||
"/media/竞女01.strm": "竞女01",
|
||||
"/media/竞女01.mkv": "竞女01",
|
||||
"/lib/Show.S01E02.mkv.strm": "Show.S01E02",
|
||||
`C:/lib/Show.S01E02.mkv.strm`: "Show.S01E02",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := mediaSidecarBase(in); got != want {
|
||||
t.Fatalf("mediaSidecarBase(%q)=%q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNFOPathUsesSharedStemForKeepExt(t *testing.T) {
|
||||
got := nfoPath("/strm/竞女01.mkv.strm")
|
||||
want := filepath.Join("/strm", "竞女01.nfo")
|
||||
if got != want {
|
||||
t.Fatalf("nfoPath keep_ext = %q want %q", got, want)
|
||||
}
|
||||
if nfoPath("/strm/竞女01.mp4.strm") != want {
|
||||
t.Fatalf("mkv/mp4 versions should share nfo path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindMovieNFOMatchesSharedStemBesideKeepExtStrm(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
media := filepath.Join(dir, "竞女01.mkv.strm")
|
||||
nfo := filepath.Join(dir, "竞女01.nfo")
|
||||
writeFileContent(t, media, "http://example/play")
|
||||
writeFileContent(t, nfo, `<movie><title>竞女01</title></movie>`)
|
||||
|
||||
doc, path, err := findMovieNFO(media, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path != nfo {
|
||||
t.Fatalf("path=%q want %q", path, nfo)
|
||||
}
|
||||
if doc == nil || strings.TrimSpace(doc.Title) != "竞女01" {
|
||||
t.Fatalf("unexpected nfo doc: %#v", doc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalPosterCandidatesIncludeSharedStem(t *testing.T) {
|
||||
cands := localPosterCandidates("/strm/竞女01.mkv.strm")
|
||||
found := false
|
||||
for _, name := range cands {
|
||||
if name == "竞女01-poster" || name == "竞女01" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected shared stem poster candidates, got %#v", cands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleDiscoveryMatchesSharedStemBesideKeepExt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mediaPath := filepath.Join(dir, "竞女01.mkv.strm")
|
||||
subPath := filepath.Join(dir, "竞女01.zh.srt")
|
||||
writeFileContent(t, mediaPath, "http://example/play")
|
||||
writeFileContent(t, subPath, "1\n00:00:01,000 --> 00:00:02,000\nhi\n")
|
||||
|
||||
db := newServiceTestDB(t, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
media := model.Media{Title: "竞女01", Path: mediaPath}
|
||||
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &SubtitleService{repo: repos}
|
||||
tracks, err := svc.discoverUncached(t.Context(), media.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tracks) != 1 {
|
||||
t.Fatalf("tracks=%d want 1 (%#v)", len(tracks), tracks)
|
||||
}
|
||||
if tracks[0].Path != subPath {
|
||||
t.Fatalf("track path=%q want %q", tracks[0].Path, subPath)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFileContent(t *testing.T, path, data string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func TestResolveAccessibleMappedPathMapsEmbeddedHostDownloadMarker(t *testing.T)
|
||||
}
|
||||
t.Setenv("MEBOX_DOWNLOAD_CONTAINER_DIR", containerDownloads)
|
||||
|
||||
got, _, err := resolveAccessibleMappedPath("/vol1/1000/Docker/qbittorrent/downloads/国产剧")
|
||||
got, _, err := resolveAccessibleMappedPath("/vol1/1000/nas/downloads/国产剧")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveAccessibleMappedPath() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -119,6 +120,25 @@ func GroupMediaVersions(items []model.Media) []MediaItem {
|
||||
return groupMediaVersions(items)
|
||||
}
|
||||
|
||||
// GroupEpisodeVersionsForDisplay folds encoding/container variants into one
|
||||
// episode while preserving season/episode ordering for episode-list APIs.
|
||||
func GroupEpisodeVersionsForDisplay(items []model.Media) []MediaItem {
|
||||
grouped := groupMediaVersions(items)
|
||||
if grouped == nil {
|
||||
return []MediaItem{}
|
||||
}
|
||||
sort.SliceStable(grouped, func(i, j int) bool {
|
||||
if grouped[i].SeasonNum != grouped[j].SeasonNum {
|
||||
return grouped[i].SeasonNum < grouped[j].SeasonNum
|
||||
}
|
||||
if grouped[i].EpisodeNum != grouped[j].EpisodeNum {
|
||||
return grouped[i].EpisodeNum < grouped[j].EpisodeNum
|
||||
}
|
||||
return grouped[i].CreatedAt.Before(grouped[j].CreatedAt)
|
||||
})
|
||||
return grouped
|
||||
}
|
||||
|
||||
func mediaVersionGroupKey(m model.Media) string {
|
||||
// 远程 Emby 挂载条目保持独立,不与其它远程条目或本地条目折叠合并。
|
||||
if IsEmbyRemoteID(m.ID) {
|
||||
@@ -179,6 +199,7 @@ func mediaVersionGroupKey(m model.Media) string {
|
||||
}
|
||||
return "thetvdb:" + strings.ToLower(strings.TrimSpace(m.TheTVDBID))
|
||||
}
|
||||
|
||||
title := firstNonEmpty(m.OriginalName, m.Title)
|
||||
titleYear := 0
|
||||
if title == "" {
|
||||
@@ -187,6 +208,10 @@ func mediaVersionGroupKey(m model.Media) string {
|
||||
title, titleYear = mediaVersionTitleKey(title)
|
||||
}
|
||||
if title == "" {
|
||||
// 无标题时退回同目录词干(覆盖 keep_ext 的 name.mkv.strm / name.mp4.strm)
|
||||
if stemKey := mediaVersionStemGroupKey(m, libKey); stemKey != "" {
|
||||
return stemKey
|
||||
}
|
||||
return ""
|
||||
}
|
||||
year := m.Year
|
||||
@@ -202,6 +227,114 @@ func mediaVersionGroupKey(m model.Media) string {
|
||||
return fmt.Sprintf("movie:%s:%d", title, year)
|
||||
}
|
||||
|
||||
// mediaVersionStemGroupKey 按「库 + 父目录 + 文件词干」折叠多版本
|
||||
// (如 竞女01.mkv.strm 与 竞女01.mp4.strm)。
|
||||
func mediaVersionStemGroupKey(m model.Media, libKey string) string {
|
||||
path := strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/")
|
||||
if path == "" || strings.HasPrefix(strings.ToLower(path), "cloud://") {
|
||||
return ""
|
||||
}
|
||||
dir := ""
|
||||
base := path
|
||||
if idx := strings.LastIndex(path, "/"); idx >= 0 {
|
||||
dir = path[:idx]
|
||||
base = path[idx+1:]
|
||||
}
|
||||
stem := mediaVersionFileStem(base)
|
||||
if stem == "" {
|
||||
return ""
|
||||
}
|
||||
stem = normalizeMediaVersionText(stem)
|
||||
if stem == "" {
|
||||
return ""
|
||||
}
|
||||
if libKey == "" {
|
||||
libKey = "_"
|
||||
}
|
||||
return fmt.Sprintf("stem:%s:%s:%s", libKey, strings.ToLower(dir), stem)
|
||||
}
|
||||
|
||||
// mediaVersionFileStem 去掉最终扩展名;若为 .strm 且前一层是视频扩展,再剥一层。
|
||||
func mediaVersionFileStem(name string) string {
|
||||
return mediaFileStem(name)
|
||||
}
|
||||
|
||||
// MediaVersionLabel 生成版本切换展示名(分辨率 / 容器 / 编码 / 体积 / 文件名)。
|
||||
func MediaVersionLabel(m model.Media) string {
|
||||
parts := make([]string, 0, 4)
|
||||
if m.Height > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%dp", m.Height))
|
||||
} else if m.Width > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%dw", m.Width))
|
||||
}
|
||||
container := strings.Trim(strings.ToLower(strings.TrimSpace(m.Container)), ". ")
|
||||
if container == "" || container == "strm" {
|
||||
container = mediaVersionContainerFromPath(m.Path, m.STRMURL)
|
||||
}
|
||||
if container != "" && container != "strm" {
|
||||
parts = append(parts, strings.ToUpper(container))
|
||||
}
|
||||
if codec := strings.TrimSpace(m.VideoCodec); codec != "" {
|
||||
parts = append(parts, strings.ToUpper(codec))
|
||||
}
|
||||
if m.SizeBytes > 0 {
|
||||
parts = append(parts, formatMediaSize(m.SizeBytes))
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(m.Path), "\\", "/"))
|
||||
if base == "" || base == "." {
|
||||
return firstNonEmpty(m.Title, m.OriginalName, m.ID)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func mediaVersionContainerFromPath(path, strmURL string) string {
|
||||
base := filepath.Base(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"))
|
||||
ext := strings.ToLower(filepath.Ext(base))
|
||||
name := strings.TrimSuffix(base, ext)
|
||||
if ext == ".strm" {
|
||||
if second := strings.ToLower(filepath.Ext(name)); second != "" {
|
||||
if _, ok := videoExtensions[second]; ok {
|
||||
return strings.TrimPrefix(second, ".")
|
||||
}
|
||||
}
|
||||
// 从 strm 播放 URL 的 /video.mkv 推断
|
||||
u := strings.ToLower(strmURL)
|
||||
if idx := strings.LastIndex(u, "/video."); idx >= 0 {
|
||||
rest := u[idx+len("/video."):]
|
||||
if end := strings.IndexAny(rest, "?#&/"); end >= 0 {
|
||||
rest = rest[:end]
|
||||
}
|
||||
rest = strings.Trim(rest, ".")
|
||||
if rest != "" {
|
||||
return rest
|
||||
}
|
||||
}
|
||||
return "strm"
|
||||
}
|
||||
if ext != "" {
|
||||
if _, ok := videoExtensions[ext]; ok {
|
||||
return strings.TrimPrefix(ext, ".")
|
||||
}
|
||||
}
|
||||
return strings.TrimPrefix(ext, ".")
|
||||
}
|
||||
|
||||
func formatMediaSize(bytes int64) string {
|
||||
if bytes < 1024 {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
const unit = 1024
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
func mediaVersionTitleKey(value string) (string, int) {
|
||||
cleaned, year := CleanQuery(value)
|
||||
if strings.TrimSpace(cleaned) == "" {
|
||||
@@ -232,6 +365,10 @@ func normalizeMediaVersionText(value string) string {
|
||||
if _, noise := noiseTokenSet[field]; noise {
|
||||
continue
|
||||
}
|
||||
// 去掉视频容器词干残留(keep_ext / 旧标题「竞女01 mkv」)
|
||||
if _, ok := videoExtensions["."+field]; ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, field)
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -48,6 +49,28 @@ func TestGroupMediaVersionsMergesEpisodeByExternalIDAcrossLibraries(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupEpisodeVersionsForDisplayMergesAndKeepsEpisodeOrder(t *testing.T) {
|
||||
rows := []model.Media{
|
||||
{Base: model.Base{ID: "ep2"}, LibraryID: "tv", Title: "Show", SeasonNum: 1, EpisodeNum: 2, TMDbID: 99, Path: "/show/s01e02.mkv"},
|
||||
{Base: model.Base{ID: "ep1-small"}, LibraryID: "tv", Title: "Show", SeasonNum: 1, EpisodeNum: 1, TMDbID: 99, Path: "/show/s01e01.mp4", SizeBytes: 100},
|
||||
{Base: model.Base{ID: "ep1-large"}, LibraryID: "tv", Title: "Show", SeasonNum: 1, EpisodeNum: 1, TMDbID: 99, Path: "/show/s01e01.mkv", SizeBytes: 200},
|
||||
}
|
||||
|
||||
grouped := GroupEpisodeVersionsForDisplay(rows)
|
||||
if len(grouped) != 2 {
|
||||
t.Fatalf("grouped len = %d, want 2: %#v", len(grouped), grouped)
|
||||
}
|
||||
if grouped[0].EpisodeNum != 1 || len(grouped[0].Versions) != 2 {
|
||||
t.Fatalf("episode 1 was not merged first: %#v", grouped)
|
||||
}
|
||||
if grouped[0].ID != "ep1-large" {
|
||||
t.Fatalf("primary version = %q, want ep1-large", grouped[0].ID)
|
||||
}
|
||||
if grouped[1].EpisodeNum != 2 {
|
||||
t.Fatalf("second item episode = %d, want 2", grouped[1].EpisodeNum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaVersionsMergesMovieEncodingVariants(t *testing.T) {
|
||||
hd := model.Media{
|
||||
LibraryID: "movies",
|
||||
@@ -264,3 +287,43 @@ func TestGroupMediaVersionsDoesNotMergeAcrossDifferentLibrariesForMovies(t *test
|
||||
t.Fatalf("expected 2 separate groups for movies in different libraries, got %d", len(grouped))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaVersionsMergesKeepExtStrmVariants(t *testing.T) {
|
||||
mkv := model.Media{
|
||||
LibraryID: "movies",
|
||||
Title: "竞女01",
|
||||
Path: "/strm/竞女01.mkv.strm",
|
||||
SizeBytes: 500,
|
||||
STRMURL: "/api/strm/play/cloud115/video.mkv?pickcode=a",
|
||||
}
|
||||
mp4 := model.Media{
|
||||
LibraryID: "movies",
|
||||
Title: "竞女01",
|
||||
Path: "/strm/竞女01.mp4.strm",
|
||||
SizeBytes: 100,
|
||||
STRMURL: "/api/strm/play/cloud115/video.mp4?pickcode=b",
|
||||
}
|
||||
grouped := groupMediaVersions([]model.Media{mkv, mp4})
|
||||
if len(grouped) != 1 {
|
||||
t.Fatalf("grouped len = %d, want 1", len(grouped))
|
||||
}
|
||||
if len(grouped[0].Versions) != 2 {
|
||||
t.Fatalf("versions len = %d, want 2", len(grouped[0].Versions))
|
||||
}
|
||||
if grouped[0].Path != mkv.Path {
|
||||
t.Fatalf("primary should be larger mkv, got %q", grouped[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaVersionLabelUsesContainerAndSize(t *testing.T) {
|
||||
label := MediaVersionLabel(model.Media{
|
||||
Title: "竞女01",
|
||||
Path: "/strm/竞女01.mkv.strm",
|
||||
Height: 1080,
|
||||
SizeBytes: 1024 * 1024 * 1200,
|
||||
STRMURL: "/api/strm/play/cloud115/video.mkv?pickcode=a",
|
||||
})
|
||||
if !strings.Contains(label, "1080p") || !strings.Contains(strings.ToUpper(label), "MKV") {
|
||||
t.Fatalf("unexpected label %q", label)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ func (p *MetaTubeProvider) Search(ctx context.Context, cfg MetaTubeConfig, query
|
||||
|
||||
matches := make([]*Match, 0, len(results))
|
||||
for _, res := range results {
|
||||
match := p.convertSearchResultToMatch(query, &res)
|
||||
match := p.convertSearchResultToMatch(cfg, query, &res)
|
||||
if match != nil {
|
||||
matches = append(matches, match)
|
||||
}
|
||||
@@ -134,7 +134,7 @@ func (p *MetaTubeProvider) GetMovie(ctx context.Context, cfg MetaTubeConfig, pro
|
||||
return nil, fmt.Errorf("decode metatube movie response failed: %w", directErr)
|
||||
}
|
||||
|
||||
return p.convertMovieInfoToMatch(&movie), nil
|
||||
return p.convertMovieInfoToMatch(cfg, &movie), nil
|
||||
}
|
||||
|
||||
// SearchAndGetBestMatch 执行搜索并拉取首个最佳结果的完整电影详情。
|
||||
@@ -276,7 +276,7 @@ func (p *MetaTubeProvider) applyAuthHeader(req *http.Request, token string) {
|
||||
req.Header.Set("User-Agent", "MeBox/1.0 (MetaTube Client)")
|
||||
}
|
||||
|
||||
func (p *MetaTubeProvider) convertSearchResultToMatch(query string, res *MetaTubeSearchResult) *Match {
|
||||
func (p *MetaTubeProvider) convertSearchResultToMatch(cfg MetaTubeConfig, query string, res *MetaTubeSearchResult) *Match {
|
||||
if res == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -300,14 +300,21 @@ func (p *MetaTubeProvider) convertSearchResultToMatch(query string, res *MetaTub
|
||||
}
|
||||
}
|
||||
|
||||
posterURL := firstNonEmpty(res.BigCoverURL, res.CoverURL, res.BigThumbURL, res.ThumbURL)
|
||||
posterSource := firstNonEmpty(res.BigCoverURL, res.CoverURL, res.BigThumbURL, res.ThumbURL)
|
||||
posterURL, backdropURL := metaTubeArtworkURLs(cfg, res.Provider, res.ID, posterSource)
|
||||
if posterURL == "" {
|
||||
posterURL = firstNonEmpty(res.BigThumbURL, res.ThumbURL, res.BigCoverURL, res.CoverURL)
|
||||
}
|
||||
if backdropURL == "" {
|
||||
backdropURL = firstNonEmpty(res.BigCoverURL, res.CoverURL, posterURL)
|
||||
}
|
||||
|
||||
return &Match{
|
||||
MediaType: "adult",
|
||||
Title: formattedTitle,
|
||||
OriginalName: code,
|
||||
PosterURL: posterURL,
|
||||
BackdropURL: posterURL,
|
||||
BackdropURL: backdropURL,
|
||||
Year: year,
|
||||
ReleaseDate: cleanDateString(res.ReleaseDate),
|
||||
Rating: res.Score,
|
||||
@@ -318,7 +325,7 @@ func (p *MetaTubeProvider) convertSearchResultToMatch(query string, res *MetaTub
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MetaTubeProvider) convertMovieInfoToMatch(movie *MetaTubeMovieInfo) *Match {
|
||||
func (p *MetaTubeProvider) convertMovieInfoToMatch(cfg MetaTubeConfig, movie *MetaTubeMovieInfo) *Match {
|
||||
if movie == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -336,10 +343,17 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(movie *MetaTubeMovieInfo) *Ma
|
||||
|
||||
year := parseYearFromDate(movie.ReleaseDate)
|
||||
|
||||
posterURL := firstNonEmpty(movie.BigCoverURL, movie.CoverURL, movie.BigThumbURL, movie.ThumbURL)
|
||||
backdropURL := posterURL
|
||||
if len(movie.PreviewImages) > 0 && movie.PreviewImages[0] != "" {
|
||||
backdropURL = movie.PreviewImages[0]
|
||||
posterSource := firstNonEmpty(movie.BigCoverURL, movie.CoverURL, movie.BigThumbURL, movie.ThumbURL)
|
||||
posterURL, backdropURL := metaTubeArtworkURLs(cfg, movie.Provider, movie.ID, posterSource)
|
||||
if posterURL == "" {
|
||||
posterURL = firstNonEmpty(movie.BigThumbURL, movie.ThumbURL, movie.BigCoverURL, movie.CoverURL)
|
||||
}
|
||||
if backdropURL == "" {
|
||||
if len(movie.PreviewImages) > 0 && movie.PreviewImages[0] != "" {
|
||||
backdropURL = movie.PreviewImages[0]
|
||||
} else {
|
||||
backdropURL = firstNonEmpty(movie.BigCoverURL, movie.CoverURL, posterURL)
|
||||
}
|
||||
}
|
||||
|
||||
genres := make([]string, 0, len(movie.Genres)+len(movie.Actors)+4)
|
||||
@@ -385,6 +399,37 @@ func (p *MetaTubeProvider) convertMovieInfoToMatch(movie *MetaTubeMovieInfo) *Ma
|
||||
}
|
||||
}
|
||||
|
||||
func metaTubeArtworkURLs(cfg MetaTubeConfig, provider, id, posterSource string) (string, string) {
|
||||
serverURL := strings.TrimRight(strings.TrimSpace(cfg.ServerURL), "/")
|
||||
provider = strings.TrimSpace(provider)
|
||||
id = strings.TrimSpace(id)
|
||||
if serverURL == "" || provider == "" || id == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
imageURL := func(kind string, primary bool) string {
|
||||
base := fmt.Sprintf("%s/v1/images/%s/%s/%s", serverURL, kind, url.PathEscape(provider), url.PathEscape(id))
|
||||
values := url.Values{}
|
||||
values.Set("quality", "90")
|
||||
if primary {
|
||||
values.Set("ratio", "-1")
|
||||
if strings.TrimSpace(posterSource) != "" {
|
||||
values.Set("url", strings.TrimSpace(posterSource))
|
||||
}
|
||||
if cfg.CropCover {
|
||||
values.Set("pos", "1")
|
||||
values.Set("auto", "true")
|
||||
} else {
|
||||
values.Set("pos", "-1")
|
||||
values.Set("auto", "false")
|
||||
}
|
||||
}
|
||||
return base + "?" + values.Encode()
|
||||
}
|
||||
|
||||
return imageURL("primary", true), imageURL("backdrop", false)
|
||||
}
|
||||
|
||||
func parseYearFromDate(dateStr string) int {
|
||||
dateStr = strings.TrimSpace(dateStr)
|
||||
if len(dateStr) >= 4 {
|
||||
|
||||
@@ -74,6 +74,12 @@ func TestMetaTubeProviderSearch(t *testing.T) {
|
||||
if len(m.Genres) != 1 || m.Genres[0] != "相沢みなみ" {
|
||||
t.Errorf("unexpected genres: %v", m.Genres)
|
||||
}
|
||||
if want := server.URL + "/v1/images/primary/javdb/123456?auto=false&pos=-1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fcover.jpg"; m.PosterURL != want {
|
||||
t.Errorf("poster URL = %q, want %q", m.PosterURL, want)
|
||||
}
|
||||
if want := server.URL + "/v1/images/backdrop/javdb/123456?quality=90"; m.BackdropURL != want {
|
||||
t.Errorf("backdrop URL = %q, want %q", m.BackdropURL, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaTubeProviderGetMovie(t *testing.T) {
|
||||
@@ -112,6 +118,7 @@ func TestMetaTubeProviderGetMovie(t *testing.T) {
|
||||
provider := NewMetaTubeProvider(zap.NewNop())
|
||||
cfg := MetaTubeConfig{
|
||||
ServerURL: server.URL,
|
||||
CropCover: true,
|
||||
}
|
||||
|
||||
match, err := provider.GetMovie(context.Background(), cfg, "javdb", "123456")
|
||||
@@ -124,11 +131,11 @@ func TestMetaTubeProviderGetMovie(t *testing.T) {
|
||||
if match.Overview != "超绝美少女相沢みなみ出道作品!" {
|
||||
t.Errorf("unexpected overview: %s", match.Overview)
|
||||
}
|
||||
if match.PosterURL != "https://example.com/big_cover.jpg" {
|
||||
t.Errorf("unexpected poster URL: %s", match.PosterURL)
|
||||
if want := server.URL + "/v1/images/primary/javdb/123456?auto=true&pos=1&quality=90&ratio=-1&url=https%3A%2F%2Fexample.com%2Fbig_cover.jpg"; match.PosterURL != want {
|
||||
t.Errorf("poster URL = %q, want %q", match.PosterURL, want)
|
||||
}
|
||||
if match.BackdropURL != "https://example.com/preview1.jpg" {
|
||||
t.Errorf("unexpected backdrop URL: %s", match.BackdropURL)
|
||||
if want := server.URL + "/v1/images/backdrop/javdb/123456?quality=90"; match.BackdropURL != want {
|
||||
t.Errorf("backdrop URL = %q, want %q", match.BackdropURL, want)
|
||||
}
|
||||
if match.Year != 2018 {
|
||||
t.Errorf("expected year 2018, got %d", match.Year)
|
||||
|
||||
@@ -115,7 +115,10 @@ func (s *NFOService) ExportLibrary(ctx context.Context, libraryID string) (int,
|
||||
|
||||
func nfoPath(media string) string {
|
||||
dir := filepath.Dir(media)
|
||||
base := strings.TrimSuffix(filepath.Base(media), filepath.Ext(media))
|
||||
base := mediaSidecarBase(media)
|
||||
if base == "" {
|
||||
base = strings.TrimSuffix(filepath.Base(media), filepath.Ext(media))
|
||||
}
|
||||
return filepath.Join(dir, fmt.Sprintf("%s.nfo", base))
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ func (o *OrganizerService) OrganizeSourceCandidates(ctx context.Context) []Organ
|
||||
out = append(out, OrganizeSourceCandidate{Label: label, Path: clean, Kind: kind})
|
||||
}
|
||||
add("默认整理源", o.settingValue(ctx, "organize.source_dir"), "source")
|
||||
add("下载器保存目录", o.settingValue(ctx, "qbittorrent.savepath"), "download")
|
||||
add("下载器保存目录", o.downloaderSavepath(ctx), "download")
|
||||
add("下载目录", envOrDefault("MEBOX_DOWNLOAD_CONTAINER_DIR", "/downloads"), "download")
|
||||
add("媒体目录", envOrDefault("MEBOX_MEDIA_CONTAINER_DIR", "/media"), "media")
|
||||
return out
|
||||
@@ -56,8 +56,17 @@ func (o *OrganizerService) settingValue(ctx context.Context, key string) string
|
||||
return ""
|
||||
}
|
||||
|
||||
// downloaderSavepath 返回下载器保存目录,优先新键 downloader.savepath,
|
||||
// 兼容历史键名 qbittorrent.savepath(旧版本部署已写入的配置不丢失)。
|
||||
func (o *OrganizerService) downloaderSavepath(ctx context.Context) string {
|
||||
if v := o.settingValue(ctx, "downloader.savepath"); v != "" {
|
||||
return v
|
||||
}
|
||||
return o.settingValue(ctx, "qbittorrent.savepath")
|
||||
}
|
||||
|
||||
// defaultSourceRoot resolves the source root for a directory organize:
|
||||
// explicit override → organize.source_dir setting → qB default save path →
|
||||
// explicit override → organize.source_dir setting → downloader save path →
|
||||
// download container dir.
|
||||
func (o *OrganizerService) defaultSourceRoot(ctx context.Context, override string) string {
|
||||
if r := strings.TrimSpace(override); r != "" {
|
||||
@@ -66,7 +75,7 @@ func (o *OrganizerService) defaultSourceRoot(ctx context.Context, override strin
|
||||
if v := o.settingValue(ctx, "organize.source_dir"); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := o.settingValue(ctx, "qbittorrent.savepath"); v != "" {
|
||||
if v := o.downloaderSavepath(ctx); v != "" {
|
||||
return v
|
||||
}
|
||||
return envOrDefault("MEBOX_DOWNLOAD_CONTAINER_DIR", "/downloads")
|
||||
|
||||
@@ -278,16 +278,21 @@ func randomSuffix() string {
|
||||
// moveStagedArtwork renames artwork sidecars staged alongside `stage` into
|
||||
// their final names next to `dst`.
|
||||
func moveStagedArtwork(stage, dst string) {
|
||||
stageBase := strings.TrimSuffix(filepath.Base(stage), filepath.Ext(stage))
|
||||
dstBase := strings.TrimSuffix(filepath.Base(dst), filepath.Ext(dst))
|
||||
stageBases := mediaSidecarBaseVariants(stage)
|
||||
dstBase := mediaSidecarBase(dst)
|
||||
if dstBase == "" {
|
||||
dstBase = strings.TrimSuffix(filepath.Base(dst), filepath.Ext(dst))
|
||||
}
|
||||
stageDir := filepath.Dir(stage)
|
||||
for _, suffix := range artworkSidecarSuffixes {
|
||||
for _, ext := range artworkSidecarExtensions {
|
||||
srcPath := filepath.Join(stageDir, stageBase+suffix+ext)
|
||||
if _, err := os.Stat(srcPath); err != nil {
|
||||
continue
|
||||
for _, stageBase := range stageBases {
|
||||
for _, suffix := range artworkSidecarSuffixes {
|
||||
for _, ext := range artworkSidecarExtensions {
|
||||
srcPath := filepath.Join(stageDir, stageBase+suffix+ext)
|
||||
if _, err := os.Stat(srcPath); err != nil {
|
||||
continue
|
||||
}
|
||||
_ = os.Rename(srcPath, filepath.Join(stageDir, dstBase+suffix+ext))
|
||||
}
|
||||
_ = os.Rename(srcPath, filepath.Join(stageDir, dstBase+suffix+ext))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -306,12 +311,13 @@ func moveSidecarRename(from, to string) {
|
||||
// removeStagedArtwork removes artwork sidecars that were staged alongside
|
||||
// `stage`, used when a replace fails and its staged outputs must be cleaned up.
|
||||
func removeStagedArtwork(stage string) {
|
||||
stageBase := strings.TrimSuffix(filepath.Base(stage), filepath.Ext(stage))
|
||||
stageBases := mediaSidecarBaseVariants(stage)
|
||||
stageDir := filepath.Dir(stage)
|
||||
for _, suffix := range artworkSidecarSuffixes {
|
||||
for _, ext := range artworkSidecarExtensions {
|
||||
path := filepath.Join(stageDir, stageBase+suffix+ext)
|
||||
_ = os.Remove(path)
|
||||
for _, stageBase := range stageBases {
|
||||
for _, suffix := range artworkSidecarSuffixes {
|
||||
for _, ext := range artworkSidecarExtensions {
|
||||
_ = os.Remove(filepath.Join(stageDir, stageBase+suffix+ext))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ func (o *OrganizerService) resolveTransferMode(ctx context.Context, override Tra
|
||||
}
|
||||
}
|
||||
if mode == TransferMove && o.keepSeedingEnabled(ctx) {
|
||||
// 移动会删除源文件导致 qBittorrent 停止做种;保种开启时改用硬链接
|
||||
// 移动会删除源文件导致下载器停止做种;保种开启时改用硬链接
|
||||
// 既规范命名又保留源文件继续做种上传。硬链接失败时会报错,避免静默
|
||||
// 退化复制后占用双份磁盘空间。
|
||||
return TransferHardlink
|
||||
|
||||
@@ -51,23 +51,25 @@ var artworkSidecarExtensions = []string{".jpg", ".jpeg", ".png", ".webp", ".gif"
|
||||
// behind in the old folder; this keeps artwork with the organized file.
|
||||
func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error {
|
||||
srcDir := filepath.Dir(srcMedia)
|
||||
base := strings.TrimSuffix(filepath.Base(srcMedia), filepath.Ext(srcMedia))
|
||||
if base == "" || base == "." {
|
||||
bases := mediaSidecarBaseVariants(srcMedia)
|
||||
if len(bases) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Find every existing sidecar by probing suffix + extension combinations.
|
||||
sources := make([]string, 0, len(artworkSidecarSuffixes)*len(artworkSidecarExtensions))
|
||||
sources := make([]string, 0, len(artworkSidecarSuffixes)*len(artworkSidecarExtensions)*len(bases))
|
||||
seen := map[string]struct{}{}
|
||||
for _, suffix := range artworkSidecarSuffixes {
|
||||
for _, ext := range artworkSidecarExtensions {
|
||||
path := filepath.Join(srcDir, base+suffix+ext)
|
||||
key := strings.ToLower(filepath.Clean(path))
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
sources = append(sources, path)
|
||||
for _, base := range bases {
|
||||
for _, suffix := range artworkSidecarSuffixes {
|
||||
for _, ext := range artworkSidecarExtensions {
|
||||
path := filepath.Join(srcDir, base+suffix+ext)
|
||||
key := strings.ToLower(filepath.Clean(path))
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
sources = append(sources, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,15 +80,36 @@ func transferSidecarArtwork(srcMedia, dstMedia string, mode TransferMode) error
|
||||
if err := os.MkdirAll(dstDir, 0o755); err != nil { // #nosec G301 -- sidecar media directories must remain readable by NAS/player users.
|
||||
return err
|
||||
}
|
||||
var firstErr error
|
||||
dstBase := mediaSidecarBase(dstMedia)
|
||||
if dstBase == "" {
|
||||
dstBase = strings.TrimSuffix(filepath.Base(dstMedia), filepath.Ext(dstMedia))
|
||||
}
|
||||
for _, src := range sources {
|
||||
dst := filepath.Join(dstDir, filepath.Base(src))
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
continue // never clobber an existing artwork at the destination
|
||||
name := filepath.Base(src)
|
||||
// Remap any legacy "Title.mkv-poster.jpg" onto the shared destination stem.
|
||||
suffix := ""
|
||||
lowerName := strings.ToLower(name)
|
||||
for _, base := range bases {
|
||||
prefix := strings.ToLower(base)
|
||||
if strings.HasPrefix(lowerName, prefix) {
|
||||
suffix = name[len(base):]
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := transferFile(src, dst, mode); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
dstName := name
|
||||
if suffix != "" && dstBase != "" {
|
||||
dstName = dstBase + suffix
|
||||
}
|
||||
dst := filepath.Join(dstDir, dstName)
|
||||
if strings.EqualFold(filepath.Clean(src), filepath.Clean(dst)) {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
continue
|
||||
}
|
||||
if err := transferFile(src, dst, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
// translateClientPath 将下载客户端报告的路径转换为容器内可访问的路径。
|
||||
// 常见场景:qBittorrent在另一个容器,报告的路径是其容器内路径,需要映射到当前容器。
|
||||
// 常见场景:下载器在另一个容器,报告的路径是其容器内路径,需要映射到当前容器。
|
||||
func translateClientPath(clientPath string, mappings map[string]string) string {
|
||||
if clientPath == "" {
|
||||
return ""
|
||||
|
||||
@@ -27,7 +27,7 @@ func NewExternalHTTPClient(timeout time.Duration) *http.Client {
|
||||
}
|
||||
|
||||
// NewInternalHTTPClient builds an HTTP client for LAN / Docker-internal
|
||||
// services such as qBittorrent, Transmission and Aria2. These endpoints are
|
||||
// services such as downloaders and other local tools. These endpoints are
|
||||
// usually 127.0.0.1, host.docker.internal, 172.17.0.1 or a NAS LAN IP; sending
|
||||
// them through HTTP_PROXY/SOCKS proxies makes local WebUI logins hang or fail.
|
||||
func NewInternalHTTPClient(timeout time.Duration) *http.Client {
|
||||
|
||||
@@ -138,7 +138,10 @@ type localScanMediaInput struct {
|
||||
func (s *ScannerService) buildLocalScanMedia(in localScanMediaInput) *model.Media {
|
||||
title, year := CleanQueryWithRecognition(context.Background(), s.repo, in.path)
|
||||
if title == "" {
|
||||
title = strings.TrimSuffix(filepath.Base(in.path), in.ext)
|
||||
title = mediaSidecarBase(in.path)
|
||||
if title == "" {
|
||||
title = strings.TrimSuffix(filepath.Base(in.path), in.ext)
|
||||
}
|
||||
}
|
||||
title, year = preferISOParentScrapeIdentity(in.path, in.lib.Path, title, year)
|
||||
|
||||
|
||||
@@ -119,8 +119,22 @@ func (s *ScraperService) applyProviderMatch(ctx context.Context, m *model.Media,
|
||||
func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *model.Media, lib *model.Library, match *Match, options ScrapeOptions) error {
|
||||
posterCandidate := match.PosterURL
|
||||
backdropCandidate := match.BackdropURL
|
||||
posterURL, removePoster := s.prepareScrapedArtworkURL(ctx, m.ID, "poster_url", m.PosterURL, posterCandidate)
|
||||
backdropURL, removeBackdrop := s.prepareScrapedArtworkURL(ctx, m.ID, "backdrop_url", m.BackdropURL, backdropCandidate)
|
||||
currentPoster := m.PosterURL
|
||||
currentBackdrop := m.BackdropURL
|
||||
if options.RebuildIdentity {
|
||||
currentPoster = ""
|
||||
currentBackdrop = ""
|
||||
}
|
||||
posterURL, removePoster := s.prepareScrapedArtworkURL(ctx, m.ID, "poster_url", currentPoster, posterCandidate)
|
||||
backdropURL, removeBackdrop := s.prepareScrapedArtworkURL(ctx, m.ID, "backdrop_url", currentBackdrop, backdropCandidate)
|
||||
if options.RebuildIdentity {
|
||||
if strings.TrimSpace(m.PosterURL) != posterURL {
|
||||
removePoster = m.PosterURL
|
||||
}
|
||||
if strings.TrimSpace(m.BackdropURL) != backdropURL {
|
||||
removeBackdrop = m.BackdropURL
|
||||
}
|
||||
}
|
||||
updates := map[string]any{
|
||||
"title": match.Title,
|
||||
"overview": match.Overview,
|
||||
@@ -142,6 +156,12 @@ func (s *ScraperService) applyProviderMatchWithOptions(ctx context.Context, m *m
|
||||
updates["languages"] = strings.Join(match.Languages, ",")
|
||||
updates["nsfw"] = match.NSFW
|
||||
}
|
||||
if options.RebuildIdentity {
|
||||
updates["season_num"] = m.SeasonNum
|
||||
updates["episode_num"] = m.EpisodeNum
|
||||
updates["episode_title"] = ""
|
||||
updates["series_id"] = ""
|
||||
}
|
||||
if match.ReleaseDate != "" {
|
||||
updates["release_date"] = match.ReleaseDate
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ type ScrapeOptions struct {
|
||||
EpisodeArtwork *bool
|
||||
DeferEpisodeDetails bool
|
||||
ForceRematch bool
|
||||
RebuildIdentity bool
|
||||
}
|
||||
|
||||
func (o ScrapeOptions) episodeArtworkEnabled() bool {
|
||||
|
||||
@@ -75,6 +75,12 @@ func CleanQuery(raw string) (title string, year int) {
|
||||
base = strings.TrimSpace(raw)
|
||||
}
|
||||
name := strings.TrimSuffix(base, filepath.Ext(base))
|
||||
// .strm 可能保留视频扩展名(name.mkv.strm),再剥一层真实视频扩展,
|
||||
// 避免「竞女01.mkv」与「竞女01.mp4」被当成不同标题。
|
||||
name = mediaFileStem(base)
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
lower := strings.ToLower(name)
|
||||
|
||||
if m := yearPattern.FindStringSubmatch(lower); len(m) >= 2 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -24,6 +25,10 @@ type ScraperService struct {
|
||||
hub *Hub
|
||||
cache *RuntimeCacheService
|
||||
images *ImageProxy
|
||||
|
||||
// Serializes final sidecar replacement. Windows cannot rename over an
|
||||
// existing file, and concurrent scrapes can target the same sidecar.
|
||||
artworkWriteMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewScraperService is the constructor.
|
||||
|
||||
@@ -38,33 +38,61 @@ func (s *ScraperService) writeMediaArtworkFilesAfterScrape(ctx context.Context,
|
||||
if dir == "" || dir == "." {
|
||||
return
|
||||
}
|
||||
// Scope sidecar names by the media file's base name (e.g. A.mp4 -> A-poster.jpg)
|
||||
// so that multiple movies sharing one directory (A.mp4 + B.mp4) never clash.
|
||||
base := strings.TrimSuffix(filepath.Base(refreshed.Path), filepath.Ext(refreshed.Path))
|
||||
// Scope sidecar names by the shared media stem (e.g. A.mkv.strm / A.mp4.strm -> A-poster.jpg)
|
||||
// so multi-version files in one folder share artwork and never diverge by container.
|
||||
base := mediaSidecarBase(refreshed.Path)
|
||||
if base == "" || base == "." {
|
||||
return
|
||||
}
|
||||
isAdult := IsAdultMediaPathOrMetadata(refreshed.Path, refreshed.LibraryID, refreshed.NSFW) || IsAdultArtworkURL(refreshed.PosterURL)
|
||||
isAdult := shouldCropAdultPoster(refreshed, lib)
|
||||
artworkUpdates := map[string]any{}
|
||||
if refreshed.PosterURL != "" {
|
||||
s.downloadArtworkToPathWithOptions(ctx, dir, base+"-poster", refreshed.PosterURL, isAdult)
|
||||
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-poster", refreshed.PosterURL, isAdult); dst != "" {
|
||||
artworkUpdates["poster_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
|
||||
}
|
||||
}
|
||||
if refreshed.BackdropURL != "" {
|
||||
s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.BackdropURL, false)
|
||||
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.BackdropURL, false); dst != "" {
|
||||
artworkUpdates["backdrop_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
|
||||
}
|
||||
} else if isAdult && refreshed.PosterURL != "" {
|
||||
// 番号海报原图为完整封套横图,在无独立背景图时直接作为背景图写出
|
||||
s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.PosterURL, false)
|
||||
if dst := s.downloadArtworkToPathWithOptions(ctx, dir, base+"-backdrop", refreshed.PosterURL, false); dst != "" {
|
||||
artworkUpdates["backdrop_url"] = filepath.Join(filepath.Dir(refreshed.Path), filepath.Base(dst))
|
||||
}
|
||||
}
|
||||
if len(artworkUpdates) > 0 {
|
||||
if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("id = ?", refreshed.ID).Updates(artworkUpdates).Error; err != nil {
|
||||
s.log.Warn("save local scraped artwork paths failed", zap.String("media_id", refreshed.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScraperService) downloadArtworkToPath(ctx context.Context, dir, name, raw string) {
|
||||
s.downloadArtworkToPathWithOptions(ctx, dir, name, raw, false)
|
||||
func (s *ScraperService) downloadArtworkToPath(ctx context.Context, dir, name, raw string) string {
|
||||
return s.downloadArtworkToPathWithOptions(ctx, dir, name, raw, false)
|
||||
}
|
||||
|
||||
// shouldCropAdultPoster keeps adult-cover handling independent of which
|
||||
// metadata provider won. A code-numbered title may match TMDb first, so the
|
||||
// provider's NSFW flag or artwork host alone is not sufficient.
|
||||
func shouldCropAdultPoster(media *model.Media, lib *model.Library) bool {
|
||||
if media == nil {
|
||||
return false
|
||||
}
|
||||
mediaType := ""
|
||||
if lib != nil {
|
||||
mediaType = lib.Type
|
||||
}
|
||||
return IsAdultMediaPathOrMetadata(media.Path, mediaType, media.NSFW) ||
|
||||
IsAdultArtworkURL(media.PosterURL)
|
||||
}
|
||||
|
||||
// downloadArtworkToPathWithOptions fetches an artwork URL via the image proxy cache and
|
||||
// writes it under dir/<name>.<ext>. For adult posters, it crops the right half of the cover.
|
||||
func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, dir, name, raw string, cropAdultPoster bool) {
|
||||
func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, dir, name, raw string, cropAdultPoster bool) string {
|
||||
if !isHTTPish(raw) {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
data, ctype, err := s.images.Fetch(ctx, raw)
|
||||
if err != nil || len(data) == 0 {
|
||||
@@ -72,10 +100,10 @@ func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, d
|
||||
zap.String("name", name),
|
||||
zap.String("url", raw),
|
||||
zap.Error(err))
|
||||
return
|
||||
return ""
|
||||
}
|
||||
if !isImageContentType(ctype) || isTransparentPlaceholderData(data) {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
if cropAdultPoster {
|
||||
if cropped, croppedType, err := CropAdultCoverPoster(data); err == nil && len(cropped) > 0 {
|
||||
@@ -83,7 +111,7 @@ func (s *ScraperService) downloadArtworkToPathWithOptions(ctx context.Context, d
|
||||
ctype = croppedType
|
||||
}
|
||||
}
|
||||
s.writeArtworkDataToPath(dir, name, ctype, data)
|
||||
return s.writeArtworkDataToPath(dir, name, ctype, data)
|
||||
}
|
||||
|
||||
// writeArtworkDataToPath writes in-memory artwork bytes to dir/<name>.<ext>
|
||||
@@ -110,10 +138,23 @@ func (s *ScraperService) writeArtworkDataToPath(dir, name, ctype string, data []
|
||||
s.log.Warn("scrape artwork write failed", zap.String("dst", dst), zap.Error(err))
|
||||
return ""
|
||||
}
|
||||
_ = tmp.Close()
|
||||
if err := os.Rename(tmp.Name(), dst); err != nil {
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmp.Name())
|
||||
s.log.Warn("scrape artwork rename failed", zap.String("dst", dst), zap.Error(err))
|
||||
s.log.Warn("scrape artwork close failed", zap.String("dst", dst), zap.Error(err))
|
||||
return ""
|
||||
}
|
||||
|
||||
// On Windows os.Rename does not replace dst. Serialize remove+rename so two
|
||||
// concurrent scrapes cannot leave the previous uncropped DVD cover behind.
|
||||
s.artworkWriteMu.Lock()
|
||||
err = os.Remove(dst)
|
||||
if err == nil || os.IsNotExist(err) {
|
||||
err = os.Rename(tmp.Name(), dst)
|
||||
}
|
||||
s.artworkWriteMu.Unlock()
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp.Name())
|
||||
s.log.Warn("scrape artwork replace failed", zap.String("dst", dst), zap.Error(err))
|
||||
return ""
|
||||
}
|
||||
s.log.Debug("scrape artwork written", zap.String("dst", dst))
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// TestWriteArtworkDataToPathWritesJellyfinSidecar verifies that in-memory
|
||||
@@ -33,6 +42,47 @@ func TestWriteArtworkDataToPathWritesJellyfinSidecar(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteArtworkDataToPathReplacesExistingSidecar(t *testing.T) {
|
||||
scraper := &ScraperService{log: zap.NewNop()}
|
||||
mediaDir := t.TempDir()
|
||||
dst := filepath.Join(mediaDir, "poster.jpg")
|
||||
if err := os.WriteFile(dst, []byte("old DVD cover"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := scraper.writeArtworkDataToPath(mediaDir, "poster", "image/jpeg", testJPEG); got != dst {
|
||||
t.Fatalf("destination = %q, want %q", got, dst)
|
||||
}
|
||||
data, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != string(testJPEG) {
|
||||
t.Fatal("existing sidecar was not replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldCropAdultPosterWhenTMDbArtworkMatchedCodePath(t *testing.T) {
|
||||
media := &model.Media{
|
||||
Path: filepath.Join(t.TempDir(), "IPX-235.mp4"),
|
||||
Title: "TMDb matched title",
|
||||
PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg",
|
||||
}
|
||||
if !shouldCropAdultPoster(media, &model.Library{Type: "movie"}) {
|
||||
t.Fatal("code-numbered media should retain adult crop handling after a TMDb match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldCropAdultPosterUsesLibraryType(t *testing.T) {
|
||||
media := &model.Media{
|
||||
Path: filepath.Join(t.TempDir(), "renamed.mp4"),
|
||||
PosterURL: "https://image.tmdb.org/t/p/w500/poster.jpg",
|
||||
}
|
||||
if !shouldCropAdultPoster(media, &model.Library{Type: "adult"}) {
|
||||
t.Fatal("adult library media should be cropped regardless of provider")
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageExtForContentType verifies the MIME -> extension mapping used to
|
||||
// name Jellyfin sidecar files.
|
||||
func TestImageExtForContentType(t *testing.T) {
|
||||
@@ -87,3 +137,65 @@ func TestSameDirectoryMediaUsesBaseScopedSidecars(t *testing.T) {
|
||||
t.Fatalf("base = %q, want A-poster.jpg", filepath.Base(dst))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalAdultArtworkStoresFaceAwarePosterPath(t *testing.T) {
|
||||
wideCover := createTestImage(
|
||||
900,
|
||||
600,
|
||||
color.RGBA{R: 255, A: 255},
|
||||
color.RGBA{B: 255, A: 255},
|
||||
)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(wideCover)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Cache.CacheDir = t.TempDir()
|
||||
images := NewImageProxy(cfg, zap.NewNop())
|
||||
images.allowedRemoteHostsFn = func() []string { return []string{"127.0.0.1"} }
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Media{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
mediaDir := t.TempDir()
|
||||
media := model.Media{
|
||||
Title: "IPX-235",
|
||||
Path: filepath.Join(mediaDir, "IPX-235.mp4"),
|
||||
PosterURL: upstream.URL + "/cover.jpg",
|
||||
NSFW: true,
|
||||
ScrapeStatus: "matched",
|
||||
}
|
||||
if err := repos.DB.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scraper := &ScraperService{cfg: cfg, log: zap.NewNop(), repo: repos, images: images}
|
||||
scraper.writeMediaArtworkFilesAfterScrape(t.Context(), &media, &model.Library{Type: "adult"})
|
||||
|
||||
var got model.Media
|
||||
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filepath.Base(got.PosterURL) != "IPX-235-poster.jpg" {
|
||||
t.Fatalf("poster URL = %q, want local face-aware sidecar", got.PosterURL)
|
||||
}
|
||||
data, err := os.ReadFile(got.PosterURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cropped, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ratio := float64(cropped.Bounds().Dx()) / float64(cropped.Bounds().Dy())
|
||||
if ratio < 0.65 || ratio > 0.68 {
|
||||
t.Fatalf("local poster ratio = %.3f, want MetaTube primary ratio", ratio)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,10 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
b.c.FFTools = NewFFmpegToolsService(b.cfg, b.log, b.repos)
|
||||
// 弹幕 hash 识别需要把 strm 指向解析成可拉取的直链/本地路径。
|
||||
b.c.Danmaku.SetStrmResolver(b.c.Strm.ResolvePlay)
|
||||
// STRM 直连失败后的 HLS 转码:把 .strm 解析成 ffmpeg 可读取的本地路径或 HTTP 直链。
|
||||
b.c.Transcoder.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
|
||||
b.c.Transcoder.SetProbe(b.c.FFprobe)
|
||||
b.c.Subtitle.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
|
||||
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
|
||||
if b.c.EmbyRemote != nil {
|
||||
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
|
||||
@@ -177,6 +181,11 @@ func (b *serviceContainerBuilder) initIdentityServices() {
|
||||
func (b *serviceContainerBuilder) initImageProxy() {
|
||||
b.c.ImageProxy = NewImageProxy(b.cfg, b.log)
|
||||
b.c.ImageProxy.SetLibraryRootsProvider(b.libraryRoots)
|
||||
if b.c.EmbyRemote != nil {
|
||||
b.c.ImageProxy.SetAllowedRemoteHostsProvider(func() []string {
|
||||
return b.c.EmbyRemote.ConfiguredRemoteHosts(context.Background())
|
||||
})
|
||||
}
|
||||
b.c.Scan.SetImageProxy(b.c.ImageProxy)
|
||||
b.c.Scraper.SetImageProxy(b.c.ImageProxy)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ func playableSTRMTarget(ctx context.Context, repo *repository.Container, raw str
|
||||
}
|
||||
|
||||
// IsStrmMediaRow 判断媒体行是否为 .strm(远程直链)媒体:STRMURL 非空、
|
||||
// container=strm 或路径以 .strm 结尾。strm 媒体只能直连播放,禁止转码。
|
||||
// container=strm 或路径以 .strm 结尾。网页播放默认直连,失败后可转码。
|
||||
func IsStrmMediaRow(m *model.Media) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
|
||||
@@ -6,23 +6,36 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ServeHLSPlaylist makes sure a transcode is running and writes the m3u8.
|
||||
// We block (with a 30s timeout) until the playlist file shows up.
|
||||
//
|
||||
// Optional query `start` (seconds) restarts ffmpeg from that source offset so
|
||||
// the web player can scrub the full timeline without waiting for a full
|
||||
// head-to-tail transcode.
|
||||
func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request, mediaID string) error {
|
||||
// 「客户端直连解码」模式下宿主机不提供转码,HLS 一律拒绝,
|
||||
// 迫使播放器走 direct play 本地解码。
|
||||
if s.directPlayOnly(r.Context()) {
|
||||
return ErrTranscodeDisabled
|
||||
}
|
||||
if _, err := s.transcoder.EnsureJob(r.Context(), mediaID); err != nil {
|
||||
startSec := parseHLSStartSec(r)
|
||||
seekGen := parseHLSSeekGen(r)
|
||||
subtitleStream := parseHLSSubtitleStream(r)
|
||||
if _, err := s.transcoder.EnsureJobFromSubtitle(r.Context(), mediaID, startSec, seekGen, subtitleStream); err != nil {
|
||||
return err
|
||||
}
|
||||
s.transcoder.TouchJob(mediaID)
|
||||
if !s.transcoder.WaitReady(r.Context(), mediaID, 30*time.Second) {
|
||||
readyTimeout := 45 * time.Second
|
||||
if startSec > 0.05 {
|
||||
// Mid-file HTTP seeks (esp. WMV) need longer before the first segment appears.
|
||||
readyTimeout = 120 * time.Second
|
||||
}
|
||||
if !s.transcoder.WaitReady(r.Context(), mediaID, readyTimeout) {
|
||||
return errors.New("hls playlist not ready")
|
||||
}
|
||||
playlist := s.transcoder.PlaylistPath(mediaID)
|
||||
@@ -48,10 +61,61 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseHLSSubtitleStream(r *http.Request) int {
|
||||
if r == nil {
|
||||
return -1
|
||||
}
|
||||
raw := strings.TrimSpace(r.URL.Query().Get("subtitle"))
|
||||
if raw == "" {
|
||||
return -1
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v < 0 {
|
||||
return -1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func parseHLSStartSec(r *http.Request) float64 {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
raw := strings.TrimSpace(r.URL.Query().Get("start"))
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
v, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil || v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func parseHLSSeekGen(r *http.Request) int64 {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
raw := strings.TrimSpace(r.URL.Query().Get("_seek"))
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
v, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func appendQueryToHLSSegments(playlist, rawQuery string) string {
|
||||
if strings.TrimSpace(rawQuery) == "" {
|
||||
return playlist
|
||||
}
|
||||
// Segment fetches do not need start=, but must keep _seek as a cache-busting
|
||||
// generation because every transcode restart reuses seg_00000.ts names.
|
||||
q := filterHLSSegmentQuery(rawQuery)
|
||||
if q == "" {
|
||||
return playlist
|
||||
}
|
||||
lines := strings.SplitAfter(playlist, "\n")
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
@@ -65,12 +129,32 @@ func appendQueryToHLSSegments(playlist, rawQuery string) string {
|
||||
} else if strings.HasSuffix(line, "\n") {
|
||||
lineEnding = "\n"
|
||||
}
|
||||
lines[i] = strings.TrimRight(line, "\r\n") + "?" + rawQuery + lineEnding
|
||||
lines[i] = strings.TrimRight(line, "\r\n") + "?" + q + lineEnding
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "")
|
||||
}
|
||||
|
||||
func filterHLSSegmentQuery(rawQuery string) string {
|
||||
parts := strings.Split(rawQuery, "&")
|
||||
kept := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
key := part
|
||||
if i := strings.IndexByte(part, '='); i >= 0 {
|
||||
key = part[:i]
|
||||
}
|
||||
switch strings.ToLower(key) {
|
||||
case "start":
|
||||
continue
|
||||
}
|
||||
kept = append(kept, part)
|
||||
}
|
||||
return strings.Join(kept, "&")
|
||||
}
|
||||
|
||||
// ServeHLSSegment writes a single .ts segment from the on-disk cache.
|
||||
func (s *StreamService) ServeHLSSegment(w http.ResponseWriter, r *http.Request, mediaID, segment string) error {
|
||||
s.transcoder.TouchJob(mediaID)
|
||||
|
||||
@@ -302,9 +302,12 @@ func TestRequestTokenFromMediaBrowserAuthorizationHeader(t *testing.T) {
|
||||
|
||||
func TestAppendQueryToHLSSegments(t *testing.T) {
|
||||
in := "#EXTM3U\n#EXTINF:4.0,\nseg_00000.ts\n#EXTINF:4.0,\nseg_00001.ts?old=1\n"
|
||||
got := appendQueryToHLSSegments(in, "token=abc")
|
||||
if !strings.Contains(got, "seg_00000.ts?token=abc") {
|
||||
t.Fatalf("missing tokenized segment: %q", got)
|
||||
got := appendQueryToHLSSegments(in, "token=abc&start=120.5&_seek=1001")
|
||||
if !strings.Contains(got, "seg_00000.ts?token=abc&_seek=1001") {
|
||||
t.Fatalf("missing token or seek generation on segment: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "start=120.5") {
|
||||
t.Fatalf("segment URL must not contain transcode start: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "seg_00001.ts?old=1") {
|
||||
t.Fatalf("existing query should be preserved: %q", got)
|
||||
|
||||
@@ -293,7 +293,11 @@ func (s *StrmService) save115OAuthToken(ctx context.Context, session *strm115Aut
|
||||
acct.LastTestAt = &now
|
||||
acct.LastTestResult = "授权成功"
|
||||
acct.LastTestOK = true
|
||||
return s.repo.StrmAccount.Update(ctx, acct)
|
||||
if err := s.repo.StrmAccount.Update(ctx, acct); err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidate115Provider(acct.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StrmService) drop115AuthSession(sessionID string) {
|
||||
@@ -382,8 +386,15 @@ func (s *StrmService) refresh115TokensOnce(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
client := cloud115.NewOpenClient(cfg["app_id"], cfg["access_token"], cfg["refresh_token"])
|
||||
token, err := client.RefreshToken(cfg["refresh_token"])
|
||||
provider, err := s.providerFor(ctx, acct)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
openProvider, ok := provider.(interface{ OpenClient() *cloud115.OpenClient })
|
||||
if !ok || openProvider.OpenClient() == nil {
|
||||
continue
|
||||
}
|
||||
token, err := openProvider.OpenClient().RefreshToken("")
|
||||
if err != nil {
|
||||
msg := "令牌刷新失败:" + err.Error()
|
||||
if cloud115.IsRefreshTokenDead(err) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||
)
|
||||
|
||||
func TestStrmAccountConfigPreviewOf(t *testing.T) {
|
||||
@@ -71,6 +72,90 @@ func TestUpdateStrmAccountMergesConfigWithoutClearingSecrets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderForReuses115ClientUntilCredentialsChange(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
ctx := context.Background()
|
||||
acct, err := svc.CreateStrmAccount(ctx, "115", model.StrmProvider115, map[string]string{
|
||||
"app_id": "100195129",
|
||||
"access_token": "at-1",
|
||||
"refresh_token": "rt-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
|
||||
first, err := svc.providerFor(ctx, acct)
|
||||
if err != nil {
|
||||
t.Fatalf("first provider: %v", err)
|
||||
}
|
||||
second, err := svc.providerFor(ctx, acct)
|
||||
if err != nil {
|
||||
t.Fatalf("second provider: %v", err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatal("115 provider should be shared per account")
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateStrmAccount(ctx, acct.ID, "", nil, map[string]string{
|
||||
"access_token": "at-2",
|
||||
"refresh_token": "rt-2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update account: %v", err)
|
||||
}
|
||||
third, err := svc.providerFor(ctx, updated)
|
||||
if err != nil {
|
||||
t.Fatalf("provider after credential update: %v", err)
|
||||
}
|
||||
if first == third {
|
||||
t.Fatal("credential update must invalidate the shared provider")
|
||||
}
|
||||
|
||||
oldClient := first.(interface{ OpenClient() *cloud115.OpenClient }).OpenClient()
|
||||
oldClient.OnTokenRefreshed("at-stale", "rt-stale")
|
||||
fresh, err := svc.repo.StrmAccount.FindByID(ctx, acct.ID)
|
||||
if err != nil || fresh == nil {
|
||||
t.Fatalf("reload account: %v", err)
|
||||
}
|
||||
freshCfg, err := svc.strmAccountConfig(fresh)
|
||||
if err != nil {
|
||||
t.Fatalf("decode refreshed account: %v", err)
|
||||
}
|
||||
if freshCfg["access_token"] != "at-2" || freshCfg["refresh_token"] != "rt-2" {
|
||||
t.Fatalf("stale client overwrote new credentials: %#v", freshCfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersist115TokensKeepsSharedClient(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
ctx := context.Background()
|
||||
acct, err := svc.CreateStrmAccount(ctx, "115", model.StrmProvider115, map[string]string{
|
||||
"app_id": "100195129",
|
||||
"access_token": "at-1",
|
||||
"refresh_token": "rt-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
first, err := svc.providerFor(ctx, acct)
|
||||
if err != nil {
|
||||
t.Fatalf("first provider: %v", err)
|
||||
}
|
||||
|
||||
svc.persist115Tokens(acct.ID, "at-2", "rt-2")
|
||||
fresh, err := svc.repo.StrmAccount.FindByID(ctx, acct.ID)
|
||||
if err != nil || fresh == nil {
|
||||
t.Fatalf("reload account: %v", err)
|
||||
}
|
||||
second, err := svc.providerFor(ctx, fresh)
|
||||
if err != nil {
|
||||
t.Fatalf("second provider: %v", err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatal("automatic token persistence must keep the in-memory shared client")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteStrmAccountCascadesEmbyMounts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}, &model.StrmSyncPath{})
|
||||
|
||||
@@ -26,6 +26,8 @@ import (
|
||||
|
||||
const (
|
||||
strmMaxTaskRetry = 3
|
||||
// strmRecentUploadSkipWindow:上传已成功但 115 列表尚未反映时,同步扫描跳过同路径同大小再入队的宽限窗口。
|
||||
strmRecentUploadSkipWindow = 30 * time.Minute
|
||||
)
|
||||
|
||||
// downloadWorker 下载队列 worker:认领 → 解析直链 → 下载 → 落盘。
|
||||
@@ -332,18 +334,28 @@ func (s *StrmService) processUploadTask(ctx context.Context, task *model.StrmUpl
|
||||
}
|
||||
|
||||
// processUpload115 115 元数据上传:task.RemotePath 存的是父目录 cid,FileName 为远端文件名。
|
||||
//
|
||||
// 幂等要点:
|
||||
// 1. 上传/重试前按父目录 + 文件名 + SHA1 探活:远端已有同内容副本则跳过上传,仅清理其它脏副本;
|
||||
// 2. 真正上传成功后把新 file_id 写回 RemoteRef,供下次同步/重试识别;
|
||||
// 3. 115 上传不保证同名覆盖,内容不同时仍先删旧再传。
|
||||
func (s *StrmService) processUpload115(ctx context.Context, task *model.StrmUploadTask) {
|
||||
finish := func(status, message string) {
|
||||
finish := func(status, message, remoteRef string) {
|
||||
now := time.Now()
|
||||
task.Status = status
|
||||
task.Error = message
|
||||
task.FinishedAt = &now
|
||||
// 条件化收尾:与下载侧一致,防止覆盖已取消任务。
|
||||
if ok, err := s.repo.StrmUpload.UpdateIfRunning(context.Background(), task.ID, map[string]any{
|
||||
updates := map[string]any{
|
||||
"status": status,
|
||||
"error": message,
|
||||
"finished_at": &now,
|
||||
}); err != nil {
|
||||
}
|
||||
if remoteRef != "" {
|
||||
task.RemoteRef = remoteRef
|
||||
updates["remote_ref"] = remoteRef
|
||||
}
|
||||
// 条件化收尾:与下载侧一致,防止覆盖已取消任务。
|
||||
if ok, err := s.repo.StrmUpload.UpdateIfRunning(context.Background(), task.ID, updates); err != nil {
|
||||
s.log.Warn("update strm upload task failed", zap.Error(err))
|
||||
} else if !ok {
|
||||
s.log.Info("strm upload task already closed elsewhere", zap.String("id", task.ID))
|
||||
@@ -351,7 +363,7 @@ func (s *StrmService) processUpload115(ctx context.Context, task *model.StrmUplo
|
||||
}
|
||||
acct, err := s.repo.StrmAccount.FindByID(ctx, task.AccountID)
|
||||
if err != nil || acct == nil {
|
||||
finish(model.StrmTaskFailed, "网盘账号不存在")
|
||||
finish(model.StrmTaskFailed, "网盘账号不存在", "")
|
||||
return
|
||||
}
|
||||
provider, err := s.providerFor(ctx, acct)
|
||||
@@ -359,39 +371,97 @@ func (s *StrmService) processUpload115(ctx context.Context, task *model.StrmUplo
|
||||
s.uploadTaskFailWithRetry(task, err.Error())
|
||||
return
|
||||
}
|
||||
named, ok := provider.(interface {
|
||||
PutFileNamed(ctx context.Context, parentCID, fileName string, r io.Reader) error
|
||||
})
|
||||
open115, ok := provider.(cloud.OpenAPI115Provider)
|
||||
if !ok {
|
||||
finish(model.StrmTaskFailed, "该网盘不支持元数据上传")
|
||||
finish(model.StrmTaskFailed, "该网盘不支持元数据上传", "")
|
||||
return
|
||||
}
|
||||
// 以本地为准:网盘端已有同名但内容不同的旧元数据时,先删除旧文件再上传。
|
||||
// 115 的上传接口不保证同名覆盖,直接上传可能产生同名重复文件;删除失败则
|
||||
// 任务重试(旧文件 ID 失效的场景会在下次同步后自动修复)。
|
||||
client := open115.OpenClient()
|
||||
|
||||
localSHA1, shaErr := cloud115.FileSHA1(task.LocalPath)
|
||||
if shaErr != nil {
|
||||
s.uploadTaskFailWithRetry(task, "计算本地 SHA1 失败:"+shaErr.Error())
|
||||
return
|
||||
}
|
||||
info, statErr := os.Stat(task.LocalPath)
|
||||
if statErr != nil {
|
||||
s.uploadTaskFailWithRetry(task, "读取本地文件失败:"+statErr.Error())
|
||||
return
|
||||
}
|
||||
localSize := info.Size()
|
||||
|
||||
// ── 探活:父目录下是否已有同名同内容副本(覆盖「上传成功但本地当失败重试」)──
|
||||
matched, sameName, probeErr := client.FindNamedContentInParent(ctx, task.RemotePath, task.FileName, localSHA1, localSize)
|
||||
if probeErr != nil {
|
||||
// 探活失败不阻断上传:按原路径继续,避免列表接口抖动导致任务永久卡住
|
||||
s.log.Warn("115 上传前探活失败,继续上传",
|
||||
zap.String("task_id", task.ID),
|
||||
zap.String("local_path", task.LocalPath),
|
||||
zap.Error(probeErr))
|
||||
} else if matched != nil && matched.FileId != "" {
|
||||
staleIDs := collectStale115FileIDs(task.RemoteRef, sameName, matched.FileId)
|
||||
if len(staleIDs) > 0 {
|
||||
if err := client.DeleteFiles(ctx, task.RemotePath, staleIDs...); err != nil {
|
||||
s.log.Warn("探活命中后清理 115 脏副本失败(已跳过上传)",
|
||||
zap.String("task_id", task.ID),
|
||||
zap.String("matched_id", matched.FileId),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
s.log.Info("115 元数据已存在同内容副本,跳过上传",
|
||||
zap.String("task_id", task.ID),
|
||||
zap.String("local_path", task.LocalPath),
|
||||
zap.String("file_id", matched.FileId))
|
||||
finish(model.StrmTaskDone, "", matched.FileId)
|
||||
return
|
||||
}
|
||||
|
||||
// ── 需要上传:先尽量删掉任务携带的旧副本,再真正上传 ──
|
||||
// 删除失败时不中止——继续上传新文件,旧副本交由下次同步 cleanupBatchRedundantFiles。
|
||||
if task.RemoteRef != "" {
|
||||
open115, ok := provider.(cloud.OpenAPI115Provider)
|
||||
if !ok {
|
||||
finish(model.StrmTaskFailed, "该网盘不支持删除远端旧元数据")
|
||||
return
|
||||
}
|
||||
if err := open115.OpenClient().DeleteFiles(ctx, task.RemotePath, task.RemoteRef); err != nil {
|
||||
s.uploadTaskFailWithRetry(task, "删除网盘旧元数据失败:"+err.Error())
|
||||
return
|
||||
refs := strings.Split(task.RemoteRef, ",")
|
||||
if err := client.DeleteFiles(ctx, task.RemotePath, refs...); err != nil {
|
||||
s.log.Warn("删除网盘旧元数据失败,跳过删除继续上传新文件",
|
||||
zap.String("task_id", task.ID),
|
||||
zap.String("local_path", task.LocalPath),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
f, err := os.Open(task.LocalPath)
|
||||
|
||||
result, err := client.Upload(ctx, task.LocalPath, task.RemotePath, "", "")
|
||||
if err != nil {
|
||||
s.uploadTaskFailWithRetry(task, "打开本地文件失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
if err := named.PutFileNamed(ctx, task.RemotePath, task.FileName, f); err != nil {
|
||||
_ = f.Close()
|
||||
s.uploadTaskFailWithRetry(task, "上传失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
_ = f.Close()
|
||||
finish(model.StrmTaskDone, "")
|
||||
newID := ""
|
||||
if result != nil {
|
||||
newID = strings.TrimSpace(result.FileId)
|
||||
}
|
||||
finish(model.StrmTaskDone, "", newID)
|
||||
}
|
||||
|
||||
// collectStale115FileIDs 汇总待删脏副本:任务 RemoteRef + 探活所见同名文件,排除 keepID。
|
||||
func collectStale115FileIDs(remoteRef string, sameName []cloud115.RemoteFile, keepID string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
var out []string
|
||||
add := func(id string) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || id == keepID {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
return
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
for _, id := range strings.Split(remoteRef, ",") {
|
||||
add(id)
|
||||
}
|
||||
for _, f := range sameName {
|
||||
add(f.FileId)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// downloadTaskFailWithRetry 下载失败任务按退避重试,超过上限标记 failed。
|
||||
|
||||
@@ -3,10 +3,12 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -174,8 +176,8 @@ func TestRequeueDownloadTask(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestProcessUpload115DeletesStaleRemoteMetaFirst 验证 115 覆盖上传语义(以本地为准):
|
||||
// 任务携带网盘旧文件 ID 时,必须先调用 /open/ufile/delete 删除旧元数据再上传本地文件,
|
||||
// 避免 115 出现同名重复文件;删除请求应携带 file_ids 与父目录 cid。
|
||||
// 探活未命中时,任务携带网盘旧文件 ID 必须先 /open/ufile/delete 再上传;
|
||||
// 上传成功后 RemoteRef 回写为新 file_id。
|
||||
func TestProcessUpload115DeletesStaleRemoteMetaFirst(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
localDir := t.TempDir()
|
||||
@@ -197,6 +199,10 @@ func TestProcessUpload115DeletesStaleRemoteMetaFirst(t *testing.T) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch r.URL.Path {
|
||||
case "/open/ufile/files":
|
||||
calls = append(calls, "probe")
|
||||
// 探活:目录为空 / 无同内容副本 → 继续删旧上传
|
||||
w.Write([]byte(`{"state":true,"data":[]}`))
|
||||
case "/open/ufile/delete":
|
||||
calls = append(calls, "delete")
|
||||
deleteForm["file_ids"] = r.FormValue("file_ids")
|
||||
@@ -232,10 +238,13 @@ func TestProcessUpload115DeletesStaleRemoteMetaFirst(t *testing.T) {
|
||||
if task.Status != model.StrmTaskDone {
|
||||
t.Fatalf("upload task should succeed, status = %s, error = %s", task.Status, task.Error)
|
||||
}
|
||||
if task.RemoteRef != "new-1" {
|
||||
t.Fatalf("successful upload should record new file_id in RemoteRef, got %q", task.RemoteRef)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(calls) != 2 || calls[0] != "delete" || calls[1] != "upload" {
|
||||
t.Fatalf("expected delete before upload, got calls = %v", calls)
|
||||
if len(calls) != 3 || calls[0] != "probe" || calls[1] != "delete" || calls[2] != "upload" {
|
||||
t.Fatalf("expected probe→delete→upload, got calls = %v", calls)
|
||||
}
|
||||
if deleteForm["file_ids"] != "old-file-1" {
|
||||
t.Fatalf("delete file_ids = %q, want old-file-1", deleteForm["file_ids"])
|
||||
@@ -245,6 +254,97 @@ func TestProcessUpload115DeletesStaleRemoteMetaFirst(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProcessUpload115SkipsWhenProbeFindsSameContent 验证上传/重试前探活命中同内容副本时跳过上传,
|
||||
// 并清理其它脏副本,RemoteRef 回写为已存在的 file_id。
|
||||
func TestProcessUpload115SkipsWhenProbeFindsSameContent(t *testing.T) {
|
||||
svc := testStrmService(t)
|
||||
localDir := t.TempDir()
|
||||
localFile := filepath.Join(localDir, "movie.nfo")
|
||||
content := []byte("already-on-115")
|
||||
if err := os.WriteFile(localFile, content, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sha, err := cloud115.FileSHA1(localFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
acct := &model.StrmAccount{Name: "fake115", Provider: "cloud115", Config: "{}", Enabled: true}
|
||||
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var calls []string
|
||||
deleteForm := map[string]string{}
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch r.URL.Path {
|
||||
case "/open/ufile/files":
|
||||
calls = append(calls, "probe")
|
||||
w.Write([]byte(fmt.Sprintf(
|
||||
`{"state":true,"data":[
|
||||
{"fid":"keep-1","fc":"1","fn":"movie.nfo","fs":%d,"sha1":%q,"fta":"1"},
|
||||
{"fid":"dirty-2","fc":"1","fn":"movie.nfo","fs":9,"sha1":"DEADBEEF","fta":"1"}
|
||||
]}`, len(content), sha)))
|
||||
case "/open/ufile/delete":
|
||||
calls = append(calls, "delete")
|
||||
deleteForm["file_ids"] = r.FormValue("file_ids")
|
||||
w.Write([]byte(`{"state":true,"data":[]}`))
|
||||
case "/open/upload/init":
|
||||
calls = append(calls, "upload")
|
||||
w.Write([]byte(`{"state":true,"data":{"status":2,"file_id":"should-not","pick_code":"x","callback":null}}`))
|
||||
default:
|
||||
t.Errorf("unexpected 115 api path %s", r.URL.Path)
|
||||
w.Write([]byte(`{"state":false,"message":"unexpected path"}`))
|
||||
}
|
||||
}))
|
||||
defer api.Close()
|
||||
oldPro := cloud115.ProAPIBase
|
||||
cloud115.ProAPIBase = api.URL
|
||||
defer func() { cloud115.ProAPIBase = oldPro }()
|
||||
|
||||
task := &model.StrmUploadTask{
|
||||
Base: model.Base{ID: "up-skip-1"},
|
||||
SyncPathID: "p1",
|
||||
AccountID: acct.ID,
|
||||
Provider: model.StrmProvider115,
|
||||
FileName: "movie.nfo",
|
||||
LocalPath: localFile,
|
||||
RemotePath: "777",
|
||||
RemoteRef: "old-ref",
|
||||
Size: int64(len(content)),
|
||||
Status: model.StrmTaskRunning,
|
||||
}
|
||||
svc.processUpload115(context.Background(), task)
|
||||
|
||||
if task.Status != model.StrmTaskDone {
|
||||
t.Fatalf("probe hit should finish done, status=%s err=%s", task.Status, task.Error)
|
||||
}
|
||||
if task.RemoteRef != "keep-1" {
|
||||
t.Fatalf("RemoteRef should be matched file_id keep-1, got %q", task.RemoteRef)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(calls) != 2 || calls[0] != "probe" || calls[1] != "delete" {
|
||||
t.Fatalf("expected probe→delete (no upload), got %v", calls)
|
||||
}
|
||||
// 脏副本 dirty-2 与任务旧 ref 都应被清理,keep-1 不得出现
|
||||
ids := strings.Split(deleteForm["file_ids"], ",")
|
||||
idSet := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
idSet[strings.TrimSpace(id)] = true
|
||||
}
|
||||
if !idSet["dirty-2"] || !idSet["old-ref"] {
|
||||
t.Fatalf("delete should include dirty-2 and old-ref, got %q", deleteForm["file_ids"])
|
||||
}
|
||||
if idSet["keep-1"] {
|
||||
t.Fatalf("must not delete matched keep-1, got %q", deleteForm["file_ids"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchResolve115Links 验证下载队列的批量换链:同账号多个 115 任务的
|
||||
// pickcode 合并为一次 downurl 请求(官方接口支持逗号分隔多 pick_code),
|
||||
// 重复引用去重、非 115 任务不参与、直链携带绑定 UA。
|
||||
|
||||
@@ -42,6 +42,7 @@ const (
|
||||
StrmSettingDownloadMeta = "strm.download_meta"
|
||||
StrmSettingUploadMeta = "strm.upload_meta"
|
||||
StrmSettingDeleteDir = "strm.delete_dir"
|
||||
StrmSettingKeepExt = "strm.keep_ext"
|
||||
StrmSettingDownloadThreads = "strm.download_threads"
|
||||
StrmSettingUploadThreads = "strm.upload_threads"
|
||||
)
|
||||
@@ -69,6 +70,7 @@ var StrmSettingDefs = map[string]struct {
|
||||
StrmSettingDownloadMeta: {Default: "true", Label: "下载元数据", Kind: "bool", Help: "同步时把远端 nfo/图片/字幕下载到本地输出目录"},
|
||||
StrmSettingUploadMeta: {Default: "false", Label: "上传元数据", Kind: "bool", Help: "同步时把本地元数据上传到远端;本地与网盘元数据不同时以本地为准覆盖(需网盘支持写入)"},
|
||||
StrmSettingDeleteDir: {Default: "false", Label: "清理空目录", Kind: "bool", Help: "清理远端已删除的多余 .strm/元数据后,删除空目录"},
|
||||
StrmSettingKeepExt: {Default: "false", Label: "保留视频扩展名(多版本)", Kind: "bool", Help: "关闭(默认):同名不同扩展(如 竞女01.mkv / 竞女01.mp4)按体积→mtime→扩展名优先级择优生成一条 name.strm;开启:分别生成 name.mkv.strm / name.mp4.strm,保留全部版本供播放切换"},
|
||||
Strm115RelayKeySetting: {Default: "", Label: "115 中继授权共享密钥", Kind: "text", Help: "QMediaSync/MQFamily 中继授权的共享 AES 密钥(OAUTH_RELAY_ENCRYPTION_KEY);不配置则中继授权不可用"},
|
||||
StrmSettingDownloadThreads: {Default: "6", Label: "下载队列线程数", Kind: "number", Help: "OpenList/CloudDrive2 元数据下载并发数(115 独立限速为 3)"},
|
||||
StrmSettingUploadThreads: {Default: "2", Label: "上传队列线程数", Kind: "number", Help: "元数据上传并发数"},
|
||||
@@ -93,6 +95,9 @@ type StrmService struct {
|
||||
oauthSessions map[string]*strm115AuthSession
|
||||
wafUntil time.Time // 115 风控/限流熔断截止时间(由 mu 保护)
|
||||
|
||||
providerMu sync.Mutex
|
||||
provider115Cache map[string]cloud.Provider // account ID -> shared provider/OpenClient
|
||||
|
||||
downloadSem115 chan struct{} // 115 换直链+下载并发上限(风控兜底)
|
||||
downloadSemDAV chan struct{} // WebDAV/OpenList/CloudDrive2 元数据下载并发上限
|
||||
downloadSemOnce sync.Once
|
||||
@@ -156,15 +161,16 @@ func (s *StrmService) releaseDownloadSlot(provider string) {
|
||||
// NewStrmService constructs the STRM service.
|
||||
func NewStrmService(cfg *config.Config, log *zap.Logger, repos *repository.Container, crypto *CryptoService) *StrmService {
|
||||
return &StrmService{
|
||||
log: log,
|
||||
repo: repos,
|
||||
cfg: cfg,
|
||||
crypto: crypto,
|
||||
http: &http.Client{Timeout: 90 * time.Second},
|
||||
stopCh: make(chan struct{}),
|
||||
baseCtx: context.Background(),
|
||||
running: map[string]context.CancelFunc{},
|
||||
oauthSessions: map[string]*strm115AuthSession{},
|
||||
log: log,
|
||||
repo: repos,
|
||||
cfg: cfg,
|
||||
crypto: crypto,
|
||||
http: &http.Client{Timeout: 90 * time.Second},
|
||||
stopCh: make(chan struct{}),
|
||||
baseCtx: context.Background(),
|
||||
running: map[string]context.CancelFunc{},
|
||||
oauthSessions: map[string]*strm115AuthSession{},
|
||||
provider115Cache: map[string]cloud.Provider{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +413,9 @@ func (s *StrmService) UpdateStrmAccount(ctx context.Context, id, name string, en
|
||||
if err := s.repo.StrmAccount.Update(ctx, acct); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if acct.Provider == model.StrmProvider115 && len(config) > 0 {
|
||||
s.invalidate115Provider(acct.ID)
|
||||
}
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
@@ -424,6 +433,7 @@ func (s *StrmService) DeleteStrmAccount(ctx context.Context, id string) error {
|
||||
if err := s.repo.StrmAccount.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.invalidate115Provider(id)
|
||||
// 级联清理远程 Emby 挂载:否则留下孤儿挂载,挂载计数/列表仍会显示。
|
||||
// 账号已删,挂载清理失败只记日志,不让删除请求报错。
|
||||
if _, err := s.repo.EmbyMount.DeleteByAccountID(ctx, id); err != nil && s.log != nil {
|
||||
@@ -462,6 +472,23 @@ func (s *StrmService) ListAccounts(ctx context.Context) ([]model.StrmAccount, er
|
||||
|
||||
// providerFor 依据账号配置构建网盘驱动。
|
||||
func (s *StrmService) providerFor(ctx context.Context, acct *model.StrmAccount) (cloud.Provider, error) {
|
||||
if acct != nil && acct.Provider == model.StrmProvider115 {
|
||||
s.providerMu.Lock()
|
||||
defer s.providerMu.Unlock()
|
||||
if provider := s.provider115Cache[acct.ID]; provider != nil {
|
||||
return provider, nil
|
||||
}
|
||||
provider, err := s.newProvider(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.provider115Cache[acct.ID] = provider
|
||||
return provider, nil
|
||||
}
|
||||
return s.newProvider(ctx, acct)
|
||||
}
|
||||
|
||||
func (s *StrmService) newProvider(ctx context.Context, acct *model.StrmAccount) (cloud.Provider, error) {
|
||||
cfg, err := s.strmAccountConfig(acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -480,13 +507,33 @@ func (s *StrmService) providerFor(ctx context.Context, acct *model.StrmAccount)
|
||||
// refresh_token 再刷(一次性轮转),两者互相作废,最终把有效账号
|
||||
// 标成“授权已失效”。
|
||||
if oc, ok := provider.(interface{ OpenClient() *cloud115.OpenClient }); ok {
|
||||
oc.OpenClient().OnTokenRefreshed = func(accessToken, refreshToken string) {
|
||||
client := oc.OpenClient()
|
||||
client.OnTokenRefreshed = func(accessToken, refreshToken string) {
|
||||
// 账号重新授权/修改凭据后,旧客户端可能仍有在途请求。旧请求
|
||||
// 刷新的令牌不能覆盖新授权写入的凭据。
|
||||
if !s.isCurrent115Client(acct.ID, client) {
|
||||
return
|
||||
}
|
||||
s.persist115Tokens(acct.ID, accessToken, refreshToken)
|
||||
}
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func (s *StrmService) invalidate115Provider(accountID string) {
|
||||
s.providerMu.Lock()
|
||||
delete(s.provider115Cache, accountID)
|
||||
s.providerMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *StrmService) isCurrent115Client(accountID string, client *cloud115.OpenClient) bool {
|
||||
s.providerMu.Lock()
|
||||
defer s.providerMu.Unlock()
|
||||
provider := s.provider115Cache[accountID]
|
||||
openProvider, ok := provider.(interface{ OpenClient() *cloud115.OpenClient })
|
||||
return ok && openProvider.OpenClient() == client
|
||||
}
|
||||
|
||||
// ─── 全局设置 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetStrmSettings 返回全局 strm 设置(含默认值)。
|
||||
@@ -731,6 +778,7 @@ func (s *StrmService) strmEffectiveConfig(ctx context.Context, p *model.StrmSync
|
||||
cfg.DownloadMeta = p.DownloadMeta
|
||||
cfg.UploadMeta = p.UploadMeta
|
||||
cfg.DeleteDir = p.DeleteDir
|
||||
cfg.KeepExt = p.KeepExt
|
||||
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -964,6 +1012,7 @@ type strmPathConfig struct {
|
||||
DownloadMeta bool
|
||||
UploadMeta bool
|
||||
DeleteDir bool
|
||||
KeepExt bool
|
||||
}
|
||||
|
||||
// ─── 本地目录浏览(添加同步目录用,兼容 Windows/Linux) ─────────────────────────
|
||||
|
||||
+682
-202
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user