Compare commits

...

33 Commits

Author SHA1 Message Date
truewhile 2e636ce43f feat(web): 支持生成电视端临时登录码
- 新增创建临时登录密码的 API 请求
- 新增电视端临时登录密码弹窗组件并在用户菜单中提供入口
- 在个人资料页面添加电视临时登录码生成与倒计时展示
2026-09-06 23:22:34 +08:00
truewhile 28aa466107 feat: 支持临时登录密码并完善 Emby 媒体详情兼容
- 新增 6 位临时密码生成与验证接口,支持 TV 及客户端快速登录
- 媒体及剧集详情补充 People 演职员信息解析与返回
- 优化剧集背景图与海报标签继承机制及播放时长兜底逻辑
- 增加临时密码登录相关的单元测试
2026-09-06 22:58:41 +08:00
truewhile f1b8010496 bug处理+emby客户端兼容 2026-09-06 22:10:23 +08:00
truewhile 394d1a3cda feat(danmaku): 支持快捷填入当前片名并优化重新搜索机制 2026-09-06 20:32:12 +08:00
truewhile 465e98a792 bug 2026-09-06 19:02:53 +08:00
truewhile 292ae22dcd fix(sync): deduplicate remoteMeta by file ID to prevent deleting valid remote metadata copies 2026-09-06 17:36:54 +08:00
truewhile bc7e5fc79d 内网挂载emby封面无法加载问题处理 2026-09-06 17:35:33 +08:00
truewhile f7fec93d44 fix(upload): eliminate concurrent temp file name collision and support direct local file upload for 115 2026-09-06 17:05:56 +08:00
truewhile b4a1dc38bb 优化同步逻辑
优化同步逻辑
2026-09-06 16:17:42 +08:00
truewhile 3150971f10 feat(sync): implement 115 adaptive hierarchical flat scan to bypass deep paging limit 2026-09-06 16:15:26 +08:00
truewhile a71a18ce82 fix(upload): 删旧元数据失败时不中止任务,继续上传新文件
旧逻辑:DeleteFiles 失败 → uploadTaskFailWithRetry → 任务重试 →
再次 DeleteFiles 失败 → 永远无法上传,形成死循环。

新逻辑:DeleteFiles 失败时记录 warn 日志后继续上传新文件。
旧副本由下次同步的 scanLocalMetaForUpload 检测(新旧两个副本,
命中新版本后把旧版本 cid 收入 pendingDeletes)并通过
cleanupBatchRedundantFiles 异步批量清理。

这修复了小姐姐库在 115 限流环境下每次增量同步都重复上传
大量元数据的问题(Heyzo 等目录文件被上传了 7 次以上)。
2026-09-06 13:42:20 +08:00
truewhile 5a189a44fc Merge pull request #27 from gaodyoffice/fix/strm-sync-metadata
fix: OpenList metadata download 401 - use API instead of WebDAV
2026-09-06 12:30:40 +08:00
truewhile fb84c62e9a fix(sync): fallback to recursive traversal when 115 flat list hits deep-paging limit
115 API's flat list (search under the hood) enforces a hard limit of offset+limit <= 10000.
When syncing huge directories (e.g. >10000 files), this silently truncates results,
causing remote files to appear as missing locally, leading to infinite metadata
re-upload loops and potential wrongful deletion of strm files.

This patch auto-detects if the total file count >= 9500 and dynamically
falls back to the standard recursive concurrent traversal (walkRemote).
2026-09-06 12:27:56 +08:00
Gaodaiyang 355fd06036 fix: OpenList metadata download using API instead of WebDAV
OpenList 同步目录下载元数据(nfo/jpg/png/srt 等)全部失败,错误 http 401。

根因:Resolve() 中非视频文件走 WebDAV 直接下载,用 API token 作为 Authorization。
但 AList WebDAV 端点不接受 API token 认证,需要 Basic Auth。

修复:OpenList 在有 apiBase 时,所有文件都走 API /api/fs/get 获取直链,
不再走 WebDAV。API 失败时非视频文件可回退到 WebDAV。

影响范围:
- 只影响 OpenList 类型的非视频文件下载
- 不影响 115、CloudDrive2 等其他网盘
- 不影响视频播放/strm 生成
2026-09-06 11:41:12 +08:00
truewhile 4173caac5d bug处理 2026-09-06 01:16:30 +08:00
truewhile 2aeedcc182 bug处理 2026-09-06 00:01:40 +08:00
truewhile d96782622d bug处理 2026-09-05 23:02:26 +08:00
truewhile ebe425036b CI调整:每次push main也发布Release与多平台二进制(保留原有发布习惯) 2026-09-05 18:33:27 +08:00
truewhile edbaa1c84b CI改造:VERSION文件迁移至独立version分支,CI读取并自增写回该分支,main不再含版本文件 2026-09-05 18:27:39 +08:00
truewhile 0179332013 CI改造:版本号改为tag驱动,移除每次push回写VERSION的bump提交,消除本地推送冲突 2026-09-05 18:16:50 +08:00
github-actions[bot] 7fb3db0f4c chore: bump version to 0.1.15 [skip ci] 2026-09-05 10:10:42 +00:00
truewhile e4a101b502 Merge branch 'main' of https://github.com/truewhile/MeBox 2026-09-05 18:10:22 +08:00
truewhile 28551c7883 清理qBittorrent接入残留:删除死schema分组与前端下载类型,下载器保存目录键改为downloader.savepath并兼容旧键 2026-09-05 18:08:52 +08:00
github-actions[bot] a826ed9613 chore: bump version to 0.1.14 [skip ci] 2026-09-05 10:00:38 +00:00
truewhile 030ed5f325 文档修正:移除已不存在的qBittorrent接入与站点订阅描述,改为下载目录自动整理口径 2026-09-05 18:00:21 +08:00
github-actions[bot] 60308800fb chore: bump version to 0.1.13 [skip ci] 2026-09-05 09:18:29 +00:00
truewhile 582495dece 论坛教程图片改为 GitHub 直链 2026-09-05 17:18:14 +08:00
github-actions[bot] 2a4545eb44 chore: bump version to 0.1.12 [skip ci] 2026-09-05 09:17:14 +00:00
truewhile 2bde71099e 优化,添加Telegram群组与赞赏区,强化Emby客户端兼容说明,新增论坛图文教程与截图,修正MetaTube文档链接 2026-09-05 17:16:52 +08:00
github-actions[bot] 1b06a01603 chore: bump version to 0.1.11 [skip ci] 2026-09-05 08:31:45 +00:00
truewhile fe407caf9e Bump version from 0.0.110 to 0.1.10 2026-09-05 16:31:30 +08:00
github-actions[bot] ae02c14d27 chore: bump version to 0.0.110 [skip ci] 2026-09-05 08:22:11 +00:00
truewhile 5404e7773f 优化 2026-09-05 16:21:58 +08:00
89 changed files with 3671 additions and 728 deletions
+66 -107
View File
@@ -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 "==== 部署完成并已启动 ===="
+25 -7
View File
@@ -7,7 +7,7 @@
<h3 align="center">面向 NAS 与家庭影音场景的私人媒体中心</h3>
<p align="center">
<strong>媒体库 · 刮削整理 · 网盘 STRM · Emby 协议 · 远程 Emby 挂载 · 多用户权限 · Docker 一键部署</strong>
<strong>媒体库 · 刮削整理 · 网盘 STRM · 兼容 Emby/Jellyfin 客户端 · 远程 Emby 挂载 · 多用户权限 · Docker 一键部署</strong>
</p>
<p align="center">
@@ -17,7 +17,8 @@
<a href="#鸣谢">鸣谢</a> ·
<a href="#开发构建">开发构建</a> ·
<a href="README_EN.md">English</a> ·
<a href="CONTRIBUTING.md">贡献规范</a>
<a href="CONTRIBUTING.md">贡献规范</a> ·
<a href="https://t.me/MeBoxGroup">Telegram 群组</a>
</p>
<p align="center">
@@ -46,10 +47,10 @@
| **媒体库** | 电影、电视剧、动漫、综艺、音乐与自定义库;多根目录、扫库、海报墙、继续观看 |
| **元数据刮削** | TMDb、Bangumi、Douban、TheTVDB、Fanart 等;支持 NFO、手动匹配、刮削队列 |
| **播放** | 网页播放器、HLS 转码、弹幕、字幕、播放配置档、观看历史与收藏 |
| **Emby 协议** | Infuse、SenPlayer、Fileball 等客户端可直接添加本服务,使用 MeBox 账号登录 |
| **Emby/Jellyfin 客户端兼容** | 内置完整 Emby 服务端协议实现:Infuse、SenPlayer、Fileball、Emby/Jellyfin 官方客户端等可直接把本服务当作 Emby 服务器添加,使用 MeBox 账号登录,海报墙、进度同步、多用户无缝衔接 |
| **远程 Emby 挂载** | 将远程 Emby 媒体库挂载到本地界面统一浏览(无需单独开 Emby 客户端) |
| **网盘与 STRM** | OpenList、CloudDrive2、115、WebDAV 等;STRM 同步、上传/下载队列、直链/302 播放 |
| **下载与整理** | qBittorrent 接入、站点搜索与订阅、下载后自动整理、文件管理器(复制/移动/硬链/软链) |
| **下载与整理** | 下载目录定时自动整理(智能分类、自动注册媒体库)、文件管理器(复制/移动/硬链/软链) |
| **用户与权限** | 管理员/普通用户、有效期、成人内容开关、播放配置 PIN、细粒度操作权限 |
| **运维能力** | 统一任务队列、存储统计、DLNA 投屏、系统设置与日志 |
@@ -85,6 +86,8 @@ http://服务器IP:18080
默认账号:`admin` / `admin123`(首次登录后请立即修改密码)
> 💡 **Emby 用户无缝切换**:MeBox 完整兼容 Emby/Jellyfin 客户端协议。手机、电视、平板上的 Infuse、SenPlayer、Fileball、Emby/Jellyfin 官方客户端,直接按「添加 Emby 服务器」填入 `http://服务器IP:18080`,用 MeBox 账号登录即可,无需改变原有使用习惯。
镜像地址:
```text
@@ -179,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 账号登录
@@ -190,8 +193,8 @@ environment:
**扫库或入库很慢?**
先确认路径映射与数据库档位。网盘扫描还受接口限速与目录规模影响;大库可考虑第二档 Redis 或第三档 OpenSearch。
**qBittorrent 下载后无法整理?**
确认下载目录已通过 `volumes` 挂进容器,且 `MEBOX_DOWNLOAD_*` 环境变量对应正确。
**下载目录文件没有被自动整理?**
确认下载目录已通过 `volumes` 挂进容器,且 `MEBOX_DOWNLOAD_*` 环境变量对应正确。MeBox 负责目录整理入库,qBittorrent 等下载器按普通软件自行部署即可。
**硬链接失败(cross-device link)?**
硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 btrfs 子卷或网盘挂载时请改用复制或软链接。
@@ -251,3 +254,18 @@ MeBox 在 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) 的基础
## 许可证
本项目采用 [GPL-3.0](LICENSE) 许可证。
---
## 赞赏
如果 MeBox 帮你把家庭影音折腾明白了,欢迎请作者喝杯咖啡 ☕
<p align="center">
<img src="docs/images/donation-qr.png" width="320" alt="WhileTrue 的赞赏码" />
</p>
<p align="center">
<strong>Telegram 交流群</strong>:<a href="https://t.me/MeBoxGroup">https://t.me/MeBoxGroup</a><br/>
使用问题、功能建议、更新动态,欢迎来群里聊
</p>
+25 -7
View File
@@ -7,7 +7,7 @@
<h3 align="center">A self-hosted media center for NAS and home theater</h3>
<p align="center">
<strong>Libraries · Metadata · Cloud STRM · Emby protocol · Remote Emby mounts · Multi-user · Docker-first</strong>
<strong>Libraries · Metadata · Cloud STRM · Emby/Jellyfin client compatible · Remote Emby mounts · Multi-user · Docker-first</strong>
</p>
<p align="center">
@@ -16,7 +16,8 @@
<a href="#quick-start">Quick Start</a> ·
<a href="#deployment-tiers">Deployment</a> ·
<a href="#acknowledgements">Acknowledgements</a> ·
<a href="#development">Development</a>
<a href="#development">Development</a> ·
<a href="https://t.me/MeBoxGroup">Telegram</a>
</p>
<p align="center">
@@ -45,10 +46,10 @@ In practice, MeBox gives you:
| **Libraries** | Movies, TV, anime, variety, music, custom libraries; multi-root scanning; poster wall; continue watching |
| **Metadata** | TMDb, Bangumi, Douban, TheTVDB, Fanart, NFO import, manual matching, scrape queue |
| **Playback** | Web player, HLS transcoding, danmaku, subtitles, play profiles, history and favourites |
| **Emby protocol** | Add MeBox in Infuse, SenPlayer, Fileball, etc. and sign in with MeBox accounts |
| **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 |
@@ -84,6 +85,8 @@ http://SERVER_IP:18080
Default login: `admin` / `admin123` — change the password immediately.
> 💡 **Seamless for Emby users**: MeBox fully implements the Emby/Jellyfin client protocol. Infuse, SenPlayer, Fileball, and official Emby/Jellyfin apps on phones, TVs, and tablets can add it as an Emby server at `http://SERVER_IP:18080` and sign in with MeBox accounts — no change to your existing workflow.
Image:
```text
@@ -159,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
@@ -170,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.
@@ -227,3 +230,18 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md) before ope
## License
This project is licensed under [GPL-3.0](LICENSE).
---
## Support & Donate
If MeBox makes your home theater life easier, feel free to buy the maintainer a coffee ☕
<p align="center">
<img src="docs/images/donation-qr.png" width="320" alt="WhileTrue donation QR" />
</p>
<p align="center">
<strong>Telegram group</strong>: <a href="https://t.me/MeBoxGroup">https://t.me/MeBoxGroup</a><br/>
Questions, feature requests, and release news — come chat with us
</p>
-1
View File
@@ -1 +0,0 @@
0.0.109
+5 -2
View File
@@ -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
+1 -1
View File
@@ -55,7 +55,7 @@ services:
bind:
create_host_path: false
# 下载目录。需要和 qBittorrent 保存路径保持一致。
# 下载目录。需要和下载器保存路径保持一致。
# NAS 示例:source: /vol1/1000/Downloads
# Windows Docker Desktop 示例:source: D:/Downloads
- type: bind
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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
View File
@@ -29,7 +29,7 @@ services:
ports:
- "18080:8080"
# 让容器可以访问宿主机上的 qBittorrent。
# 让容器可以访问宿主机上的服务(如下载器)。
# qB 地址通常可填:http://host.docker.internal:8085
extra_hosts:
- "host.docker.internal:host-gateway"
+190
View File
@@ -0,0 +1,190 @@
# 【开源推荐】MeBox:把 NAS / 网盘 / 远程 Emby 统一家里的观影入口,Docker 一键部署
> 配图已托管在 GitHub 仓库(`raw.githubusercontent.com` 直链),发帖时可直接引用,或下载 `docs/tutorial-screenshots/` 后作为附件上传。
---
## 写在前面
给论坛的朋友们推荐一个我维护的开源项目 —— **MeBox**,一个面向 NAS 与家庭影音场景的**自托管私人媒体中心**(GPL-3.0,Go + React)。
GitHub:https://github.com/truewhile/MeBox
一句话介绍:**部署一个服务,同时获得媒体库后台、网盘 STRM 整理、Emby 客户端协议网关三件套。** 内置完整 Emby/Jellyfin 服务端协议实现——手机、电视、平板上的 Infuse、SenPlayer、Fileball、Emby/Jellyfin 官方客户端直接「添加 Emby 服务器」就能连,一套账号体系全搞定,Emby 老用户零学习成本。
项目 fork 自 MediaStationGo 并持续二开,围绕网盘播放、任务队列、远程挂载和权限体系做了大量增强。
---
## 它能解决什么问题?
家里看电影电视的痛点,MeBox 基本一把梭:
| 痛点 | MeBox 的解法 |
| --- | --- |
| 硬盘散落各处,海报墙乱七八糟 | 多根目录媒体库 + TMDb/Bangumi/Douban 自动刮削,海报墙、继续观看、多季剧集一应俱全 |
| 网盘资源看一部下一部太麻烦 | OpenList / CloudDrive2 / 115 / WebDAV 接入,STRM 同步 + 直链/302 播放,不占本地空间 |
| 已经有一台 Emby,出门还得开 App | **远程 Emby 挂载**:把远程 Emby 的媒体库直接挂进 MeBox 界面统一浏览 |
| 家人乱动设置、小孩看不该看的 | 多用户 + 有效期 + 成人内容开关 + 播放配置 PIN,细粒度权限 |
| 每个设备装一套专属 App 太折腾 | **完整兼容 Emby/Jellyfin 客户端**:Infuse、SenPlayer、Fileball、官方客户端按「添加 Emby 服务器」填地址 + MeBox 账号即可,海报墙、观看进度、多用户直接同步 |
---
## 特点一览
**1. 现代化 Web UI,海报墙开箱即用**
![登录页](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/01-login.png)
深色系登录页,默认账号 `admin / admin123`(首次登录请立即改密)。
![首页](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/02-home.png)
首页自带焦点推荐轮播 + 媒体库入口卡片,继续观看、最近添加直接呈现。
**2. 媒体库与刮削**
![媒体库总览](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/03-libraries.png)
20 个媒体库、1600+ 条目一眼尽收:每库自带封面拼贴、条目数统计,支持「全库修复+重刮」「刮削队列」批量处理。
![海报墙](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/04-library-posters.png)
库内海报墙带评分、集数角标,支持按最后集添加日期排序,点开即看。
**3. 详情页与多季管理**
![详情页](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/05-media-detail.png)
剧情简介、类型标签、多季分集(特别篇/第 1-N 季)、每集缩略图与时长;一键立即播放、调用外部播放器、加入收藏。
**4. Emby/Jellyfin 客户端无缝兼容**
这是我最想强调的一点:**MeBox 内置了完整的 Emby 服务端协议实现**。手机、电视、平板上的 Infuse、SenPlayer、Fileball,甚至 Emby/Jellyfin 官方客户端,都不需要任何插件或改造——按「添加 Emby 服务器」填入 `http://服务器IP:18080`,用 MeBox 账号登录,海报墙、观看进度、收藏、多用户权限全部无缝衔接。已经习惯 Emby 生态的朋友可以零成本迁移,家人只用电视端 App 也完全无感。
**5. 网页播放器 + 弹幕自动匹配**
![播放器与弹幕](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/06-player-danmu.png)
内置网页播放器支持 HLS 转码、字幕、播放配置档;**弹幕按剧名自动匹配全季分集**(截图中自动匹配到《一拳超人》39 集),屏幕占比/透明度/字号随意调,追新番体验直接拉满。
**6. 网盘 STRM:网盘当本地盘用**
![STRM 管理](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/07-strm-cloud.png)
添加网盘账号(**115 支持二维码扫码登录**)→ 添加同步目录 → 系统把网盘/本地目录里的视频生成 `.strm` 文件,元数据经下载/上传队列双向同步,播放走直链/302 不落盘。
**7. 远程 Emby 挂载(特色功能)**
![Emby 挂载](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/08-emby-mount.png)
已有远程 Emby 服务器?填一次账号,按需勾选要挂载的媒体库(支持同服务器多线路自动切换、直连开关、排序),远程库直接出现在 MeBox 首页,不必再开 Emby 客户端。
**8. 任务队列统一管理**
![任务队列](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/09-task-queue.png)
刮削 / 下载 / 上传三类任务统一看板,排队中、进行中、已匹配、失败分类计数,支持搜索与批量清理。
**9. 下载与自动整理**
![文件管理](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/11-file-manager.png)
配合任意下载器(qBittorrent、Transmission 等下载到本地目录即可),MeBox 定时自动整理入媒体库:智能分类子库、自动注册目的地媒体库、复制/移动/硬链/软链多种整理方式,命名规则可配。
**10. 多用户与权限**
![用户管理](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/12-user-admin.png)
管理员/普通用户分级、单实例用户数上限、账号有效期、成人内容开关、播放配置 PIN——给家人开号放心给。
**11. 运维省心**
![系统设置](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/10-settings.png)
FFmpeg/FFprobe 一键下载安装、转码与硬件加速开关、TMDb 语言、识别词、弹幕、Adult/NSFW 开关全在设置页分组管理;另有 DLNA 投屏、存储统计、海报墙聚合视图:
![海报墙聚合](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/13-poster-wall.png)
---
## 使用教程:从零到海报墙只要 5 步
### 第 1 步:Docker 一键部署
推荐 Docker Compose(仓库提供 4 份互相独立的完整模板,无需 `.env`):
```bash
mkdir -p MeBox && cd MeBox
# 最省心:单镜像 + 内置 SQLite
curl -fsSL https://raw.githubusercontent.com/truewhile/MeBox/main/docker-compose.simple.yml -o docker-compose.yml
# 多用户/大数据量可选 PostgreSQL 档、Redis 档、OpenSearch 档,见仓库 README「部署档位」
docker compose up -d
```
浏览器访问 `http://服务器IP:18080`,镜像:`ghcr.io/truewhile/mebox:latest`(amd64 / arm64 都有,也提供 Windows/Linux/macOS 单文件可执行程序,不想装 Docker 直接下载跑)。
### 第 2 步:登录并修改密码
默认账号 `admin / admin123`,登录后右上角头像 → 个人资料修改密码。
### 第 3 步:创建媒体库 + 扫库
后台 → 媒体库 → 管理媒体库,添加本地路径(Docker 部署记得填**容器内**路径,如 `/media/电影`,`volumes` 左侧挂宿主机真实目录)→ 执行扫库。
### 第 4 步:配置元数据刮削
系统设置 → 外部 API,填入 TMDb / Bangumi / Douban 等 API Key;媒体库页可对单个库「全库修复+重刮」,刮削进度在任务队列实时可见。
### 第 5 步(可选但强烈推荐):
- **网盘用户**:STRM 管理 → 添加网盘账号(115 可扫码)→ 添加同步目录 → 生成 STRM 后直链播放;
- **已有 Emby**:Emby 挂载 → 添加 Emby 账号 → 勾选要挂载的媒体库;
- **第三方播放器(Emby 客户端全兼容)**:Infuse / SenPlayer / Fileball / Emby、Jellyfin 官方客户端,按「添加 Emby 服务器」填 `http://服务器IP:18080`,用 MeBox 账号登录即可,原有使用习惯完全不变;
- **下载党**:qBittorrent 等任意下载器把视频下到下载目录,在文件管理里把它设为整理源,下完自动分类入库。
### 路径映射小抄(Docker 最常见坑)
```yaml
volumes:
- /vol1/1000/Media:/media # 左:宿主机真实路径;右:容器内路径(网页里填这个)
environment:
MEBOX_MEDIA_DIR: /vol1/1000/Media
MEBOX_MEDIA_CONTAINER_DIR: /media
```
硬链接要求同一文件系统/子卷,跨盘请改复制或软链。
---
## 部署档位怎么选?
| 档位 | 文件 | 组件 | 适合 |
| --- | --- | --- | --- |
| 极简 | `docker-compose.simple.yml` | 单镜像 + SQLite | 个人使用、低配设备 |
| 标准 | `docker-compose.yml` | + PostgreSQL | 多用户家庭共享 |
| 增强 | `docker-compose.standard.yml` | + Redis | 大媒体库高频访问 |
| 搜索 | `docker-compose.search.yml` | + OpenSearch | 超大库全文搜索 |
---
## 技术栈与致谢
- 后端:Go · Gin · GORM · SQLite/PostgreSQL · 可选 Redis / OpenSearch
- 前端:React 18 · Vite · TypeScript · Tailwind CSS · Zustand
- 部署:Docker Compose 多档模板,amd64/arm64 镜像 + 单文件可执行
感谢上游 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) 的奠基,网盘同步/STRM/整理部分参考了 [qmediasync](https://github.com/qicfan/qmediasync) 的思路。
---
## 链接
- GitHub:https://github.com/truewhile/MeBox
- Issue / PR:欢迎提 bug(附部署方式+复现步骤+日志)与功能建议
- License:GPL-3.0
觉得有用的话求个 Star ⭐,也欢迎论坛里的朋友反馈使用体验,我长期维护。
Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+20 -1
View File
@@ -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,
})
}
}
+30 -1
View File
@@ -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
}
+2 -1
View File
@@ -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)
+190
View File
@@ -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
}
+8
View File
@@ -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
+12 -5
View File
@@ -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 {
+25 -8
View File
@@ -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
@@ -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) {
+1
View File
@@ -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))
}
+62 -36
View File
@@ -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,25 @@ 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"`
Cron string `json:"cron"`
EnableCron *bool `json:"enable_cron"`
SyncMode string `json:"sync_mode"`
Enabled *bool `json:"enabled"`
}
type strmSyncPathView struct {
@@ -240,6 +255,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 +688,25 @@ 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),
Cron: strings.TrimSpace(req.Cron),
EnableCron: boolValue(req.EnableCron, false),
SyncMode: strings.TrimSpace(req.SyncMode),
Enabled: boolValue(req.Enabled, true),
}
}
-10
View File
@@ -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": "系统更新",
+5 -1
View File
@@ -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
+23 -22
View File
@@ -95,28 +95,29 @@ 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,
"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
})
}
+115 -1
View File
@@ -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
}
+7 -7
View File
@@ -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)
+3 -2
View File
@@ -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)
}
}
+26 -20
View File
@@ -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
+7 -5
View File
@@ -5,9 +5,10 @@ import (
"encoding/hex"
"io"
"os"
"strings"
)
// FileSHA1 计算文件完整 SHA1(小写 hex)。
// FileSHA1 计算文件完整 SHA1(大写 hex,115 全链路统一大写)。
func FileSHA1(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
@@ -18,11 +19,12 @@ func FileSHA1(path string) (string, error) {
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
return strings.ToUpper(hex.EncodeToString(h.Sum(nil))), nil
}
// FileSHA1Partial 计算文件 [start,end](含)字节区间的 SHA1(小写 hex)。
// 用于 115 上传二次签名按 sign_check 指定的区间重算哈希。
// FileSHA1Partial 计算文件 [start,end](含)字节区间的 SHA1(大写 hex)。
// 用于 115 上传二次签名按 sign_check 指定的区间重算哈希;sign_val 必须为大写,
// 否则 115 以 status=8「签名认证失败」拒绝。
func FileSHA1Partial(path string, start, end int64) (string, error) {
if start < 0 {
start = 0
@@ -45,5 +47,5 @@ func FileSHA1Partial(path string, start, end int64) (string, error) {
if _, err := io.CopyN(h, f, length); err != nil && err != io.EOF {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
return strings.ToUpper(hex.EncodeToString(h.Sum(nil))), nil
}
+4 -4
View File
@@ -21,8 +21,8 @@ func TestFileSHA1(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// sha1("hello") = aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d
if sum != "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d" {
// sha1("hello") = aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d,115 要求大写
if sum != "AAF4C61DDCC5E8A2DABEDE0F3B482CD9AEA9434D" {
t.Errorf("unexpected sha1: %s", sum)
}
}
@@ -39,7 +39,7 @@ func TestFileSHA1Partial(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if sum != "0ec09ef9836da03f1add21e3ef607627e687e790" {
if sum != "0EC09EF9836DA03F1ADD21E3EF607627E687E790" {
t.Errorf("unexpected partial sha1: %s", sum)
}
}
@@ -59,7 +59,7 @@ func TestFileSHA1PartialSmallerThanWindow(t *testing.T) {
t.Fatalf("compute partial sha1 for small file should not fail: %v", err)
}
// 应等于整个文件(6 字节)的 sha1
if sum != "1f8ac10f23c5b5bc1167bda84b833e5c057a77d2" {
if sum != "1F8AC10F23C5B5BC1167BDA84B833E5C057A77D2" {
t.Errorf("unexpected partial sha1: %s", sum)
}
}
+1 -1
View File
@@ -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.
+96
View File
@@ -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,92 @@ 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)
ext := filepath.Ext(m.Path)
base := strings.TrimSuffix(m.Path, ext)
candidates := []string{
base + ".nfo",
filepath.Join(dir, "movie.nfo"),
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, ok, err := decodeNFOFile(p)
if err == nil && ok && 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])
}
+87 -30
View File
@@ -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
}
+32
View File
@@ -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 {
+70 -16
View File
@@ -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())
+127
View File
@@ -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)
}
}
+19 -5
View File
@@ -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 -3
View File
@@ -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,
+5 -1
View File
@@ -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())
+4 -3
View File
@@ -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)
+62 -6
View File
@@ -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 {
+1 -1
View File
@@ -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
+12 -8
View File
@@ -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)
}
+9 -4
View File
@@ -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)
}
}
}
+26 -3
View File
@@ -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())
}
}
+1 -1
View File
@@ -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)
}
@@ -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")
+1 -1
View File
@@ -54,7 +54,7 @@ func (o *OrganizerService) resolveTransferMode(ctx context.Context, override Tra
}
}
if mode == TransferMove && o.keepSeedingEnabled(ctx) {
// 移动会删除源文件导致 qBittorrent 停止做种;保种开启时改用硬链接
// 移动会删除源文件导致下载器停止做种;保种开启时改用硬链接
// 既规范命名又保留源文件继续做种上传。硬链接失败时会报错,避免静默
// 退化复制后占用双份磁盘空间。
return TransferHardlink
+1 -1
View File
@@ -7,7 +7,7 @@ import (
)
// translateClientPath 将下载客户端报告的路径转换为容器内可访问的路径。
// 常见场景:qBittorrent在另一个容器,报告的路径是其容器内路径,需要映射到当前容器。
// 常见场景:下载器在另一个容器,报告的路径是其容器内路径,需要映射到当前容器。
func translateClientPath(clientPath string, mappings map[string]string) string {
if clientPath == "" {
return ""
+1 -1
View File
@@ -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 {
+5
View File
@@ -177,6 +177,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)
}
+35 -17
View File
@@ -366,33 +366,51 @@ func (s *StrmService) processUpload115(ctx context.Context, task *model.StrmUplo
finish(model.StrmTaskFailed, "该网盘不支持元数据上传")
return
}
// 以本地为准:网盘端已有同名但内容不同的旧元数据时,先删除旧文件再上传。
// 115 的上传接口不保证同名覆盖,直接上传可能产生同名重复文件;删除失败则
// 任务重试(旧文件 ID 失效的场景会在下次同步后自动修复)。
// 以本地为准:网盘端已有同名但内容不同的旧元数据时,先尝试批量删除所有旧副本再上传。
// 115 的上传接口不保证同名覆盖,直接上传可能产生同名重复文件。
// 删除失败时不中止任务——继续上传新文件,旧副本交由下次同步的 cleanupBatchRedundantFiles
// 按目录批量清理(下次同步会看到新旧两个版本,命中新版本后把旧版本 cid 收入 pendingDeletes
// 异步删除)。这样避免了「删旧失败 → 任务重试 → 再次删旧失败 → 永远无法上传」的死循环。
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())
refs := strings.Split(task.RemoteRef, ",")
if err := open115.OpenClient().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))
// 不 return:继续上传新文件,旧副本由下次同步清理
}
}
// 优先使用直接本地文件上传接口,零拷贝且彻底根除并发临时文件同名碰撞
if localUploader, ok := provider.(interface {
PutLocalFile(ctx context.Context, parentCID, localPath string) error
}); ok {
if err := localUploader.PutLocalFile(ctx, task.RemotePath, task.LocalPath); err != nil {
s.uploadTaskFailWithRetry(task, "上传失败:"+err.Error())
return
}
finish(model.StrmTaskDone, "")
return
}
f, err := os.Open(task.LocalPath)
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, err := os.Open(task.LocalPath)
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
finish(model.StrmTaskDone, "")
}
_ = f.Close()
finish(model.StrmTaskDone, "")
}
// downloadTaskFailWithRetry 下载失败任务按退避重试,超过上限标记 failed。
func (s *StrmService) downloadTaskFailWithRetry(task *model.StrmDownloadTask, message string) {
+520 -144
View File
@@ -6,10 +6,10 @@ import (
"context"
"errors"
"fmt"
"reflect"
"net/url"
"os"
"path/filepath"
"reflect"
"sort"
"strconv"
"strings"
@@ -25,6 +25,16 @@ import (
"github.com/truewhile/MeBox/internal/service/cloud115"
)
var errFallbackToWalkRemote = errors.New("fallback to walk remote")
// remoteMetaItem 记录远端存在的单个元数据文件副本信息(大小、文件ID、内容SHA1、修改时间)。
type remoteMetaItem struct {
ID string
Size int64
Sha1 string
MTime int64
}
// strmSyncState 是一次同步执行的上下文。
type strmSyncState struct {
s *StrmService
@@ -37,13 +47,11 @@ type strmSyncState struct {
syncType string
mu sync.Mutex
processed int // 已处理文件计数(用于定期落库进度)
lastProgressFlush time.Time // 上次进度落库时间
seenVideo map[string]bool // "v:"+去掉扩展名的相对路径 → 远端存在该视频
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
remoteMeta map[string]int64 // 远端元数据大小(上传比对用)
remoteMetaRef map[string]string // "m:"+相对路径 → 远端元数据文件引用(115 文件 ID,覆盖上传前删除旧文件用)
remoteMetaSha1 map[string]string // "m:"+相对路径 → 远端元数据内容 SHA1(115 列表返回;上传/下载精确比对用,其他网盘为空)
processed int // 已处理文件计数(用于定期落库进度)
lastProgressFlush time.Time // 上次进度落库时间
seenVideo map[string]bool // "v:"+去掉扩展名的相对路径 → 远端存在该视频
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
remoteMeta map[string][]remoteMetaItem // "m:"+相对路径 → 远端元数据副本列表(多副本聚合,支持择优比对与冗余清理)
seenMetaTarget map[string]cloud.FileEntry
seenVideoTarget map[string]cloud.FileEntry
activeDownloadPaths map[string]bool // 本地已在排队/进行的下载任务路径(内存去重)
@@ -184,6 +192,66 @@ func (s *StrmService) ListRemoteDir(ctx context.Context, accountID, dir string)
return provider.List(ctx, dir)
}
// ResolveRemoteDirPath 解析远端目录的完整展示路径。115 的目录以 ID 存储,
// 用户无法辨认,这里按 ID 反查 115 返回的祖先链拼出人类可读路径;路径型
// 网盘(CD2/OpenList)与本地目录的 remote_path 本身就是路径,原样返回。
func (s *StrmService) ResolveRemoteDirPath(ctx context.Context, accountID, dir string) (string, error) {
acct, err := s.repo.StrmAccount.FindByID(ctx, accountID)
if err != nil || acct == nil {
return "", errNotFoundOr(err, "网盘账号不存在")
}
dir = strings.TrimSpace(dir)
if dir == "" {
return "", nil
}
if acct.Provider != model.StrmProvider115 {
return dir, nil
}
if dir == "0" {
return "", nil
}
provider, err := s.providerFor(ctx, acct)
if err != nil {
return "", err
}
oc, ok := provider.(interface{ OpenClient() *cloud115.OpenClient })
if !ok {
return "", fmt.Errorf("115: 客户端初始化失败")
}
detail, err := oc.OpenClient().GetFsDetailByCid(ctx, dir)
if err != nil {
return "", fmt.Errorf("115: 解析目录路径失败:%w", err)
}
return cloud115FullPath(detail), nil
}
// cloud115FullPath 把 115 目录详情的祖先链拼成以 / 开头的完整路径。
func cloud115FullPath(detail *cloud115.RemoteFileDetail) string {
if detail == nil {
return ""
}
segments := make([]string, 0, len(detail.Paths)+1)
hasSelf := false
for _, p := range detail.Paths {
if p.FileId == "0" || p.FileId == "" {
continue
}
if p.FileId == detail.FileId {
hasSelf = true
}
if name := strings.TrimSpace(p.Name); name != "" {
segments = append(segments, name)
}
}
if !hasSelf && strings.TrimSpace(detail.FileName) != "" {
segments = append(segments, strings.TrimSpace(detail.FileName))
}
if len(segments) == 0 {
return ""
}
return "/" + strings.Join(segments, "/")
}
// runSync 执行同步主体;结束时更新记录与目录状态。
func (s *StrmService) runSync(ctx context.Context, p *model.StrmSyncPath, rec *model.StrmSyncRecord, cancel context.CancelFunc) {
defer s.clearRunning(p.ID, cancel)
@@ -203,9 +271,7 @@ func (s *StrmService) runSync(ctx context.Context, p *model.StrmSyncPath, rec *m
syncType: rec.SyncType,
seenVideo: map[string]bool{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
remoteMetaSha1: map[string]string{},
remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: map[string]cloud.FileEntry{},
}
@@ -288,8 +354,17 @@ func (st *strmSyncState) run() error {
if st.provider != nil {
if open115, ok := st.provider.(cloud.OpenAPI115Provider); ok && st.p.Provider == model.StrmProvider115 {
if err := st.walk115Flat(open115.OpenClient()); err != nil {
return err
err := st.walk115Flat(open115.OpenClient())
if err != nil {
if errors.Is(err, errFallbackToWalkRemote) {
st.s.log.Warn("115: 扁平列表文件数超限,自动降级为传统并发递归同步",
zap.String("path_id", st.p.ID))
if errWalk := st.walkRemote(); errWalk != nil {
return errWalk
}
} else {
return err
}
}
} else {
if err := st.walkRemote(); err != nil {
@@ -439,28 +514,31 @@ func (st *strmSyncState) walkRemote() error {
if task.rel != "" {
rel = task.rel + "/" + cleanName
}
if entry.IsDir {
push(dirTask{id: entry.ID, rel: rel})
} else {
st.processRemoteFile(entry, rel)
if entry.IsDir {
st.dirCache.Store(entry.ID, rel)
st.deferDirCacheSave(entry.ID, rel)
push(dirTask{id: entry.ID, rel: rel})
} else {
st.processRemoteFile(entry, rel)
}
}
walkMu.Lock()
pending--
if pending == 0 {
walkCond.Broadcast()
}
walkMu.Unlock()
}
walkMu.Lock()
pending--
if pending == 0 {
walkCond.Broadcast()
}
walkMu.Unlock()
}); err != nil {
cancel()
}
}); err != nil {
cancel()
}
}()
}
wg.Wait()
if firstErr != nil {
return firstErr
}
}()
}
wg.Wait()
st.flushDirCacheSave()
if firstErr != nil {
return firstErr
}
return ctx.Err()
}
@@ -599,86 +677,16 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
if pathCounts[item.Path] > 1 {
continue
}
st.dirCache.Store(item.DirID, cleanDirRel(item.Path))
st.dirCache.Store(item.DirID, cleanDirRel(item.Path))
}
}
}
}
// 2. 探测文件总数
const pageSize = 1150
firstBatch, totalCount, err := open115.GetFsListFlat(ctx, rootCID, 0, pageSize)
if err != nil {
return fmt.Errorf("115: 获取文件列表失败:%w", err)
}
st.updateSyncMessage(fmt.Sprintf("正在拉取远端文件列表 (共 %d 个文件)...", totalCount))
allFiles := make([]cloud115.RemoteFile, 0, totalCount)
allFiles = append(allFiles, firstBatch...)
// 3. 并发分页拉取剩余文件
if totalCount > int64(len(firstBatch)) {
totalPages := int((totalCount + pageSize - 1) / pageSize)
type pageTask struct {
offset int
// 2. 自适应分治拉取文件列表(单目录超 9500 时自动对子目录并发分治扁平化)
allFiles, err := st.fetch115FilesAdaptive(ctx, open115, rootCID)
if err != nil {
return err
}
pageTasks := make([]pageTask, 0, totalPages-1)
for page := 1; page < totalPages; page++ {
pageTasks = append(pageTasks, pageTask{offset: page * pageSize})
}
var (
filesMu sync.Mutex
wg sync.WaitGroup
taskCh = make(chan pageTask, len(pageTasks))
errMu sync.Mutex
fetchErr error
)
for _, t := range pageTasks {
taskCh <- t
}
close(taskCh)
workers := 8
if len(pageTasks) < workers {
workers = len(pageTasks)
}
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// 分页拉取 panic 时取消整个同步;正常退出不取消。
if err := helper.Recover(st.s.log, "strm.sync.walk115.page", func() error {
for t := range taskCh {
if ctx.Err() != nil {
return nil
}
files, _, err := open115.GetFsListFlat(ctx, rootCID, t.offset, pageSize)
if err != nil {
errMu.Lock()
if fetchErr == nil {
fetchErr = err
}
errMu.Unlock()
return nil
}
filesMu.Lock()
allFiles = append(allFiles, files...)
filesMu.Unlock()
}
return nil
}); err != nil {
cancel()
}
}()
}
wg.Wait()
if fetchErr != nil {
return fmt.Errorf("115: 分页拉取失败:%w", fetchErr)
}
}
if ctx.Err() != nil {
return ctx.Err()
@@ -819,9 +827,9 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
}
fileCh := make(chan strmFileTask)
var (
procWg sync.WaitGroup
procWg sync.WaitGroup
procErrMu sync.Mutex
procErr error
procErr error
)
for i := 0; i < strmProcessWorkers; i++ {
procWg.Add(1)
@@ -894,6 +902,289 @@ feed:
return ctx.Err()
}
const (
flat115PageSize = 1150
flat115Threshold = 9500
max115AdaptiveDepth = 10
)
type adaptive115Task struct {
cid string
rel string
depth int
}
// fetch115FlatSubtree 扁平拉取单个文件数在安全阈值内的子树全部文件。
func fetch115FlatSubtree(ctx context.Context, open115 *cloud115.OpenClient, cid string, firstBatch []cloud115.RemoteFile, totalCount int64, log *zap.Logger) ([]cloud115.RemoteFile, error) {
allFiles := make([]cloud115.RemoteFile, 0, totalCount)
allFiles = append(allFiles, firstBatch...)
if totalCount <= int64(len(firstBatch)) {
return allFiles, nil
}
totalPages := int((totalCount + flat115PageSize - 1) / flat115PageSize)
type pageTask struct {
offset int
}
pageTasks := make([]pageTask, 0, totalPages-1)
for page := 1; page < totalPages; page++ {
pageTasks = append(pageTasks, pageTask{offset: page * flat115PageSize})
}
var (
filesMu sync.Mutex
wg sync.WaitGroup
taskCh = make(chan pageTask, len(pageTasks))
errMu sync.Mutex
fetchErr error
)
for _, t := range pageTasks {
taskCh <- t
}
close(taskCh)
workers := 8
if len(pageTasks) < workers {
workers = len(pageTasks)
}
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if err := helper.Recover(log, "strm.sync.walk115.page", func() error {
for t := range taskCh {
if ctx.Err() != nil {
return nil
}
files, _, err := open115.GetFsListFlat(ctx, cid, t.offset, flat115PageSize)
if err != nil {
errMu.Lock()
if fetchErr == nil {
fetchErr = err
}
errMu.Unlock()
return nil
}
filesMu.Lock()
allFiles = append(allFiles, files...)
filesMu.Unlock()
}
return nil
}); err != nil {
errMu.Lock()
if fetchErr == nil {
fetchErr = err
}
errMu.Unlock()
}
}()
}
wg.Wait()
if fetchErr != nil {
return nil, fmt.Errorf("115: 分页拉取失败:%w", fetchErr)
}
return allFiles, nil
}
// list115DirDirect 列出指定目录下的直接子项(单层 cur=1&show_dir=1)。
func list115DirDirect(ctx context.Context, open115 *cloud115.OpenClient, cid string) ([]cloud115.RemoteFile, error) {
var out []cloud115.RemoteFile
for offset := 0; ; offset += flat115PageSize {
files, _, err := open115.GetFsList(ctx, cid, offset, flat115PageSize)
if err != nil {
return nil, err
}
out = append(out, files...)
if len(files) < flat115PageSize {
break
}
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
}
return out, nil
}
// fetch115FilesAdaptive 采用自适应分治策略抓取 115 目录树下的全部文件:
// 115 开放平台扁平搜索对 offset+limit 有 10000 的最大深度限制。
// - 若子树文件总数 < 9500,直接使用全速扁平分页批量拉取;
// - 若子树文件总数 >= 9500(大库或超大分类目录),自动分治:仅单层列出该目录的直属子项(cur=1),
// 直属纯文件直接收集,直属子目录则派发为独立的子树任务继续递归探测与拉取;
// - 若超大单目录下无子目录或层级过深(>10层),安全回退到 errFallbackToWalkRemote。
func (st *strmSyncState) fetch115FilesAdaptive(ctx context.Context, open115 *cloud115.OpenClient, rootCID string) ([]cloud115.RemoteFile, error) {
var (
allFiles []cloud115.RemoteFile
filesMu sync.Mutex
walkMu sync.Mutex
walkCond = sync.NewCond(&walkMu)
work []adaptive115Task
pending int
errMu sync.Mutex
firstErr error
)
push := func(t adaptive115Task) {
walkMu.Lock()
work = append(work, t)
pending++
walkCond.Signal()
walkMu.Unlock()
}
go func() {
<-ctx.Done()
walkMu.Lock()
walkCond.Broadcast()
walkMu.Unlock()
}()
push(adaptive115Task{cid: rootCID, rel: "", depth: 0})
workers := 8
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if err := helper.Recover(st.s.log, "strm.sync.walk115.adaptive", func() error {
for {
walkMu.Lock()
for len(work) == 0 {
if ctx.Err() != nil || pending == 0 {
walkMu.Unlock()
return nil
}
walkCond.Wait()
}
task := work[0]
work = work[1:]
walkMu.Unlock()
if ctx.Err() != nil {
walkMu.Lock()
pending--
walkCond.Broadcast()
walkMu.Unlock()
return nil
}
firstBatch, totalCount, err := open115.GetFsListFlat(ctx, task.cid, 0, flat115PageSize)
if err != nil {
errMu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("115: 获取文件列表失败(cid=%s):%w", task.cid, err)
}
errMu.Unlock()
walkMu.Lock()
pending--
walkCond.Broadcast()
walkMu.Unlock()
return nil
}
if totalCount < flat115Threshold {
// 安全深度内:直接扁平拉取该子树全部文件
files, err := fetch115FlatSubtree(ctx, open115, task.cid, firstBatch, totalCount, st.s.log)
if err != nil {
errMu.Lock()
if firstErr == nil {
firstErr = err
}
errMu.Unlock()
walkMu.Lock()
pending--
walkCond.Broadcast()
walkMu.Unlock()
return nil
}
filesMu.Lock()
allFiles = append(allFiles, files...)
currentCount := len(allFiles)
filesMu.Unlock()
st.updateSyncMessage(fmt.Sprintf("正在拉取远端文件列表 (已获取 %d 个文件)...", currentCount))
} else {
// 子树过大(>=9500):分治展开该目录直接子项
if task.depth >= max115AdaptiveDepth {
// 深度超限兜底:单目录嵌套超 10 层仍超 9500,回退为传统递归
errMu.Lock()
if firstErr == nil {
firstErr = errFallbackToWalkRemote
}
errMu.Unlock()
walkMu.Lock()
pending--
walkCond.Broadcast()
walkMu.Unlock()
return nil
}
st.s.log.Info("115: 目录文件数超限,自动分治展开子目录并发扁平拉取",
zap.String("cid", task.cid),
zap.String("rel", task.rel),
zap.Int64("total_count", totalCount),
zap.Int("depth", task.depth))
directEntries, err := list115DirDirect(ctx, open115, task.cid)
if err != nil {
errMu.Lock()
if firstErr == nil {
firstErr = fmt.Errorf("115: 列出单层目录失败(cid=%s):%w", task.cid, err)
}
errMu.Unlock()
walkMu.Lock()
pending--
walkCond.Broadcast()
walkMu.Unlock()
return nil
}
for _, f := range directEntries {
if f.Category == cloud115.TypeDir {
cleanName := cleanEntryName(f.FileName, true)
subRel := cleanName
if task.rel != "" {
subRel = task.rel + "/" + cleanName
}
st.dirCache.Store(f.FileId, subRel)
st.deferDirCacheSave(f.FileId, subRel)
push(adaptive115Task{cid: f.FileId, rel: subRel, depth: task.depth + 1})
} else {
filesMu.Lock()
allFiles = append(allFiles, f)
filesMu.Unlock()
}
}
}
walkMu.Lock()
pending--
if pending == 0 {
walkCond.Broadcast()
}
walkMu.Unlock()
}
}); err != nil {
errMu.Lock()
if firstErr == nil {
firstErr = err
}
errMu.Unlock()
}
}()
}
wg.Wait()
if firstErr != nil {
return nil, firstErr
}
return allFiles, nil
}
// handleVideo 生成/更新 .strm 文件。
func (st *strmSyncState) handleVideo(entry cloud.FileEntry, rel, ext string) {
relSansExt := rel[:len(rel)-len(ext)]
@@ -1049,25 +1340,29 @@ func (st *strmSyncState) localSha1Matches(path, remoteSha1 string) bool {
return strings.EqualFold(local, remoteSha1)
}
// recordRemoteMeta 记录远端存在的元数据索引、文件大小、文件引用及内容 SHA1。
// recordRemoteMeta 记录远端存在的元数据索引、文件大小、文件引用及内容 SHA1(多副本聚合追加)。
// 对同一文件 ID 严格去重,避免当 download_meta 与 upload_meta 同时开启时因重复记录引发误判与自杀式删除。
func (st *strmSyncState) recordRemoteMeta(entry cloud.FileEntry, rel string) {
st.mu.Lock()
defer st.mu.Unlock()
if st.remoteMeta == nil {
st.remoteMeta = map[string]int64{}
st.remoteMeta = map[string][]remoteMetaItem{}
}
if st.remoteMetaRef == nil {
st.remoteMetaRef = map[string]string{}
key := "m:" + rel
st.seenMeta[key] = true
if entry.ID != "" {
for _, existing := range st.remoteMeta[key] {
if existing.ID == entry.ID {
return
}
}
}
if st.remoteMetaSha1 == nil {
st.remoteMetaSha1 = map[string]string{}
}
st.seenMeta["m:"+rel] = true
st.remoteMeta["m:"+rel] = entry.Size
st.remoteMetaRef["m:"+rel] = entry.ID
if sha := usableSha1(entry.Sha1); sha != "" {
st.remoteMetaSha1["m:"+rel] = sha
}
st.mu.Unlock()
st.remoteMeta[key] = append(st.remoteMeta[key], remoteMetaItem{
ID: entry.ID,
Size: entry.Size,
Sha1: usableSha1(entry.Sha1),
MTime: entry.MTime,
})
}
func (st *strmSyncState) flushPendingDownloads() {
@@ -1289,8 +1584,12 @@ func (st *strmSyncState) scanLocalMetaForUpload() error {
st.activeUploadPaths = map[string]bool{}
}
}
var pendingDeletes map[string][]string
if st.p.Provider == model.StrmProvider115 {
pendingDeletes = map[string][]string{}
}
localRoot := filepath.Clean(st.p.LocalPath)
return filepath.WalkDir(localRoot, func(path string, d os.DirEntry, err error) error {
err := filepath.WalkDir(localRoot, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
@@ -1319,22 +1618,48 @@ func (st *strmSyncState) scanLocalMetaForUpload() error {
return nil
}
st.mu.Lock()
remoteSize, exists := st.remoteMeta["m:"+rel]
remoteRef := st.remoteMetaRef["m:"+rel]
remoteSha1 := st.remoteMetaSha1["m:"+rel]
entries, exists := st.remoteMeta["m:"+rel]
st.mu.Unlock()
if exists && remoteSize == info.Size() {
// 网盘端同名同大小:候选同一文件。115 提供远端 SHA1 时做内容级比对,
// 识别"同大小不同内容"(如 nfo 改一个字符长度不变)避免漏传;
// 无哈希(其他网盘/列表未返回)视为同一文件,保持大小比对兜底。
if remoteSha1 == "" || st.localSha1Matches(path, remoteSha1) {
return nil
if exists && len(entries) > 0 {
// 择优比对:只要远端存在任一副本的大小匹配且 SHA1 匹配(或无 SHA1),即判定远端已有最新副本,无需上传
matchedIdx := -1
for i, it := range entries {
if it.Size == info.Size() {
if it.Sha1 == "" || st.localSha1Matches(path, it.Sha1) {
matchedIdx = i
break
}
}
}
if matchedIdx >= 0 {
// 远端已存在完全一致的副本,跳过上传!
// 若远端还存在其他同名脏副本(副本总数 > 1),在 115 下收集待删除 ID,稍后按目录批量删除。
// 严禁将已命中的最新副本 ID (matchedID) 放入待删列表,杜绝误杀唯一有效副本。
if len(entries) > 1 && st.p.Provider == model.StrmProvider115 {
parentCID := st.uploadRemoteTarget(rel)
if parentCID != "" {
matchedID := entries[matchedIdx].ID
for i, it := range entries {
if i != matchedIdx && it.ID != "" && it.ID != matchedID {
pendingDeletes[parentCID] = append(pendingDeletes[parentCID], it.ID)
}
}
}
}
return nil
}
}
// 入队上传(以本地为准):网盘端不存在;或同名但大小不同(必然内容不同);
// 或同名同大小但 SHA1 不同(精确比对发现的同大小不同内容)。
// 115 的上传接口不保证同名覆盖,任务携带远端旧文件 ID(RemoteRef),
// 由上传端先删旧文件再上传;WebDAV/OpenList 的 PutFile 本身即覆盖上传。
remoteTarget := st.uploadRemoteTarget(rel)
if st.p.Provider == model.StrmProvider115 && remoteTarget == "" {
// 115 远端不存在对应父目录(如孤儿子目录),禁止降级到根目录上传以防错位死循环
return nil
}
// 入队上传(以本地为准):网盘端不存在;或所有远端副本均内容不同。
// 115 的上传接口不保证同名覆盖,任务携带远端所有旧副本 ID(RemoteRef,以逗号连接),
// 由上传端一次性批量删除所有旧文件后再上传,彻底根除同名文件堆积。
st.mu.Lock()
if st.activeUploadPaths != nil && st.activeUploadPaths[path] {
st.mu.Unlock()
@@ -1351,12 +1676,18 @@ func (st *strmSyncState) scanLocalMetaForUpload() error {
Provider: st.p.Provider,
FileName: filepath.Base(rel),
LocalPath: path,
RemotePath: st.uploadRemoteTarget(rel),
RemotePath: remoteTarget,
Size: info.Size(),
Status: model.StrmTaskPending,
}
if exists && st.p.Provider == model.StrmProvider115 {
task.RemoteRef = remoteRef
if exists && len(entries) > 0 && st.p.Provider == model.StrmProvider115 {
var oldIDs []string
for _, it := range entries {
if it.ID != "" {
oldIDs = append(oldIDs, it.ID)
}
}
task.RemoteRef = strings.Join(oldIDs, ",")
}
st.mu.Lock()
st.pendingUploads = append(st.pendingUploads, task)
@@ -1369,6 +1700,51 @@ func (st *strmSyncState) scanLocalMetaForUpload() error {
}
return nil
})
if err != nil {
return err
}
// 批量清理 115 冗余旧元数据副本(按父目录聚合,一次请求批量删除该目录下全部冗余文件,彻底避免并发触发限流器超时)
if len(pendingDeletes) > 0 {
st.cleanupBatchRedundantFiles(pendingDeletes)
}
return nil
}
// cleanupBatchRedundantFiles 异步按父目录批量清理 115 远端冗余旧元数据副本。
func (st *strmSyncState) cleanupBatchRedundantFiles(deletesByParent map[string][]string) {
if len(deletesByParent) == 0 || st.provider == nil {
return
}
open115, ok := st.provider.(cloud.OpenAPI115Provider)
if !ok {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
totalPruned := 0
for parentCID, fileIDs := range deletesByParent {
if ctx.Err() != nil {
break
}
if len(fileIDs) == 0 {
continue
}
if err := open115.OpenClient().DeleteFiles(ctx, parentCID, fileIDs...); err != nil {
st.s.log.Warn("批量清理 115 冗余旧元数据副本失败",
zap.String("parent_cid", parentCID),
zap.Int("count", len(fileIDs)),
zap.Error(err))
} else {
totalPruned += len(fileIDs)
}
time.Sleep(100 * time.Millisecond)
}
if totalPruned > 0 {
st.s.log.Info("已完成批量清理 115 冗余旧元数据副本", zap.Int("total_pruned", totalPruned))
}
}()
}
// remoteUploadPath 远端元数据目标路径 = 同步目录远端根 + 相对路径。
@@ -1398,9 +1774,9 @@ func (st *strmSyncState) uploadRemoteTarget(rel string) string {
if cid, ok := st.dirPathToID[dir]; ok && cid != "" {
return cid
}
// 父目录未在缓存中(父目录可能本次未扫描到),降级为用户配置的同步根 cid,
// 由上传端尽力处理(可能失败记日志,不影响下载)。
return st.p.RemotePath
// 子目录在 115 远端不存在(本地孤儿子目录或远端已删除该分类文件夹),
// 返回空串,禁止降级回退到根目录上传以防污染根目录与错位死循环。
return ""
}
return st.remoteUploadPath(rel)
}
+342 -50
View File
@@ -368,19 +368,18 @@ func TestScanLocalMetaForUpload(t *testing.T) {
}
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"jpg", "nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"jpg", "nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{},
}
// 模拟远端已存在 poster.jpg(与本地同一文件)和 tvshow.nfo(与本地不同)
st.remoteMeta["m:动漫/poster.jpg"] = int64(len("poster-data"))
st.remoteMeta["m:动漫/tvshow.nfo"] = 999
st.remoteMeta["m:动漫/poster.jpg"] = []remoteMetaItem{{ID: "f1", Size: int64(len("poster-data"))}}
st.remoteMeta["m:动漫/tvshow.nfo"] = []remoteMetaItem{{ID: "f2", Size: 999}}
if err := st.scanLocalMetaForUpload(); err != nil {
t.Fatalf("scanLocalMetaForUpload failed: %v", err)
@@ -442,17 +441,15 @@ func TestScanLocalMetaForUpload115CarriesRemoteRef(t *testing.T) {
}
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"jpg", "nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"jpg", "nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{},
}
st.remoteMeta["m:movie.nfo"] = 1
st.remoteMetaRef["m:movie.nfo"] = "file-42"
st.remoteMeta["m:movie.nfo"] = []remoteMetaItem{{ID: "file-42", Size: 1}}
if err := st.scanLocalMetaForUpload(); err != nil {
t.Fatalf("scanLocalMetaForUpload failed: %v", err)
@@ -522,7 +519,7 @@ func TestPruneLocalKeepsLocalMeta(t *testing.T) {
syncType: model.StrmSyncTypeFull,
seenVideo: map[string]bool{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMeta: map[string][]remoteMetaItem{},
}
// 本次远端扫描既没有看到视频,也没有看到任何元数据
if err := st.pruneLocal(); err != nil {
@@ -569,8 +566,7 @@ func TestHandleMetaKeepsLocalWhenUploadEnabled(t *testing.T) {
cfg: &strmPathConfig{DownloadMeta: true, UploadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{},
}
@@ -630,22 +626,21 @@ func TestScanLocalMetaForUploadSha1Identity(t *testing.T) {
UploadMeta: true,
}
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
remoteMetaSha1: map[string]string{},
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{},
}
// 115 返回大写 SHA1,本地计算为小写:同时验证大小写不敏感比对
st.remoteMeta["m:same.nfo"] = int64(len("same-content"))
st.remoteMetaSha1["m:same.nfo"] = strings.ToUpper(sameSha)
st.remoteMeta["m:diff.nfo"] = int64(len("diff-content"))
st.remoteMetaSha1["m:diff.nfo"] = strings.ToUpper(otherSha)
st.remoteMetaRef["m:diff.nfo"] = "old-diff-1"
st.remoteMeta["m:same.nfo"] = []remoteMetaItem{
{ID: "same-1", Size: int64(len("same-content")), Sha1: strings.ToUpper(sameSha)},
}
st.remoteMeta["m:diff.nfo"] = []remoteMetaItem{
{ID: "old-diff-1", Size: int64(len("diff-content")), Sha1: strings.ToUpper(otherSha)},
}
if err := st.scanLocalMetaForUpload(); err != nil {
t.Fatalf("scanLocalMetaForUpload failed: %v", err)
@@ -703,9 +698,7 @@ func TestHandleMetaSha1Identity(t *testing.T) {
cfg: &strmPathConfig{DownloadMeta: true, UploadMeta: false, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
remoteMetaSha1: map[string]string{},
remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{},
}
@@ -802,7 +795,7 @@ func TestWalkRemoteConcurrent(t *testing.T) {
rec: &model.StrmSyncRecord{},
seenVideo: map[string]bool{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMeta: map[string][]remoteMetaItem{},
}
if err := st.walkRemote(); err != nil {
t.Fatalf("walkRemote failed: %v", err)
@@ -911,7 +904,7 @@ func TestStrmDuplicateFileConflictResolution(t *testing.T) {
cfg: &strmPathConfig{DownloadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: map[string]cloud.FileEntry{},
}
@@ -950,7 +943,7 @@ func TestStrmDuplicateFileConflictResolution(t *testing.T) {
cfg: &strmPathConfig{DownloadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: map[string]cloud.FileEntry{},
}
@@ -1019,7 +1012,7 @@ func TestWalk115FlatAbortsOnDirResolveFailure(t *testing.T) {
dirCache: sync.Map{},
seenVideo: map[string]bool{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMeta: map[string][]remoteMetaItem{},
}
err := st.walk115Flat(oc)
if err == nil {
@@ -1088,9 +1081,7 @@ func TestWalk115FlatConcurrentProcessing(t *testing.T) {
dirCache: sync.Map{},
seenVideo: map[string]bool{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
remoteMetaSha1: map[string]string{},
remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: map[string]cloud.FileEntry{},
}
@@ -1145,9 +1136,7 @@ func TestWalk115FlatConcurrentProcessing(t *testing.T) {
dirCache: sync.Map{},
seenVideo: map[string]bool{},
seenMeta: map[string]bool{},
remoteMeta: map[string]int64{},
remoteMetaRef: map[string]string{},
remoteMetaSha1: map[string]string{},
remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: map[string]cloud.FileEntry{},
}
@@ -1161,3 +1150,306 @@ func TestWalk115FlatConcurrentProcessing(t *testing.T) {
t.Fatalf("incremental sync should skip all, new = %d skipped = %d", st2.rec.NewStrm, st2.rec.Skipped)
}
}
func TestCloud115FullPath(t *testing.T) {
paths := func(ids, names []string) []struct {
FileId string `json:"file_id"`
Name string `json:"file_name"`
} {
out := make([]struct {
FileId string `json:"file_id"`
Name string `json:"file_name"`
}, 0, len(ids))
for i := range ids {
out = append(out, struct {
FileId string `json:"file_id"`
Name string `json:"file_name"`
}{FileId: ids[i], Name: names[i]})
}
return out
}
// 祖先链已包含当前目录自身(115 常规返回)
d := &cloud115.RemoteFileDetail{
FileId: "330",
FileName: "剧集",
Paths: paths([]string{"0", "100", "330"}, []string{"", "电影", "剧集"}),
}
if got := cloud115FullPath(d); got != "/电影/剧集" {
t.Fatalf("full path = %q, want /电影/剧集", got)
}
// 祖先链不含当前目录自身,用 FileName 兜底
d = &cloud115.RemoteFileDetail{
FileId: "330",
FileName: "剧集",
Paths: paths([]string{"0", "100"}, []string{"", "电影"}),
}
if got := cloud115FullPath(d); got != "/电影/剧集" {
t.Fatalf("full path = %q, want /电影/剧集", got)
}
// 根目录:无有效祖先段
d = &cloud115.RemoteFileDetail{FileId: "0"}
if got := cloud115FullPath(d); got != "" {
t.Fatalf("root full path = %q, want empty", got)
}
}
// TestScanLocalMetaForUploadMultipleCopiesBestEffortMatch 验证:
// 当远端同一路径存在多个副本(1个与本地一致的副本 + 1个脏副本)时,
// 能够择优识别出匹配的副本,跳过上传(uploaded = 0),避免盲盒覆盖导致的重复上传。
func TestScanLocalMetaForUploadMultipleCopiesBestEffortMatch(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
nfoPath := filepath.Join(localDir, "test.nfo")
writeFile(t, nfoPath, "correct-content")
correctSha, err := cloud115.FileSHA1(nfoPath)
if err != nil {
t.Fatal(err)
}
p := &model.StrmSyncPath{
Base: model.Base{ID: "multi-copy-match-path"},
Provider: model.StrmProvider115,
RemotePath: "root-cid",
LocalPath: localDir,
UploadMeta: true,
}
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{},
}
// 模拟远端存在两个同名副本:一个脏副本(较早),一个正确副本(较晚)
st.recordRemoteMeta(cloud.FileEntry{
ID: "stale-id",
Name: "test.nfo",
Size: int64(len("stale-dirty-content")),
Sha1: "STALE_SHA1",
MTime: 1000,
}, "test.nfo")
st.recordRemoteMeta(cloud.FileEntry{
ID: "correct-id",
Name: "test.nfo",
Size: int64(len("correct-content")),
Sha1: correctSha,
MTime: 2000,
}, "test.nfo")
if err := st.scanLocalMetaForUpload(); err != nil {
t.Fatalf("scanLocalMetaForUpload failed: %v", err)
}
// 择优匹配:命中 correct-id,不产生上传任务
tasks, _, err := svc.repo.StrmUpload.List(context.Background(), "", 1, 10)
if err != nil {
t.Fatal(err)
}
if len(tasks) != 0 {
t.Fatalf("expected 0 upload tasks when a matching copy exists, got %d: %v", len(tasks), taskNames(tasks))
}
}
// TestScanLocalMetaForUploadMultipleCopiesAllStale 验证:
// 当远端同一路径存在多个副本,且所有副本均与本地内容不一致时,
// 上传任务应携带所有旧副本的 ID(逗号分隔),以便上传前批量清理所有旧副本。
func TestScanLocalMetaForUploadMultipleCopiesAllStale(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
nfoPath := filepath.Join(localDir, "test.nfo")
writeFile(t, nfoPath, "brand-new-content")
p := &model.StrmSyncPath{
Base: model.Base{ID: "multi-copy-stale-path"},
Provider: model.StrmProvider115,
RemotePath: "root-cid",
LocalPath: localDir,
UploadMeta: true,
}
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{},
}
// 模拟远端存在两个不同大小和哈希的旧副本
st.recordRemoteMeta(cloud.FileEntry{
ID: "old-1",
Name: "test.nfo",
Size: 10,
Sha1: "OLD_SHA1",
MTime: 1000,
}, "test.nfo")
st.recordRemoteMeta(cloud.FileEntry{
ID: "old-2",
Name: "test.nfo",
Size: 20,
Sha1: "OLD_SHA2",
MTime: 2000,
}, "test.nfo")
if err := st.scanLocalMetaForUpload(); err != nil {
t.Fatalf("scanLocalMetaForUpload failed: %v", err)
}
tasks, _, err := svc.repo.StrmUpload.List(context.Background(), "", 1, 10)
if err != nil {
t.Fatal(err)
}
if len(tasks) != 1 {
t.Fatalf("expected 1 upload task, got %d", len(tasks))
}
if tasks[0].RemoteRef != "old-1,old-2" {
t.Fatalf("expected RemoteRef to be 'old-1,old-2', got %q", tasks[0].RemoteRef)
}
}
// TestRecordRemoteMetaDeduplication 验证当 download_meta 与 upload_meta 同时开启时,
// 同一远端元数据被多次入账不会在 remoteMeta 中生成重复副本,杜绝误判自杀式删除。
func TestRecordRemoteMetaDeduplication(t *testing.T) {
st := &strmSyncState{
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{},
}
entry := cloud.FileEntry{
ID: "unique-fid-1",
Name: "test.nfo",
Size: 100,
Sha1: "AAAABBBBCCCC",
MTime: 12345,
}
// 连续记录两次同一文件
st.recordRemoteMeta(entry, "dir/test.nfo")
st.recordRemoteMeta(entry, "dir/test.nfo")
items := st.remoteMeta["m:dir/test.nfo"]
if len(items) != 1 {
t.Fatalf("expected 1 item after duplicate record, got %d", len(items))
}
}
// TestWalk115AdaptiveHierarchicalFlatScan 验证自适应分治扁平化扫描:
// 当根目录探测总数 >= 9500 时,系统自动分治展开单层直接子项,对各子目录分别执行扁平拉取,
// 正确合并根目录直属文件与各子目录深层文件,突破 115 开放平台 10000 深度限制。
func TestWalk115AdaptiveHierarchicalFlatScan(t *testing.T) {
svc := testStrmService(t)
localDir := t.TempDir()
acct := &model.StrmAccount{Name: "fake115-adaptive", Provider: "cloud115", Config: "{}", Enabled: true}
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
t.Fatal(err)
}
p := &model.StrmSyncPath{
Base: model.Base{ID: "adaptive-path"},
AccountID: acct.ID,
Provider: model.StrmProvider115,
RemotePath: "0",
LocalPath: localDir,
}
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
cid := q.Get("cid")
cur := q.Get("cur")
switch r.URL.Path {
case "/open/ufile/files":
if cid == "0" && cur == "0" {
// 根目录扁平探测:模拟总文件数 12000 超限 (>= 9500)
w.Write([]byte(`{"state":true,"count":12000,"data":[]}`))
return
}
if cid == "0" && cur == "1" {
// 根目录单层列举:返回 1 个直属视频和 2 个子目录
w.Write([]byte(`{"state":true,"count":3,"data":[
{"fid":"100","pid":"0","fc":"1","fn":"root.mkv","pc":"pcr","upt":1700000000,"fs":1024},
{"fid":"1001","pid":"0","fc":"0","fn":"Heyzo","upt":1700000001,"fs":0},
{"fid":"1002","pid":"0","fc":"0","fn":"S1","upt":1700000002,"fs":0}]}`))
return
}
if cid == "1001" {
// 子目录 Heyzo 扁平拉取:文件数安全 (< 9500)
w.Write([]byte(`{"state":true,"count":2,"data":[
{"fid":"201","pid":"1001","fc":"1","fn":"h1.mkv","pc":"pc201","upt":1700000001,"fs":1024},
{"fid":"202","pid":"1001","fc":"1","fn":"h2.mkv","pc":"pc202","upt":1700000002,"fs":2048}]}`))
return
}
if cid == "1002" {
// 子目录 S1 扁平拉取:文件数安全 (< 9500)
w.Write([]byte(`{"state":true,"count":1,"data":[
{"fid":"301","pid":"1002","fc":"1","fn":"s1.mkv","pc":"pc301","upt":1700000003,"fs":4096}]}`))
return
}
t.Errorf("unexpected files query: %s", r.URL.RawQuery)
case "/open/folder/get_info":
fileID := q.Get("file_id")
switch fileID {
case "1001":
w.Write([]byte(`{"state":true,"data":{"file_id":"1001","file_name":"Heyzo","file_category":"0",
"paths":[{"file_id":"0","file_name":"根目录"},{"file_id":"1001","file_name":"Heyzo"}]}}`))
case "1002":
w.Write([]byte(`{"state":true,"data":{"file_id":"1002","file_name":"S1","file_category":"0",
"paths":[{"file_id":"0","file_name":"根目录"},{"file_id":"1002","file_name":"S1"}]}}`))
default:
t.Errorf("unexpected get_info file_id: %s", fileID)
}
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
}))
defer api.Close()
oldPro := cloud115.ProAPIBase
cloud115.ProAPIBase = api.URL
defer func() { cloud115.ProAPIBase = oldPro }()
oc := cloud115.NewOpenClient("app", "at", "rt")
st := &strmSyncState{
s: svc,
ctx: context.Background(),
p: p,
provider: cloud.NewOpenAPI115("app", "at", "rt"),
cfg: &strmPathConfig{VideoExt: []string{"mkv"}, MetaExt: []string{"nfo"}, AddPath: 1},
rec: &model.StrmSyncRecord{},
syncType: model.StrmSyncTypeFull,
dirCache: sync.Map{},
seenVideo: map[string]bool{},
seenMeta: map[string]bool{},
remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: map[string]cloud.FileEntry{},
}
if err := st.walk115Flat(oc); err != nil {
t.Fatalf("walk115Flat adaptive failed: %v", err)
}
// 1个根目录视频 + 2个Heyzo视频 + 1个S1视频 = 共4个视频成功生成 .strm
if st.rec.NewStrm != 4 {
t.Fatalf("expected 4 strm created, got %d", st.rec.NewStrm)
}
expectedFiles := []string{
filepath.Join(localDir, "root.strm"),
filepath.Join(localDir, "Heyzo", "h1.strm"),
filepath.Join(localDir, "Heyzo", "h2.strm"),
filepath.Join(localDir, "S1", "s1.strm"),
}
for _, f := range expectedFiles {
if _, err := os.Stat(f); err != nil {
t.Fatalf("expected strm %s to exist, err: %v", f, err)
}
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
// hardlink 硬链接(同盘零额外占用,保留源文件;做种不受影响)
// symlink 软链接(保留源文件,指向源)
//
// 除 move 外,其余方式都保留源文件,因此 qBittorrent 等下载器仍能在原
// 除 move 外,其余方式都保留源文件,因此下载器仍能在原
// 路径找到数据继续做种上传。
package service
+3
View File
@@ -19,4 +19,7 @@ export const authAPI = {
api
.post('/me/password', { old_password: oldPassword, new_password: newPassword })
.then((r) => r.data),
createTemporaryPassword: () =>
api.post<{ code: string; expires_in: number }>('/me/temporary-password').then((r) => r.data),
}
+6
View File
@@ -80,6 +80,12 @@ export const strmAPI = {
.get<StrmRemoteEntry[]>(`/admin/strm/accounts/${accountId}/list`, { params: { dir } })
.then((r) => r.data),
// 按远端目录引用(115 为目录 ID)反查完整展示路径
resolveRemoteDirPath: (accountId: string, dir: string) =>
api
.get<{ path: string }>(`/admin/strm/accounts/${accountId}/resolve`, { params: { dir } })
.then((r) => r.data.path),
list115Sources: () =>
api.get<Strm115Sources>('/admin/strm/115/sources').then((r) => r.data),
+5 -2
View File
@@ -28,6 +28,8 @@ type DanmakuStageProps = {
search?: string | null
/** Explicit danmaku library chosen by the user; null = auto-resolve. */
episodeId?: number | string | null
/** Counter or token changed to trigger refetch even when search stays identical. */
searchTrigger?: number
/** Called after each fetch attempt (success or error) finishes with metadata. */
onLoaded?: (info: DanmakuLoadedInfo | null) => void
/** Called when multiple anime matched and the user must pick one. */
@@ -50,6 +52,7 @@ export function DanmakuStage({
area = 1,
search = null,
episodeId = null,
searchTrigger = 0,
onLoaded,
onCandidates,
}: DanmakuStageProps) {
@@ -252,8 +255,8 @@ export function DanmakuStage({
manager.unmount()
managerRef.current = null
}
// search / episodeId 变化时重新拉取弹幕(含媒体/开关切换)。
}, [media, videoRef, enabled, search, episodeId, onLoaded, onCandidates])
// search / episodeId / searchTrigger 变化时重新拉取弹幕(含媒体/开关切换)。
}, [media, videoRef, enabled, search, episodeId, searchTrigger, onLoaded, onCandidates])
// Live renderer knobs: opacity / area / font size without recreating the
// engine. font size additionally rescales currently visible comments.
+1 -1
View File
@@ -137,7 +137,7 @@ export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: Layou
}
return (
<main id="app-main-scroll" className={clsx('flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10', bottomPad)}>
<main id="app-main-scroll" className={clsx('flex-1 overflow-y-auto [overflow-anchor:none] px-4 py-6 md:px-8 md:py-10', bottomPad)}>
<div className="max-w-7xl mx-auto">
<AnimatePresence mode="wait">
<motion.div
+20 -6
View File
@@ -2,11 +2,12 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNo
import { createPortal } from 'react-dom'
import { Link, useLocation } from 'react-router-dom'
import { AnimatePresence, motion } from 'framer-motion'
import { Cast, ChevronDown, Clock, Heart, ListMusic, LogOut, Settings, UserCog } from 'lucide-react'
import { Cast, ChevronDown, Clock, Heart, ListMusic, LogOut, Settings, Tv, UserCog } from 'lucide-react'
import clsx from 'clsx'
import type { PlayProfile } from '../types'
import { LayoutThemeToggle } from './LayoutThemeToggle'
import { TemporaryPasswordDialog } from './TemporaryPasswordDialog'
import type { ThemeMode } from './useThemeMode'
type MenuPosition = {
@@ -52,6 +53,7 @@ export function LayoutUserMenu({
const triggerRef = useRef<HTMLButtonElement>(null)
const onCloseRef = useRef(onClose)
const [menuPosition, setMenuPosition] = useState<MenuPosition | null>(null)
const [isOtpOpen, setIsOtpOpen] = useState(false)
onCloseRef.current = onClose
@@ -121,11 +123,22 @@ export function LayoutUserMenu({
className="fixed z-[121] w-56 origin-top-right rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-xl"
onPointerDown={(event) => event.stopPropagation()}
>
<UserMenuLink to="/profile" icon={<Settings size={16} />} label="设置" onNavigate={onClose} />
<UserMenuLink to="/favourites" icon={<Heart size={16} />} label="我的收藏" onNavigate={onClose} />
<UserMenuLink to="/playlists" icon={<ListMusic size={16} />} label="播放列表" onNavigate={onClose} />
<UserMenuLink to="/history" icon={<Clock size={16} />} label="观看历史" onNavigate={onClose} />
<UserMenuLink to="/dlna" icon={<Cast size={16} />} label="DLNA投屏" onNavigate={onClose} />
<UserMenuLink to="/profile" icon={<Settings size={16} />} label="设置" onNavigate={onClose} />
<UserMenuLink to="/favourites" icon={<Heart size={16} />} label="我的收藏" onNavigate={onClose} />
<UserMenuLink to="/playlists" icon={<ListMusic size={16} />} label="播放列表" onNavigate={onClose} />
<UserMenuLink to="/history" icon={<Clock size={16} />} label="观看历史" onNavigate={onClose} />
<UserMenuLink to="/dlna" icon={<Cast size={16} />} label="DLNA投屏" onNavigate={onClose} />
<button
type="button"
onClick={() => {
onClose()
setIsOtpOpen(true)
}}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-sm text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
>
<Tv size={16} />
<span>电视端临时登录码</span>
</button>
{themeMode && onThemeChange ? (
<div className="px-3 py-2 sm:hidden">
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
@@ -203,6 +216,7 @@ export function LayoutUserMenu({
<ChevronDown size={14} className="text-[var(--app-muted)]" />
</button>
{menuPortal}
<TemporaryPasswordDialog isOpen={isOtpOpen} onClose={() => setIsOtpOpen(false)} />
</div>
)
}
+18 -3
View File
@@ -25,6 +25,8 @@ type PlayerDanmakuPanelProps = {
candidates: DanmakuAnime[]
/** Human-readable label of the currently selected library. */
selectedSource?: string
/** Title used by auto-matching (e.g. anime title, media title or filename). */
autoMatchTitle?: string
/** Loaded danmaku metadata (title, episode, count, match mode). */
danmakuInfo?: DanmakuLoadedInfo | null
onSelectEpisode: (episodeId: number, animeTitle: string, episodeTitle: string) => void
@@ -47,6 +49,7 @@ export function PlayerDanmakuPanel({
onFontSizeChange,
candidates,
selectedSource,
autoMatchTitle,
danmakuInfo,
onSelectEpisode,
onResetAuto,
@@ -203,7 +206,19 @@ export function PlayerDanmakuPanel({
{/* 搜索弹幕 */}
<div className="mb-4">
<div className="mb-1 text-xs text-white/60">搜索弹幕(留空 = 按视频名自动匹配)</div>
<div className="mb-1 flex items-center justify-between text-xs text-white/60">
<span>搜索弹幕(留空 = 按视频名自动匹配)</span>
{autoMatchTitle && (
<button
type="button"
onClick={() => setDraft(autoMatchTitle)}
className="text-[11px] text-rose-300 transition hover:text-rose-200"
title="填入当前识别到的视频名"
>
填入当前名
</button>
)}
</div>
<div className="flex items-center gap-1.5">
<input
value={draft}
@@ -211,8 +226,8 @@ export function PlayerDanmakuPanel({
onKeyDown={(e) => {
if (e.key === 'Enter') onSearch(draft.trim())
}}
placeholder="输入番剧或电影名…"
className="min-w-0 flex-1 rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-xs outline-none placeholder:text-white/35 focus:border-rose-400/60"
placeholder={autoMatchTitle ? `自动匹配:${autoMatchTitle}` : '输入番剧或电影名…'}
className="min-w-0 flex-1 rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-xs outline-none placeholder:text-white/40 focus:border-rose-400/60"
/>
<button
onClick={() => onSearch(draft.trim())}
@@ -0,0 +1,191 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Check, Copy, Loader2, RefreshCw, Tv, X } from 'lucide-react'
import toast from 'react-hot-toast'
import { authAPI } from '../api/auth'
import { useAuthStore } from '../stores/auth'
export function TemporaryPasswordDialog({
isOpen,
onClose,
}: {
isOpen: boolean
onClose: () => void
}) {
const user = useAuthStore((s) => s.user)
const [code, setCode] = useState('')
const [expiresIn, setExpiresIn] = useState(0)
const [loading, setLoading] = useState(false)
const [copied, setCopied] = useState(false)
const fetchCode = async () => {
setLoading(true)
try {
const res = await authAPI.createTemporaryPassword()
setCode(res.code)
setExpiresIn(res.expires_in)
setCopied(false)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'获取临时密码失败'
toast.error(msg)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (isOpen) {
fetchCode()
} else {
setCode('')
setExpiresIn(0)
}
}, [isOpen])
useEffect(() => {
if (expiresIn <= 0) return
const timer = setInterval(() => {
setExpiresIn((prev) => {
if (prev <= 1) {
clearInterval(timer)
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(timer)
}, [expiresIn])
const copyCode = async () => {
if (!code) return
try {
await navigator.clipboard.writeText(code)
setCopied(true)
toast.success('已复制到剪贴板')
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error('复制失败,请手动长按复制')
}
}
if (!isOpen || typeof document === 'undefined') return null
return createPortal(
<div
className="fixed inset-0 z-[999] flex items-center justify-center bg-black/65 p-3 sm:p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
className="w-full max-w-[calc(100vw-2rem)] sm:max-w-md max-h-[90dvh] flex flex-col overflow-hidden rounded-3xl border border-[var(--app-border)] bg-[var(--app-panel)] p-4 sm:p-6 shadow-2xl transition-all"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--app-border)] pb-3 sm:pb-4 shrink-0">
<div className="flex items-center gap-2.5 sm:gap-3 min-w-0">
<div className="flex h-9 w-9 sm:h-10 sm:w-10 shrink-0 items-center justify-center rounded-2xl bg-brand-500/10 text-brand-500">
<Tv size={20} className="sm:w-[22px] sm:h-[22px]" />
</div>
<div className="min-w-0">
<h3 className="font-display text-base sm:text-lg font-bold text-[var(--app-text)] truncate">
电视端临时登录码 (OTP)
</h3>
<p className="text-[11px] sm:text-xs text-[var(--app-muted)] truncate">
适用于 Emby / Jellyfin / Infuse 客户端快速免密登录
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-xl p-1.5 text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] shrink-0 ml-2"
>
<X size={18} />
</button>
</div>
{/* Content */}
<div className="my-4 space-y-3.5 sm:space-y-4 overflow-y-auto flex-1 pr-0.5">
<div className="flex items-center justify-between text-[11px] sm:text-xs text-[var(--app-muted)]">
<div>
<span>登录账号:</span>
<span className="font-mono font-bold text-[var(--app-text)]">{user?.username}</span>
</div>
{expiresIn > 0 ? (
<div>
<span>有效时间剩余:</span>
<span className={`font-mono font-bold ${expiresIn < 60 ? 'text-red-500' : 'text-brand-500'}`}>
{Math.floor(expiresIn / 60).toString().padStart(2, '0')}:{(expiresIn % 60).toString().padStart(2, '0')}
</span>
</div>
) : null}
</div>
<div className="flex items-center justify-between rounded-2xl border border-[var(--app-border)] bg-[var(--app-bg)] p-3 sm:p-4">
{loading ? (
<div className="flex h-10 w-full items-center justify-center text-[var(--app-muted)]">
<Loader2 size={24} className="animate-spin text-brand-500" />
</div>
) : code && expiresIn > 0 ? (
<>
<div className="font-mono text-2xl sm:text-3xl font-black tracking-widest text-brand-500 select-all">
{code}
</div>
<button
type="button"
onClick={copyCode}
className="flex items-center gap-1.5 rounded-xl bg-brand-500/10 px-3 sm:px-3.5 py-1.5 sm:py-2 text-xs font-semibold text-brand-500 transition-colors hover:bg-brand-500/20 shrink-0"
>
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? '已复制' : '复制密码'}
</button>
</>
) : (
<div className="flex w-full items-center justify-between text-xs sm:text-sm text-[var(--app-muted)]">
<span>登录码已失效或未生成</span>
<button
type="button"
onClick={fetchCode}
className="flex items-center gap-1.5 rounded-xl bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white shrink-0"
>
<RefreshCw size={13} />
立即生成
</button>
</div>
)}
</div>
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-elevated)] p-3 text-[11px] sm:text-xs leading-relaxed text-[var(--app-muted)]">
<span className="font-semibold text-[var(--app-text)]">使用方法:</span>
在电视端或外部设备的 Emby 登录界面输入用户名 <code className="font-bold text-brand-500">{user?.username}</code> 和上方 6 位临时码。登录后临时码立即作废(一次性阅后即焚),客户端将自动获取长期持久令牌。
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-[var(--app-border)] pt-3 sm:pt-4 shrink-0">
<button
type="button"
onClick={fetchCode}
disabled={loading}
className="flex items-center gap-1.5 rounded-xl px-2.5 sm:px-3 py-1.5 sm:py-2 text-xs font-medium text-[var(--app-muted)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-50"
>
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
重新生成
</button>
<button
type="button"
onClick={onClose}
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-elevated)] px-3.5 sm:px-4 py-1.5 sm:py-2 text-xs font-semibold text-[var(--app-text)] transition-colors hover:bg-[var(--app-hover)]"
>
完成并关闭
</button>
</div>
</div>
</div>,
document.body
)
}
+103 -47
View File
@@ -1,37 +1,24 @@
import { forwardRef, useEffect, useState, type CSSProperties, type ReactNode } from 'react'
import { VirtuosoGrid } from 'react-virtuoso'
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import { Virtuoso } from 'react-virtuoso'
import clsx from 'clsx'
// 与 LibraryMediaSections 等处的海报网格保持同一套响应式列配置。
export const MEDIA_GRID_CLASS =
'grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8'
// 首帧滚动容器尚未就绪时的占位渲染数量(约一屏)。
const FIRST_PAINT_COUNT = 28
function getFallbackColumns(width: number): number {
if (width >= 1536) return 8
if (width >= 1280) return 7
if (width >= 1024) return 6
if (width >= 768) return 5
if (width >= 640) return 4
return 3
}
const GridList = forwardRef<HTMLDivElement, { style?: CSSProperties; children?: ReactNode }>(
function GridList({ children, style, ...rest }, ref) {
return (
<div ref={ref} style={style} {...rest} className={MEDIA_GRID_CLASS}>
{children}
</div>
)
},
)
const GridItem = forwardRef<HTMLDivElement, { style?: CSSProperties; children?: ReactNode }>(
function GridItem({ children, style, ...rest }, ref) {
return (
<div ref={ref} style={style} {...rest}>
{children}
</div>
)
},
)
// VirtualMediaGrid 大库性能优化:无论库里有 1 千还是 5 万张卡片,
// 实际挂载的只有视口内及邻近一屏的节点。滚动容器是 Layout 的
// <main id="app-main-scroll">(非 window 滚动),通过 customScrollParent 接入;
// 元素尚未挂载时先渲染一屏卡片,挂载后切换为虚拟列表。
// VirtualMediaGrid 大库性能优化:
// 采用按行虚拟滚动(Row-based Virtualization)。相比 VirtuosoGrid 强制要求所有网格项
// 绝对等高且易受 CSS Grid 亚像素尺寸扰动引发死循环闪烁,按行使用基础 Virtuoso 组件
// 天然支持每行真实高度,并且每一行内部保持原生的响应式 CSS Grid 布局。
export function VirtualMediaGrid({
totalCount,
renderItem,
@@ -39,29 +26,98 @@ export function VirtualMediaGrid({
totalCount: number
renderItem: (index: number) => ReactNode
}) {
const [scrollParent, setScrollParent] = useState<HTMLElement | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const probeRef = useRef<HTMLDivElement>(null)
const [columns, setColumns] = useState(() => {
if (typeof window !== 'undefined') {
return getFallbackColumns(window.innerWidth)
}
return 4
})
useEffect(() => {
setScrollParent(document.getElementById('app-main-scroll'))
const updateColumns = useCallback(() => {
if (probeRef.current) {
const computed = window.getComputedStyle(probeRef.current).gridTemplateColumns
if (computed && computed !== 'none') {
const count = computed.trim().split(/\s+/).filter(Boolean).length
if (count > 0) {
setColumns((prev) => (prev !== count ? count : prev))
return
}
}
}
const width = containerRef.current?.clientWidth || (typeof window !== 'undefined' ? window.innerWidth : 0)
if (width > 0) {
const fallback = getFallbackColumns(width)
setColumns((prev) => (prev !== fallback ? fallback : prev))
}
}, [])
if (!scrollParent) {
return (
<div className={MEDIA_GRID_CLASS}>
{Array.from({ length: Math.min(totalCount, FIRST_PAINT_COUNT) }, (_, index) => (
<div key={index}>{renderItem(index)}</div>
))}
</div>
)
}
useLayoutEffect(() => {
updateColumns()
}, [updateColumns])
useEffect(() => {
const el = containerRef.current
if (!el || typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateColumns)
return () => window.removeEventListener('resize', updateColumns)
}
const observer = new ResizeObserver(() => {
updateColumns()
})
observer.observe(el)
return () => observer.disconnect()
}, [updateColumns])
const [scrollParent, setScrollParent] = useState<HTMLElement | null>(() => {
return typeof document !== 'undefined' ? document.getElementById('app-main-scroll') : null
})
useEffect(() => {
if (!scrollParent) {
setScrollParent(document.getElementById('app-main-scroll'))
}
}, [scrollParent])
const rowCount = Math.ceil(totalCount / columns)
return (
<VirtuosoGrid
customScrollParent={scrollParent}
totalCount={totalCount}
overscan={800}
components={{ List: GridList, Item: GridItem }}
itemContent={renderItem}
/>
<div ref={containerRef} className="relative w-full">
{/* 隐藏探针节点:跟随 Tailwind MEDIA_GRID_CLASS 响应式断点自动计算当前列数 */}
<div
ref={probeRef}
className={clsx(MEDIA_GRID_CLASS, 'pointer-events-none invisible absolute h-0 w-full overflow-hidden')}
aria-hidden="true"
/>
{!scrollParent ? (
<div className={MEDIA_GRID_CLASS}>
{Array.from({ length: Math.min(totalCount, columns * 4) }, (_, index) => (
<div key={index}>{renderItem(index)}</div>
))}
</div>
) : (
<Virtuoso
customScrollParent={scrollParent}
totalCount={rowCount}
overscan={800}
itemContent={(rowIndex) => {
const start = rowIndex * columns
return (
<div className={clsx(MEDIA_GRID_CLASS, rowIndex < rowCount - 1 && 'pb-4')}>
{Array.from({ length: columns }, (_, colIndex) => {
const itemIndex = start + colIndex
if (itemIndex >= totalCount) {
return <div key={colIndex} aria-hidden="true" />
}
return <div key={itemIndex}>{renderItem(itemIndex)}</div>
})}
</div>
)
}}
/>
)}
</div>
)
}
+14 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type DragEvent, type MouseEvent, type Reac
import { createPortal } from 'react-dom'
import { Folder, GripVertical, Image, MoreVertical, Plus, Power, PowerOff, RefreshCw, Save, Trash2 } from 'lucide-react'
import { imageURL } from '../api/client'
import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog'
import type { Library, LibraryRoot } from '../types'
import type { RootDraft } from './adminLibraryPanelModel'
@@ -169,7 +170,19 @@ function LibraryTableRow({ library, dragging, dragOver, onDragStart, onDragOver,
</td>
<td className="py-2 pr-3 font-medium text-ink-600">
<div className="flex items-center gap-2">
{library.cover_url && <img src={library.cover_url} alt="" loading="lazy" decoding="async" className="h-10 w-8 rounded object-cover" />}
{library.cover_url && (
<img
src={imageURL(library.cover_url, library.updated_at)}
alt=""
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
className="h-10 w-8 rounded object-cover"
onError={(e) => {
e.currentTarget.style.visibility = 'hidden'
}}
/>
)}
<span>{library.name}</span>
</div>
</td>
+1 -1
View File
@@ -425,7 +425,7 @@ export function AdultSettingsPanel() {
<span className="text-sm font-semibold text-ink-600">MetaTube 后端配置</span>
</div>
<a
href="https://github.com/metatube-community/metatube-server"
href="https://metatube-community.github.io/README_ZH/"
target="_blank"
rel="noreferrer"
className="text-xs text-brand-500 hover:underline flex items-center gap-1"
+12
View File
@@ -74,6 +74,7 @@ export function PlayerPage() {
const [danmakuOpen, setDanmakuOpen] = useState(false)
const [danmakuEnabled, setDanmakuEnabled] = useState(true)
const [danmakuSearch, setDanmakuSearch] = useState<string | null>(null)
const [danmakuSearchTrigger, setDanmakuSearchTrigger] = useState(0)
const [danmakuSearching, setDanmakuSearching] = useState(false)
// 用户从候选列表选定的弹幕库;null = 自动匹配。
const [danmakuEpisodeId, setDanmakuEpisodeId] = useState<number | string | null>(null)
@@ -141,6 +142,7 @@ export function PlayerPage() {
setDanmakuEpisodeId(null)
setDanmakuInfo(null)
setDanmakuSearch(kw || null)
setDanmakuSearchTrigger((prev) => prev + 1)
}, [])
const danmakuLoaded = useCallback((info: DanmakuLoadedInfo | null) => {
@@ -164,6 +166,7 @@ export function PlayerPage() {
setDanmakuSearching(true)
// 展示当前所选来源(面板标题处可见)。
setDanmakuSelectedSource(episodeTitle ? `${animeTitle}・${episodeTitle}` : animeTitle)
setDanmakuSearchTrigger((prev) => prev + 1)
}, [])
// 回到自动匹配(清除用户手动选择)。
@@ -174,6 +177,7 @@ export function PlayerPage() {
setDanmakuSearch(null)
setDanmakuSelectedSource('')
setDanmakuInfo(null)
setDanmakuSearchTrigger((prev) => prev + 1)
}, [])
// 切换视频时重置媒体与弹幕状态,确保新视频自动重新识别并加载弹幕
@@ -549,6 +553,12 @@ export function PlayerPage() {
toast.error('视频播放失败,请检查文件是否存在')
}, [directOnly, hlsUnavailable, media, mode, params, setParams])
const danmakuAutoTitle =
danmakuInfo?.animeTitle ||
media?.original_name?.trim() ||
media?.title?.trim() ||
''
return (
<div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black">
<PlayerTopBar
@@ -580,6 +590,7 @@ export function PlayerPage() {
danmakuArea={danmakuArea}
danmakuSearch={danmakuSearch}
danmakuEpisodeId={danmakuEpisodeId}
danmakuSearchTrigger={danmakuSearchTrigger}
danmakuOpen={danmakuOpen}
onToggleDanmaku={toggleDanmakuOpen}
onDanmakuLoaded={danmakuLoaded}
@@ -619,6 +630,7 @@ export function PlayerPage() {
onFontSizeChange={setDanmakuFontSize}
candidates={danmakuCandidates}
selectedSource={danmakuSelectedSource}
autoMatchTitle={danmakuAutoTitle}
danmakuInfo={danmakuInfo}
onSelectEpisode={danmakuSelectEpisode}
onResetAuto={danmakuResetAuto}
+3
View File
@@ -24,6 +24,7 @@ type PlayerVideoStageProps = {
danmakuArea: number
danmakuSearch: string | null
danmakuEpisodeId: number | string | null
danmakuSearchTrigger?: number
danmakuOpen: boolean
onToggleDanmaku: () => void
onDanmakuLoaded: (info: DanmakuLoadedInfo | null) => void
@@ -58,6 +59,7 @@ export function PlayerVideoStage({
danmakuArea,
danmakuSearch,
danmakuEpisodeId,
danmakuSearchTrigger = 0,
danmakuOpen,
onToggleDanmaku,
onDanmakuLoaded,
@@ -280,6 +282,7 @@ export function PlayerVideoStage({
area={danmakuArea}
search={danmakuSearch}
episodeId={danmakuEpisodeId}
searchTrigger={danmakuSearchTrigger}
onLoaded={onDanmakuLoaded}
onCandidates={onDanmakuCandidates}
/>
+119 -2
View File
@@ -1,6 +1,6 @@
import { FormEvent, useState } from 'react'
import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { EyeOff, KeyRound, Loader2, Save } from 'lucide-react'
import { Check, Copy, EyeOff, KeyRound, Loader2, Save, Tv } from 'lucide-react'
import { authAPI } from '../api/auth'
import { profileAPI } from '../api/profile'
@@ -21,6 +21,65 @@ export function ProfilePage() {
const [savingProfile, setSavingProfile] = useState(false)
const [savingPassword, setSavingPassword] = useState(false)
const [otpCode, setOtpCode] = useState('')
const [otpExpiresIn, setOtpExpiresIn] = useState(0)
const [generatingOtp, setGeneratingOtp] = useState(false)
const [copiedOtp, setCopiedOtp] = useState(false)
useEffect(() => {
if (otpExpiresIn <= 0) return
const timer = setInterval(() => {
setOtpExpiresIn((prev) => {
if (prev <= 1) {
clearInterval(timer)
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(timer)
}, [otpExpiresIn])
useEffect(() => {
if (window.location.hash === '#tv-otp') {
const el = document.getElementById('tv-otp')
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
}
}
}, [])
const onGenerateOTP = async () => {
if (generatingOtp) return
setGeneratingOtp(true)
try {
const res = await authAPI.createTemporaryPassword()
setOtpCode(res.code)
setOtpExpiresIn(res.expires_in)
setCopiedOtp(false)
toast.success('已生成 6 位临时登录码')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'生成临时密码失败'
toast.error(msg)
} finally {
setGeneratingOtp(false)
}
}
const copyOTP = async () => {
if (!otpCode) return
try {
await navigator.clipboard.writeText(otpCode)
setCopiedOtp(true)
toast.success('已复制到剪贴板')
setTimeout(() => setCopiedOtp(false), 2000)
} catch {
toast.error('复制失败,请手动长按复制')
}
}
const onProfile = async (e: FormEvent) => {
e.preventDefault()
if (savingProfile) return
@@ -171,6 +230,64 @@ export function ProfilePage() {
更新密码
</button>
</form>
<section id="tv-otp" className="glass-panel space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="font-display text-lg font-semibold text-ink-600 flex items-center gap-2">
<Tv size={20} className="text-brand-500" />
Emby / 电视端临时登录码 (OTP)
</h2>
<p className="mt-1 text-sm text-ink-50">
适用于电视盒子、Apple TV、车机或朋友设备上的 Emby / Infuse / Jellyfin 客户端快速登录。无需使用遥控器输入长密码。
</p>
</div>
<button
type="button"
onClick={onGenerateOTP}
disabled={generatingOtp}
className="neon-button shrink-0"
>
{generatingOtp ? <Loader2 size={16} className="animate-spin" /> : <KeyRound size={16} />}
{otpCode && otpExpiresIn > 0 ? '重新生成' : '获取 6 位临时登录码'}
</button>
</div>
{otpCode && otpExpiresIn > 0 ? (
<div className="rounded-2xl border border-brand-200 bg-brand-50/40 p-4 space-y-3">
<div className="flex items-center justify-between text-xs text-ink-50">
<div>
<span>登录账号:</span>
<span className="font-mono font-bold text-ink-600">{user?.username}</span>
</div>
<div>
<span>有效时间剩余:</span>
<span className={`font-mono font-bold ${otpExpiresIn < 60 ? 'text-red-500' : 'text-brand-600'}`}>
{Math.floor(otpExpiresIn / 60).toString().padStart(2, '0')}:{(otpExpiresIn % 60).toString().padStart(2, '0')}
</span>
</div>
</div>
<div className="flex items-center justify-between rounded-xl bg-white/90 p-4 border border-brand-100 shadow-sm">
<div className="font-mono text-3xl font-black tracking-widest text-brand-600 select-all">
{otpCode}
</div>
<button
type="button"
onClick={copyOTP}
className="flex items-center gap-1.5 rounded-lg bg-brand-100 px-3 py-1.5 text-xs font-semibold text-brand-700 hover:bg-brand-200 transition-colors"
>
{copiedOtp ? <Check size={14} /> : <Copy size={14} />}
{copiedOtp ? '已复制' : '复制密码'}
</button>
</div>
<p className="text-xs text-ink-50 leading-relaxed">
在客户端的 Emby / Jellyfin 登录界面输入用户名 <code className="font-bold text-ink-600">{user?.username}</code> 和上方 6 位数字临时密码即可完成登录。登录成功后临时密码立即作废(一次性使用),客户端将自动换取长期持久令牌。
</p>
</div>
) : null}
</section>
</div>
)
}
+161 -45
View File
@@ -29,7 +29,7 @@ import {
normalizeEmbyRemoteLines,
type EmbyRemoteLine,
} from '../utils/embyRemoteLines'
import { lastPathSegment, syncLocalPathWithRemote } from '../utils/strmPaths'
import { lastPathSegment, remoteTailNameOf, syncLocalPathWithRemote } from '../utils/strmPaths'
// ─── 弹框外壳 ────────────────────────────────────────────────────────────────
@@ -573,22 +573,33 @@ export function StrmSettingsDialog({ onClose }: { onClose: () => void }) {
// ─── 添加/编辑同步目录 ────────────────────────────────────────────────────────
/** 推断已有配置当前实际拼在本地输出目录末尾的尾段(目录名或 115 目录 ID)。 */
function initRemoteTail(existing: StrmSyncPath): string {
const cidTail = lastPathSegment(existing.remote_path)
const nameTail = existing.remote_display_path ? lastPathSegment(existing.remote_display_path) : ''
if (nameTail && lastPathSegment(existing.local_path) === nameTail) return nameTail
return cidTail
}
export function StrmSyncPathDialog({
accounts,
existing,
onClose,
onSaved,
onOpenSettings,
}: {
accounts: StrmAccount[]
existing: StrmSyncPath | null
onClose: () => void
onSaved: () => void
onOpenSettings?: () => void
}) {
const [form, setForm] = useState<StrmSyncPathInput>(() => ({
name: existing?.name ?? '',
provider: existing?.provider ?? 'cloud115',
account_id: existing?.account_id ?? '',
remote_path: existing?.remote_path ?? '',
remote_display_path: existing?.remote_display_path ?? '',
local_path: existing?.local_path ?? '',
strm_base_url: existing?.strm_base_url ?? '',
video_ext: existing?.video_ext ?? '',
@@ -607,22 +618,68 @@ export function StrmSyncPathDialog({
const [saving, setSaving] = useState(false)
const [browsing, setBrowsing] = useState(false)
const [browsingLocal, setBrowsingLocal] = useState<null | 'remote_path' | 'local_path'>(null)
const prevRemoteTailRef = useRef(existing ? lastPathSegment(existing.remote_path) : '')
// 输入框显示的远端目录文本:对于 115 优先显示完整路径,若尚未反查到则显示 ID
const [remoteInputValue, setRemoteInputValue] = useState(
() => existing?.remote_display_path || existing?.remote_path || '',
)
// 反查展示路径的序号守卫:远端目录快速连续变更时丢弃过期响应
const resolveSeqRef = useRef(0)
// prevRemoteTailRef 记录当前拼在本地输出目录末尾、由本弹窗管理的尾段。
// 兼容两类历史数据:新版保存的 local_path 末段是目录名,旧版是目录 ID。
const prevRemoteTailRef = useRef(existing ? initRemoteTail(existing) : '')
const set = <K extends keyof StrmSyncPathInput>(key: K, value: StrmSyncPathInput[K]) =>
setForm((f) => ({ ...f, [key]: value }))
const updateRemotePath = (remotePath: string) => {
const updateRemotePath = (remotePath: string, displayPath?: string) => {
const shown = (displayPath || remotePath).trim()
setRemoteInputValue(shown)
setForm((f) => {
const synced = syncLocalPathWithRemote(f.local_path, remotePath, prevRemoteTailRef.current)
const tail = remoteTailNameOf(remotePath, displayPath)
const synced = syncLocalPathWithRemote(f.local_path, remotePath, prevRemoteTailRef.current, tail)
prevRemoteTailRef.current = synced.remoteTail
return { ...f, remote_path: remotePath, local_path: synced.localPath }
return {
...f,
remote_path: remotePath,
remote_display_path: displayPath ?? '',
local_path: synced.localPath,
}
})
}
// 按 ID 反查完整展示路径。默认仅补展示、不改动已配置的本地输出目录;
// resyncTail 用于手动输入 ID 的新配置:把刚拼上的 ID 尾段替换为目录名。
const resolveDisplayPath = (accountId: string, remotePath: string, resyncTail = false) => {
const seq = ++resolveSeqRef.current
strmAPI
.resolveRemoteDirPath(accountId, remotePath)
.then((fullPath) => {
if (seq !== resolveSeqRef.current) return
if (fullPath) setRemoteInputValue(fullPath)
setForm((f) => {
if (f.remote_path !== remotePath) return f
if (resyncTail) {
const tail = remoteTailNameOf(remotePath, fullPath)
const synced = syncLocalPathWithRemote(f.local_path, remotePath, prevRemoteTailRef.current, tail)
prevRemoteTailRef.current = synced.remoteTail
return { ...f, remote_display_path: fullPath, local_path: synced.localPath }
}
// 旧配置的 local_path 末段若恰好就是目录名,把它认作受管尾段,
// 后续重新选择目录时才能正确替换而不是叠加
const tail = lastPathSegment(fullPath)
if (tail && prevRemoteTailRef.current !== tail && lastPathSegment(f.local_path) === tail) {
prevRemoteTailRef.current = tail
}
return { ...f, remote_display_path: fullPath }
})
})
.catch(() => undefined)
}
const commitLocalPath = (localPath: string) => {
setForm((f) => {
const synced = syncLocalPathWithRemote(localPath, f.remote_path, prevRemoteTailRef.current)
const tail = remoteTailNameOf(f.remote_path, f.remote_display_path)
const synced = syncLocalPathWithRemote(localPath, f.remote_path, prevRemoteTailRef.current, tail)
prevRemoteTailRef.current = synced.remoteTail
return { ...f, local_path: synced.localPath }
})
@@ -630,11 +687,30 @@ export function StrmSyncPathDialog({
const isLocal = form.provider === 'local'
const availableAccounts = accounts.filter((a) => a.provider === form.provider && a.has_credential)
const remoteDisplayPath = isLocal ? '' : form.remote_display_path?.trim() ?? ''
const remoteTail = isLocal ? '' : remoteTailNameOf(form.remote_path, remoteDisplayPath)
// 编辑旧配置时若尚未存展示路径,按 ID 反查补齐并更新输入框展示
useEffect(() => {
if (isLocal || form.provider !== 'cloud115') return
if (!form.account_id || !form.remote_path || form.remote_display_path) return
resolveDisplayPath(form.account_id, form.remote_path)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const submit = async (event: FormEvent) => {
event.preventDefault()
setSaving(true)
try {
if (!existing) {
const settings = await strmAPI.getSettings()
if (!settings?.['strm.base_url']?.trim()) {
toast.error('未填写strm地址')
onClose()
onOpenSettings?.()
return
}
}
if (existing) {
await strmAPI.updatePath(existing.id, form)
toast.success('同步目录已更新')
@@ -671,6 +747,9 @@ export function StrmSyncPathDialog({
const provider = e.target.value as StrmProvider
set('provider', provider)
set('account_id', '')
set('remote_path', '')
set('remote_display_path', '')
setRemoteInputValue('')
}}
>
<option value="cloud115">115 网盘</option>
@@ -707,17 +786,39 @@ export function StrmSyncPathDialog({
isLocal
? '扫描该目录下的视频生成 strm'
: form.provider === 'cloud115'
? '115 目录 ID(可通过浏览选择)'
? form.remote_path
? `115 目录 ID:${form.remote_path}(可通过右侧「浏览」选择更换)`
: '可通过右侧「浏览」选择 115 目录,或直接粘贴目录 ID'
: '远端路径(可通过浏览选择)'
}
>
<input
className={inputCls}
value={form.remote_path}
placeholder={isLocal ? 'D:\\movies' : '/'}
onChange={(e) => set('remote_path', e.target.value)}
value={remoteInputValue}
placeholder={isLocal ? 'D:\\movies' : form.provider === 'cloud115' ? '点击右侧「浏览」选择目录' : '/'}
onChange={(e) => setRemoteInputValue(e.target.value)}
onBlur={(e) => {
if (!isLocal) updateRemotePath(e.target.value)
const value = e.target.value.trim()
if (isLocal) {
set('remote_path', value)
return
}
if (form.provider === 'cloud115') {
if (!value) {
updateRemotePath('', '')
return
}
// 若用户直接输入/粘贴纯数字目录 ID,触发反查并自动替换为完整路径
if (/^\d+$/.test(value)) {
updateRemotePath(value, '')
if (form.account_id) resolveDisplayPath(form.account_id, value, true)
return
}
// 若当前展示的是完整路径且未变动,不做处理
if (value === form.remote_display_path) return
} else {
updateRemotePath(value)
}
}}
/>
</Field>
@@ -745,8 +846,8 @@ export function StrmSyncPathDialog({
<Field
label="本地输出目录"
hint={
!isLocal && lastPathSegment(form.remote_path)
? `将自动拼接远端末级目录「${lastPathSegment(form.remote_path)}」`
!isLocal && remoteTail
? `将自动拼接远端末级目录「${remoteTail}」`
: '生成的 .strm 与下载的元数据写到这里的对应目录结构下'
}
>
@@ -774,20 +875,6 @@ export function StrmSyncPathDialog({
<details className="rounded-2xl border border-gray-100 bg-gray-50/50 p-3">
<summary className="cursor-pointer text-sm font-semibold text-ink-600">高级选项</summary>
<div className="mt-3 space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<Field label="STRM 基础地址(覆盖全局)" hint="留空使用全局 STRM 设置">
<input className={inputCls} value={form.strm_base_url ?? ''} placeholder="http://host:port" onChange={(e) => set('strm_base_url', e.target.value)} />
</Field>
<Field label="最小视频大小(MB)" hint="0 表示继承全局设置">
<input
className={inputCls}
type="number"
min={0}
value={form.min_video_size_mb ?? 0}
onChange={(e) => set('min_video_size_mb', Number(e.target.value))}
/>
</Field>
</div>
<div className="grid gap-3 md:grid-cols-3">
<Field label="视频扩展名(覆盖全局)">
<input className={inputCls} value={form.video_ext ?? ''} placeholder="mkv,mp4,avi" onChange={(e) => set('video_ext', e.target.value)} />
@@ -800,6 +887,15 @@ export function StrmSyncPathDialog({
</Field>
</div>
<div className="grid gap-3 md:grid-cols-3">
<Field label="最小视频大小(MB)" hint="0 表示继承全局设置">
<input
className={inputCls}
type="number"
min={0}
value={form.min_video_size_mb ?? 0}
onChange={(e) => set('min_video_size_mb', Number(e.target.value))}
/>
</Field>
<Field label="STRM 链接 path 参数">
<select className={inputCls} value={form.add_path ?? 1} onChange={(e) => set('add_path', Number(e.target.value))}>
<option value={1}>完整远端路径</option>
@@ -813,10 +909,10 @@ export function StrmSyncPathDialog({
<option value="full">全量同步(全量校验)</option>
</select>
</Field>
<Field label="定时同步 Cron" hint="5 段表达式,如 0 */6 * * *">
<input className={inputCls} value={form.cron ?? ''} placeholder="0 */6 * * *" onChange={(e) => set('cron', e.target.value)} />
</Field>
</div>
<Field label="定时同步 Cron" hint="5 段表达式,如 0 */6 * * *">
<input className={inputCls} value={form.cron ?? ''} placeholder="0 */6 * * *" onChange={(e) => set('cron', e.target.value)} />
</Field>
<div className="grid gap-2 md:grid-cols-2">
<ToggleRow label="下载元数据" checked={form.download_meta ?? true} onChange={(v) => set('download_meta', v)} />
<ToggleRow label="上传元数据" checked={form.upload_meta ?? false} onChange={(v) => set('upload_meta', v)} />
@@ -842,8 +938,8 @@ export function StrmSyncPathDialog({
<StrmDirBrowserDialog
accountId={form.account_id}
initialDir={form.remote_path || undefined}
onSelect={(id) => {
updateRemotePath(id)
onSelect={(id, _name, fullPath) => {
updateRemotePath(id, form.provider === 'cloud115' ? fullPath : undefined)
setBrowsing(false)
}}
onClose={() => setBrowsing(false)}
@@ -882,6 +978,12 @@ function ToggleRow({ label, checked, onChange }: { label: string; checked: boole
)
}
/** 把浏览时经过的目录名称链拼成以 / 开头的完整展示路径。 */
function chainFullPath(chain: { id: string; name: string }[]): string {
const names = chain.map((item) => item.name.trim()).filter(Boolean)
return names.length > 0 ? '/' + names.join('/') : ''
}
// ─── 远端目录浏览选择器 ───────────────────────────────────────────────────────
function StrmDirBrowserDialog({
@@ -892,25 +994,28 @@ function StrmDirBrowserDialog({
}: {
accountId: string
initialDir?: string
onSelect: (path: string) => void
onSelect: (id: string, name?: string, fullPath?: string) => void
onClose: () => void
}) {
const [dir, setDir] = useState(initialDir ?? '')
const [crumbs, setCrumbs] = useState<string[]>([])
const [entries, setEntries] = useState<StrmRemoteEntry[]>([])
const [loading, setLoading] = useState(true)
// 已进入目录的名称链:面包屑按名称展示,「选择当前目录」时回传末级名称
const [dirChain, setDirChain] = useState<{ id: string; name: string }[]>([])
const dirChainRef = useRef<{ id: string; name: string }[]>([])
// 递增序号守卫:快速连续进入目录时丢弃过期目录响应
const loadSeqRef = useRef(0)
const load = async (target: string) => {
const load = async (target: string, nextChain?: { id: string; name: string }[]) => {
const seq = ++loadSeqRef.current
setLoading(true)
try {
const list = await strmAPI.listRemoteDir(accountId, target)
if (seq !== loadSeqRef.current) return
if (nextChain) dirChainRef.current = nextChain
setEntries(list)
setDir(target)
setCrumbs(target ? target.split('/').filter(Boolean) : [])
setDirChain([...dirChainRef.current])
} catch (err) {
if (seq !== loadSeqRef.current) return
toast.error(apiErrorMessage(err))
@@ -924,7 +1029,13 @@ function StrmDirBrowserDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accountId])
const enterDir = (id: string) => load(id).catch(() => undefined)
const enterDir = (entry: StrmRemoteEntry) => {
const chain = [...dirChainRef.current]
const existingIdx = chain.findIndex((item) => item.id === entry.id)
if (existingIdx >= 0) chain.splice(existingIdx + 1)
else chain.push({ id: entry.id, name: entry.name })
load(entry.id, chain).catch(() => undefined)
}
return (
<div
@@ -944,13 +1055,13 @@ function StrmDirBrowserDialog({
</button>
</div>
<div className="flex items-center gap-1 border-b border-gray-100 px-6 py-2.5 text-xs text-sand-500">
<button type="button" className="hover:text-brand-500" onClick={() => enterDir('')}>
<button type="button" className="hover:text-brand-500" onClick={() => load('', [])}>
根目录
</button>
{crumbs.map((crumb, index) => (
<span key={crumb + index} className="flex items-center gap-1">
{(dirChain.length > 0 ? dirChain.map((item) => item.name) : dir.split('/').filter(Boolean)).map((label, index) => (
<span key={label + index} className="flex items-center gap-1">
<ChevronRight size={12} />
<span>{crumb}</span>
<span>{label}</span>
</span>
))}
<span className="ml-2 text-ink-50">{dir || '(根目录 / 0)'}</span>
@@ -971,8 +1082,8 @@ function StrmDirBrowserDialog({
key={entry.id}
type="button"
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left text-sm transition hover:bg-gray-50"
onClick={() => (entry.is_dir ? enterDir(entry.id) : undefined)}
onDoubleClick={() => entry.is_dir && enterDir(entry.id)}
onClick={() => (entry.is_dir ? enterDir(entry) : undefined)}
onDoubleClick={() => entry.is_dir && enterDir(entry)}
>
<Icon size={16} className={entry.is_dir ? 'text-brand-400' : 'text-sand-400'} />
<span className="flex-1 truncate text-ink-600">{entry.name}</span>
@@ -982,7 +1093,7 @@ function StrmDirBrowserDialog({
className="rounded-lg border border-brand-200 bg-brand-50 px-2.5 py-1 text-xs font-semibold text-brand-500 hover:bg-brand-100"
onClick={(e) => {
e.stopPropagation()
onSelect(entry.id)
onSelect(entry.id, entry.name, chainFullPath([...dirChainRef.current, { id: entry.id, name: entry.name }]))
}}
>
选择此目录
@@ -998,7 +1109,12 @@ function StrmDirBrowserDialog({
</div>
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-3">
<span className="text-xs text-sand-500">双击进入目录,点击「选择此目录」使用该目录 ID / 路径</span>
<button type="button" className="neon-button" onClick={() => onSelect(dir)} disabled={loading}>
<button
type="button"
className="neon-button"
onClick={() => onSelect(dir, dirChain[dirChain.length - 1]?.name, chainFullPath(dirChain))}
disabled={loading}
>
选择当前目录
</button>
</div>
+25 -3
View File
@@ -163,6 +163,22 @@ export function StrmManagePage() {
}
}
const openAddPathDialog = async () => {
try {
const settings = await strmAPI.getSettings()
const baseUrl = settings?.['strm.base_url']?.trim()
if (!baseUrl) {
toast.error('未填写strm地址')
setDialog('settings')
return
}
setEditingPath(null)
setDialog('path')
} catch (err) {
toast.error(apiErrorMessage(err))
}
}
return (
<div className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
@@ -186,7 +202,7 @@ export function StrmManagePage() {
<Settings size={16} />
STRM 设置
</button>
<button type="button" onClick={() => { setEditingPath(null); setDialog('path') }} className="neon-button">
<button type="button" onClick={openAddPathDialog} className="neon-button">
<FolderPlus size={16} />
添加同步目录
</button>
@@ -210,7 +226,7 @@ export function StrmManagePage() {
<SyncPathSection
paths={paths}
actingPath={actingPath}
onAdd={() => { setEditingPath(null); setDialog('path') }}
onAdd={openAddPathDialog}
onEdit={(path) => { setEditingPath(path); setDialog('path') }}
onDelete={deletePath}
onStart={startSync}
@@ -236,6 +252,7 @@ export function StrmManagePage() {
existing={editingPath}
onClose={() => setDialog(null)}
onSaved={() => { setDialog(null); refresh().catch(() => undefined) }}
onOpenSettings={() => setDialog('settings')}
/>
)}
</div>
@@ -442,7 +459,12 @@ function SyncPathSection({
</div>
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs text-ink-50">
<span className="max-w-[45%] truncate">{path.remote_path || '(根目录)'}</span>
<span
className="max-w-[45%] truncate"
title={path.remote_display_path || path.remote_path || '(根目录)'}
>
{path.remote_display_path || path.remote_path || '(根目录)'}
</span>
<span className="text-sand-500">→</span>
<span className="max-w-[45%] truncate">{path.local_path}</span>
</div>
-60
View File
@@ -1,60 +0,0 @@
export interface DownloadTask {
id: string
source: string
download_client_id?: string
external_id?: string
title: string
poster_url?: string
backdrop_url?: string
overview?: string
save_path: string
status: string
progress: number
state?: string
dlspeed?: number
upspeed?: number
num_seeds?: number
num_leechs?: number
size?: number
downloaded?: number
created_at: string
updated_at: string
}
export interface QBitTorrent {
hash: string
client_id: string
source: 'qbittorrent' | 'transmission' | 'aria2'
name: string
title: string
poster_url?: string
backdrop_url?: string
overview?: string
state: string
progress: number
dlspeed: number
upspeed: number
num_seeds: number
num_leechs: number
size: number
downloaded: number
save_path: string
}
export interface DownloadClient {
id: string
name: string
type: 'qbittorrent' | 'transmission' | 'aria2'
host: string
username: string
is_default: boolean
enabled: boolean
created_at: string
updated_at: string
}
export interface DownloadClientTypeInfo {
type: string
name: string
description: string
}
-1
View File
@@ -2,7 +2,6 @@
export * from './api'
export * from './auth'
export * from './downloads'
export * from './events'
export * from './history'
export * from './library'
+2
View File
@@ -59,6 +59,7 @@ export interface StrmSyncPath {
account_id: string
provider: StrmProvider
remote_path: string
remote_display_path?: string
local_path: string
strm_base_url: string
video_ext: string
@@ -86,6 +87,7 @@ export interface StrmSyncPathInput {
account_id?: string
provider: StrmProvider
remote_path: string
remote_display_path?: string
local_path: string
strm_base_url?: string
video_ext?: string
+20 -2
View File
@@ -6,6 +6,23 @@ export function lastPathSegment(path: string): string {
return parts[parts.length - 1] ?? ''
}
/**
* 取远端末级目录的展示/拼接名。115 等以 ID 作为 remote_path 的网盘
* 传入浏览时拿到的目录名 tailName,避免把数字 ID 拼进本地路径。
*/
export function resolveRemoteTailName(remotePath: string, tailName?: string): string {
const name = tailName?.trim()
if (name) return name
return lastPathSegment(remotePath)
}
/** 优先取展示路径(115 浏览/反查得到)的末级目录名,否则取远端路径末段。 */
export function remoteTailNameOf(remotePath: string, displayPath?: string): string {
const trimmed = displayPath?.trim()
if (trimmed) return lastPathSegment(trimmed)
return lastPathSegment(remotePath)
}
function pathSeparator(path: string): '/' | '\\' {
return path.includes('\\') ? '\\' : '/'
}
@@ -30,13 +47,14 @@ function stripRemoteTail(localPath: string, prevRemoteTail: string): string {
return trimmed
}
/** 将远端最后一级目录名拼到本地输出目录末尾;远端变更时替换旧尾段。 */
/** 将远端最后一级目录名拼到本地输出目录末尾;远端变更时替换旧尾段。tailName 为浏览时拿到的目录名。 */
export function syncLocalPathWithRemote(
localPath: string,
remotePath: string,
prevRemoteTail = '',
tailName?: string,
): { localPath: string; remoteTail: string } {
const remoteTail = lastPathSegment(remotePath)
const remoteTail = resolveRemoteTailName(remotePath, tailName)
const base = stripRemoteTail(localPath, prevRemoteTail)
if (!remoteTail) {
return { localPath: base || localPath.trim(), remoteTail: '' }