Compare commits

...

11 Commits

32 changed files with 538 additions and 336 deletions
+66 -107
View File
@@ -1,122 +1,78 @@
name: AuTo Docker Image
name: Build & Publish
# 版本策略(version 分支托管,main 零污染):
# - VERSION 文件单独存放在 version 分支,CI 构建时读取并自增写回 version 分支,
# main 分支不再出现任何 CI 提交,本地推送永不与远程冲突。
# - push 到 main:版本号自动 patch+1,发布 latest + 版本镜像、GitHub Release、
# 多平台单文件二进制,并部署服务器。
# - push tag v*:正式发版,版本号取 tag 名(不 bump version 分支),其余同上。
# - 手动触发:版本号在 version 分支当前值上自增,等同 push main 全量发布。
# 查看当前版本号:git show origin/version:VERSION
on:
push:
branches: [main]
tags: ['v*']
# 保留手动触发作为备选
workflow_dispatch:
inputs:
version_type:
description: '版本递增类型'
required: true
default: 'patch'
type: choice
options:
- patch # 0.0.x
- minor # 0.x.0
- major # x.0.0
permissions:
contents: write # 需要写入权限来更新版本文件
contents: write # 读写 version 分支、发布 Release 与上传二进制需要
packages: write
jobs:
version-and-publish:
build-image:
runs-on: ubuntu-latest
outputs:
new_version: ${{ steps.bump_version.outputs.new_version }}
tag: ${{ steps.bump_version.outputs.tag }}
version: ${{ steps.version.outputs.version }}
release_tag: ${{ steps.version.outputs.release_tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # 获取完整历史以便版本计算
token: ${{ secrets.GITHUB_TOKEN }}
# 1. 获取或初始化版本号
- name: Get current version
id: get_version
# 1. 解析版本号:tag 触发取 tag 名(去掉 v 前缀);其余场景读 version 分支并 patch+1
- name: Resolve version
id: version
run: |
# 从文件读取版本号,或使用默认值
if [ -f VERSION ]; then
CURRENT_VERSION=$(cat VERSION)
if [ "${{ github.ref_type }}" = "tag" ]; then
VERSION="${GITHUB_REF_NAME#v}"
else
CURRENT_VERSION="0.0.0"
echo $CURRENT_VERSION > VERSION
git fetch origin version
BASE=$(git show FETCH_HEAD:VERSION 2>/dev/null || echo "0.0.0")
MAJOR=$(echo "$BASE" | cut -d. -f1)
MINOR=$(echo "$BASE" | cut -d. -f2)
PATCH=$(echo "$BASE" | cut -d. -f3)
VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
fi
echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
# 分离版本组成部分
MAJOR=$(echo $CURRENT_VERSION | cut -d. -f1)
MINOR=$(echo $CURRENT_VERSION | cut -d. -f2)
PATCH=$(echo $CURRENT_VERSION | cut -d. -f3)
echo "major=$MAJOR" >> $GITHUB_OUTPUT
echo "minor=$MINOR" >> $GITHUB_OUTPUT
echo "patch=$PATCH" >> $GITHUB_OUTPUT
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "release_tag=mebox-v${VERSION}" >> "$GITHUB_OUTPUT"
echo "new_version=${VERSION}" >> "$GITHUB_OUTPUT"
# 2. 计算新版本号
- name: Bump version
id: bump_version
run: |
MAJOR=${{ steps.get_version.outputs.major }}
MINOR=${{ steps.get_version.outputs.minor }}
PATCH=${{ steps.get_version.outputs.patch }}
# 手动触发时根据选择递增
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TYPE="${{ github.event.inputs.version_type }}"
if [ "$TYPE" = "major" ]; then
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
elif [ "$TYPE" = "minor" ]; then
MINOR=$((MINOR + 1))
PATCH=0
else # patch
PATCH=$((PATCH + 1))
fi
else
# 自动触发时默认 patch 递增
PATCH=$((PATCH + 1))
fi
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "tag=MeBox-v${NEW_VERSION}" >> $GITHUB_OUTPUT
echo "tag=mebox-v${NEW_VERSION}" >> $GITHUB_OUTPUT
# 3. 更新 VERSION 文件
- name: Update version file
run: |
echo "${{ steps.bump_version.outputs.new_version }}" > VERSION
# 如果存在 go.mod,也更新其中的版本(可选)
# if [ -f go.mod ]; then
# sed -i "s/^version .*/version ${{ steps.bump_version.outputs.new_version }}/" go.mod
# fi
# 4. 提交版本变更
- name: Commit version bump
# 2. 把新版本号写回 version 分支(clone 单分支写入,冲突时 rebase 重试)
# tag 触发的正式发版版本号来自 tag 本身,跳过自增。
- name: Bump version branch
if: github.ref_type != 'tag'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
REPO="https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git"
git clone --depth 1 --branch version "$REPO" "$RUNNER_TEMP/version-branch"
cd "$RUNNER_TEMP/version-branch"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add VERSION
git commit -m "chore: bump version to ${{ steps.bump_version.outputs.new_version }} [skip ci]"
git push
echo "${{ steps.version.outputs.new_version }}" > VERSION
git commit -am "chore: bump version to ${{ steps.version.outputs.new_version }}"
ok=0
for i in 1 2 3 4 5; do
if git push origin version; then ok=1; break; fi
git pull --rebase origin version || true
sleep 5
done
[ "$ok" = "1" ] || { echo "::error::version 分支推送冲突,重试 5 次仍失败"; exit 1; }
# 5. 创建 Git Tag
- name: Create and push tag
run: |
TAG="${{ steps.bump_version.outputs.tag }}"
git tag $TAG
git push origin $TAG
# 6. 设置 Docker QEMU 和 Buildx
# 3. 设置 Docker QEMU 和 Buildx
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
# 7. 登录 GHCR
# 3. 登录 GHCR
- name: Log in to GHCR
uses: docker/login-action@v3
with:
@@ -124,7 +80,7 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# 8. 提取镜像元数据
# 4. 提取镜像元数据
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
@@ -132,10 +88,9 @@ jobs:
images: ghcr.io/${{ github.repository_owner }}/mebox
tags: |
type=raw,value=latest
type=raw,value=${{ steps.bump_version.outputs.tag }}
type=raw,value=${{ steps.bump_version.outputs.new_version }}
type=raw,value=${{ steps.version.outputs.version }}
# 9. 构建并推送
# 5. 构建并推送
- name: Build & push
uses: docker/build-push-action@v6
with:
@@ -147,13 +102,14 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.bump_version.outputs.new_version }}
VERSION=${{ steps.version.outputs.release_tag }}
cache-from: type=gha
cache-to: type=gha,mode=max
# 单文件可执行构建:把前端打包进二进制(go:embed),交叉编译 Windows /
# Linux / macOS 的 amd64 / arm64 产物,作为 GitHub Release 附件发布。
build-frontend:
needs: [build-image]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -178,7 +134,7 @@ jobs:
# 先创建(幂等)空的 GitHub Release,供后续 build-binaries 并行上传附件,
# 也避免矩阵各 job 并发 upload 时 release 尚不存在而互相竞争。
publish-create-release:
needs: [version-and-publish]
needs: [build-image]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -187,17 +143,21 @@ jobs:
- name: Create release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.version-and-publish.outputs.tag }}
RELEASE_TAG: ${{ needs.build-image.outputs.release_tag }}
run: |
set -eux
# tag 已由 version-and-publish 推送;若 release 已存在则忽略(--verify-tag 幂等)
# tag push 时 tag 已存在;手动触发时基于当前 main 创建 tag(幂等)
if ! git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then
git tag "$RELEASE_TAG"
git push origin "$RELEASE_TAG"
fi
gh release create "$RELEASE_TAG" \
--title "MeBox ${{ needs.version-and-publish.outputs.new_version }}" \
--notes "自动化发布 ${{ needs.version-and-publish.outputs.new_version }}" \
--title "MeBox ${{ needs.build-image.outputs.version }}" \
--notes "自动化发布 ${{ needs.build-image.outputs.version }}" \
--verify-tag --latest || true
build-binaries:
needs: [version-and-publish, build-frontend, publish-create-release]
needs: [build-image, build-frontend, publish-create-release]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -237,7 +197,7 @@ jobs:
- name: Build binary
run: |
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
go build -trimpath -ldflags="-s -w -X main.version=${{ needs.version-and-publish.outputs.tag }}" \
go build -trimpath -ldflags="-s -w -X main.version=${{ needs.build-image.outputs.release_tag }}" \
-o "dist/mebox-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server
- name: Package
run: |
@@ -252,7 +212,7 @@ jobs:
- name: Upload to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.version-and-publish.outputs.tag }}
RELEASE_TAG: ${{ needs.build-image.outputs.release_tag }}
run: |
set -eux
PKG="mebox_${{ matrix.goos }}_${{ matrix.goarch }}.zip"
@@ -267,7 +227,7 @@ jobs:
deploy:
name: Deploy to Server
needs: [version-and-publish]
needs: [build-image]
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
@@ -302,4 +262,3 @@ jobs:
docker image prune -f
echo "==== 部署完成并已启动 ===="
+4 -4
View File
@@ -50,7 +50,7 @@
| **Emby/Jellyfin 客户端兼容** | 内置完整 Emby 服务端协议实现:Infuse、SenPlayer、Fileball、Emby/Jellyfin 官方客户端等可直接把本服务当作 Emby 服务器添加,使用 MeBox 账号登录,海报墙、进度同步、多用户无缝衔接 |
| **远程 Emby 挂载** | 将远程 Emby 媒体库挂载到本地界面统一浏览(无需单独开 Emby 客户端) |
| **网盘与 STRM** | OpenList、CloudDrive2、115、WebDAV 等;STRM 同步、上传/下载队列、直链/302 播放 |
| **下载与整理** | qBittorrent 接入、站点搜索与订阅、下载后自动整理、文件管理器(复制/移动/硬链/软链) |
| **下载与整理** | 下载目录定时自动整理(智能分类、自动注册媒体库)、文件管理器(复制/移动/硬链/软链) |
| **用户与权限** | 管理员/普通用户、有效期、成人内容开关、播放配置 PIN、细粒度操作权限 |
| **运维能力** | 统一任务队列、存储统计、DLNA 投屏、系统设置与日志 |
@@ -182,7 +182,7 @@ environment:
1. **创建媒体库** → 填写 `/media/...` → 执行扫库
2. **配置元数据源** → 系统设置中添加 TMDb、Bangumi 等 API
3. **(可选)连接 qBittorrent** → 下载客户端设置,宿主机可用 `http://host.docker.internal:8085`
3. **(可选)配置下载目录自动整理** → 文件管理中将下载目录设为整理源,下载完成后自动分类入库
4. **(可选)配置网盘账号** → STRM 管理中添加 OpenList / 115 / WebDAV 等
5. **第三方播放器** → 以 Emby 服务器添加 `http://服务器IP:18080`,使用 MeBox 账号登录
@@ -193,8 +193,8 @@ environment:
**扫库或入库很慢?**
先确认路径映射与数据库档位。网盘扫描还受接口限速与目录规模影响;大库可考虑第二档 Redis 或第三档 OpenSearch。
**qBittorrent 下载后无法整理?**
确认下载目录已通过 `volumes` 挂进容器,且 `MEBOX_DOWNLOAD_*` 环境变量对应正确。
**下载目录文件没有被自动整理?**
确认下载目录已通过 `volumes` 挂进容器,且 `MEBOX_DOWNLOAD_*` 环境变量对应正确。MeBox 负责目录整理入库,qBittorrent 等下载器按普通软件自行部署即可。
**硬链接失败(cross-device link)?**
硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 btrfs 子卷或网盘挂载时请改用复制或软链接。
+4 -4
View File
@@ -49,7 +49,7 @@ In practice, MeBox gives you:
| **Emby/Jellyfin client compatible** | Full Emby server protocol implementation: Infuse, SenPlayer, Fileball, and official Emby/Jellyfin clients can add MeBox as an Emby server and sign in with MeBox accounts — poster walls, watch progress, and multi-user work out of the box |
| **Remote Emby mounts** | Browse remote Emby libraries inside MeBox without a separate Emby client |
| **Cloud & STRM** | OpenList, CloudDrive2, 115, WebDAV; STRM sync; upload/download queues; direct or 302 playback |
| **Downloads & organize** | qBittorrent, site search/subscriptions, post-download organization, file manager |
| **Downloads & organize** | Scheduled download-folder organization (smart classification, auto library registration), file manager (copy/move/hardlink/symlink) |
| **Users & permissions** | Admin/regular users, expiry, NSFW toggle, play-profile PIN, granular permissions |
| **Operations** | Unified task queue, storage stats, DLNA casting, settings and logs |
@@ -162,7 +162,7 @@ environment:
1. Create a library with a container path such as `/media/Movies`, then scan
2. Add metadata providers (TMDb, Bangumi, etc.) in system settings
3. Optionally connect qBittorrent (`http://host.docker.internal:8085` when qB runs on the host)
3. Optionally set up download-folder auto-organization under file management so finished downloads land in the right library
4. Optionally configure cloud accounts under STRM management
5. Add the server in Emby-compatible players at `http://SERVER_IP:18080` using MeBox credentials
@@ -173,8 +173,8 @@ environment:
**Library scan is slow**
Check path mapping and DB tier. Cloud scans also depend on API limits and folder size.
**qBittorrent downloads are not organized**
Ensure the download directory is mounted into the container and env vars match.
**Downloaded files are not organized**
Ensure the download directory is mounted into the container and env vars match. MeBox handles folder organization; run qBittorrent or any downloader yourself as a regular app.
**Hardlink fails with cross-device link**
Hardlinks require the same filesystem/subvolume; use copy or symlink across disks or cloud mounts.
-1
View File
@@ -1 +0,0 @@
0.1.12
+1 -1
View File
@@ -55,7 +55,7 @@ services:
bind:
create_host_path: false
# 下载目录。需要和 qBittorrent 保存路径保持一致。
# 下载目录。需要和下载器保存路径保持一致。
# NAS 示例:source: /vol1/1000/Downloads
# Windows Docker Desktop 示例:source: D:/Downloads
- type: bind
+1 -1
View File
@@ -69,7 +69,7 @@ services:
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
extra_hosts:
# 容器访问宿主机服务用,例如 qBittorrent: http://host.docker.internal:8085
# 容器访问宿主机服务(如下载器等)用: http://host.docker.internal:8085
- "host.docker.internal:host-gateway"
healthcheck:
+1 -1
View File
@@ -50,7 +50,7 @@ services:
bind:
create_host_path: false
# 下载目录。需要和 qBittorrent 保存路径保持一致。
# 下载目录。需要和下载器保存路径保持一致。
# NAS 示例:source: /vol1/1000/Downloads
# Windows Docker Desktop 示例:source: D:/Downloads
- type: bind
+1 -1
View File
@@ -29,7 +29,7 @@ services:
ports:
- "18080:8080"
# 让容器可以访问宿主机上的 qBittorrent。
# 让容器可以访问宿主机上的服务(如下载器)。
# qB 地址通常可填:http://host.docker.internal:8085
extra_hosts:
- "host.docker.internal:host-gateway"
+16 -16
View File
@@ -1,6 +1,6 @@
# 【开源推荐】MeBox:把 NAS / 网盘 / 远程 Emby 统一家里的观影入口,Docker 一键部署
> 配图位于 `docs/tutorial-screenshots/`,发帖时按文中标注的文件名上传即可。
> 配图已托管在 GitHub 仓库(`raw.githubusercontent.com` 直链),发帖时可直接引用,或下载 `docs/tutorial-screenshots/` 后作为附件上传。
---
@@ -34,27 +34,27 @@ GitHub:https://github.com/truewhile/MeBox
**1. 现代化 Web UI,海报墙开箱即用**
![登录页](tutorial-screenshots/01-login.png)
![登录页](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/01-login.png)
深色系登录页,默认账号 `admin / admin123`(首次登录请立即改密)。
![首页](tutorial-screenshots/02-home.png)
![首页](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/02-home.png)
首页自带焦点推荐轮播 + 媒体库入口卡片,继续观看、最近添加直接呈现。
**2. 媒体库与刮削**
![媒体库总览](tutorial-screenshots/03-libraries.png)
![媒体库总览](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/03-libraries.png)
20 个媒体库、1600+ 条目一眼尽收:每库自带封面拼贴、条目数统计,支持「全库修复+重刮」「刮削队列」批量处理。
![海报墙](tutorial-screenshots/04-library-posters.png)
![海报墙](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/04-library-posters.png)
库内海报墙带评分、集数角标,支持按最后集添加日期排序,点开即看。
**3. 详情页与多季管理**
![详情页](tutorial-screenshots/05-media-detail.png)
![详情页](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/05-media-detail.png)
剧情简介、类型标签、多季分集(特别篇/第 1-N 季)、每集缩略图与时长;一键立即播放、调用外部播放器、加入收藏。
@@ -64,47 +64,47 @@ GitHub:https://github.com/truewhile/MeBox
**5. 网页播放器 + 弹幕自动匹配**
![播放器与弹幕](tutorial-screenshots/06-player-danmu.png)
![播放器与弹幕](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/06-player-danmu.png)
内置网页播放器支持 HLS 转码、字幕、播放配置档;**弹幕按剧名自动匹配全季分集**(截图中自动匹配到《一拳超人》39 集),屏幕占比/透明度/字号随意调,追新番体验直接拉满。
**6. 网盘 STRM:网盘当本地盘用**
![STRM 管理](tutorial-screenshots/07-strm-cloud.png)
![STRM 管理](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/07-strm-cloud.png)
添加网盘账号(**115 支持二维码扫码登录**)→ 添加同步目录 → 系统把网盘/本地目录里的视频生成 `.strm` 文件,元数据经下载/上传队列双向同步,播放走直链/302 不落盘。
**7. 远程 Emby 挂载(特色功能)**
![Emby 挂载](tutorial-screenshots/08-emby-mount.png)
![Emby 挂载](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/08-emby-mount.png)
已有远程 Emby 服务器?填一次账号,按需勾选要挂载的媒体库(支持同服务器多线路自动切换、直连开关、排序),远程库直接出现在 MeBox 首页,不必再开 Emby 客户端。
**8. 任务队列统一管理**
![任务队列](tutorial-screenshots/09-task-queue.png)
![任务队列](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/09-task-queue.png)
刮削 / 下载 / 上传三类任务统一看板,排队中、进行中、已匹配、失败分类计数,支持搜索与批量清理。
**9. 下载与自动整理**
![文件管理](tutorial-screenshots/11-file-manager.png)
![文件管理](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/11-file-manager.png)
接入 qBittorrent,下载目录定时自动整理入媒体库:智能分类子库、自动注册目的地媒体库、复制/移动/硬链/软链多种整理方式,命名规则可配。
配合任意下载器(qBittorrent、Transmission 等下载到本地目录即可),MeBox 定时自动整理入媒体库:智能分类子库、自动注册目的地媒体库、复制/移动/硬链/软链多种整理方式,命名规则可配。
**10. 多用户与权限**
![用户管理](tutorial-screenshots/12-user-admin.png)
![用户管理](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/12-user-admin.png)
管理员/普通用户分级、单实例用户数上限、账号有效期、成人内容开关、播放配置 PIN——给家人开号放心给。
**11. 运维省心**
![系统设置](tutorial-screenshots/10-settings.png)
![系统设置](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/10-settings.png)
FFmpeg/FFprobe 一键下载安装、转码与硬件加速开关、TMDb 语言、识别词、弹幕、Adult/NSFW 开关全在设置页分组管理;另有 DLNA 投屏、存储统计、海报墙聚合视图:
![海报墙聚合](tutorial-screenshots/13-poster-wall.png)
![海报墙聚合](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/13-poster-wall.png)
---
@@ -144,7 +144,7 @@ docker compose up -d
- **网盘用户**:STRM 管理 → 添加网盘账号(115 可扫码)→ 添加同步目录 → 生成 STRM 后直链播放;
- **已有 Emby**:Emby 挂载 → 添加 Emby 账号 → 勾选要挂载的媒体库;
- **第三方播放器(Emby 客户端全兼容)**:Infuse / SenPlayer / Fileball / Emby、Jellyfin 官方客户端,按「添加 Emby 服务器」填 `http://服务器IP:18080`,用 MeBox 账号登录即可,原有使用习惯完全不变;
- **下载党**:设置里接入 qBittorrent,配置下载目录自动整理,下完即入库。
- **下载党**:qBittorrent 等任意下载器把视频下到下载目录,在文件管理里把它设为整理源,下完自动分类入库。
### 路径映射小抄(Docker 最常见坑)
+1
View File
@@ -62,6 +62,7 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.DELETE("/strm/accounts/:id", deleteStrmAccountHandler(svc))
admin.POST("/strm/accounts/:id/test", testStrmAccountHandler(svc))
admin.GET("/strm/accounts/:id/list", listStrmRemoteDirHandler(svc))
admin.GET("/strm/accounts/:id/resolve", resolveStrmRemoteDirHandler(svc))
admin.GET("/strm/115/sources", listStrm115SourcesHandler(svc))
admin.POST("/strm/accounts/:id/oauth/start", startStrm115OAuthHandler(svc))
admin.POST("/strm/accounts/:id/oauth/poll", pollStrm115OAuthHandler(svc))
+62 -36
View File
@@ -3,6 +3,7 @@
package handler
import (
"context"
"errors"
"net/http"
"net/url"
@@ -172,6 +173,19 @@ func listStrmRemoteDirHandler(svc *service.Container) gin.HandlerFunc {
}
}
// resolveStrmRemoteDirHandler 按远端目录引用(115 为目录 ID)反查完整展示路径。
func resolveStrmRemoteDirHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
dir := strings.TrimSpace(c.Query("dir"))
path, err := svc.Strm.ResolveRemoteDirPath(c.Request.Context(), c.Param("id"), dir)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"path": path})
}
}
// ─── 全局设置 ──────────────────────────────────────────────────────────────────
func getStrmSettingsHandler(svc *service.Container) gin.HandlerFunc {
@@ -203,24 +217,25 @@ func updateStrmSettingsHandler(svc *service.Container) gin.HandlerFunc {
// ─── 同步目录 ──────────────────────────────────────────────────────────────────
type strmSyncPathReq struct {
Name string `json:"name"`
AccountID string `json:"account_id"`
Provider string `json:"provider"`
RemotePath string `json:"remote_path"`
LocalPath string `json:"local_path"`
StrmBaseURL string `json:"strm_base_url"`
VideoExt string `json:"video_ext"`
MetaExt string `json:"meta_ext"`
ExcludeName string `json:"exclude_name"`
MinVideoSizeMB int64 `json:"min_video_size_mb"`
AddPath int `json:"add_path"`
DownloadMeta *bool `json:"download_meta"`
UploadMeta *bool `json:"upload_meta"`
DeleteDir *bool `json:"delete_dir"`
Cron string `json:"cron"`
EnableCron *bool `json:"enable_cron"`
SyncMode string `json:"sync_mode"`
Enabled *bool `json:"enabled"`
Name string `json:"name"`
AccountID string `json:"account_id"`
Provider string `json:"provider"`
RemotePath string `json:"remote_path"`
RemoteDisplayPath string `json:"remote_display_path"`
LocalPath string `json:"local_path"`
StrmBaseURL string `json:"strm_base_url"`
VideoExt string `json:"video_ext"`
MetaExt string `json:"meta_ext"`
ExcludeName string `json:"exclude_name"`
MinVideoSizeMB int64 `json:"min_video_size_mb"`
AddPath int `json:"add_path"`
DownloadMeta *bool `json:"download_meta"`
UploadMeta *bool `json:"upload_meta"`
DeleteDir *bool `json:"delete_dir"`
Cron string `json:"cron"`
EnableCron *bool `json:"enable_cron"`
SyncMode string `json:"sync_mode"`
Enabled *bool `json:"enabled"`
}
type strmSyncPathView struct {
@@ -240,6 +255,16 @@ func strmSyncPathViews(svc *service.Container, c *gin.Context, paths []model.Str
view.AccountEnabled = acct.Enabled
}
}
// 历史 115 数据若尚未记录展示路径,尝试反查一次并回写数据库自愈
if p.Provider == model.StrmProvider115 && strings.TrimSpace(p.RemoteDisplayPath) == "" && strings.TrimSpace(p.RemotePath) != "" && p.AccountID != "" {
resolveCtx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
if fullPath, err := svc.Strm.ResolveRemoteDirPath(resolveCtx, p.AccountID, p.RemotePath); err == nil && fullPath != "" {
view.RemoteDisplayPath = fullPath
p.RemoteDisplayPath = fullPath
_ = svc.Repo.StrmSyncPath.Update(context.Background(), &p)
}
cancel()
}
out = append(out, view)
}
return out
@@ -663,24 +688,25 @@ func strmPlayHandler(svc *service.Container) gin.HandlerFunc {
// strmSyncPathFromReq 组装同步目录模型(缺省值交给服务层处理)。
func strmSyncPathFromReq(req strmSyncPathReq) *model.StrmSyncPath {
return &model.StrmSyncPath{
Name: strings.TrimSpace(req.Name),
AccountID: strings.TrimSpace(req.AccountID),
Provider: strings.TrimSpace(req.Provider),
RemotePath: strings.TrimSpace(req.RemotePath),
LocalPath: strings.TrimSpace(req.LocalPath),
StrmBaseURL: strings.TrimSpace(req.StrmBaseURL),
VideoExt: req.VideoExt,
MetaExt: req.MetaExt,
ExcludeName: req.ExcludeName,
MinVideoSizeMB: req.MinVideoSizeMB,
AddPath: req.AddPath,
DownloadMeta: boolValue(req.DownloadMeta, true),
UploadMeta: boolValue(req.UploadMeta, false),
DeleteDir: boolValue(req.DeleteDir, false),
Cron: strings.TrimSpace(req.Cron),
EnableCron: boolValue(req.EnableCron, false),
SyncMode: strings.TrimSpace(req.SyncMode),
Enabled: boolValue(req.Enabled, true),
Name: strings.TrimSpace(req.Name),
AccountID: strings.TrimSpace(req.AccountID),
Provider: strings.TrimSpace(req.Provider),
RemotePath: strings.TrimSpace(req.RemotePath),
RemoteDisplayPath: strings.TrimSpace(req.RemoteDisplayPath),
LocalPath: strings.TrimSpace(req.LocalPath),
StrmBaseURL: strings.TrimSpace(req.StrmBaseURL),
VideoExt: req.VideoExt,
MetaExt: req.MetaExt,
ExcludeName: req.ExcludeName,
MinVideoSizeMB: req.MinVideoSizeMB,
AddPath: req.AddPath,
DownloadMeta: boolValue(req.DownloadMeta, true),
UploadMeta: boolValue(req.UploadMeta, false),
DeleteDir: boolValue(req.DeleteDir, false),
Cron: strings.TrimSpace(req.Cron),
EnableCron: boolValue(req.EnableCron, false),
SyncMode: strings.TrimSpace(req.SyncMode),
Enabled: boolValue(req.Enabled, true),
}
}
-10
View File
@@ -116,16 +116,6 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
{"key": "adult.pin", "type": "text"},
},
},
{
"key": "qbittorrent",
"label": "qBittorrent",
"items": []gin.H{
{"key": "qbittorrent.url", "type": "text"},
{"key": "qbittorrent.username", "type": "text"},
{"key": "qbittorrent.password", "type": "text"},
{"key": "qbittorrent.savepath", "type": "text"},
},
},
{
"key": "system-update",
"label": "系统更新",
+5 -1
View File
@@ -36,7 +36,11 @@ type StrmSyncPath struct {
AccountID string `gorm:"size:36;index" json:"account_id"` // StrmAccount.ID;local 为空
Provider string `gorm:"size:32" json:"provider"` // StrmProvider*(冗余,便于列表展示)
RemotePath string `gorm:"size:1024" json:"remote_path"` // 远端目录:115=目录ID,OpenList/CD2=路径,local=源目录
LocalPath string `gorm:"size:1024" json:"local_path"` // STRM/元数据本地输出目录
// RemoteDisplayPath 是远端目录的完整展示路径(如 /电影/剧集)。115 的
// RemotePath 是目录 ID,用户无法辨认,浏览选择或按 ID 反查时把人类可读
// 路径存到这里;路径型网盘(CD2/OpenList)与 local 留空(RemotePath 即路径)。
RemoteDisplayPath string `gorm:"size:1024" json:"remote_display_path"`
LocalPath string `gorm:"size:1024" json:"local_path"` // STRM/元数据本地输出目录
// STRM 链接配置(空值继承全局 strm.* 设置)
StrmBaseURL string `gorm:"size:512" json:"strm_base_url"` // 覆盖 strm.base_url
VideoExt string `gorm:"size:512" json:"video_ext"` // 逗号分隔,覆盖 strm.video_ext
+23 -22
View File
@@ -95,28 +95,29 @@ func (r *StrmSyncPathRepository) List(ctx context.Context) ([]model.StrmSyncPath
func (r *StrmSyncPathRepository) Update(ctx context.Context, p *model.StrmSyncPath) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.StrmSyncPath{}).Where("id = ?", p.ID).Updates(map[string]any{
"name": p.Name,
"account_id": p.AccountID,
"provider": p.Provider,
"remote_path": p.RemotePath,
"local_path": p.LocalPath,
"strm_base_url": p.StrmBaseURL,
"video_ext": p.VideoExt,
"meta_ext": p.MetaExt,
"exclude_name": p.ExcludeName,
"min_video_size_mb": p.MinVideoSizeMB,
"add_path": p.AddPath,
"download_meta": p.DownloadMeta,
"upload_meta": p.UploadMeta,
"delete_dir": p.DeleteDir,
"cron": p.Cron,
"enable_cron": p.EnableCron,
"sync_mode": p.SyncMode,
"enabled": p.Enabled,
"last_sync_at": p.LastSyncAt,
"last_sync_status": p.LastSyncStatus,
"last_sync_message": p.LastSyncMessage,
"updated_at": time.Now(),
"name": p.Name,
"account_id": p.AccountID,
"provider": p.Provider,
"remote_path": p.RemotePath,
"remote_display_path": p.RemoteDisplayPath,
"local_path": p.LocalPath,
"strm_base_url": p.StrmBaseURL,
"video_ext": p.VideoExt,
"meta_ext": p.MetaExt,
"exclude_name": p.ExcludeName,
"min_video_size_mb": p.MinVideoSizeMB,
"add_path": p.AddPath,
"download_meta": p.DownloadMeta,
"upload_meta": p.UploadMeta,
"delete_dir": p.DeleteDir,
"cron": p.Cron,
"enable_cron": p.EnableCron,
"sync_mode": p.SyncMode,
"enabled": p.Enabled,
"last_sync_at": p.LastSyncAt,
"last_sync_status": p.LastSyncStatus,
"last_sync_message": p.LastSyncMessage,
"updated_at": time.Now(),
}).Error
})
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Package service — AES-GCM crypto helper for at-rest secrets.
//
// Sensitive fields (third-party API keys, qBittorrent passwords, …) are
// Sensitive fields (third-party API keys, service passwords, …) are
// stored in SQLite. We encrypt them with AES-256-GCM keyed off the JWT
// secret so a stolen DB file alone is not enough to recover the
// plaintext credentials.
+5 -1
View File
@@ -77,7 +77,11 @@ func (s *FileManagerService) allowedRoots() (map[string]string, error) {
}
addSetting("organize-source", "organize.source_dir")
addSetting("organize-target", "organize.target_dir")
addSetting("qb-savepath", "qbittorrent.savepath")
addSetting("downloader-savepath", "downloader.savepath")
// 兼容历史键名 qbittorrent.savepath:旧版本把下载器保存目录存在该键下
if value, err := s.repo.Setting.Get(context.Background(), "downloader.savepath"); err != nil || strings.TrimSpace(value) == "" {
addSetting("downloader-savepath", "qbittorrent.savepath")
}
}
if s.repo != nil && s.repo.Library != nil {
libs, err := s.repo.Library.List(context.Background())
+4 -3
View File
@@ -196,9 +196,10 @@ func TestFileManagerIncludesConfiguredOrganizeRoots(t *testing.T) {
got[root.Label] = root.Path
}
for label, want := range map[string]string{
"organize-source": filepath.Clean(sourceDir),
"organize-target": filepath.Clean(targetDir),
"qb-savepath": filepath.Clean(qbDir),
"organize-source": filepath.Clean(sourceDir),
"organize-target": filepath.Clean(targetDir),
// 旧键 qbittorrent.savepath 写入应经兼容回退落在 downloader-savepath 下
"downloader-savepath": filepath.Clean(qbDir),
} {
if got[label] != want {
t.Fatalf("root %s = %q, want %q; roots=%#v", label, got[label], want, listing.Roots)
+1 -1
View File
@@ -136,7 +136,7 @@ func TestResolveAccessibleMappedPathMapsEmbeddedHostDownloadMarker(t *testing.T)
}
t.Setenv("MEBOX_DOWNLOAD_CONTAINER_DIR", containerDownloads)
got, _, err := resolveAccessibleMappedPath("/vol1/1000/Docker/qbittorrent/downloads/国产剧")
got, _, err := resolveAccessibleMappedPath("/vol1/1000/nas/downloads/国产剧")
if err != nil {
t.Fatalf("resolveAccessibleMappedPath() error = %v", err)
}
@@ -40,7 +40,7 @@ func (o *OrganizerService) OrganizeSourceCandidates(ctx context.Context) []Organ
out = append(out, OrganizeSourceCandidate{Label: label, Path: clean, Kind: kind})
}
add("默认整理源", o.settingValue(ctx, "organize.source_dir"), "source")
add("下载器保存目录", o.settingValue(ctx, "qbittorrent.savepath"), "download")
add("下载器保存目录", o.downloaderSavepath(ctx), "download")
add("下载目录", envOrDefault("MEBOX_DOWNLOAD_CONTAINER_DIR", "/downloads"), "download")
add("媒体目录", envOrDefault("MEBOX_MEDIA_CONTAINER_DIR", "/media"), "media")
return out
@@ -56,8 +56,17 @@ func (o *OrganizerService) settingValue(ctx context.Context, key string) string
return ""
}
// downloaderSavepath 返回下载器保存目录,优先新键 downloader.savepath,
// 兼容历史键名 qbittorrent.savepath(旧版本部署已写入的配置不丢失)。
func (o *OrganizerService) downloaderSavepath(ctx context.Context) string {
if v := o.settingValue(ctx, "downloader.savepath"); v != "" {
return v
}
return o.settingValue(ctx, "qbittorrent.savepath")
}
// defaultSourceRoot resolves the source root for a directory organize:
// explicit override → organize.source_dir setting → qB default save path →
// explicit override → organize.source_dir setting → downloader save path →
// download container dir.
func (o *OrganizerService) defaultSourceRoot(ctx context.Context, override string) string {
if r := strings.TrimSpace(override); r != "" {
@@ -66,7 +75,7 @@ func (o *OrganizerService) defaultSourceRoot(ctx context.Context, override strin
if v := o.settingValue(ctx, "organize.source_dir"); v != "" {
return v
}
if v := o.settingValue(ctx, "qbittorrent.savepath"); v != "" {
if v := o.downloaderSavepath(ctx); v != "" {
return v
}
return envOrDefault("MEBOX_DOWNLOAD_CONTAINER_DIR", "/downloads")
+1 -1
View File
@@ -54,7 +54,7 @@ func (o *OrganizerService) resolveTransferMode(ctx context.Context, override Tra
}
}
if mode == TransferMove && o.keepSeedingEnabled(ctx) {
// 移动会删除源文件导致 qBittorrent 停止做种;保种开启时改用硬链接
// 移动会删除源文件导致下载器停止做种;保种开启时改用硬链接
// 既规范命名又保留源文件继续做种上传。硬链接失败时会报错,避免静默
// 退化复制后占用双份磁盘空间。
return TransferHardlink
+1 -1
View File
@@ -7,7 +7,7 @@ import (
)
// translateClientPath 将下载客户端报告的路径转换为容器内可访问的路径。
// 常见场景:qBittorrent在另一个容器,报告的路径是其容器内路径,需要映射到当前容器。
// 常见场景:下载器在另一个容器,报告的路径是其容器内路径,需要映射到当前容器。
func translateClientPath(clientPath string, mappings map[string]string) string {
if clientPath == "" {
return ""
+1 -1
View File
@@ -27,7 +27,7 @@ func NewExternalHTTPClient(timeout time.Duration) *http.Client {
}
// NewInternalHTTPClient builds an HTTP client for LAN / Docker-internal
// services such as qBittorrent, Transmission and Aria2. These endpoints are
// services such as downloaders and other local tools. These endpoints are
// usually 127.0.0.1, host.docker.internal, 172.17.0.1 or a NAS LAN IP; sending
// them through HTTP_PROXY/SOCKS proxies makes local WebUI logins hang or fail.
func NewInternalHTTPClient(timeout time.Duration) *http.Client {
+67 -7
View File
@@ -6,10 +6,10 @@ import (
"context"
"errors"
"fmt"
"reflect"
"net/url"
"os"
"path/filepath"
"reflect"
"sort"
"strconv"
"strings"
@@ -37,10 +37,10 @@ type strmSyncState struct {
syncType string
mu sync.Mutex
processed int // 已处理文件计数(用于定期落库进度)
lastProgressFlush time.Time // 上次进度落库时间
seenVideo map[string]bool // "v:"+去掉扩展名的相对路径 → 远端存在该视频
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
processed int // 已处理文件计数(用于定期落库进度)
lastProgressFlush time.Time // 上次进度落库时间
seenVideo map[string]bool // "v:"+去掉扩展名的相对路径 → 远端存在该视频
seenMeta map[string]bool // "m:"+相对路径 → 远端存在该元数据
remoteMeta map[string]int64 // 远端元数据大小(上传比对用)
remoteMetaRef map[string]string // "m:"+相对路径 → 远端元数据文件引用(115 文件 ID,覆盖上传前删除旧文件用)
remoteMetaSha1 map[string]string // "m:"+相对路径 → 远端元数据内容 SHA1(115 列表返回;上传/下载精确比对用,其他网盘为空)
@@ -184,6 +184,66 @@ func (s *StrmService) ListRemoteDir(ctx context.Context, accountID, dir string)
return provider.List(ctx, dir)
}
// ResolveRemoteDirPath 解析远端目录的完整展示路径。115 的目录以 ID 存储,
// 用户无法辨认,这里按 ID 反查 115 返回的祖先链拼出人类可读路径;路径型
// 网盘(CD2/OpenList)与本地目录的 remote_path 本身就是路径,原样返回。
func (s *StrmService) ResolveRemoteDirPath(ctx context.Context, accountID, dir string) (string, error) {
acct, err := s.repo.StrmAccount.FindByID(ctx, accountID)
if err != nil || acct == nil {
return "", errNotFoundOr(err, "网盘账号不存在")
}
dir = strings.TrimSpace(dir)
if dir == "" {
return "", nil
}
if acct.Provider != model.StrmProvider115 {
return dir, nil
}
if dir == "0" {
return "", nil
}
provider, err := s.providerFor(ctx, acct)
if err != nil {
return "", err
}
oc, ok := provider.(interface{ OpenClient() *cloud115.OpenClient })
if !ok {
return "", fmt.Errorf("115: 客户端初始化失败")
}
detail, err := oc.OpenClient().GetFsDetailByCid(ctx, dir)
if err != nil {
return "", fmt.Errorf("115: 解析目录路径失败:%w", err)
}
return cloud115FullPath(detail), nil
}
// cloud115FullPath 把 115 目录详情的祖先链拼成以 / 开头的完整路径。
func cloud115FullPath(detail *cloud115.RemoteFileDetail) string {
if detail == nil {
return ""
}
segments := make([]string, 0, len(detail.Paths)+1)
hasSelf := false
for _, p := range detail.Paths {
if p.FileId == "0" || p.FileId == "" {
continue
}
if p.FileId == detail.FileId {
hasSelf = true
}
if name := strings.TrimSpace(p.Name); name != "" {
segments = append(segments, name)
}
}
if !hasSelf && strings.TrimSpace(detail.FileName) != "" {
segments = append(segments, strings.TrimSpace(detail.FileName))
}
if len(segments) == 0 {
return ""
}
return "/" + strings.Join(segments, "/")
}
// runSync 执行同步主体;结束时更新记录与目录状态。
func (s *StrmService) runSync(ctx context.Context, p *model.StrmSyncPath, rec *model.StrmSyncRecord, cancel context.CancelFunc) {
defer s.clearRunning(p.ID, cancel)
@@ -819,9 +879,9 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
}
fileCh := make(chan strmFileTask)
var (
procWg sync.WaitGroup
procWg sync.WaitGroup
procErrMu sync.Mutex
procErr error
procErr error
)
for i := 0; i < strmProcessWorkers; i++ {
procWg.Add(1)
+45
View File
@@ -1161,3 +1161,48 @@ func TestWalk115FlatConcurrentProcessing(t *testing.T) {
t.Fatalf("incremental sync should skip all, new = %d skipped = %d", st2.rec.NewStrm, st2.rec.Skipped)
}
}
func TestCloud115FullPath(t *testing.T) {
paths := func(ids, names []string) []struct {
FileId string `json:"file_id"`
Name string `json:"file_name"`
} {
out := make([]struct {
FileId string `json:"file_id"`
Name string `json:"file_name"`
}, 0, len(ids))
for i := range ids {
out = append(out, struct {
FileId string `json:"file_id"`
Name string `json:"file_name"`
}{FileId: ids[i], Name: names[i]})
}
return out
}
// 祖先链已包含当前目录自身(115 常规返回)
d := &cloud115.RemoteFileDetail{
FileId: "330",
FileName: "剧集",
Paths: paths([]string{"0", "100", "330"}, []string{"", "电影", "剧集"}),
}
if got := cloud115FullPath(d); got != "/电影/剧集" {
t.Fatalf("full path = %q, want /电影/剧集", got)
}
// 祖先链不含当前目录自身,用 FileName 兜底
d = &cloud115.RemoteFileDetail{
FileId: "330",
FileName: "剧集",
Paths: paths([]string{"0", "100"}, []string{"", "电影"}),
}
if got := cloud115FullPath(d); got != "/电影/剧集" {
t.Fatalf("full path = %q, want /电影/剧集", got)
}
// 根目录:无有效祖先段
d = &cloud115.RemoteFileDetail{FileId: "0"}
if got := cloud115FullPath(d); got != "" {
t.Fatalf("root full path = %q, want empty", got)
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
// hardlink 硬链接(同盘零额外占用,保留源文件;做种不受影响)
// symlink 软链接(保留源文件,指向源)
//
// 除 move 外,其余方式都保留源文件,因此 qBittorrent 等下载器仍能在原
// 除 move 外,其余方式都保留源文件,因此下载器仍能在原
// 路径找到数据继续做种上传。
package service
+6
View File
@@ -80,6 +80,12 @@ export const strmAPI = {
.get<StrmRemoteEntry[]>(`/admin/strm/accounts/${accountId}/list`, { params: { dir } })
.then((r) => r.data),
// 按远端目录引用(115 为目录 ID)反查完整展示路径
resolveRemoteDirPath: (accountId: string, dir: string) =>
api
.get<{ path: string }>(`/admin/strm/accounts/${accountId}/resolve`, { params: { dir } })
.then((r) => r.data.path),
list115Sources: () =>
api.get<Strm115Sources>('/admin/strm/115/sources').then((r) => r.data),
+161 -45
View File
@@ -29,7 +29,7 @@ import {
normalizeEmbyRemoteLines,
type EmbyRemoteLine,
} from '../utils/embyRemoteLines'
import { lastPathSegment, syncLocalPathWithRemote } from '../utils/strmPaths'
import { lastPathSegment, remoteTailNameOf, syncLocalPathWithRemote } from '../utils/strmPaths'
// ─── 弹框外壳 ────────────────────────────────────────────────────────────────
@@ -573,22 +573,33 @@ export function StrmSettingsDialog({ onClose }: { onClose: () => void }) {
// ─── 添加/编辑同步目录 ────────────────────────────────────────────────────────
/** 推断已有配置当前实际拼在本地输出目录末尾的尾段(目录名或 115 目录 ID)。 */
function initRemoteTail(existing: StrmSyncPath): string {
const cidTail = lastPathSegment(existing.remote_path)
const nameTail = existing.remote_display_path ? lastPathSegment(existing.remote_display_path) : ''
if (nameTail && lastPathSegment(existing.local_path) === nameTail) return nameTail
return cidTail
}
export function StrmSyncPathDialog({
accounts,
existing,
onClose,
onSaved,
onOpenSettings,
}: {
accounts: StrmAccount[]
existing: StrmSyncPath | null
onClose: () => void
onSaved: () => void
onOpenSettings?: () => void
}) {
const [form, setForm] = useState<StrmSyncPathInput>(() => ({
name: existing?.name ?? '',
provider: existing?.provider ?? 'cloud115',
account_id: existing?.account_id ?? '',
remote_path: existing?.remote_path ?? '',
remote_display_path: existing?.remote_display_path ?? '',
local_path: existing?.local_path ?? '',
strm_base_url: existing?.strm_base_url ?? '',
video_ext: existing?.video_ext ?? '',
@@ -607,22 +618,68 @@ export function StrmSyncPathDialog({
const [saving, setSaving] = useState(false)
const [browsing, setBrowsing] = useState(false)
const [browsingLocal, setBrowsingLocal] = useState<null | 'remote_path' | 'local_path'>(null)
const prevRemoteTailRef = useRef(existing ? lastPathSegment(existing.remote_path) : '')
// 输入框显示的远端目录文本:对于 115 优先显示完整路径,若尚未反查到则显示 ID
const [remoteInputValue, setRemoteInputValue] = useState(
() => existing?.remote_display_path || existing?.remote_path || '',
)
// 反查展示路径的序号守卫:远端目录快速连续变更时丢弃过期响应
const resolveSeqRef = useRef(0)
// prevRemoteTailRef 记录当前拼在本地输出目录末尾、由本弹窗管理的尾段。
// 兼容两类历史数据:新版保存的 local_path 末段是目录名,旧版是目录 ID。
const prevRemoteTailRef = useRef(existing ? initRemoteTail(existing) : '')
const set = <K extends keyof StrmSyncPathInput>(key: K, value: StrmSyncPathInput[K]) =>
setForm((f) => ({ ...f, [key]: value }))
const updateRemotePath = (remotePath: string) => {
const updateRemotePath = (remotePath: string, displayPath?: string) => {
const shown = (displayPath || remotePath).trim()
setRemoteInputValue(shown)
setForm((f) => {
const synced = syncLocalPathWithRemote(f.local_path, remotePath, prevRemoteTailRef.current)
const tail = remoteTailNameOf(remotePath, displayPath)
const synced = syncLocalPathWithRemote(f.local_path, remotePath, prevRemoteTailRef.current, tail)
prevRemoteTailRef.current = synced.remoteTail
return { ...f, remote_path: remotePath, local_path: synced.localPath }
return {
...f,
remote_path: remotePath,
remote_display_path: displayPath ?? '',
local_path: synced.localPath,
}
})
}
// 按 ID 反查完整展示路径。默认仅补展示、不改动已配置的本地输出目录;
// resyncTail 用于手动输入 ID 的新配置:把刚拼上的 ID 尾段替换为目录名。
const resolveDisplayPath = (accountId: string, remotePath: string, resyncTail = false) => {
const seq = ++resolveSeqRef.current
strmAPI
.resolveRemoteDirPath(accountId, remotePath)
.then((fullPath) => {
if (seq !== resolveSeqRef.current) return
if (fullPath) setRemoteInputValue(fullPath)
setForm((f) => {
if (f.remote_path !== remotePath) return f
if (resyncTail) {
const tail = remoteTailNameOf(remotePath, fullPath)
const synced = syncLocalPathWithRemote(f.local_path, remotePath, prevRemoteTailRef.current, tail)
prevRemoteTailRef.current = synced.remoteTail
return { ...f, remote_display_path: fullPath, local_path: synced.localPath }
}
// 旧配置的 local_path 末段若恰好就是目录名,把它认作受管尾段,
// 后续重新选择目录时才能正确替换而不是叠加
const tail = lastPathSegment(fullPath)
if (tail && prevRemoteTailRef.current !== tail && lastPathSegment(f.local_path) === tail) {
prevRemoteTailRef.current = tail
}
return { ...f, remote_display_path: fullPath }
})
})
.catch(() => undefined)
}
const commitLocalPath = (localPath: string) => {
setForm((f) => {
const synced = syncLocalPathWithRemote(localPath, f.remote_path, prevRemoteTailRef.current)
const tail = remoteTailNameOf(f.remote_path, f.remote_display_path)
const synced = syncLocalPathWithRemote(localPath, f.remote_path, prevRemoteTailRef.current, tail)
prevRemoteTailRef.current = synced.remoteTail
return { ...f, local_path: synced.localPath }
})
@@ -630,11 +687,30 @@ export function StrmSyncPathDialog({
const isLocal = form.provider === 'local'
const availableAccounts = accounts.filter((a) => a.provider === form.provider && a.has_credential)
const remoteDisplayPath = isLocal ? '' : form.remote_display_path?.trim() ?? ''
const remoteTail = isLocal ? '' : remoteTailNameOf(form.remote_path, remoteDisplayPath)
// 编辑旧配置时若尚未存展示路径,按 ID 反查补齐并更新输入框展示
useEffect(() => {
if (isLocal || form.provider !== 'cloud115') return
if (!form.account_id || !form.remote_path || form.remote_display_path) return
resolveDisplayPath(form.account_id, form.remote_path)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const submit = async (event: FormEvent) => {
event.preventDefault()
setSaving(true)
try {
if (!existing) {
const settings = await strmAPI.getSettings()
if (!settings?.['strm.base_url']?.trim()) {
toast.error('未填写strm地址')
onClose()
onOpenSettings?.()
return
}
}
if (existing) {
await strmAPI.updatePath(existing.id, form)
toast.success('同步目录已更新')
@@ -671,6 +747,9 @@ export function StrmSyncPathDialog({
const provider = e.target.value as StrmProvider
set('provider', provider)
set('account_id', '')
set('remote_path', '')
set('remote_display_path', '')
setRemoteInputValue('')
}}
>
<option value="cloud115">115 网盘</option>
@@ -707,17 +786,39 @@ export function StrmSyncPathDialog({
isLocal
? '扫描该目录下的视频生成 strm'
: form.provider === 'cloud115'
? '115 目录 ID(可通过浏览选择)'
? form.remote_path
? `115 目录 ID:${form.remote_path}(可通过右侧「浏览」选择更换)`
: '可通过右侧「浏览」选择 115 目录,或直接粘贴目录 ID'
: '远端路径(可通过浏览选择)'
}
>
<input
className={inputCls}
value={form.remote_path}
placeholder={isLocal ? 'D:\\movies' : '/'}
onChange={(e) => set('remote_path', e.target.value)}
value={remoteInputValue}
placeholder={isLocal ? 'D:\\movies' : form.provider === 'cloud115' ? '点击右侧「浏览」选择目录' : '/'}
onChange={(e) => setRemoteInputValue(e.target.value)}
onBlur={(e) => {
if (!isLocal) updateRemotePath(e.target.value)
const value = e.target.value.trim()
if (isLocal) {
set('remote_path', value)
return
}
if (form.provider === 'cloud115') {
if (!value) {
updateRemotePath('', '')
return
}
// 若用户直接输入/粘贴纯数字目录 ID,触发反查并自动替换为完整路径
if (/^\d+$/.test(value)) {
updateRemotePath(value, '')
if (form.account_id) resolveDisplayPath(form.account_id, value, true)
return
}
// 若当前展示的是完整路径且未变动,不做处理
if (value === form.remote_display_path) return
} else {
updateRemotePath(value)
}
}}
/>
</Field>
@@ -745,8 +846,8 @@ export function StrmSyncPathDialog({
<Field
label="本地输出目录"
hint={
!isLocal && lastPathSegment(form.remote_path)
? `将自动拼接远端末级目录「${lastPathSegment(form.remote_path)}」`
!isLocal && remoteTail
? `将自动拼接远端末级目录「${remoteTail}」`
: '生成的 .strm 与下载的元数据写到这里的对应目录结构下'
}
>
@@ -774,20 +875,6 @@ export function StrmSyncPathDialog({
<details className="rounded-2xl border border-gray-100 bg-gray-50/50 p-3">
<summary className="cursor-pointer text-sm font-semibold text-ink-600">高级选项</summary>
<div className="mt-3 space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<Field label="STRM 基础地址(覆盖全局)" hint="留空使用全局 STRM 设置">
<input className={inputCls} value={form.strm_base_url ?? ''} placeholder="http://host:port" onChange={(e) => set('strm_base_url', e.target.value)} />
</Field>
<Field label="最小视频大小(MB)" hint="0 表示继承全局设置">
<input
className={inputCls}
type="number"
min={0}
value={form.min_video_size_mb ?? 0}
onChange={(e) => set('min_video_size_mb', Number(e.target.value))}
/>
</Field>
</div>
<div className="grid gap-3 md:grid-cols-3">
<Field label="视频扩展名(覆盖全局)">
<input className={inputCls} value={form.video_ext ?? ''} placeholder="mkv,mp4,avi" onChange={(e) => set('video_ext', e.target.value)} />
@@ -800,6 +887,15 @@ export function StrmSyncPathDialog({
</Field>
</div>
<div className="grid gap-3 md:grid-cols-3">
<Field label="最小视频大小(MB)" hint="0 表示继承全局设置">
<input
className={inputCls}
type="number"
min={0}
value={form.min_video_size_mb ?? 0}
onChange={(e) => set('min_video_size_mb', Number(e.target.value))}
/>
</Field>
<Field label="STRM 链接 path 参数">
<select className={inputCls} value={form.add_path ?? 1} onChange={(e) => set('add_path', Number(e.target.value))}>
<option value={1}>完整远端路径</option>
@@ -813,10 +909,10 @@ export function StrmSyncPathDialog({
<option value="full">全量同步(全量校验)</option>
</select>
</Field>
<Field label="定时同步 Cron" hint="5 段表达式,如 0 */6 * * *">
<input className={inputCls} value={form.cron ?? ''} placeholder="0 */6 * * *" onChange={(e) => set('cron', e.target.value)} />
</Field>
</div>
<Field label="定时同步 Cron" hint="5 段表达式,如 0 */6 * * *">
<input className={inputCls} value={form.cron ?? ''} placeholder="0 */6 * * *" onChange={(e) => set('cron', e.target.value)} />
</Field>
<div className="grid gap-2 md:grid-cols-2">
<ToggleRow label="下载元数据" checked={form.download_meta ?? true} onChange={(v) => set('download_meta', v)} />
<ToggleRow label="上传元数据" checked={form.upload_meta ?? false} onChange={(v) => set('upload_meta', v)} />
@@ -842,8 +938,8 @@ export function StrmSyncPathDialog({
<StrmDirBrowserDialog
accountId={form.account_id}
initialDir={form.remote_path || undefined}
onSelect={(id) => {
updateRemotePath(id)
onSelect={(id, _name, fullPath) => {
updateRemotePath(id, form.provider === 'cloud115' ? fullPath : undefined)
setBrowsing(false)
}}
onClose={() => setBrowsing(false)}
@@ -882,6 +978,12 @@ function ToggleRow({ label, checked, onChange }: { label: string; checked: boole
)
}
/** 把浏览时经过的目录名称链拼成以 / 开头的完整展示路径。 */
function chainFullPath(chain: { id: string; name: string }[]): string {
const names = chain.map((item) => item.name.trim()).filter(Boolean)
return names.length > 0 ? '/' + names.join('/') : ''
}
// ─── 远端目录浏览选择器 ───────────────────────────────────────────────────────
function StrmDirBrowserDialog({
@@ -892,25 +994,28 @@ function StrmDirBrowserDialog({
}: {
accountId: string
initialDir?: string
onSelect: (path: string) => void
onSelect: (id: string, name?: string, fullPath?: string) => void
onClose: () => void
}) {
const [dir, setDir] = useState(initialDir ?? '')
const [crumbs, setCrumbs] = useState<string[]>([])
const [entries, setEntries] = useState<StrmRemoteEntry[]>([])
const [loading, setLoading] = useState(true)
// 已进入目录的名称链:面包屑按名称展示,「选择当前目录」时回传末级名称
const [dirChain, setDirChain] = useState<{ id: string; name: string }[]>([])
const dirChainRef = useRef<{ id: string; name: string }[]>([])
// 递增序号守卫:快速连续进入目录时丢弃过期目录响应
const loadSeqRef = useRef(0)
const load = async (target: string) => {
const load = async (target: string, nextChain?: { id: string; name: string }[]) => {
const seq = ++loadSeqRef.current
setLoading(true)
try {
const list = await strmAPI.listRemoteDir(accountId, target)
if (seq !== loadSeqRef.current) return
if (nextChain) dirChainRef.current = nextChain
setEntries(list)
setDir(target)
setCrumbs(target ? target.split('/').filter(Boolean) : [])
setDirChain([...dirChainRef.current])
} catch (err) {
if (seq !== loadSeqRef.current) return
toast.error(apiErrorMessage(err))
@@ -924,7 +1029,13 @@ function StrmDirBrowserDialog({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accountId])
const enterDir = (id: string) => load(id).catch(() => undefined)
const enterDir = (entry: StrmRemoteEntry) => {
const chain = [...dirChainRef.current]
const existingIdx = chain.findIndex((item) => item.id === entry.id)
if (existingIdx >= 0) chain.splice(existingIdx + 1)
else chain.push({ id: entry.id, name: entry.name })
load(entry.id, chain).catch(() => undefined)
}
return (
<div
@@ -944,13 +1055,13 @@ function StrmDirBrowserDialog({
</button>
</div>
<div className="flex items-center gap-1 border-b border-gray-100 px-6 py-2.5 text-xs text-sand-500">
<button type="button" className="hover:text-brand-500" onClick={() => enterDir('')}>
<button type="button" className="hover:text-brand-500" onClick={() => load('', [])}>
根目录
</button>
{crumbs.map((crumb, index) => (
<span key={crumb + index} className="flex items-center gap-1">
{(dirChain.length > 0 ? dirChain.map((item) => item.name) : dir.split('/').filter(Boolean)).map((label, index) => (
<span key={label + index} className="flex items-center gap-1">
<ChevronRight size={12} />
<span>{crumb}</span>
<span>{label}</span>
</span>
))}
<span className="ml-2 text-ink-50">{dir || '(根目录 / 0)'}</span>
@@ -971,8 +1082,8 @@ function StrmDirBrowserDialog({
key={entry.id}
type="button"
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left text-sm transition hover:bg-gray-50"
onClick={() => (entry.is_dir ? enterDir(entry.id) : undefined)}
onDoubleClick={() => entry.is_dir && enterDir(entry.id)}
onClick={() => (entry.is_dir ? enterDir(entry) : undefined)}
onDoubleClick={() => entry.is_dir && enterDir(entry)}
>
<Icon size={16} className={entry.is_dir ? 'text-brand-400' : 'text-sand-400'} />
<span className="flex-1 truncate text-ink-600">{entry.name}</span>
@@ -982,7 +1093,7 @@ function StrmDirBrowserDialog({
className="rounded-lg border border-brand-200 bg-brand-50 px-2.5 py-1 text-xs font-semibold text-brand-500 hover:bg-brand-100"
onClick={(e) => {
e.stopPropagation()
onSelect(entry.id)
onSelect(entry.id, entry.name, chainFullPath([...dirChainRef.current, { id: entry.id, name: entry.name }]))
}}
>
选择此目录
@@ -998,7 +1109,12 @@ function StrmDirBrowserDialog({
</div>
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-3">
<span className="text-xs text-sand-500">双击进入目录,点击「选择此目录」使用该目录 ID / 路径</span>
<button type="button" className="neon-button" onClick={() => onSelect(dir)} disabled={loading}>
<button
type="button"
className="neon-button"
onClick={() => onSelect(dir, dirChain[dirChain.length - 1]?.name, chainFullPath(dirChain))}
disabled={loading}
>
选择当前目录
</button>
</div>
+25 -3
View File
@@ -163,6 +163,22 @@ export function StrmManagePage() {
}
}
const openAddPathDialog = async () => {
try {
const settings = await strmAPI.getSettings()
const baseUrl = settings?.['strm.base_url']?.trim()
if (!baseUrl) {
toast.error('未填写strm地址')
setDialog('settings')
return
}
setEditingPath(null)
setDialog('path')
} catch (err) {
toast.error(apiErrorMessage(err))
}
}
return (
<div className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
@@ -186,7 +202,7 @@ export function StrmManagePage() {
<Settings size={16} />
STRM 设置
</button>
<button type="button" onClick={() => { setEditingPath(null); setDialog('path') }} className="neon-button">
<button type="button" onClick={openAddPathDialog} className="neon-button">
<FolderPlus size={16} />
添加同步目录
</button>
@@ -210,7 +226,7 @@ export function StrmManagePage() {
<SyncPathSection
paths={paths}
actingPath={actingPath}
onAdd={() => { setEditingPath(null); setDialog('path') }}
onAdd={openAddPathDialog}
onEdit={(path) => { setEditingPath(path); setDialog('path') }}
onDelete={deletePath}
onStart={startSync}
@@ -236,6 +252,7 @@ export function StrmManagePage() {
existing={editingPath}
onClose={() => setDialog(null)}
onSaved={() => { setDialog(null); refresh().catch(() => undefined) }}
onOpenSettings={() => setDialog('settings')}
/>
)}
</div>
@@ -442,7 +459,12 @@ function SyncPathSection({
</div>
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs text-ink-50">
<span className="max-w-[45%] truncate">{path.remote_path || '(根目录)'}</span>
<span
className="max-w-[45%] truncate"
title={path.remote_display_path || path.remote_path || '(根目录)'}
>
{path.remote_display_path || path.remote_path || '(根目录)'}
</span>
<span className="text-sand-500">→</span>
<span className="max-w-[45%] truncate">{path.local_path}</span>
</div>
-60
View File
@@ -1,60 +0,0 @@
export interface DownloadTask {
id: string
source: string
download_client_id?: string
external_id?: string
title: string
poster_url?: string
backdrop_url?: string
overview?: string
save_path: string
status: string
progress: number
state?: string
dlspeed?: number
upspeed?: number
num_seeds?: number
num_leechs?: number
size?: number
downloaded?: number
created_at: string
updated_at: string
}
export interface QBitTorrent {
hash: string
client_id: string
source: 'qbittorrent' | 'transmission' | 'aria2'
name: string
title: string
poster_url?: string
backdrop_url?: string
overview?: string
state: string
progress: number
dlspeed: number
upspeed: number
num_seeds: number
num_leechs: number
size: number
downloaded: number
save_path: string
}
export interface DownloadClient {
id: string
name: string
type: 'qbittorrent' | 'transmission' | 'aria2'
host: string
username: string
is_default: boolean
enabled: boolean
created_at: string
updated_at: string
}
export interface DownloadClientTypeInfo {
type: string
name: string
description: string
}
-1
View File
@@ -2,7 +2,6 @@
export * from './api'
export * from './auth'
export * from './downloads'
export * from './events'
export * from './history'
export * from './library'
+2
View File
@@ -59,6 +59,7 @@ export interface StrmSyncPath {
account_id: string
provider: StrmProvider
remote_path: string
remote_display_path?: string
local_path: string
strm_base_url: string
video_ext: string
@@ -86,6 +87,7 @@ export interface StrmSyncPathInput {
account_id?: string
provider: StrmProvider
remote_path: string
remote_display_path?: string
local_path: string
strm_base_url?: string
video_ext?: string
+20 -2
View File
@@ -6,6 +6,23 @@ export function lastPathSegment(path: string): string {
return parts[parts.length - 1] ?? ''
}
/**
* 取远端末级目录的展示/拼接名。115 等以 ID 作为 remote_path 的网盘
* 传入浏览时拿到的目录名 tailName,避免把数字 ID 拼进本地路径。
*/
export function resolveRemoteTailName(remotePath: string, tailName?: string): string {
const name = tailName?.trim()
if (name) return name
return lastPathSegment(remotePath)
}
/** 优先取展示路径(115 浏览/反查得到)的末级目录名,否则取远端路径末段。 */
export function remoteTailNameOf(remotePath: string, displayPath?: string): string {
const trimmed = displayPath?.trim()
if (trimmed) return lastPathSegment(trimmed)
return lastPathSegment(remotePath)
}
function pathSeparator(path: string): '/' | '\\' {
return path.includes('\\') ? '\\' : '/'
}
@@ -30,13 +47,14 @@ function stripRemoteTail(localPath: string, prevRemoteTail: string): string {
return trimmed
}
/** 将远端最后一级目录名拼到本地输出目录末尾;远端变更时替换旧尾段。 */
/** 将远端最后一级目录名拼到本地输出目录末尾;远端变更时替换旧尾段。tailName 为浏览时拿到的目录名。 */
export function syncLocalPathWithRemote(
localPath: string,
remotePath: string,
prevRemoteTail = '',
tailName?: string,
): { localPath: string; remoteTail: string } {
const remoteTail = lastPathSegment(remotePath)
const remoteTail = resolveRemoteTailName(remotePath, tailName)
const base = stripRemoteTail(localPath, prevRemoteTail)
if (!remoteTail) {
return { localPath: base || localPath.trim(), remoteTail: '' }