Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a71a18ce82 | |||
| 5a189a44fc | |||
| fb84c62e9a | |||
| 355fd06036 | |||
| 4173caac5d | |||
| 2aeedcc182 | |||
| d96782622d | |||
| ebe425036b | |||
| edbaa1c84b | |||
| 0179332013 | |||
| 7fb3db0f4c | |||
| e4a101b502 | |||
| 28551c7883 | |||
| a826ed9613 | |||
| 030ed5f325 | |||
| 60308800fb | |||
| 582495dece | |||
| 2a4545eb44 | |||
| 2bde71099e |
@@ -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 "==== 部署完成并已启动 ===="
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,海报墙开箱即用**
|
||||||
|
|
||||||
|

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

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

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

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

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

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

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

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

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

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

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

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

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用教程:从零到海报墙只要 5 步
|
||||||
|
|
||||||
|
### 第 1 步:Docker 一键部署
|
||||||
|
|
||||||
|
推荐 Docker Compose(仓库提供 4 份互相独立的完整模板,无需 `.env`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p MeBox && cd MeBox
|
||||||
|
|
||||||
|
# 最省心:单镜像 + 内置 SQLite
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/truewhile/MeBox/main/docker-compose.simple.yml -o docker-compose.yml
|
||||||
|
|
||||||
|
# 多用户/大数据量可选 PostgreSQL 档、Redis 档、OpenSearch 档,见仓库 README「部署档位」
|
||||||
|
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器访问 `http://服务器IP:18080`,镜像:`ghcr.io/truewhile/mebox:latest`(amd64 / arm64 都有,也提供 Windows/Linux/macOS 单文件可执行程序,不想装 Docker 直接下载跑)。
|
||||||
|
|
||||||
|
### 第 2 步:登录并修改密码
|
||||||
|
|
||||||
|
默认账号 `admin / admin123`,登录后右上角头像 → 个人资料修改密码。
|
||||||
|
|
||||||
|
### 第 3 步:创建媒体库 + 扫库
|
||||||
|
|
||||||
|
后台 → 媒体库 → 管理媒体库,添加本地路径(Docker 部署记得填**容器内**路径,如 `/media/电影`,`volumes` 左侧挂宿主机真实目录)→ 执行扫库。
|
||||||
|
|
||||||
|
### 第 4 步:配置元数据刮削
|
||||||
|
|
||||||
|
系统设置 → 外部 API,填入 TMDb / Bangumi / Douban 等 API Key;媒体库页可对单个库「全库修复+重刮」,刮削进度在任务队列实时可见。
|
||||||
|
|
||||||
|
### 第 5 步(可选但强烈推荐):
|
||||||
|
|
||||||
|
- **网盘用户**:STRM 管理 → 添加网盘账号(115 可扫码)→ 添加同步目录 → 生成 STRM 后直链播放;
|
||||||
|
- **已有 Emby**:Emby 挂载 → 添加 Emby 账号 → 勾选要挂载的媒体库;
|
||||||
|
- **第三方播放器(Emby 客户端全兼容)**:Infuse / SenPlayer / Fileball / Emby、Jellyfin 官方客户端,按「添加 Emby 服务器」填 `http://服务器IP:18080`,用 MeBox 账号登录即可,原有使用习惯完全不变;
|
||||||
|
- **下载党**:qBittorrent 等任意下载器把视频下到下载目录,在文件管理里把它设为整理源,下完自动分类入库。
|
||||||
|
|
||||||
|
### 路径映射小抄(Docker 最常见坑)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- /vol1/1000/Media:/media # 左:宿主机真实路径;右:容器内路径(网页里填这个)
|
||||||
|
environment:
|
||||||
|
MEBOX_MEDIA_DIR: /vol1/1000/Media
|
||||||
|
MEBOX_MEDIA_CONTAINER_DIR: /media
|
||||||
|
```
|
||||||
|
|
||||||
|
硬链接要求同一文件系统/子卷,跨盘请改复制或软链。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 部署档位怎么选?
|
||||||
|
|
||||||
|
| 档位 | 文件 | 组件 | 适合 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 极简 | `docker-compose.simple.yml` | 单镜像 + SQLite | 个人使用、低配设备 |
|
||||||
|
| 标准 | `docker-compose.yml` | + PostgreSQL | 多用户家庭共享 |
|
||||||
|
| 增强 | `docker-compose.standard.yml` | + Redis | 大媒体库高频访问 |
|
||||||
|
| 搜索 | `docker-compose.search.yml` | + OpenSearch | 超大库全文搜索 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术栈与致谢
|
||||||
|
|
||||||
|
- 后端:Go · Gin · GORM · SQLite/PostgreSQL · 可选 Redis / OpenSearch
|
||||||
|
- 前端:React 18 · Vite · TypeScript · Tailwind CSS · Zustand
|
||||||
|
- 部署:Docker Compose 多档模板,amd64/arm64 镜像 + 单文件可执行
|
||||||
|
|
||||||
|
感谢上游 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) 的奠基,网盘同步/STRM/整理部分参考了 [qmediasync](https://github.com/qicfan/qmediasync) 的思路。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 链接
|
||||||
|
|
||||||
|
- GitHub:https://github.com/truewhile/MeBox
|
||||||
|
- Issue / PR:欢迎提 bug(附部署方式+复现步骤+日志)与功能建议
|
||||||
|
- License:GPL-3.0
|
||||||
|
|
||||||
|
觉得有用的话求个 Star ⭐,也欢迎论坛里的朋友反馈使用体验,我长期维护。
|
||||||
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 620 KiB |
|
After Width: | Height: | Size: 743 KiB |
|
After Width: | Height: | Size: 793 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 612 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 376 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -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))
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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": "系统更新",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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.
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// hardlink 硬链接(同盘零额外占用,保留源文件;做种不受影响)
|
// hardlink 硬链接(同盘零额外占用,保留源文件;做种不受影响)
|
||||||
// symlink 软链接(保留源文件,指向源)
|
// symlink 软链接(保留源文件,指向源)
|
||||||
//
|
//
|
||||||
// 除 move 外,其余方式都保留源文件,因此 qBittorrent 等下载器仍能在原
|
// 除 move 外,其余方式都保留源文件,因此下载器仍能在原
|
||||||
// 路径找到数据继续做种上传。
|
// 路径找到数据继续做种上传。
|
||||||
package service
|
package service
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: '' }
|
||||||
|
|||||||