Compare commits

...

19 Commits

Author SHA1 Message Date
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
51 changed files with 1114 additions and 436 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: on:
push: push:
branches: [main] branches: [main]
tags: ['v*']
# 保留手动触发作为备选
workflow_dispatch: 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: permissions:
contents: write # 需要写入权限来更新版本文件 contents: write # 读写 version 分支、发布 Release 与上传二进制需要
packages: write packages: write
jobs: jobs:
version-and-publish: build-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
new_version: ${{ steps.bump_version.outputs.new_version }} version: ${{ steps.version.outputs.version }}
tag: ${{ steps.bump_version.outputs.tag }} release_tag: ${{ steps.version.outputs.release_tag }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0 # 获取完整历史以便版本计算
token: ${{ secrets.GITHUB_TOKEN }}
# 1. 获取或初始化版本号 # 1. 解析版本号:tag 触发取 tag 名(去掉 v 前缀);其余场景读 version 分支并 patch+1
- name: Get current version - name: Resolve version
id: get_version id: version
run: | run: |
# 从文件读取版本号,或使用默认值 if [ "${{ github.ref_type }}" = "tag" ]; then
if [ -f VERSION ]; then VERSION="${GITHUB_REF_NAME#v}"
CURRENT_VERSION=$(cat VERSION)
else else
CURRENT_VERSION="0.0.0" git fetch origin version
echo $CURRENT_VERSION > 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 fi
echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "release_tag=mebox-v${VERSION}" >> "$GITHUB_OUTPUT"
# 分离版本组成部分 echo "new_version=${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
# 2. 计算新版本号 # 2. 把新版本号写回 version 分支(clone 单分支写入,冲突时 rebase 重试)
- name: Bump version # tag 触发的正式发版版本号来自 tag 本身,跳过自增。
id: bump_version - name: Bump version branch
run: | if: github.ref_type != 'tag'
MAJOR=${{ steps.get_version.outputs.major }} env:
MINOR=${{ steps.get_version.outputs.minor }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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
run: | 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.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
git add VERSION echo "${{ steps.version.outputs.new_version }}" > VERSION
git commit -m "chore: bump version to ${{ steps.bump_version.outputs.new_version }} [skip ci]" git commit -am "chore: bump version to ${{ steps.version.outputs.new_version }}"
git push 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 # 3. 设置 Docker QEMU 和 Buildx
- name: Create and push tag
run: |
TAG="${{ steps.bump_version.outputs.tag }}"
git tag $TAG
git push origin $TAG
# 6. 设置 Docker QEMU 和 Buildx
- uses: docker/setup-qemu-action@v3 - uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
# 7. 登录 GHCR # 3. 登录 GHCR
- name: Log in to GHCR - name: Log in to GHCR
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
@@ -124,7 +80,7 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
# 8. 提取镜像元数据 # 4. 提取镜像元数据
- name: Extract image metadata - name: Extract image metadata
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
@@ -132,10 +88,9 @@ jobs:
images: ghcr.io/${{ github.repository_owner }}/mebox images: ghcr.io/${{ github.repository_owner }}/mebox
tags: | tags: |
type=raw,value=latest type=raw,value=latest
type=raw,value=${{ steps.bump_version.outputs.tag }} type=raw,value=${{ steps.version.outputs.version }}
type=raw,value=${{ steps.bump_version.outputs.new_version }}
# 9. 构建并推送 # 5. 构建并推送
- name: Build & push - name: Build & push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
@@ -147,13 +102,14 @@ jobs:
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: | build-args: |
VERSION=${{ steps.bump_version.outputs.new_version }} VERSION=${{ steps.version.outputs.release_tag }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
# 单文件可执行构建:把前端打包进二进制(go:embed),交叉编译 Windows / # 单文件可执行构建:把前端打包进二进制(go:embed),交叉编译 Windows /
# Linux / macOS 的 amd64 / arm64 产物,作为 GitHub Release 附件发布。 # Linux / macOS 的 amd64 / arm64 产物,作为 GitHub Release 附件发布。
build-frontend: build-frontend:
needs: [build-image]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -178,7 +134,7 @@ jobs:
# 先创建(幂等)空的 GitHub Release,供后续 build-binaries 并行上传附件, # 先创建(幂等)空的 GitHub Release,供后续 build-binaries 并行上传附件,
# 也避免矩阵各 job 并发 upload 时 release 尚不存在而互相竞争。 # 也避免矩阵各 job 并发 upload 时 release 尚不存在而互相竞争。
publish-create-release: publish-create-release:
needs: [version-and-publish] needs: [build-image]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
@@ -187,17 +143,21 @@ jobs:
- name: Create release - name: Create release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.version-and-publish.outputs.tag }} RELEASE_TAG: ${{ needs.build-image.outputs.release_tag }}
run: | run: |
set -eux 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" \ gh release create "$RELEASE_TAG" \
--title "MeBox ${{ needs.version-and-publish.outputs.new_version }}" \ --title "MeBox ${{ needs.build-image.outputs.version }}" \
--notes "自动化发布 ${{ needs.version-and-publish.outputs.new_version }}" \ --notes "自动化发布 ${{ needs.build-image.outputs.version }}" \
--verify-tag --latest || true --verify-tag --latest || true
build-binaries: build-binaries:
needs: [version-and-publish, build-frontend, publish-create-release] needs: [build-image, build-frontend, publish-create-release]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
@@ -237,7 +197,7 @@ jobs:
- name: Build binary - name: Build binary
run: | run: |
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \ 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 -o "dist/mebox-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server
- name: Package - name: Package
run: | run: |
@@ -252,7 +212,7 @@ jobs:
- name: Upload to GitHub Release - name: Upload to GitHub Release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.version-and-publish.outputs.tag }} RELEASE_TAG: ${{ needs.build-image.outputs.release_tag }}
run: | run: |
set -eux set -eux
PKG="mebox_${{ matrix.goos }}_${{ matrix.goarch }}.zip" PKG="mebox_${{ matrix.goos }}_${{ matrix.goarch }}.zip"
@@ -267,7 +227,7 @@ jobs:
deploy: deploy:
name: Deploy to Server name: Deploy to Server
needs: [version-and-publish] needs: [build-image]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Deploy via SSH - name: Deploy via SSH
@@ -302,4 +262,3 @@ jobs:
docker image prune -f docker image prune -f
echo "==== 部署完成并已启动 ====" echo "==== 部署完成并已启动 ===="
+25 -7
View File
@@ -7,7 +7,7 @@
<h3 align="center">面向 NAS 与家庭影音场景的私人媒体中心</h3> <h3 align="center">面向 NAS 与家庭影音场景的私人媒体中心</h3>
<p align="center"> <p align="center">
<strong>媒体库 · 刮削整理 · 网盘 STRM · Emby 协议 · 远程 Emby 挂载 · 多用户权限 · Docker 一键部署</strong> <strong>媒体库 · 刮削整理 · 网盘 STRM · 兼容 Emby/Jellyfin 客户端 · 远程 Emby 挂载 · 多用户权限 · Docker 一键部署</strong>
</p> </p>
<p align="center"> <p align="center">
@@ -17,7 +17,8 @@
<a href="#鸣谢">鸣谢</a> · <a href="#鸣谢">鸣谢</a> ·
<a href="#开发构建">开发构建</a> · <a href="#开发构建">开发构建</a> ·
<a href="README_EN.md">English</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>
<p align="center"> <p align="center">
@@ -46,10 +47,10 @@
| **媒体库** | 电影、电视剧、动漫、综艺、音乐与自定义库;多根目录、扫库、海报墙、继续观看 | | **媒体库** | 电影、电视剧、动漫、综艺、音乐与自定义库;多根目录、扫库、海报墙、继续观看 |
| **元数据刮削** | TMDb、Bangumi、Douban、TheTVDB、Fanart 等;支持 NFO、手动匹配、刮削队列 | | **元数据刮削** | TMDb、Bangumi、Douban、TheTVDB、Fanart 等;支持 NFO、手动匹配、刮削队列 |
| **播放** | 网页播放器、HLS 转码、弹幕、字幕、播放配置档、观看历史与收藏 | | **播放** | 网页播放器、HLS 转码、弹幕、字幕、播放配置档、观看历史与收藏 |
| **Emby 协议** | Infuse、SenPlayer、Fileball 等客户端可直接添加本服务,使用 MeBox 账号登录 | | **Emby/Jellyfin 客户端兼容** | 内置完整 Emby 服务端协议实现:Infuse、SenPlayer、Fileball、Emby/Jellyfin 官方客户端等可直接把本服务当作 Emby 服务器添加,使用 MeBox 账号登录,海报墙、进度同步、多用户无缝衔接 |
| **远程 Emby 挂载** | 将远程 Emby 媒体库挂载到本地界面统一浏览(无需单独开 Emby 客户端) | | **远程 Emby 挂载** | 将远程 Emby 媒体库挂载到本地界面统一浏览(无需单独开 Emby 客户端) |
| **网盘与 STRM** | OpenList、CloudDrive2、115、WebDAV 等;STRM 同步、上传/下载队列、直链/302 播放 | | **网盘与 STRM** | OpenList、CloudDrive2、115、WebDAV 等;STRM 同步、上传/下载队列、直链/302 播放 |
| **下载与整理** | qBittorrent 接入、站点搜索与订阅、下载后自动整理、文件管理器(复制/移动/硬链/软链) | | **下载与整理** | 下载目录定时自动整理(智能分类、自动注册媒体库)、文件管理器(复制/移动/硬链/软链) |
| **用户与权限** | 管理员/普通用户、有效期、成人内容开关、播放配置 PIN、细粒度操作权限 | | **用户与权限** | 管理员/普通用户、有效期、成人内容开关、播放配置 PIN、细粒度操作权限 |
| **运维能力** | 统一任务队列、存储统计、DLNA 投屏、系统设置与日志 | | **运维能力** | 统一任务队列、存储统计、DLNA 投屏、系统设置与日志 |
@@ -85,6 +86,8 @@ http://服务器IP:18080
默认账号:`admin` / `admin123`(首次登录后请立即修改密码) 默认账号:`admin` / `admin123`(首次登录后请立即修改密码)
> 💡 **Emby 用户无缝切换**:MeBox 完整兼容 Emby/Jellyfin 客户端协议。手机、电视、平板上的 Infuse、SenPlayer、Fileball、Emby/Jellyfin 官方客户端,直接按「添加 Emby 服务器」填入 `http://服务器IP:18080`,用 MeBox 账号登录即可,无需改变原有使用习惯。
镜像地址: 镜像地址:
```text ```text
@@ -179,7 +182,7 @@ environment:
1. **创建媒体库** → 填写 `/media/...` → 执行扫库 1. **创建媒体库** → 填写 `/media/...` → 执行扫库
2. **配置元数据源** → 系统设置中添加 TMDb、Bangumi 等 API 2. **配置元数据源** → 系统设置中添加 TMDb、Bangumi 等 API
3. **(可选)连接 qBittorrent** → 下载客户端设置,宿主机可用 `http://host.docker.internal:8085` 3. **(可选)配置下载目录自动整理** → 文件管理中将下载目录设为整理源,下载完成后自动分类入库
4. **(可选)配置网盘账号** → STRM 管理中添加 OpenList / 115 / WebDAV 等 4. **(可选)配置网盘账号** → STRM 管理中添加 OpenList / 115 / WebDAV 等
5. **第三方播放器** → 以 Emby 服务器添加 `http://服务器IP:18080`,使用 MeBox 账号登录 5. **第三方播放器** → 以 Emby 服务器添加 `http://服务器IP:18080`,使用 MeBox 账号登录
@@ -190,8 +193,8 @@ environment:
**扫库或入库很慢?** **扫库或入库很慢?**
先确认路径映射与数据库档位。网盘扫描还受接口限速与目录规模影响;大库可考虑第二档 Redis 或第三档 OpenSearch。 先确认路径映射与数据库档位。网盘扫描还受接口限速与目录规模影响;大库可考虑第二档 Redis 或第三档 OpenSearch。
**qBittorrent 下载后无法整理?** **下载目录文件没有被自动整理?**
确认下载目录已通过 `volumes` 挂进容器,且 `MEBOX_DOWNLOAD_*` 环境变量对应正确。 确认下载目录已通过 `volumes` 挂进容器,且 `MEBOX_DOWNLOAD_*` 环境变量对应正确。MeBox 负责目录整理入库,qBittorrent 等下载器按普通软件自行部署即可。
**硬链接失败(cross-device link)?** **硬链接失败(cross-device link)?**
硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 btrfs 子卷或网盘挂载时请改用复制或软链接。 硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 btrfs 子卷或网盘挂载时请改用复制或软链接。
@@ -251,3 +254,18 @@ MeBox 在 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) 的基础
## 许可证 ## 许可证
本项目采用 [GPL-3.0](LICENSE) 许可证。 本项目采用 [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> <h3 align="center">A self-hosted media center for NAS and home theater</h3>
<p align="center"> <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>
<p align="center"> <p align="center">
@@ -16,7 +16,8 @@
<a href="#quick-start">Quick Start</a> · <a href="#quick-start">Quick Start</a> ·
<a href="#deployment-tiers">Deployment</a> · <a href="#deployment-tiers">Deployment</a> ·
<a href="#acknowledgements">Acknowledgements</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>
<p align="center"> <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 | | **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 | | **Metadata** | TMDb, Bangumi, Douban, TheTVDB, Fanart, NFO import, manual matching, scrape queue |
| **Playback** | Web player, HLS transcoding, danmaku, subtitles, play profiles, history and favourites | | **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 | | **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 | | **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 | | **Users & permissions** | Admin/regular users, expiry, NSFW toggle, play-profile PIN, granular permissions |
| **Operations** | Unified task queue, storage stats, DLNA casting, settings and logs | | **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. 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: Image:
```text ```text
@@ -159,7 +162,7 @@ environment:
1. Create a library with a container path such as `/media/Movies`, then scan 1. Create a library with a container path such as `/media/Movies`, then scan
2. Add metadata providers (TMDb, Bangumi, etc.) in system settings 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 4. Optionally configure cloud accounts under STRM management
5. Add the server in Emby-compatible players at `http://SERVER_IP:18080` using MeBox credentials 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** **Library scan is slow**
Check path mapping and DB tier. Cloud scans also depend on API limits and folder size. Check path mapping and DB tier. Cloud scans also depend on API limits and folder size.
**qBittorrent downloads are not organized** **Downloaded files are not organized**
Ensure the download directory is mounted into the container and env vars match. 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** **Hardlink fails with cross-device link**
Hardlinks require the same filesystem/subvolume; use copy or symlink across disks or cloud mounts. 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 ## License
This project is licensed under [GPL-3.0](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.1.11
+1 -1
View File
@@ -55,7 +55,7 @@ services:
bind: bind:
create_host_path: false create_host_path: false
# 下载目录。需要和 qBittorrent 保存路径保持一致。 # 下载目录。需要和下载器保存路径保持一致。
# NAS 示例:source: /vol1/1000/Downloads # NAS 示例:source: /vol1/1000/Downloads
# Windows Docker Desktop 示例:source: D:/Downloads # Windows Docker Desktop 示例:source: D:/Downloads
- type: bind - type: bind
+1 -1
View File
@@ -69,7 +69,7 @@ services:
MEBOX_LOGGING_MAX_AGE_DAYS: "30" MEBOX_LOGGING_MAX_AGE_DAYS: "30"
extra_hosts: extra_hosts:
# 容器访问宿主机服务用,例如 qBittorrent: http://host.docker.internal:8085 # 容器访问宿主机服务(如下载器等)用: http://host.docker.internal:8085
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
healthcheck: healthcheck:
+1 -1
View File
@@ -50,7 +50,7 @@ services:
bind: bind:
create_host_path: false create_host_path: false
# 下载目录。需要和 qBittorrent 保存路径保持一致。 # 下载目录。需要和下载器保存路径保持一致。
# NAS 示例:source: /vol1/1000/Downloads # NAS 示例:source: /vol1/1000/Downloads
# Windows Docker Desktop 示例:source: D:/Downloads # Windows Docker Desktop 示例:source: D:/Downloads
- type: bind - type: bind
+1 -1
View File
@@ -29,7 +29,7 @@ services:
ports: ports:
- "18080:8080" - "18080:8080"
# 让容器可以访问宿主机上的 qBittorrent。 # 让容器可以访问宿主机上的服务(如下载器)。
# qB 地址通常可填:http://host.docker.internal:8085 # qB 地址通常可填:http://host.docker.internal:8085
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "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

+1
View File
@@ -62,6 +62,7 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.DELETE("/strm/accounts/:id", deleteStrmAccountHandler(svc)) admin.DELETE("/strm/accounts/:id", deleteStrmAccountHandler(svc))
admin.POST("/strm/accounts/:id/test", testStrmAccountHandler(svc)) admin.POST("/strm/accounts/:id/test", testStrmAccountHandler(svc))
admin.GET("/strm/accounts/:id/list", listStrmRemoteDirHandler(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.GET("/strm/115/sources", listStrm115SourcesHandler(svc))
admin.POST("/strm/accounts/:id/oauth/start", startStrm115OAuthHandler(svc)) admin.POST("/strm/accounts/:id/oauth/start", startStrm115OAuthHandler(svc))
admin.POST("/strm/accounts/:id/oauth/poll", pollStrm115OAuthHandler(svc)) admin.POST("/strm/accounts/:id/oauth/poll", pollStrm115OAuthHandler(svc))
+62 -36
View File
@@ -3,6 +3,7 @@
package handler package handler
import ( import (
"context"
"errors" "errors"
"net/http" "net/http"
"net/url" "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 { func getStrmSettingsHandler(svc *service.Container) gin.HandlerFunc {
@@ -203,24 +217,25 @@ func updateStrmSettingsHandler(svc *service.Container) gin.HandlerFunc {
// ─── 同步目录 ────────────────────────────────────────────────────────────────── // ─── 同步目录 ──────────────────────────────────────────────────────────────────
type strmSyncPathReq struct { type strmSyncPathReq struct {
Name string `json:"name"` Name string `json:"name"`
AccountID string `json:"account_id"` AccountID string `json:"account_id"`
Provider string `json:"provider"` Provider string `json:"provider"`
RemotePath string `json:"remote_path"` RemotePath string `json:"remote_path"`
LocalPath string `json:"local_path"` RemoteDisplayPath string `json:"remote_display_path"`
StrmBaseURL string `json:"strm_base_url"` LocalPath string `json:"local_path"`
VideoExt string `json:"video_ext"` StrmBaseURL string `json:"strm_base_url"`
MetaExt string `json:"meta_ext"` VideoExt string `json:"video_ext"`
ExcludeName string `json:"exclude_name"` MetaExt string `json:"meta_ext"`
MinVideoSizeMB int64 `json:"min_video_size_mb"` ExcludeName string `json:"exclude_name"`
AddPath int `json:"add_path"` MinVideoSizeMB int64 `json:"min_video_size_mb"`
DownloadMeta *bool `json:"download_meta"` AddPath int `json:"add_path"`
UploadMeta *bool `json:"upload_meta"` DownloadMeta *bool `json:"download_meta"`
DeleteDir *bool `json:"delete_dir"` UploadMeta *bool `json:"upload_meta"`
Cron string `json:"cron"` DeleteDir *bool `json:"delete_dir"`
EnableCron *bool `json:"enable_cron"` Cron string `json:"cron"`
SyncMode string `json:"sync_mode"` EnableCron *bool `json:"enable_cron"`
Enabled *bool `json:"enabled"` SyncMode string `json:"sync_mode"`
Enabled *bool `json:"enabled"`
} }
type strmSyncPathView struct { type strmSyncPathView struct {
@@ -240,6 +255,16 @@ func strmSyncPathViews(svc *service.Container, c *gin.Context, paths []model.Str
view.AccountEnabled = acct.Enabled 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) out = append(out, view)
} }
return out return out
@@ -663,24 +688,25 @@ func strmPlayHandler(svc *service.Container) gin.HandlerFunc {
// strmSyncPathFromReq 组装同步目录模型(缺省值交给服务层处理)。 // strmSyncPathFromReq 组装同步目录模型(缺省值交给服务层处理)。
func strmSyncPathFromReq(req strmSyncPathReq) *model.StrmSyncPath { func strmSyncPathFromReq(req strmSyncPathReq) *model.StrmSyncPath {
return &model.StrmSyncPath{ return &model.StrmSyncPath{
Name: strings.TrimSpace(req.Name), Name: strings.TrimSpace(req.Name),
AccountID: strings.TrimSpace(req.AccountID), AccountID: strings.TrimSpace(req.AccountID),
Provider: strings.TrimSpace(req.Provider), Provider: strings.TrimSpace(req.Provider),
RemotePath: strings.TrimSpace(req.RemotePath), RemotePath: strings.TrimSpace(req.RemotePath),
LocalPath: strings.TrimSpace(req.LocalPath), RemoteDisplayPath: strings.TrimSpace(req.RemoteDisplayPath),
StrmBaseURL: strings.TrimSpace(req.StrmBaseURL), LocalPath: strings.TrimSpace(req.LocalPath),
VideoExt: req.VideoExt, StrmBaseURL: strings.TrimSpace(req.StrmBaseURL),
MetaExt: req.MetaExt, VideoExt: req.VideoExt,
ExcludeName: req.ExcludeName, MetaExt: req.MetaExt,
MinVideoSizeMB: req.MinVideoSizeMB, ExcludeName: req.ExcludeName,
AddPath: req.AddPath, MinVideoSizeMB: req.MinVideoSizeMB,
DownloadMeta: boolValue(req.DownloadMeta, true), AddPath: req.AddPath,
UploadMeta: boolValue(req.UploadMeta, false), DownloadMeta: boolValue(req.DownloadMeta, true),
DeleteDir: boolValue(req.DeleteDir, false), UploadMeta: boolValue(req.UploadMeta, false),
Cron: strings.TrimSpace(req.Cron), DeleteDir: boolValue(req.DeleteDir, false),
EnableCron: boolValue(req.EnableCron, false), Cron: strings.TrimSpace(req.Cron),
SyncMode: strings.TrimSpace(req.SyncMode), EnableCron: boolValue(req.EnableCron, false),
Enabled: boolValue(req.Enabled, true), 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": "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", "key": "system-update",
"label": "系统更新", "label": "系统更新",
+5 -1
View File
@@ -36,7 +36,11 @@ type StrmSyncPath struct {
AccountID string `gorm:"size:36;index" json:"account_id"` // StrmAccount.ID;local 为空 AccountID string `gorm:"size:36;index" json:"account_id"` // StrmAccount.ID;local 为空
Provider string `gorm:"size:32" json:"provider"` // StrmProvider*(冗余,便于列表展示) Provider string `gorm:"size:32" json:"provider"` // StrmProvider*(冗余,便于列表展示)
RemotePath string `gorm:"size:1024" json:"remote_path"` // 远端目录:115=目录ID,OpenList/CD2=路径,local=源目录 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.* 设置) // STRM 链接配置(空值继承全局 strm.* 设置)
StrmBaseURL string `gorm:"size:512" json:"strm_base_url"` // 覆盖 strm.base_url StrmBaseURL string `gorm:"size:512" json:"strm_base_url"` // 覆盖 strm.base_url
VideoExt string `gorm:"size:512" json:"video_ext"` // 逗号分隔,覆盖 strm.video_ext 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 { func (r *StrmSyncPathRepository) Update(ctx context.Context, p *model.StrmSyncPath) error {
return withSQLiteBusyRetry(ctx, func() error { return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.StrmSyncPath{}).Where("id = ?", p.ID).Updates(map[string]any{ return r.db.WithContext(ctx).Model(&model.StrmSyncPath{}).Where("id = ?", p.ID).Updates(map[string]any{
"name": p.Name, "name": p.Name,
"account_id": p.AccountID, "account_id": p.AccountID,
"provider": p.Provider, "provider": p.Provider,
"remote_path": p.RemotePath, "remote_path": p.RemotePath,
"local_path": p.LocalPath, "remote_display_path": p.RemoteDisplayPath,
"strm_base_url": p.StrmBaseURL, "local_path": p.LocalPath,
"video_ext": p.VideoExt, "strm_base_url": p.StrmBaseURL,
"meta_ext": p.MetaExt, "video_ext": p.VideoExt,
"exclude_name": p.ExcludeName, "meta_ext": p.MetaExt,
"min_video_size_mb": p.MinVideoSizeMB, "exclude_name": p.ExcludeName,
"add_path": p.AddPath, "min_video_size_mb": p.MinVideoSizeMB,
"download_meta": p.DownloadMeta, "add_path": p.AddPath,
"upload_meta": p.UploadMeta, "download_meta": p.DownloadMeta,
"delete_dir": p.DeleteDir, "upload_meta": p.UploadMeta,
"cron": p.Cron, "delete_dir": p.DeleteDir,
"enable_cron": p.EnableCron, "cron": p.Cron,
"sync_mode": p.SyncMode, "enable_cron": p.EnableCron,
"enabled": p.Enabled, "sync_mode": p.SyncMode,
"last_sync_at": p.LastSyncAt, "enabled": p.Enabled,
"last_sync_status": p.LastSyncStatus, "last_sync_at": p.LastSyncAt,
"last_sync_message": p.LastSyncMessage, "last_sync_status": p.LastSyncStatus,
"updated_at": time.Now(), "last_sync_message": p.LastSyncMessage,
"updated_at": time.Now(),
}).Error }).Error
}) })
} }
+7 -7
View File
@@ -87,15 +87,15 @@ func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*Dir
if ref == "/" { if ref == "/" {
return nil, fmt.Errorf("%s: file reference required", p.name) return nil, fmt.Errorf("%s: file reference required", p.name)
} }
if p.typ == TypeOpenList && isCloudVideoPlaybackCandidate(ref) { if p.typ == TypeOpenList && p.apiBase != nil {
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)
}
link, err := p.resolveOpenListAPIDirect(ctx, ref) link, err := p.resolveOpenListAPIDirect(ctx, ref)
if err != nil { if err == nil {
return nil, fmt.Errorf("%s: pure 302 playback requires OpenList raw_url for %s: %w", p.name, ref, err) 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) { if p.typ == TypeCloudDrive2 && isCloudVideoPlaybackCandidate(ref) {
link, err := p.resolveCloudDAVRedirectDirect(ctx, 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 { if len(entries) != 1 || entries[0].ID != "/Cloud/Movie.mkv" || entries[0].Size != 1024 {
t.Fatalf("entries = %#v", entries) t.Fatalf("entries = %#v", entries)
} }
// Video file: API fails → error (no WebDAV fallback for video)
_, err = p.Resolve(context.Background(), entries[0].ID) _, err = p.Resolve(context.Background(), entries[0].ID)
if err == nil || !strings.Contains(err.Error(), "pure 302 playback requires OpenList raw_url") { if err == nil || !strings.Contains(err.Error(), "resolve download URL") || !strings.Contains(err.Error(), "via API failed") {
t.Fatalf("openlist video resolve should require raw_url instead of WebDAV proxy fallback, err=%v", err) 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) t.Fatal(err)
} }
_, err = p.Resolve(context.Background(), "/Cloud/Movie.mkv") _, err = p.Resolve(context.Background(), "/Cloud/Movie.mkv")
if err == nil || !strings.Contains(err.Error(), "pure 302 playback requires OpenList raw_url") { if err == nil || !strings.Contains(err.Error(), "resolve download URL") || !strings.Contains(err.Error(), "via API failed") {
t.Fatalf("resolve error = %v, want raw_url requirement", err) t.Fatalf("resolve error = %v, want API resolve failure", err)
} }
if davSeen { if davSeen {
t.Fatal("openlist video resolve fell back to WebDAV after raw_url failure") 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)
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Package service — AES-GCM crypto helper for at-rest secrets. // 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 // 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 // secret so a stolen DB file alone is not enough to recover the
// plaintext credentials. // plaintext credentials.
+5 -1
View File
@@ -77,7 +77,11 @@ func (s *FileManagerService) allowedRoots() (map[string]string, error) {
} }
addSetting("organize-source", "organize.source_dir") addSetting("organize-source", "organize.source_dir")
addSetting("organize-target", "organize.target_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 { if s.repo != nil && s.repo.Library != nil {
libs, err := s.repo.Library.List(context.Background()) 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 got[root.Label] = root.Path
} }
for label, want := range map[string]string{ for label, want := range map[string]string{
"organize-source": filepath.Clean(sourceDir), "organize-source": filepath.Clean(sourceDir),
"organize-target": filepath.Clean(targetDir), "organize-target": filepath.Clean(targetDir),
"qb-savepath": filepath.Clean(qbDir), // 旧键 qbittorrent.savepath 写入应经兼容回退落在 downloader-savepath 下
"downloader-savepath": filepath.Clean(qbDir),
} { } {
if got[label] != want { if got[label] != want {
t.Fatalf("root %s = %q, want %q; roots=%#v", label, got[label], want, listing.Roots) t.Fatalf("root %s = %q, want %q; roots=%#v", label, got[label], want, listing.Roots)
+1 -1
View File
@@ -136,7 +136,7 @@ func TestResolveAccessibleMappedPathMapsEmbeddedHostDownloadMarker(t *testing.T)
} }
t.Setenv("MEBOX_DOWNLOAD_CONTAINER_DIR", containerDownloads) 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 { if err != nil {
t.Fatalf("resolveAccessibleMappedPath() error = %v", err) 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}) out = append(out, OrganizeSourceCandidate{Label: label, Path: clean, Kind: kind})
} }
add("默认整理源", o.settingValue(ctx, "organize.source_dir"), "source") 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_DOWNLOAD_CONTAINER_DIR", "/downloads"), "download")
add("媒体目录", envOrDefault("MEBOX_MEDIA_CONTAINER_DIR", "/media"), "media") add("媒体目录", envOrDefault("MEBOX_MEDIA_CONTAINER_DIR", "/media"), "media")
return out return out
@@ -56,8 +56,17 @@ func (o *OrganizerService) settingValue(ctx context.Context, key string) string
return "" 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: // 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. // download container dir.
func (o *OrganizerService) defaultSourceRoot(ctx context.Context, override string) string { func (o *OrganizerService) defaultSourceRoot(ctx context.Context, override string) string {
if r := strings.TrimSpace(override); r != "" { 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 != "" { if v := o.settingValue(ctx, "organize.source_dir"); v != "" {
return v return v
} }
if v := o.settingValue(ctx, "qbittorrent.savepath"); v != "" { if v := o.downloaderSavepath(ctx); v != "" {
return v return v
} }
return envOrDefault("MEBOX_DOWNLOAD_CONTAINER_DIR", "/downloads") 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) { if mode == TransferMove && o.keepSeedingEnabled(ctx) {
// 移动会删除源文件导致 qBittorrent 停止做种;保种开启时改用硬链接 // 移动会删除源文件导致下载器停止做种;保种开启时改用硬链接
// 既规范命名又保留源文件继续做种上传。硬链接失败时会报错,避免静默 // 既规范命名又保留源文件继续做种上传。硬链接失败时会报错,避免静默
// 退化复制后占用双份磁盘空间。 // 退化复制后占用双份磁盘空间。
return TransferHardlink return TransferHardlink
+1 -1
View File
@@ -7,7 +7,7 @@ import (
) )
// translateClientPath 将下载客户端报告的路径转换为容器内可访问的路径。 // translateClientPath 将下载客户端报告的路径转换为容器内可访问的路径。
// 常见场景:qBittorrent在另一个容器,报告的路径是其容器内路径,需要映射到当前容器。 // 常见场景:下载器在另一个容器,报告的路径是其容器内路径,需要映射到当前容器。
func translateClientPath(clientPath string, mappings map[string]string) string { func translateClientPath(clientPath string, mappings map[string]string) string {
if clientPath == "" { if clientPath == "" {
return "" 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 // 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 // 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. // them through HTTP_PROXY/SOCKS proxies makes local WebUI logins hang or fail.
func NewInternalHTTPClient(timeout time.Duration) *http.Client { func NewInternalHTTPClient(timeout time.Duration) *http.Client {
+12 -6
View File
@@ -366,18 +366,24 @@ func (s *StrmService) processUpload115(ctx context.Context, task *model.StrmUplo
finish(model.StrmTaskFailed, "该网盘不支持元数据上传") finish(model.StrmTaskFailed, "该网盘不支持元数据上传")
return return
} }
// 以本地为准:网盘端已有同名但内容不同的旧元数据时,先删除旧文件再上传。 // 以本地为准:网盘端已有同名但内容不同的旧元数据时,先尝试批量删除所有旧副本再上传。
// 115 的上传接口不保证同名覆盖,直接上传可能产生同名重复文件;删除失败则 // 115 的上传接口不保证同名覆盖,直接上传可能产生同名重复文件。
// 任务重试(旧文件 ID 失效的场景会在下次同步后自动修复)。 // 删除失败时不中止任务——继续上传新文件,旧副本交由下次同步的 cleanupBatchRedundantFiles
// 按目录批量清理(下次同步会看到新旧两个版本,命中新版本后把旧版本 cid 收入 pendingDeletes
// 异步删除)。这样避免了「删旧失败 → 任务重试 → 再次删旧失败 → 永远无法上传」的死循环。
if task.RemoteRef != "" { if task.RemoteRef != "" {
open115, ok := provider.(cloud.OpenAPI115Provider) open115, ok := provider.(cloud.OpenAPI115Provider)
if !ok { if !ok {
finish(model.StrmTaskFailed, "该网盘不支持删除远端旧元数据") finish(model.StrmTaskFailed, "该网盘不支持删除远端旧元数据")
return return
} }
if err := open115.OpenClient().DeleteFiles(ctx, task.RemotePath, task.RemoteRef); err != nil { refs := strings.Split(task.RemoteRef, ",")
s.uploadTaskFailWithRetry(task, "删除网盘旧元数据失败:"+err.Error()) if err := open115.OpenClient().DeleteFiles(ctx, task.RemotePath, refs...); err != nil {
return s.log.Warn("删除网盘旧元数据失败,跳过删除继续上传新文件",
zap.String("task_id", task.ID),
zap.String("local_path", task.LocalPath),
zap.Error(err))
// 不 return:继续上传新文件,旧副本由下次同步清理
} }
} }
f, err := os.Open(task.LocalPath) f, err := os.Open(task.LocalPath)
+206 -49
View File
@@ -6,10 +6,10 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"reflect"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -25,6 +25,16 @@ import (
"github.com/truewhile/MeBox/internal/service/cloud115" "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 是一次同步执行的上下文。 // strmSyncState 是一次同步执行的上下文。
type strmSyncState struct { type strmSyncState struct {
s *StrmService s *StrmService
@@ -37,13 +47,11 @@ type strmSyncState struct {
syncType string syncType string
mu sync.Mutex mu sync.Mutex
processed int // 已处理文件计数(用于定期落库进度) processed int // 已处理文件计数(用于定期落库进度)
lastProgressFlush time.Time // 上次进度落库时间 lastProgressFlush time.Time // 上次进度落库时间
seenVideo map[string]bool // "v:"+去掉扩展名的相对路径 → 远端存在该视频 seenVideo map[string]bool // "v:"+去掉扩展名的相对路径 → 远端存在该视频
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据 seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
remoteMeta map[string]int64 // 远端元数据大小(上传比对用) remoteMeta map[string][]remoteMetaItem // "m:"+相对路径 → 远端元数据副本列表(多副本聚合,支持择优比对与冗余清理)
remoteMetaRef map[string]string // "m:"+相对路径 → 远端元数据文件引用(115 文件 ID,覆盖上传前删除旧文件用)
remoteMetaSha1 map[string]string // "m:"+相对路径 → 远端元数据内容 SHA1(115 列表返回;上传/下载精确比对用,其他网盘为空)
seenMetaTarget map[string]cloud.FileEntry seenMetaTarget map[string]cloud.FileEntry
seenVideoTarget map[string]cloud.FileEntry seenVideoTarget map[string]cloud.FileEntry
activeDownloadPaths map[string]bool // 本地已在排队/进行的下载任务路径(内存去重) activeDownloadPaths map[string]bool // 本地已在排队/进行的下载任务路径(内存去重)
@@ -184,6 +192,66 @@ func (s *StrmService) ListRemoteDir(ctx context.Context, accountID, dir string)
return provider.List(ctx, dir) 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 执行同步主体;结束时更新记录与目录状态。 // runSync 执行同步主体;结束时更新记录与目录状态。
func (s *StrmService) runSync(ctx context.Context, p *model.StrmSyncPath, rec *model.StrmSyncRecord, cancel context.CancelFunc) { func (s *StrmService) runSync(ctx context.Context, p *model.StrmSyncPath, rec *model.StrmSyncRecord, cancel context.CancelFunc) {
defer s.clearRunning(p.ID, cancel) 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, syncType: rec.SyncType,
seenVideo: map[string]bool{}, seenVideo: map[string]bool{},
seenMeta: map[string]bool{}, seenMeta: map[string]bool{},
remoteMeta: map[string]int64{}, remoteMeta: map[string][]remoteMetaItem{},
remoteMetaRef: map[string]string{},
remoteMetaSha1: map[string]string{},
seenMetaTarget: map[string]cloud.FileEntry{}, seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: map[string]cloud.FileEntry{}, seenVideoTarget: map[string]cloud.FileEntry{},
} }
@@ -288,8 +354,17 @@ func (st *strmSyncState) run() error {
if st.provider != nil { if st.provider != nil {
if open115, ok := st.provider.(cloud.OpenAPI115Provider); ok && st.p.Provider == model.StrmProvider115 { if open115, ok := st.provider.(cloud.OpenAPI115Provider); ok && st.p.Provider == model.StrmProvider115 {
if err := st.walk115Flat(open115.OpenClient()); err != nil { err := st.walk115Flat(open115.OpenClient())
return err 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 { } else {
if err := st.walkRemote(); err != nil { if err := st.walkRemote(); err != nil {
@@ -611,6 +686,13 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
return fmt.Errorf("115: 获取文件列表失败:%w", err) return fmt.Errorf("115: 获取文件列表失败:%w", err)
} }
// 115 的扁平化列表(搜索底层)对 offset + limit 有 10000 的最大深度限制。
// 当扁平模式下的总文件数 >= 9500 时,强行拒绝继续扁平拉取,而是抛出降级错误,
// 让外层回退到使用普通的按目录并发递归(walkRemote),以免截断导致后排文件被误删/重传。
if totalCount >= 9500 {
return errFallbackToWalkRemote
}
st.updateSyncMessage(fmt.Sprintf("正在拉取远端文件列表 (共 %d 个文件)...", totalCount)) st.updateSyncMessage(fmt.Sprintf("正在拉取远端文件列表 (共 %d 个文件)...", totalCount))
allFiles := make([]cloud115.RemoteFile, 0, totalCount) allFiles := make([]cloud115.RemoteFile, 0, totalCount)
@@ -819,9 +901,9 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
} }
fileCh := make(chan strmFileTask) fileCh := make(chan strmFileTask)
var ( var (
procWg sync.WaitGroup procWg sync.WaitGroup
procErrMu sync.Mutex procErrMu sync.Mutex
procErr error procErr error
) )
for i := 0; i < strmProcessWorkers; i++ { for i := 0; i < strmProcessWorkers; i++ {
procWg.Add(1) procWg.Add(1)
@@ -1049,25 +1131,21 @@ func (st *strmSyncState) localSha1Matches(path, remoteSha1 string) bool {
return strings.EqualFold(local, remoteSha1) return strings.EqualFold(local, remoteSha1)
} }
// recordRemoteMeta 记录远端存在的元数据索引、文件大小、文件引用及内容 SHA1。 // recordRemoteMeta 记录远端存在的元数据索引、文件大小、文件引用及内容 SHA1(多副本聚合追加)。
func (st *strmSyncState) recordRemoteMeta(entry cloud.FileEntry, rel string) { func (st *strmSyncState) recordRemoteMeta(entry cloud.FileEntry, rel string) {
st.mu.Lock() st.mu.Lock()
defer st.mu.Unlock()
if st.remoteMeta == nil { if st.remoteMeta == nil {
st.remoteMeta = map[string]int64{} st.remoteMeta = map[string][]remoteMetaItem{}
} }
if st.remoteMetaRef == nil { key := "m:" + rel
st.remoteMetaRef = map[string]string{} st.seenMeta[key] = true
} st.remoteMeta[key] = append(st.remoteMeta[key], remoteMetaItem{
if st.remoteMetaSha1 == nil { ID: entry.ID,
st.remoteMetaSha1 = map[string]string{} Size: entry.Size,
} Sha1: usableSha1(entry.Sha1),
st.seenMeta["m:"+rel] = true MTime: entry.MTime,
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()
} }
func (st *strmSyncState) flushPendingDownloads() { func (st *strmSyncState) flushPendingDownloads() {
@@ -1289,8 +1367,12 @@ func (st *strmSyncState) scanLocalMetaForUpload() error {
st.activeUploadPaths = map[string]bool{} 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) 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 { if err != nil {
return nil return nil
} }
@@ -1319,22 +1401,46 @@ func (st *strmSyncState) scanLocalMetaForUpload() error {
return nil return nil
} }
st.mu.Lock() st.mu.Lock()
remoteSize, exists := st.remoteMeta["m:"+rel] entries, exists := st.remoteMeta["m:"+rel]
remoteRef := st.remoteMetaRef["m:"+rel]
remoteSha1 := st.remoteMetaSha1["m:"+rel]
st.mu.Unlock() st.mu.Unlock()
if exists && remoteSize == info.Size() {
// 网盘端同名同大小:候选同一文件。115 提供远端 SHA1 时做内容级比对, if exists && len(entries) > 0 {
// 识别"同大小不同内容"(如 nfo 改一个字符长度不变)避免漏传; // 择优比对:只要远端存在任一副本的大小匹配且 SHA1 匹配(或无 SHA1),即判定远端已有最新副本,无需上传
// 无哈希(其他网盘/列表未返回)视为同一文件,保持大小比对兜底。 matchedIdx := -1
if remoteSha1 == "" || st.localSha1Matches(path, remoteSha1) { 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,稍后按目录批量删除
if len(entries) > 1 && st.p.Provider == model.StrmProvider115 {
parentCID := st.uploadRemoteTarget(rel)
if parentCID != "" {
for i, it := range entries {
if i != matchedIdx && it.ID != "" {
pendingDeletes[parentCID] = append(pendingDeletes[parentCID], it.ID)
}
}
}
}
return nil return nil
} }
} }
// 入队上传(以本地为准):网盘端不存在;或同名但大小不同(必然内容不同);
// 或同名同大小但 SHA1 不同(精确比对发现的同大小不同内容)。 remoteTarget := st.uploadRemoteTarget(rel)
// 115 的上传接口不保证同名覆盖,任务携带远端旧文件 ID(RemoteRef), if st.p.Provider == model.StrmProvider115 && remoteTarget == "" {
// 由上传端先删旧文件再上传;WebDAV/OpenList 的 PutFile 本身即覆盖上传。 // 115 远端不存在对应父目录(如孤儿子目录),禁止降级到根目录上传以防错位死循环
return nil
}
// 入队上传(以本地为准):网盘端不存在;或所有远端副本均内容不同。
// 115 的上传接口不保证同名覆盖,任务携带远端所有旧副本 ID(RemoteRef,以逗号连接),
// 由上传端一次性批量删除所有旧文件后再上传,彻底根除同名文件堆积。
st.mu.Lock() st.mu.Lock()
if st.activeUploadPaths != nil && st.activeUploadPaths[path] { if st.activeUploadPaths != nil && st.activeUploadPaths[path] {
st.mu.Unlock() st.mu.Unlock()
@@ -1351,12 +1457,18 @@ func (st *strmSyncState) scanLocalMetaForUpload() error {
Provider: st.p.Provider, Provider: st.p.Provider,
FileName: filepath.Base(rel), FileName: filepath.Base(rel),
LocalPath: path, LocalPath: path,
RemotePath: st.uploadRemoteTarget(rel), RemotePath: remoteTarget,
Size: info.Size(), Size: info.Size(),
Status: model.StrmTaskPending, Status: model.StrmTaskPending,
} }
if exists && st.p.Provider == model.StrmProvider115 { if exists && len(entries) > 0 && st.p.Provider == model.StrmProvider115 {
task.RemoteRef = remoteRef var oldIDs []string
for _, it := range entries {
if it.ID != "" {
oldIDs = append(oldIDs, it.ID)
}
}
task.RemoteRef = strings.Join(oldIDs, ",")
} }
st.mu.Lock() st.mu.Lock()
st.pendingUploads = append(st.pendingUploads, task) st.pendingUploads = append(st.pendingUploads, task)
@@ -1369,6 +1481,51 @@ func (st *strmSyncState) scanLocalMetaForUpload() error {
} }
return nil 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 远端元数据目标路径 = 同步目录远端根 + 相对路径。 // remoteUploadPath 远端元数据目标路径 = 同步目录远端根 + 相对路径。
@@ -1398,9 +1555,9 @@ func (st *strmSyncState) uploadRemoteTarget(rel string) string {
if cid, ok := st.dirPathToID[dir]; ok && cid != "" { if cid, ok := st.dirPathToID[dir]; ok && cid != "" {
return cid return cid
} }
// 父目录未在缓存中(父目录可能本次未扫描到),降级为用户配置的同步根 cid, // 子目录在 115 远端不存在(本地孤儿子目录或远端已删除该分类文件夹),
// 由上传端尽力处理(可能失败记日志,不影响下载)。 // 返回空串,禁止降级回退到根目录上传以防污染根目录与错位死循环。
return st.p.RemotePath return ""
} }
return st.remoteUploadPath(rel) return st.remoteUploadPath(rel)
} }
+208 -50
View File
@@ -368,19 +368,18 @@ func TestScanLocalMetaForUpload(t *testing.T) {
} }
st := &strmSyncState{ st := &strmSyncState{
s: svc, s: svc,
ctx: context.Background(), ctx: context.Background(),
p: p, p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"jpg", "nfo"}}, cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"jpg", "nfo"}},
rec: &model.StrmSyncRecord{}, rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{}, seenMeta: map[string]bool{},
remoteMeta: map[string]int64{}, remoteMeta: map[string][]remoteMetaItem{},
remoteMetaRef: map[string]string{},
} }
// 模拟远端已存在 poster.jpg(与本地同一文件)和 tvshow.nfo(与本地不同) // 模拟远端已存在 poster.jpg(与本地同一文件)和 tvshow.nfo(与本地不同)
st.remoteMeta["m:动漫/poster.jpg"] = int64(len("poster-data")) st.remoteMeta["m:动漫/poster.jpg"] = []remoteMetaItem{{ID: "f1", Size: int64(len("poster-data"))}}
st.remoteMeta["m:动漫/tvshow.nfo"] = 999 st.remoteMeta["m:动漫/tvshow.nfo"] = []remoteMetaItem{{ID: "f2", Size: 999}}
if err := st.scanLocalMetaForUpload(); err != nil { if err := st.scanLocalMetaForUpload(); err != nil {
t.Fatalf("scanLocalMetaForUpload failed: %v", err) t.Fatalf("scanLocalMetaForUpload failed: %v", err)
@@ -442,17 +441,15 @@ func TestScanLocalMetaForUpload115CarriesRemoteRef(t *testing.T) {
} }
st := &strmSyncState{ st := &strmSyncState{
s: svc, s: svc,
ctx: context.Background(), ctx: context.Background(),
p: p, p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"jpg", "nfo"}}, cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"jpg", "nfo"}},
rec: &model.StrmSyncRecord{}, rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{}, seenMeta: map[string]bool{},
remoteMeta: map[string]int64{}, remoteMeta: map[string][]remoteMetaItem{},
remoteMetaRef: map[string]string{},
} }
st.remoteMeta["m:movie.nfo"] = 1 st.remoteMeta["m:movie.nfo"] = []remoteMetaItem{{ID: "file-42", Size: 1}}
st.remoteMetaRef["m:movie.nfo"] = "file-42"
if err := st.scanLocalMetaForUpload(); err != nil { if err := st.scanLocalMetaForUpload(); err != nil {
t.Fatalf("scanLocalMetaForUpload failed: %v", err) t.Fatalf("scanLocalMetaForUpload failed: %v", err)
@@ -522,7 +519,7 @@ func TestPruneLocalKeepsLocalMeta(t *testing.T) {
syncType: model.StrmSyncTypeFull, syncType: model.StrmSyncTypeFull,
seenVideo: map[string]bool{}, seenVideo: map[string]bool{},
seenMeta: map[string]bool{}, seenMeta: map[string]bool{},
remoteMeta: map[string]int64{}, remoteMeta: map[string][]remoteMetaItem{},
} }
// 本次远端扫描既没有看到视频,也没有看到任何元数据 // 本次远端扫描既没有看到视频,也没有看到任何元数据
if err := st.pruneLocal(); err != nil { if err := st.pruneLocal(); err != nil {
@@ -569,8 +566,7 @@ func TestHandleMetaKeepsLocalWhenUploadEnabled(t *testing.T) {
cfg: &strmPathConfig{DownloadMeta: true, UploadMeta: true, MetaExt: []string{"nfo"}}, cfg: &strmPathConfig{DownloadMeta: true, UploadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{}, rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{}, seenMeta: map[string]bool{},
remoteMeta: map[string]int64{}, remoteMeta: map[string][]remoteMetaItem{},
remoteMetaRef: map[string]string{},
seenMetaTarget: map[string]cloud.FileEntry{}, seenMetaTarget: map[string]cloud.FileEntry{},
} }
@@ -630,22 +626,21 @@ func TestScanLocalMetaForUploadSha1Identity(t *testing.T) {
UploadMeta: true, UploadMeta: true,
} }
st := &strmSyncState{ st := &strmSyncState{
s: svc, s: svc,
ctx: context.Background(), ctx: context.Background(),
p: p, p: p,
cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"nfo"}}, cfg: &strmPathConfig{UploadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{}, rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{}, seenMeta: map[string]bool{},
remoteMeta: map[string]int64{}, remoteMeta: map[string][]remoteMetaItem{},
remoteMetaRef: map[string]string{},
remoteMetaSha1: map[string]string{},
} }
// 115 返回大写 SHA1,本地计算为小写:同时验证大小写不敏感比对 // 115 返回大写 SHA1,本地计算为小写:同时验证大小写不敏感比对
st.remoteMeta["m:same.nfo"] = int64(len("same-content")) st.remoteMeta["m:same.nfo"] = []remoteMetaItem{
st.remoteMetaSha1["m:same.nfo"] = strings.ToUpper(sameSha) {ID: "same-1", Size: int64(len("same-content")), Sha1: strings.ToUpper(sameSha)},
st.remoteMeta["m:diff.nfo"] = int64(len("diff-content")) }
st.remoteMetaSha1["m:diff.nfo"] = strings.ToUpper(otherSha) st.remoteMeta["m:diff.nfo"] = []remoteMetaItem{
st.remoteMetaRef["m:diff.nfo"] = "old-diff-1" {ID: "old-diff-1", Size: int64(len("diff-content")), Sha1: strings.ToUpper(otherSha)},
}
if err := st.scanLocalMetaForUpload(); err != nil { if err := st.scanLocalMetaForUpload(); err != nil {
t.Fatalf("scanLocalMetaForUpload failed: %v", err) t.Fatalf("scanLocalMetaForUpload failed: %v", err)
@@ -703,9 +698,7 @@ func TestHandleMetaSha1Identity(t *testing.T) {
cfg: &strmPathConfig{DownloadMeta: true, UploadMeta: false, MetaExt: []string{"nfo"}}, cfg: &strmPathConfig{DownloadMeta: true, UploadMeta: false, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{}, rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{}, seenMeta: map[string]bool{},
remoteMeta: map[string]int64{}, remoteMeta: map[string][]remoteMetaItem{},
remoteMetaRef: map[string]string{},
remoteMetaSha1: map[string]string{},
seenMetaTarget: map[string]cloud.FileEntry{}, seenMetaTarget: map[string]cloud.FileEntry{},
} }
@@ -802,7 +795,7 @@ func TestWalkRemoteConcurrent(t *testing.T) {
rec: &model.StrmSyncRecord{}, rec: &model.StrmSyncRecord{},
seenVideo: map[string]bool{}, seenVideo: map[string]bool{},
seenMeta: map[string]bool{}, seenMeta: map[string]bool{},
remoteMeta: map[string]int64{}, remoteMeta: map[string][]remoteMetaItem{},
} }
if err := st.walkRemote(); err != nil { if err := st.walkRemote(); err != nil {
t.Fatalf("walkRemote failed: %v", err) t.Fatalf("walkRemote failed: %v", err)
@@ -911,7 +904,7 @@ func TestStrmDuplicateFileConflictResolution(t *testing.T) {
cfg: &strmPathConfig{DownloadMeta: true, MetaExt: []string{"nfo"}}, cfg: &strmPathConfig{DownloadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{}, rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{}, seenMeta: map[string]bool{},
remoteMeta: map[string]int64{}, remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{}, seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: 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"}}, cfg: &strmPathConfig{DownloadMeta: true, MetaExt: []string{"nfo"}},
rec: &model.StrmSyncRecord{}, rec: &model.StrmSyncRecord{},
seenMeta: map[string]bool{}, seenMeta: map[string]bool{},
remoteMeta: map[string]int64{}, remoteMeta: map[string][]remoteMetaItem{},
seenMetaTarget: map[string]cloud.FileEntry{}, seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: map[string]cloud.FileEntry{}, seenVideoTarget: map[string]cloud.FileEntry{},
} }
@@ -1019,7 +1012,7 @@ func TestWalk115FlatAbortsOnDirResolveFailure(t *testing.T) {
dirCache: sync.Map{}, dirCache: sync.Map{},
seenVideo: map[string]bool{}, seenVideo: map[string]bool{},
seenMeta: map[string]bool{}, seenMeta: map[string]bool{},
remoteMeta: map[string]int64{}, remoteMeta: map[string][]remoteMetaItem{},
} }
err := st.walk115Flat(oc) err := st.walk115Flat(oc)
if err == nil { if err == nil {
@@ -1088,9 +1081,7 @@ func TestWalk115FlatConcurrentProcessing(t *testing.T) {
dirCache: sync.Map{}, dirCache: sync.Map{},
seenVideo: map[string]bool{}, seenVideo: map[string]bool{},
seenMeta: map[string]bool{}, seenMeta: map[string]bool{},
remoteMeta: map[string]int64{}, remoteMeta: map[string][]remoteMetaItem{},
remoteMetaRef: map[string]string{},
remoteMetaSha1: map[string]string{},
seenMetaTarget: map[string]cloud.FileEntry{}, seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: map[string]cloud.FileEntry{}, seenVideoTarget: map[string]cloud.FileEntry{},
} }
@@ -1145,9 +1136,7 @@ func TestWalk115FlatConcurrentProcessing(t *testing.T) {
dirCache: sync.Map{}, dirCache: sync.Map{},
seenVideo: map[string]bool{}, seenVideo: map[string]bool{},
seenMeta: map[string]bool{}, seenMeta: map[string]bool{},
remoteMeta: map[string]int64{}, remoteMeta: map[string][]remoteMetaItem{},
remoteMetaRef: map[string]string{},
remoteMetaSha1: map[string]string{},
seenMetaTarget: map[string]cloud.FileEntry{}, seenMetaTarget: map[string]cloud.FileEntry{},
seenVideoTarget: map[string]cloud.FileEntry{}, seenVideoTarget: map[string]cloud.FileEntry{},
} }
@@ -1161,3 +1150,172 @@ func TestWalk115FlatConcurrentProcessing(t *testing.T) {
t.Fatalf("incremental sync should skip all, new = %d skipped = %d", st2.rec.NewStrm, st2.rec.Skipped) 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)
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
// hardlink 硬链接(同盘零额外占用,保留源文件;做种不受影响) // hardlink 硬链接(同盘零额外占用,保留源文件;做种不受影响)
// symlink 软链接(保留源文件,指向源) // symlink 软链接(保留源文件,指向源)
// //
// 除 move 外,其余方式都保留源文件,因此 qBittorrent 等下载器仍能在原 // 除 move 外,其余方式都保留源文件,因此下载器仍能在原
// 路径找到数据继续做种上传。 // 路径找到数据继续做种上传。
package service package service
+6
View File
@@ -80,6 +80,12 @@ export const strmAPI = {
.get<StrmRemoteEntry[]>(`/admin/strm/accounts/${accountId}/list`, { params: { dir } }) .get<StrmRemoteEntry[]>(`/admin/strm/accounts/${accountId}/list`, { params: { dir } })
.then((r) => r.data), .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: () => list115Sources: () =>
api.get<Strm115Sources>('/admin/strm/115/sources').then((r) => r.data), api.get<Strm115Sources>('/admin/strm/115/sources').then((r) => r.data),
+1 -1
View File
@@ -425,7 +425,7 @@ export function AdultSettingsPanel() {
<span className="text-sm font-semibold text-ink-600">MetaTube 后端配置</span> <span className="text-sm font-semibold text-ink-600">MetaTube 后端配置</span>
</div> </div>
<a <a
href="https://github.com/metatube-community/metatube-server" href="https://metatube-community.github.io/README_ZH/"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="text-xs text-brand-500 hover:underline flex items-center gap-1" className="text-xs text-brand-500 hover:underline flex items-center gap-1"
+161 -45
View File
@@ -29,7 +29,7 @@ import {
normalizeEmbyRemoteLines, normalizeEmbyRemoteLines,
type EmbyRemoteLine, type EmbyRemoteLine,
} from '../utils/embyRemoteLines' } 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({ export function StrmSyncPathDialog({
accounts, accounts,
existing, existing,
onClose, onClose,
onSaved, onSaved,
onOpenSettings,
}: { }: {
accounts: StrmAccount[] accounts: StrmAccount[]
existing: StrmSyncPath | null existing: StrmSyncPath | null
onClose: () => void onClose: () => void
onSaved: () => void onSaved: () => void
onOpenSettings?: () => void
}) { }) {
const [form, setForm] = useState<StrmSyncPathInput>(() => ({ const [form, setForm] = useState<StrmSyncPathInput>(() => ({
name: existing?.name ?? '', name: existing?.name ?? '',
provider: existing?.provider ?? 'cloud115', provider: existing?.provider ?? 'cloud115',
account_id: existing?.account_id ?? '', account_id: existing?.account_id ?? '',
remote_path: existing?.remote_path ?? '', remote_path: existing?.remote_path ?? '',
remote_display_path: existing?.remote_display_path ?? '',
local_path: existing?.local_path ?? '', local_path: existing?.local_path ?? '',
strm_base_url: existing?.strm_base_url ?? '', strm_base_url: existing?.strm_base_url ?? '',
video_ext: existing?.video_ext ?? '', video_ext: existing?.video_ext ?? '',
@@ -607,22 +618,68 @@ export function StrmSyncPathDialog({
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [browsing, setBrowsing] = useState(false) const [browsing, setBrowsing] = useState(false)
const [browsingLocal, setBrowsingLocal] = useState<null | 'remote_path' | 'local_path'>(null) 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]) => const set = <K extends keyof StrmSyncPathInput>(key: K, value: StrmSyncPathInput[K]) =>
setForm((f) => ({ ...f, [key]: value })) setForm((f) => ({ ...f, [key]: value }))
const updateRemotePath = (remotePath: string) => { const updateRemotePath = (remotePath: string, displayPath?: string) => {
const shown = (displayPath || remotePath).trim()
setRemoteInputValue(shown)
setForm((f) => { 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 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) => { const commitLocalPath = (localPath: string) => {
setForm((f) => { 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 prevRemoteTailRef.current = synced.remoteTail
return { ...f, local_path: synced.localPath } return { ...f, local_path: synced.localPath }
}) })
@@ -630,11 +687,30 @@ export function StrmSyncPathDialog({
const isLocal = form.provider === 'local' const isLocal = form.provider === 'local'
const availableAccounts = accounts.filter((a) => a.provider === form.provider && a.has_credential) 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) => { const submit = async (event: FormEvent) => {
event.preventDefault() event.preventDefault()
setSaving(true) setSaving(true)
try { try {
if (!existing) {
const settings = await strmAPI.getSettings()
if (!settings?.['strm.base_url']?.trim()) {
toast.error('未填写strm地址')
onClose()
onOpenSettings?.()
return
}
}
if (existing) { if (existing) {
await strmAPI.updatePath(existing.id, form) await strmAPI.updatePath(existing.id, form)
toast.success('同步目录已更新') toast.success('同步目录已更新')
@@ -671,6 +747,9 @@ export function StrmSyncPathDialog({
const provider = e.target.value as StrmProvider const provider = e.target.value as StrmProvider
set('provider', provider) set('provider', provider)
set('account_id', '') set('account_id', '')
set('remote_path', '')
set('remote_display_path', '')
setRemoteInputValue('')
}} }}
> >
<option value="cloud115">115 网盘</option> <option value="cloud115">115 网盘</option>
@@ -707,17 +786,39 @@ export function StrmSyncPathDialog({
isLocal isLocal
? '扫描该目录下的视频生成 strm' ? '扫描该目录下的视频生成 strm'
: form.provider === 'cloud115' : form.provider === 'cloud115'
? '115 目录 ID(可通过浏览选择)' ? form.remote_path
? `115 目录 ID:${form.remote_path}(可通过右侧「浏览」选择更换)`
: '可通过右侧「浏览」选择 115 目录,或直接粘贴目录 ID'
: '远端路径(可通过浏览选择)' : '远端路径(可通过浏览选择)'
} }
> >
<input <input
className={inputCls} className={inputCls}
value={form.remote_path} value={remoteInputValue}
placeholder={isLocal ? 'D:\\movies' : '/'} placeholder={isLocal ? 'D:\\movies' : form.provider === 'cloud115' ? '点击右侧「浏览」选择目录' : '/'}
onChange={(e) => set('remote_path', e.target.value)} onChange={(e) => setRemoteInputValue(e.target.value)}
onBlur={(e) => { 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> </Field>
@@ -745,8 +846,8 @@ export function StrmSyncPathDialog({
<Field <Field
label="本地输出目录" label="本地输出目录"
hint={ hint={
!isLocal && lastPathSegment(form.remote_path) !isLocal && remoteTail
? `将自动拼接远端末级目录「${lastPathSegment(form.remote_path)}」` ? `将自动拼接远端末级目录「${remoteTail}」`
: '生成的 .strm 与下载的元数据写到这里的对应目录结构下' : '生成的 .strm 与下载的元数据写到这里的对应目录结构下'
} }
> >
@@ -774,20 +875,6 @@ export function StrmSyncPathDialog({
<details className="rounded-2xl border border-gray-100 bg-gray-50/50 p-3"> <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> <summary className="cursor-pointer text-sm font-semibold text-ink-600">高级选项</summary>
<div className="mt-3 space-y-3"> <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"> <div className="grid gap-3 md:grid-cols-3">
<Field label="视频扩展名(覆盖全局)"> <Field label="视频扩展名(覆盖全局)">
<input className={inputCls} value={form.video_ext ?? ''} placeholder="mkv,mp4,avi" onChange={(e) => set('video_ext', e.target.value)} /> <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> </Field>
</div> </div>
<div className="grid gap-3 md:grid-cols-3"> <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 参数"> <Field label="STRM 链接 path 参数">
<select className={inputCls} value={form.add_path ?? 1} onChange={(e) => set('add_path', Number(e.target.value))}> <select className={inputCls} value={form.add_path ?? 1} onChange={(e) => set('add_path', Number(e.target.value))}>
<option value={1}>完整远端路径</option> <option value={1}>完整远端路径</option>
@@ -813,10 +909,10 @@ export function StrmSyncPathDialog({
<option value="full">全量同步(全量校验)</option> <option value="full">全量同步(全量校验)</option>
</select> </select>
</Field> </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> </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"> <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.download_meta ?? true} onChange={(v) => set('download_meta', v)} />
<ToggleRow label="上传元数据" checked={form.upload_meta ?? false} onChange={(v) => set('upload_meta', v)} /> <ToggleRow label="上传元数据" checked={form.upload_meta ?? false} onChange={(v) => set('upload_meta', v)} />
@@ -842,8 +938,8 @@ export function StrmSyncPathDialog({
<StrmDirBrowserDialog <StrmDirBrowserDialog
accountId={form.account_id} accountId={form.account_id}
initialDir={form.remote_path || undefined} initialDir={form.remote_path || undefined}
onSelect={(id) => { onSelect={(id, _name, fullPath) => {
updateRemotePath(id) updateRemotePath(id, form.provider === 'cloud115' ? fullPath : undefined)
setBrowsing(false) setBrowsing(false)
}} }}
onClose={() => 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({ function StrmDirBrowserDialog({
@@ -892,25 +994,28 @@ function StrmDirBrowserDialog({
}: { }: {
accountId: string accountId: string
initialDir?: string initialDir?: string
onSelect: (path: string) => void onSelect: (id: string, name?: string, fullPath?: string) => void
onClose: () => void onClose: () => void
}) { }) {
const [dir, setDir] = useState(initialDir ?? '') const [dir, setDir] = useState(initialDir ?? '')
const [crumbs, setCrumbs] = useState<string[]>([])
const [entries, setEntries] = useState<StrmRemoteEntry[]>([]) const [entries, setEntries] = useState<StrmRemoteEntry[]>([])
const [loading, setLoading] = useState(true) 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 loadSeqRef = useRef(0)
const load = async (target: string) => { const load = async (target: string, nextChain?: { id: string; name: string }[]) => {
const seq = ++loadSeqRef.current const seq = ++loadSeqRef.current
setLoading(true) setLoading(true)
try { try {
const list = await strmAPI.listRemoteDir(accountId, target) const list = await strmAPI.listRemoteDir(accountId, target)
if (seq !== loadSeqRef.current) return if (seq !== loadSeqRef.current) return
if (nextChain) dirChainRef.current = nextChain
setEntries(list) setEntries(list)
setDir(target) setDir(target)
setCrumbs(target ? target.split('/').filter(Boolean) : []) setDirChain([...dirChainRef.current])
} catch (err) { } catch (err) {
if (seq !== loadSeqRef.current) return if (seq !== loadSeqRef.current) return
toast.error(apiErrorMessage(err)) toast.error(apiErrorMessage(err))
@@ -924,7 +1029,13 @@ function StrmDirBrowserDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [accountId]) }, [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 ( return (
<div <div
@@ -944,13 +1055,13 @@ function StrmDirBrowserDialog({
</button> </button>
</div> </div>
<div className="flex items-center gap-1 border-b border-gray-100 px-6 py-2.5 text-xs text-sand-500"> <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> </button>
{crumbs.map((crumb, index) => ( {(dirChain.length > 0 ? dirChain.map((item) => item.name) : dir.split('/').filter(Boolean)).map((label, index) => (
<span key={crumb + index} className="flex items-center gap-1"> <span key={label + index} className="flex items-center gap-1">
<ChevronRight size={12} /> <ChevronRight size={12} />
<span>{crumb}</span> <span>{label}</span>
</span> </span>
))} ))}
<span className="ml-2 text-ink-50">{dir || '(根目录 / 0)'}</span> <span className="ml-2 text-ink-50">{dir || '(根目录 / 0)'}</span>
@@ -971,8 +1082,8 @@ function StrmDirBrowserDialog({
key={entry.id} key={entry.id}
type="button" 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" 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)} onClick={() => (entry.is_dir ? enterDir(entry) : undefined)}
onDoubleClick={() => entry.is_dir && enterDir(entry.id)} onDoubleClick={() => entry.is_dir && enterDir(entry)}
> >
<Icon size={16} className={entry.is_dir ? 'text-brand-400' : 'text-sand-400'} /> <Icon size={16} className={entry.is_dir ? 'text-brand-400' : 'text-sand-400'} />
<span className="flex-1 truncate text-ink-600">{entry.name}</span> <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" 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) => { onClick={(e) => {
e.stopPropagation() 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>
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-3"> <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> <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> </button>
</div> </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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4"> <header className="flex flex-wrap items-start justify-between gap-4">
@@ -186,7 +202,7 @@ export function StrmManagePage() {
<Settings size={16} /> <Settings size={16} />
STRM 设置 STRM 设置
</button> </button>
<button type="button" onClick={() => { setEditingPath(null); setDialog('path') }} className="neon-button"> <button type="button" onClick={openAddPathDialog} className="neon-button">
<FolderPlus size={16} /> <FolderPlus size={16} />
添加同步目录 添加同步目录
</button> </button>
@@ -210,7 +226,7 @@ export function StrmManagePage() {
<SyncPathSection <SyncPathSection
paths={paths} paths={paths}
actingPath={actingPath} actingPath={actingPath}
onAdd={() => { setEditingPath(null); setDialog('path') }} onAdd={openAddPathDialog}
onEdit={(path) => { setEditingPath(path); setDialog('path') }} onEdit={(path) => { setEditingPath(path); setDialog('path') }}
onDelete={deletePath} onDelete={deletePath}
onStart={startSync} onStart={startSync}
@@ -236,6 +252,7 @@ export function StrmManagePage() {
existing={editingPath} existing={editingPath}
onClose={() => setDialog(null)} onClose={() => setDialog(null)}
onSaved={() => { setDialog(null); refresh().catch(() => undefined) }} onSaved={() => { setDialog(null); refresh().catch(() => undefined) }}
onOpenSettings={() => setDialog('settings')}
/> />
)} )}
</div> </div>
@@ -442,7 +459,12 @@ function SyncPathSection({
</div> </div>
</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"> <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="text-sand-500">→</span>
<span className="max-w-[45%] truncate">{path.local_path}</span> <span className="max-w-[45%] truncate">{path.local_path}</span>
</div> </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 './api'
export * from './auth' export * from './auth'
export * from './downloads'
export * from './events' export * from './events'
export * from './history' export * from './history'
export * from './library' export * from './library'
+2
View File
@@ -59,6 +59,7 @@ export interface StrmSyncPath {
account_id: string account_id: string
provider: StrmProvider provider: StrmProvider
remote_path: string remote_path: string
remote_display_path?: string
local_path: string local_path: string
strm_base_url: string strm_base_url: string
video_ext: string video_ext: string
@@ -86,6 +87,7 @@ export interface StrmSyncPathInput {
account_id?: string account_id?: string
provider: StrmProvider provider: StrmProvider
remote_path: string remote_path: string
remote_display_path?: string
local_path: string local_path: string
strm_base_url?: string strm_base_url?: string
video_ext?: string video_ext?: string
+20 -2
View File
@@ -6,6 +6,23 @@ export function lastPathSegment(path: string): string {
return parts[parts.length - 1] ?? '' 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): '/' | '\\' { function pathSeparator(path: string): '/' | '\\' {
return path.includes('\\') ? '\\' : '/' return path.includes('\\') ? '\\' : '/'
} }
@@ -30,13 +47,14 @@ function stripRemoteTail(localPath: string, prevRemoteTail: string): string {
return trimmed return trimmed
} }
/** 将远端最后一级目录名拼到本地输出目录末尾;远端变更时替换旧尾段。 */ /** 将远端最后一级目录名拼到本地输出目录末尾;远端变更时替换旧尾段。tailName 为浏览时拿到的目录名。 */
export function syncLocalPathWithRemote( export function syncLocalPathWithRemote(
localPath: string, localPath: string,
remotePath: string, remotePath: string,
prevRemoteTail = '', prevRemoteTail = '',
tailName?: string,
): { localPath: string; remoteTail: string } { ): { localPath: string; remoteTail: string } {
const remoteTail = lastPathSegment(remotePath) const remoteTail = resolveRemoteTailName(remotePath, tailName)
const base = stripRemoteTail(localPath, prevRemoteTail) const base = stripRemoteTail(localPath, prevRemoteTail)
if (!remoteTail) { if (!remoteTail) {
return { localPath: base || localPath.trim(), remoteTail: '' } return { localPath: base || localPath.trim(), remoteTail: '' }