diff --git a/.dockerignore b/.dockerignore index 6257f05..aa7dbce 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,7 +18,7 @@ verify-downloads/ .tmp_* .tmp-deploy-* .tmp-deploy-data/ -.mmtl.pid +.mebox.pid *.db *.db-journal *.db-shm diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 66cde86..88de506 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - - name: Telegram MMTL 交流群 - url: https://t.me/MMTL + - name: Telegram MeBox 交流群 + url: https://t.me/MeBox about: 适合快速交流部署经验、使用问题和排查线索。 diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 64e26bb..95535dc 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -12,7 +12,7 @@ assignees: "" ## 期望方案 -请描述你希望 MMTL 如何工作。 +请描述你希望 MeBox 如何工作。 ## 使用场景 diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index 9ae6f14..5903f90 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -1,4 +1,4 @@ -name: MMTL CodeQL +name: MeBox CodeQL paths-ignore: - internal/service/fileid_other.go diff --git a/.github/workflows/Auto-docker-publish.yml b/.github/workflows/Auto-docker-publish.yml index 266a925..08be768 100644 --- a/.github/workflows/Auto-docker-publish.yml +++ b/.github/workflows/Auto-docker-publish.yml @@ -4,9 +4,7 @@ name: AuTo Docker Image on: push: branches: [main] - pull_request: - branches: [main] - + # 保留手动触发作为备选 workflow_dispatch: inputs: @@ -85,8 +83,8 @@ jobs: NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "tag=MMTL-v${NEW_VERSION}" >> $GITHUB_OUTPUT - echo "tag=mmtl-v${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 @@ -131,7 +129,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ghcr.io/${{ github.repository_owner }}/mmtl + images: ghcr.io/${{ github.repository_owner }}/mebox tags: | type=raw,value=latest type=raw,value=${{ steps.bump_version.outputs.tag }} @@ -194,7 +192,7 @@ jobs: set -eux # tag 已由 version-and-publish 推送;若 release 已存在则忽略(--verify-tag 幂等) gh release create "$RELEASE_TAG" \ - --title "MMTL ${{ needs.version-and-publish.outputs.new_version }}" \ + --title "MeBox ${{ needs.version-and-publish.outputs.new_version }}" \ --notes "自动化发布 ${{ needs.version-and-publish.outputs.new_version }}" \ --verify-tag --latest || true @@ -240,16 +238,16 @@ jobs: 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 }}" \ - -o "dist/mmtl-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server + -o "dist/mebox-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server - name: Package run: | - mkdir -p package/mmtl - cp "dist/mmtl-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" package/mmtl/mmtl${{ matrix.ext }} - cp README.md package/mmtl/ 2>/dev/null || true + mkdir -p package/mebox + cp "dist/mebox-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" package/mebox/mebox${{ matrix.ext }} + cp README.md package/mebox/ 2>/dev/null || true if [ "${{ matrix.goos }}" = "windows" ]; then - (cd package && zip -r "../mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.zip" mmtl) + (cd package && zip -r "../mebox_${{ matrix.goos }}_${{ matrix.goarch }}.zip" mebox) else - tar -czf "mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz" -C package mmtl + tar -czf "mebox_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz" -C package mebox fi - name: Upload to GitHub Release env: @@ -257,8 +255,8 @@ jobs: RELEASE_TAG: ${{ needs.version-and-publish.outputs.tag }} run: | set -eux - PKG="mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.zip" - TAR="mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz" + PKG="mebox_${{ matrix.goos }}_${{ matrix.goarch }}.zip" + TAR="mebox_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz" # 并发上传到同一 release 各自文件,--clobber 幂等覆盖 if [ -f "$PKG" ]; then for i in 1 2 3; do gh release upload "$RELEASE_TAG" "$PKG" --clobber && break || sleep 5; done diff --git a/.github/workflows/beta-build.yml b/.github/workflows/beta-build.yml index d6dee8a..e700abb 100644 --- a/.github/workflows/beta-build.yml +++ b/.github/workflows/beta-build.yml @@ -4,7 +4,7 @@ # 产出: # 1. 前端 + 后端编译验证(go vet / go test / go build) # 2. 多平台可执行二进制 artifact(linux/amd64、linux/arm64、windows/amd64) -# 3. ghcr.io/{owner}/mmtl:beta 多架构 Docker 镜像(linux/amd64 + linux/arm64) +# 3. ghcr.io/{owner}/mebox:beta 多架构 Docker 镜像(linux/amd64 + linux/arm64) # # 与 main 分支的发布流(Auto-docker-publish.yml)隔离:beta 不做版本递增、 # 不打 release tag,只构建带 -beta 标识的产物供测试。 @@ -73,21 +73,21 @@ jobs: # 多平台可执行文件(嵌入刚构建的 web/dist) - name: Build linux/amd64 - run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mmtl-beta-linux-amd64 ./cmd/server + run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server - name: Build linux/arm64 - run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mmtl-beta-linux-arm64 ./cmd/server + run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server - name: Build windows/amd64 - run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mmtl-beta-windows-amd64.exe ./cmd/server + run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: mmtl-beta-binaries + name: mebox-beta-binaries path: dist/* if-no-files-found: error # ───────────────────────────────────────────────────────────────────────────── - # 2) Beta Docker 镜像(ghcr.io/{owner}/mmtl:beta) + # 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta) # ───────────────────────────────────────────────────────────────────────────── docker-beta: name: Build & push beta Docker image @@ -123,7 +123,7 @@ jobs: push: true provenance: false sbom: false - tags: ghcr.io/${{ github.repository_owner }}/mmtl:beta + tags: ghcr.io/${{ github.repository_owner }}/mebox:beta labels: | org.opencontainers.image.revision=${{ github.sha }} org.opencontainers.image.source=${{ github.repository }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 4e5d4fb..3f8d236 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: version: - description: 'Image tag to publish, for example MMTL-v0.0.32' + description: 'Image tag to publish, for example MeBox-v0.0.32' required: true type: string ref: @@ -42,7 +42,7 @@ jobs: uses: docker/metadata-action@v5 with: # 自动使用当前仓库所有者 - images: ghcr.io/${{ github.repository_owner }}/mmtl + images: ghcr.io/${{ github.repository_owner }}/mebox tags: | type=raw,value=latest type=raw,value=${{ env.RELEASE_VERSION }} diff --git a/.gitignore b/.gitignore index ffa5aea..3f7bb76 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,7 @@ logs/ .tmp-deploy-cache/ .tmp-deploy-server.* .tmp-live-server.* -.mmtl.pid +.mebox.pid *.log *.db *.db-journal diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd4b8f7..22fccd6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # 贡献规范 -感谢你愿意帮助 MMTL 变得更稳定。这个项目主要面向 NAS、Docker 部署、媒体库整理、订阅下载和多端播放场景;提交 Issue 或 Pull Request 时,请尽量提供可复现、可验证的信息。 +感谢你愿意帮助 MeBox 变得更稳定。这个项目主要面向 NAS、Docker 部署、媒体库整理、订阅下载和多端播放场景;提交 Issue 或 Pull Request 时,请尽量提供可复现、可验证的信息。 ## Issue 提交规范 @@ -16,7 +16,7 @@ - 期望行为:你认为正确结果应该是什么。 - 复现步骤:从哪个页面、点击什么、填写什么、触发什么任务。 - 部署方式:Docker 第一档 / 第二档 / 第三档、裸机运行、反代方式等。 -- 环境信息:NAS 型号或系统、Docker / Compose 版本、浏览器、MMTL 镜像版本。 +- 环境信息:NAS 型号或系统、Docker / Compose 版本、浏览器、MeBox 镜像版本。 - 相关配置:路径映射、下载器保存路径、媒体库路径、站点类型等。请隐藏 Cookie、API Key、密码和 Token。 - 日志和任务信息:优先提供应用日志、任务队列详情、浏览器控制台错误、网络请求错误。 @@ -28,14 +28,14 @@ Docker 部署常用命令: ```bash docker compose ps -docker compose logs --tail=300 mmtl -docker compose exec mmtl sh -lc 'ls -la /data/logs || true' +docker compose logs --tail=300 mebox +docker compose exec mebox sh -lc 'ls -la /data/logs || true' ``` PostgreSQL 部署查询示例: ```bash -docker compose exec postgres psql -U mmtl -d mmtl -c "select key,value,updated_at from settings order by updated_at desc limit 30;" +docker compose exec postgres psql -U mebox -d mebox -c "select key,value,updated_at from settings order by updated_at desc limit 30;" ``` 请勿公开粘贴以下敏感信息: diff --git a/Dockerfile b/Dockerfile index 0b20f37..5d484cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.6 # ============================================================================= -# Multi-architecture build for MMTL. +# Multi-architecture build for MeBox. # # Stage 1 (frontend) : Node 20 -> static SPA bundle # Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0) @@ -8,7 +8,7 @@ # # Build: # docker buildx build --platform linux/amd64,linux/arm64 \ -# --build-arg VERSION=MMTL-v0.1.16 -t mmtl:latest --push . +# --build-arg VERSION=MeBox-v0.1.16 -t mebox:latest --push . # # Optional Intel VAAPI/QSV runtime packages: # docker buildx build --build-arg WITH_VAAPI=true ... @@ -39,7 +39,7 @@ COPY . . COPY --from=frontend /app/web/dist ./web/dist RUN --mount=type=cache,target=/go/pkg/mod \ CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ - go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o mmtl ./cmd/server + go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o mebox ./cmd/server # ---- Stage 3: runtime ------------------------------------------------------ FROM alpine:3.23 @@ -64,22 +64,22 @@ RUN apk add --no-cache \ && rm -rf /var/cache/apk/* # Non-root user for the long-running process. -RUN addgroup -S mmtl && adduser -S mmtl -G mmtl +RUN addgroup -S mebox && adduser -S mebox -G mebox WORKDIR /app -COPY --from=backend /app/mmtl /usr/local/bin/mmtl +COPY --from=backend /app/mebox /usr/local/bin/mebox COPY --from=frontend /app/web/dist /app/web/dist RUN mkdir -p /data /cache /media \ - && chown -R mmtl:mmtl /data /cache /media + && chown -R mebox:mebox /data /cache /media # Default environment (overridable via docker-compose / `docker run -e`). -ENV MMTL_APP_PORT=8080 \ - MMTL_APP_DATA_DIR=/data \ - MMTL_APP_WEB_DIR=/app/web/dist \ - MMTL_DATABASE_DB_PATH=/data/mmtl.db \ - MMTL_CACHE_CACHE_DIR=/cache \ - MMTL_LOGGING_LEVEL=info \ +ENV MEBOX_APP_PORT=8080 \ + MEBOX_APP_DATA_DIR=/data \ + MEBOX_APP_WEB_DIR=/app/web/dist \ + MEBOX_DATABASE_DB_PATH=/data/mebox.db \ + MEBOX_CACHE_CACHE_DIR=/cache \ + MEBOX_LOGGING_LEVEL=info \ TZ=Asia/Shanghai EXPOSE 8080 diff --git a/README.md b/README.md index bf33934..1e2f780 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,23 @@ -# MMTL (My Movie and TV Library) +# MeBox

- MMTL Logo + MeBox Logo

-

适合 NAS、家庭共享和多端播放的私人媒体中心

+

面向 NAS 与家庭影音场景的私人媒体中心

- Docker 一键部署 · PostgreSQL 主库 · Redis 热缓存 · OpenSearch 搜索增强 · Emby 协议兼容 · Bot 通知 + 媒体库 · 刮削整理 · 网盘 STRM · Emby 协议 · 远程 Emby 挂载 · 多用户权限 · Docker 一键部署

+ 项目简介 · 快速开始 · - 三挡部署 · - 路径映射 · - 旧 SQLite 迁移 · + 部署档位 · + 鸣谢 · 开发构建 · - 贡献规范 · - 在线演示 + English · + 贡献规范

@@ -31,400 +31,223 @@ ## 项目简介 -MMTL 是一个自托管媒体管理系统,面向 NAS、小主机、家庭影音和多用户共享场景。它把媒体库、刮削、下载整理、订阅、网盘播放、Emby 协议兼容、用户权限和 Bot 通知放在一个后台里,目标是让用户只维护一套服务,就能给网页端、手机端、电视端和第三方播放器使用。 +**MeBox** 是一个自托管私人媒体管理系统,适合 NAS、小主机、家庭共享和多端播放场景。本项目由 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) fork 并持续二开维护,在保留「一套服务覆盖网页、手机、电视与第三方播放器」思路的同时,围绕网盘播放、任务队列、远程挂载和权限体系做了大量增强。 -核心能力: +你可以把 MeBox 理解为: + +- 一个带现代 Web UI 的**媒体库后台** +- 一个兼容 Emby/Jellyfin 客户端的**协议网关** +- 一个连接本地硬盘、下载目录与网盘存储的**整理与播放入口** + +### 核心能力 + +| 模块 | 说明 | +| --- | --- | +| **媒体库** | 电影、电视剧、动漫、综艺、音乐与自定义库;多根目录、扫库、海报墙、继续观看 | +| **元数据刮削** | TMDb、Bangumi、Douban、TheTVDB、Fanart 等;支持 NFO、手动匹配、刮削队列 | +| **播放** | 网页播放器、HLS 转码、弹幕、字幕、播放配置档、观看历史与收藏 | +| **Emby 协议** | Infuse、SenPlayer、Fileball 等客户端可直接添加本服务,使用 MeBox 账号登录 | +| **远程 Emby 挂载** | 将远程 Emby 媒体库挂载到本地界面统一浏览(无需单独开 Emby 客户端) | +| **网盘与 STRM** | OpenList、CloudDrive2、115、WebDAV 等;STRM 同步、上传/下载队列、直链/302 播放 | +| **下载与整理** | qBittorrent 接入、站点搜索与订阅、下载后自动整理、文件管理器(复制/移动/硬链/软链) | +| **用户与权限** | 管理员/普通用户、有效期、成人内容开关、播放配置 PIN、细粒度操作权限 | +| **运维能力** | 统一任务队列、回收站、存储统计、DLNA 投屏、系统设置与日志 | + +### 技术栈 + +- **后端**:Go · Gin · GORM · SQLite / PostgreSQL · 可选 Redis · 可选 OpenSearch +- **前端**:React 18 · Vite · TypeScript · Tailwind CSS · Zustand +- **部署**:Docker Compose 多档模板,支持 amd64 / arm64 镜像与单文件可执行发布 + +--- -- **媒体库管理**:电影、电视剧、动漫、综艺、音乐和自定义媒体库统一管理。 -- **Emby 协议兼容**:Infuse、VidHub、SenPlayer、Fileball 等客户端可按 Emby/Jellyfin 方式添加服务器。 -- **本地 + 网盘**:支持本地硬盘、下载目录、OpenList、CloudDrive2、WebDAV、STRMURL 和 302 反代播放。 -- **订阅下载入库**:连接 qBittorrent 后支持搜索、订阅、下载完成整理、刮削和入库通知。 -- **多用户与权限**:管理员/普通用户、有效期、成人内容开关、设备管理、注册码和 Telegram Bot 绑定。 -- **灵活部署**:单镜像 SQLite 一键起步,或按规模选择 PostgreSQL、Redis、OpenSearch,低配 NAS 到大库检索都能覆盖。 ## 快速开始 -最推荐使用 Docker Compose。仓库提供四份独立完整模板,全部不依赖 `.env`。想最省心就下载单镜像档(SQLite,只有一个镜像);只需要按需修改访问端口、媒体目录、下载目录和可选硬件设备。需要多用户/高并发再选第一档起的 PostgreSQL 档位。 +推荐使用 Docker Compose。仓库提供四份**互相独立**的完整模板,无需 `.env` 即可起步。 ```bash -mkdir -p MMTL -cd MMTL -# 最省心:单镜像 + SQLite,只启动一个容器 -curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.simple.yml -o docker-compose.yml -# 或第一档:PostgreSQL(多用户/高并发更稳) -# curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.yml -o docker-compose.yml +mkdir -p MeBox && cd MeBox + +# 最省心:单镜像 + 内置 SQLite +curl -fsSL https://raw.githubusercontent.com/truewhile/MeBox/main/docker-compose.simple.yml -o docker-compose.yml + +# 或多用户场景:PostgreSQL 第一档 +# curl -fsSL https://raw.githubusercontent.com/truewhile/MeBox/main/docker-compose.yml -o docker-compose.yml + docker compose up -d ``` -启动后访问: +浏览器访问: ```text http://服务器IP:18080 ``` -默认账号: - -```text -admin / admin123 -``` - -首次登录后请立刻修改管理员密码。 +默认账号:`admin` / `admin123`(首次登录后请立即修改密码) 镜像地址: ```text -GHCR:ghcr.io/truewhile/mmtl:latest +ghcr.io/truewhile/mebox:latest ``` +--- + ## 部署档位 -MMTL 推荐按机器资源和用户规模选择部署档位。每份 Compose 文件都是完整文件,不需要再叠加多个 `-f`。想一个镜像跑起来就选单镜像档(SQLite);需要多用户 / 高并发时再用 PostgreSQL 三档。Redis 和 OpenSearch 是增强组件,不替代 PostgreSQL。 +按机器资源选择档位。每份 Compose 文件均可单独使用,**不要**叠加多个 `-f`。 -| 档位 | 完整配置文件 | 组件 | 适合场景 | +| 档位 | 配置文件 | 组件 | 适合场景 | | --- | --- | --- | --- | -| 单镜像档 | `docker-compose.simple.yml` | MMTL + 内置 SQLite | 新手、单人使用、只想一个镜像跑起来的低配机器 | -| 第一档 | `docker-compose.yml` | MMTL + PostgreSQL | 大多数 NAS、个人/家庭使用、低内存机器 | -| 第二档 | `docker-compose.standard.yml` | MMTL + PostgreSQL + Redis | 多用户、Emby 客户端频繁刷新、首页/媒体列表访问较多 | -| 第三档 | `docker-compose.search.yml` | MMTL + PostgreSQL + Redis + OpenSearch | 超大媒体库、复杂全文搜索、后续需要独立搜索索引 | +| 单镜像档 | `docker-compose.simple.yml` | MeBox + SQLite | 新手、单人、低配 NAS,只想一个容器跑起来 | +| 第一档 | `docker-compose.yml` | MeBox + PostgreSQL | 大多数家庭 NAS,多用户更稳 | +| 第二档 | `docker-compose.standard.yml` | + Redis | 多用户、Emby 客户端频繁刷新、首页/列表访问多 | +| 第三档 | `docker-compose.search.yml` | + OpenSearch | 超大媒体库、复杂全文搜索(内存占用更高) | -### 单镜像档:SQLite(最省心) +### 单镜像档要点 -只启动 MMTL 一个镜像,主数据库用内置 SQLite,不需要 PostgreSQL / Redis / `.env`。变量最少、资源占用最低,适合新手和单人使用。日后需要多用户或更高并发时,保留 `./data` 后切换到第一档的 PostgreSQL 即可。 - -```bash -mkdir -p MMTL -cd MMTL -curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.simple.yml -o docker-compose.yml -docker compose up -d -``` - -第一次部署通常只需要改 `docker-compose.yml` 里的这几处: +- 只启动 **一个** MeBox 容器,数据在 `./data/mebox.db` +- 通常只需改端口与媒体目录挂载 +- **不要**设置 `MEBOX_DATABASE_DSN`,否则会切到 PostgreSQL ```yaml ports: - - "18080:8080" # 改左边 18080 即可 + - "18080:8080" volumes: - ./data:/data # 必须备份 - - ./media:/media # 改左边为你的媒体目录,例如 /vol1/1000/Media:/media - # - /dev/dri:/dev/dri # Intel 核显硬解需要时取消注释 + - ./cache:/cache # 可重建 + - ./media:/media # 改成你的媒体目录 ``` -网页后台添加媒体库时填写容器内路径: +网页添加媒体库时填写容器内路径,例如 `/media`、`/media/电影`。 -```text -/media -/media/电影 -/media/电视剧 -``` +### PostgreSQL 档位要点 -关键数据目录: - -```text -./data JWT 密钥、运行配置、SQLite 主数据库(mmtl.db)——必须备份 -./cache 海报/临时缓存,可重建 -./media 媒体库 -``` - -> 单镜像模式请不要配置 `MMTL_DATABASE_DSN`;一旦填了 DSN 就会切回 PostgreSQL。 - -### 第一档:PostgreSQL - -第一档是默认推荐部署。它只启动主服务和 PostgreSQL,资源占用最低,适合绝大多数 NAS。 - -```bash -mkdir -p MMTL -cd MMTL -curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.yml -o docker-compose.yml -docker compose up -d -``` - -关键数据目录: - -```text -./postgres PostgreSQL 主数据库,必须备份 -./data JWT 密钥、运行配置、旧 SQLite 迁移源 -./cache 海报、临时文件、转码缓存,可删除重建 -``` - -### 第二档:PostgreSQL + Redis - -第二档是独立完整文件,包含第一档全部配置并额外启用 Redis。Redis 用作热缓存,能减轻多用户和 Emby 客户端频繁刷新时的数据库压力。 - -```bash -mkdir -p MMTL -cd MMTL -curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.standard.yml -o docker-compose.yml -docker compose up -d -``` - -Redis 数据目录是 `./redis`。它主要保存缓存,通常可重建;真正需要备份的仍然是 `./postgres` 和 `./data`。 - -### 第三档:PostgreSQL + Redis + OpenSearch - -第三档是独立完整文件,包含第二档全部配置并额外启用 OpenSearch,用于大库全文搜索和独立搜索索引。OpenSearch 常驻内存明显更高,低配 NAS 不建议开启。 - -```bash -mkdir -p MMTL -cd MMTL -curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.search.yml -o docker-compose.yml -docker compose up -d -``` - -OpenSearch 数据目录是 `./opensearch`。搜索索引可重建,但重建大库索引会花时间;机器资源足够时再开启第三档。 - -## 配置示例 - -仓库内提供四份推荐 Compose 文件: - -```text -docker-compose.simple.yml 单镜像档:MMTL + 内置 SQLite -docker-compose.yml 第一档:MMTL + PostgreSQL -docker-compose.standard.yml 第二档:MMTL + PostgreSQL + Redis -docker-compose.search.yml 第三档:MMTL + PostgreSQL + Redis + OpenSearch -``` - -仓库只保留面向用户部署和项目维护的必要文件。旧的本地部署脚本、发包脚本、开发机辅助脚本、`.env` 示例和旧高级 Compose 模板已经移除;Linux / Docker 用户按上面四个 Compose 文件部署即可。开发者本地生成的 `bin/`、`data/`、`cache/`、`logs/`、`.tmp/`、`tools/` 等目录已列入 `.gitignore`,不应提交到仓库。 - -如果直接下载为 `docker-compose.yml`,启动命令统一是: - -```bash -docker compose up -d -``` - -如果保留原始文件名,也可以这样启动: - -```bash -docker compose -f docker-compose.simple.yml up -d -docker compose -f docker-compose.standard.yml up -d -docker compose -f docker-compose.search.yml up -d -``` - -常用配置片段如下,注释保留为中文,方便直接复制到 NAS 上调整: +- 主库在 `./postgres`,配置与密钥在 `./data` +- 若存在旧版 `./data/mebox.db`,首次启动会自动迁移到 PostgreSQL +- 迁移完成后可将 `MEBOX_DATABASE_DB_PATH` 改为不存在路径,避免重复检查: ```yaml -services: - mmtl: - image: ghcr.io/truewhile/MMTL:latest - ports: - # 左边是宿主机访问端口,右边是容器内端口。 - - "18080:8080" - volumes: - # 运行数据:JWT 密钥、配置、旧 SQLite 迁移源。 - - ./data:/data - - # 缓存目录:海报、临时文件、转码缓存,可删除重建。 - - ./cache:/cache - - # 媒体库目录:自动整理/重命名/入库需要写权限。 - - /vol1/1000/Media:/media - environment: - TZ: Asia/Shanghai - - # PostgreSQL 主数据库。 - MMTL_DATABASE_TYPE: postgres - MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable - - # 旧 SQLite 迁移源:只在从旧版 data/mmtl.db 导入时使用。 - MMTL_DATABASE_DB_PATH: /data/mmtl.db - - # 路径换算:宿主机路径和容器路径必须一一对应。 - MMTL_MEDIA_DIR: /vol1/1000/Media - MMTL_MEDIA_CONTAINER_DIR: /media - MMTL_DOWNLOAD_DIR: /vol1/1000/Downloads - MMTL_DOWNLOAD_CONTAINER_DIR: /downloads +MEBOX_DATABASE_DB_PATH: /data/no-sqlite-migration.db ``` +### 必须备份与可重建 + +| 路径 | 说明 | +| --- | --- | +| `./data` | JWT 密钥、运行配置、SQLite 主库或迁移源 | +| `./postgres` | PostgreSQL 主库(PG 档位) | +| `./cache` | 海报/转码缓存,可重建 | +| `./redis` | 热缓存,可重建 | +| `./opensearch` | 搜索索引,可重建 | + +### 更新镜像 + +```bash +docker compose pull mebox +docker compose up -d --no-deps mebox +``` + +日常更新只拉 `mebox` 服务即可,不要随意 `docker compose pull` 升级 PostgreSQL/Redis/OpenSearch 基础镜像。 + +--- + ## 路径映射 -路径映射是 Docker 部署里最容易填错的地方。原则是:`volumes` 左边是宿主机真实路径,右边是容器内路径;环境变量里也要保持对应关系。 +Docker 部署最常见的问题是路径填错。记住: + +- `volumes` **左侧**是宿主机真实路径,**右侧**是容器内路径 +- 网页后台添加媒体库时,应填写**容器内**路径(如 `/media/电影`) +- 若使用自动整理/下载入库,`MEBOX_MEDIA_DIR` 与 `MEBOX_DOWNLOAD_DIR` 需与挂载一致 NAS 示例: ```yaml volumes: - - /vol1/1000/Docker/moviepilot-v2/media:/vol1/1000/Docker/moviepilot-v2/media - - /vol1/1000/qBittorrent/downloads:/vol1/1000/qBittorrent/downloads + - /vol1/1000/Media:/media + - /vol1/1000/Downloads:/downloads environment: - MMTL_MEDIA_DIR: /vol1/1000/Docker/moviepilot-v2/media - MMTL_MEDIA_CONTAINER_DIR: /vol1/1000/Docker/moviepilot-v2/media - MMTL_DOWNLOAD_DIR: /vol1/1000/qBittorrent/downloads - MMTL_DOWNLOAD_CONTAINER_DIR: /vol1/1000/qBittorrent/downloads + MEBOX_MEDIA_DIR: /vol1/1000/Media + MEBOX_MEDIA_CONTAINER_DIR: /media + MEBOX_DOWNLOAD_DIR: /vol1/1000/Downloads + MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads ``` -Windows Docker Desktop 示例: +--- -```yaml -volumes: - - D:/Media:/media -environment: - MMTL_MEDIA_DIR: D:/Media - MMTL_MEDIA_CONTAINER_DIR: /media -``` +## 首次使用建议 -如果后台添加媒体库时填的是 `/vol1/...`,Compose 里也建议把同一个 `/vol1/...` 挂进容器,避免自动整理和下载入库时路径不可访问。 +1. **创建媒体库** → 填写 `/media/...` → 执行扫库 +2. **配置元数据源** → 系统设置中添加 TMDb、Bangumi 等 API +3. **(可选)连接 qBittorrent** → 下载客户端设置,宿主机可用 `http://host.docker.internal:8085` +4. **(可选)配置网盘账号** → STRM 管理中添加 OpenList / 115 / WebDAV 等 +5. **第三方播放器** → 以 Emby 服务器添加 `http://服务器IP:18080`,使用 MeBox 账号登录 -## 旧 SQLite 迁移 - -新版推荐 PostgreSQL 作为主数据库。`MMTL_DATABASE_DB_PATH` 不是主库路径,而是旧 SQLite 数据的迁移源。 - -迁移步骤: - -1. 把旧版 `mmtl.db` 放到 `./data/mmtl.db`。 -2. 保持 `MMTL_DATABASE_DB_PATH: /data/mmtl.db`。 -3. 启动一次,确认日志显示迁移完成,网页数据正常。 -4. 备份 `./postgres` 和 `./data`。 -5. 确认不再需要 SQLite 后,把迁移源改成不存在的路径,例如: - -```yaml -environment: - # 已完成 SQLite 迁移后,建议改成不存在的路径,避免下次启动重复检查旧库。 - MMTL_DATABASE_DB_PATH: /data/no-sqlite-migration.db -``` - -不要删除 `./postgres`。PostgreSQL 已经是主数据库,删除它会丢失账号、媒体库、订阅、配置和历史数据。 - -## 日志与 STRM 路径 - -Compose 模板默认把完整应用日志写入 `./data/logs/app.log`,同时拆分 `./data/logs/warn.log` 和 `./data/logs/error.log`。Docker 自身日志也会保留 10 个 50MB 文件: - -```bash -docker compose logs -f mmtl -tail -f ./data/logs/app.log -tail -f ./data/logs/error.log -``` - -如果要排查订阅、站点搜索、自动整理或 STRM 生成问题,保持 `MMTL_LOGGING_LEVEL: info`;需要更细日志时临时改成 `debug`,确认后再改回 `info`。 - -STRM 输出目录请使用容器内可写路径,例如 `/data/strm`,或你已经挂载进容器的媒体目录。旧版本保存过 `/app/data/strm` 的部署会在生成时自动迁移到当前 `MMTL_APP_DATA_DIR`,默认就是 `/data`。 - -## 更新与备份 - -更新镜像: - -```bash -docker compose pull mmtl -docker compose up -d --no-deps mmtl -``` - -不要执行裸 `docker compose pull` 做日常更新。PostgreSQL / Redis / OpenSearch 是数据与缓存基础组件,compose 已设置为 `pull_policy: missing`,首次部署缺镜像时会拉取,日常更新只建议拉取 `mmtl`。需要升级这些基础组件时,请先备份 `./postgres`,再手动修改镜像版本并单独拉取。 - -如果第二档或第三档保留了原始文件名,更新时指定对应完整文件: - -```bash -# 第二档 -docker compose -f docker-compose.standard.yml pull mmtl -docker compose -f docker-compose.standard.yml up -d --no-deps mmtl - -# 第三档 -docker compose -f docker-compose.search.yml pull mmtl -docker compose -f docker-compose.search.yml up -d --no-deps mmtl -``` - -必须备份: - -```text -./postgres PostgreSQL 主数据库 -./data JWT 密钥、运行配置、旧 SQLite 迁移源 -``` - -可重建: - -```text -./cache 图片缓存、临时文件、转码缓存 -./redis Redis 热缓存 -./opensearch 搜索索引 -``` +--- ## 常见问题 -**启动后还是反复迁移 SQLite?** +**扫库或入库很慢?** +先确认路径映射与数据库档位。网盘扫描还受接口限速与目录规模影响;大库可考虑第二档 Redis 或第三档 OpenSearch。 -确认旧数据已经迁移成功后,把 `MMTL_DATABASE_DB_PATH` 改成不存在的路径,例如 `/data/no-sqlite-migration.db`,然后重启容器。 +**qBittorrent 下载后无法整理?** +确认下载目录已通过 `volumes` 挂进容器,且 `MEBOX_DOWNLOAD_*` 环境变量对应正确。 -**扫库或入库速度很慢?** +**硬链接失败(cross-device link)?** +硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 btrfs 子卷或网盘挂载时请改用复制或软链接。 -先确认数据库档位和路径映射正确。第一档已经足够大多数场景;第二档 Redis 能缓解频繁刷新造成的数据库压力;第三档主要增强搜索,不会替代媒体扫描本身。网盘扫描还会受网盘接口响应、目录数量和网络质量影响。 +**第三方播放器连不上?** +确认地址为 `http://IP:18080`,使用 MeBox 用户账号;反代部署需正确配置外部 URL 与 HTTPS 头。 -**qBittorrent 下载完成后无法整理?** - -确认 qBittorrent 保存路径已经通过 `volumes` 挂载进 MMTL 容器,并且 `MMTL_DOWNLOAD_DIR` 与 `MMTL_DOWNLOAD_CONTAINER_DIR` 对应正确。 - -**硬链接目录在 Docker / NAS 上看不到内容?** - -硬链接不能直接链接“目录”本身,只能链接目录里的文件。文件管理器执行目录硬链接时会递归创建目标目录结构,并为每个文件创建硬链接。硬链接还要求源文件和目标文件在容器内属于同一个文件系统/子卷;如果下载目录和媒体目录是两个独立 bind mount、不同硬盘、不同 btrfs 子卷或网盘挂载,系统会返回 `invalid cross-device link`,此时请选择“复制”或“软链接”。 - -**第三方播放器无法连接?** - -确认播放器填写的是 `http://服务器IP:18080`,账号密码使用 MMTL 用户账号。反代部署时需要正确设置外部访问地址和 HTTPS 头。 +--- ## 开发构建 -本地开发需要 Go、Node.js 和 npm。 - -后端会将 `web/dist` 通过 `go:embed` 编进二进制,因此**在编译 / 运行后端之前要先构建前端**,否则 `web` 包会因为缺少嵌入资源而编译失败。 +后端通过 `go:embed` 嵌入 `web/dist`,**编译前必须先构建前端**。 ```bash -# 前端依赖与构建(必须先做,产物被 go:embed 打进二进制) npm --prefix web ci npm --prefix web run build -# 后端测试 go test ./... - -# 本地运行后端(二进制自带前端界面,无需额外 web 目录) -go run ./cmd/server - -# 本地运行前端开发服务器 -npm --prefix web run dev +go run ./cmd/server # http://127.0.0.1:8080 +npm --prefix web run dev # http://127.0.0.1:3000 ``` -前端开发服务器默认访问: +CI 会在 Release 中提供 Windows / Linux / macOS 的 amd64、arm64 单文件可执行程序。 -```text -http://127.0.0.1:3000 -``` +--- -后端健康检查: +## 鸣谢 -```text -http://127.0.0.1:8080/api/health -``` +MeBox 在 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) 的基础上 fork 并持续演进。感谢上游项目在媒体库架构、Emby 协议兼容和自托管体验上的奠基工作。 -### 交叉编译单文件发布物 +项目中许多网盘同步、STRM 与媒体整理相关的设计与实现,也参考了 [qmediasync](https://github.com/qicfan/qmediasync)。感谢该项目的思路与实践经验。 -CI(`.github/workflows/Auto-docker-publish.yml`)每次发布会自动为 Windows / Linux(含 Debian) / macOS 交叉编译 amd64 + arm64 的单文件可执行程序,并上传到对应的 GitHub Release。你可以在 Releases 页面下载 `.zip`(Windows)或 `.tar.gz`(Linux / macOS)附件,解压后直接运行其中的 `mmtl`(Windows 为 `mmtl.exe`),无需额外携带前端目录。 - -本地手动交叉编译某个平台: - -```bash -# 先构建前端 -npm --prefix web ci && npm --prefix web run build - -# 例如:构建 Linux amd64 单文件 -CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ - go build -trimpath -ldflags="-s -w" -o mmtl-linux-amd64 ./cmd/server -``` +--- ## 贡献与反馈 -提交 Bug、功能建议或 Pull Request 前,请先阅读 [贡献规范](CONTRIBUTING.md)。 +提交 Issue 或 Pull Request 前,请阅读 [贡献规范](CONTRIBUTING.md) 与 [安全策略](SECURITY.md)。 -- Bug 反馈请使用 Issue 模板,并提供部署方式、复现步骤、日志和关键配置。 -- 功能建议请说明使用场景、期望行为和可接受的替代方案。 -- 安全漏洞请不要公开发 Issue,按 [安全策略](SECURITY.md) 使用私密渠道报告。 -- Pull Request 请从独立分支或 fork 分支发起,不要直接向 `main` 推送。 -- 分支名建议使用 `fix/...`、`feat/...`、`docs/...` 或 `test/...`,例如 `docs/contribution-guidelines`。 -- 提交前按改动范围运行 `go test ./...`、`npm --prefix web run build` 或定向测试,并在 PR 中说明验证结果。 +- Bug 请附部署方式、复现步骤与相关日志 +- 功能建议请说明使用场景与期望行为 +- PR 请从独立分支发起,提交前运行 `go test ./...` 与 `npm --prefix web run build` + +--- ## Star History - + - - - Star History Chart + + + Star History Chart +--- + ## 许可证 -本项目使用 GPL-3.0 License。详见 [LICENSE](LICENSE)。 +本项目采用 [GPL-3.0](LICENSE) 许可证。 diff --git a/README_EN.md b/README_EN.md index 9ef5096..0afda28 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,21 +1,22 @@ -# MMTL (My Movie and TV Library) +# MeBox

- MMTL Logo + MeBox Logo

-

A lightweight, polished, NAS-friendly private media center

+

A self-hosted media center for NAS and home theater

- Docker-first setup · Multi-user management · Media library · Metadata · Downloads · Emby-protocol clients · Cloud playback + Libraries · Metadata · Cloud STRM · Emby protocol · Remote Emby mounts · Multi-user · Docker-first

中文 · + Overview · Quick Start · - Docker Compose · - FAQ · - Live Demo + Deployment · + Acknowledgements · + Development

@@ -27,78 +28,51 @@ --- -## What is it? +## Overview -MMTL is a self-hosted media center for personal libraries, home NAS, and home-theater users. +**MeBox** is a self-hosted private media management system for NAS, mini PCs, family sharing, and multi-device playback. This repository is a maintained fork of [MediaStationGo](https://github.com/ShukeBta/MediaStationGo), extended with stronger cloud playback, task queues, remote mounts, and permission controls. -It helps you: +In practice, MeBox gives you: -- Manage movies, TV shows, anime, variety shows, music, and adult libraries. -- Create multiple user accounts for family members, friends, or different devices. -- Scan files and enrich posters, summaries, years, seasons, and episodes. -- Play in the web UI, or log in with a MMTL account from Emby-protocol apps such as Infuse, VidHub, SenPlayer, and Emby clients. -- Connect qBittorrent for search, subscriptions, downloads, and post-download organization. -- Connect OpenList, CloudDrive2, WebDAV, and other storage backends with STRMURL or 302 redirect playback. -- Run on NAS, mini PCs, VPS, Linux, Windows Docker Desktop, or any Docker-friendly host. +- A modern **web media library** +- An **Emby/Jellyfin-compatible protocol gateway** for third-party players +- A single panel for **local disks, download folders, and cloud storage** -> The project is moving fast. With the default PostgreSQL deployment, back up both `data/` and `postgres/`. +### Key capabilities ---- +| Area | Highlights | +| --- | --- | +| **Libraries** | Movies, TV, anime, variety, music, custom libraries; multi-root scanning; poster wall; continue watching | +| **Metadata** | TMDb, Bangumi, Douban, TheTVDB, Fanart, NFO import, manual matching, scrape queue | +| **Playback** | Web player, HLS transcoding, danmaku, subtitles, play profiles, history and favourites | +| **Emby protocol** | Add MeBox in Infuse, SenPlayer, Fileball, etc. and sign in with MeBox accounts | +| **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 | +| **Users & permissions** | Admin/regular users, expiry, NSFW toggle, play-profile PIN, granular permissions | +| **Operations** | Unified task queue, recycle bin, storage stats, DLNA casting, settings and logs | -## Key Highlights +### Tech stack -- **One server, many clients**: deploy MMTL once; you do not need to run a separate Emby server. -- **Emby-protocol compatibility**: add the server in third-party players as an Emby/Jellyfin-compatible server, then log in with your MMTL username and password. -- **Multi-user management**: supports admins, regular users, account enable/disable, expiry dates, device management, Bot registration, and redeem codes. -- **Local + cloud media in one place**: manage local disks, download folders, OpenList, CloudDrive2, WebDAV, and other storage backends from one panel. -- **Download-to-library workflow**: connect qBittorrent for search, subscriptions, download completion organization, and metadata matching. -- **NAS-friendly**: simple Docker Compose deployment. The primary database lives under `postgres/`, while runtime secrets and files live under `data/`. - ---- - -## Who is it for? - -- **Beginners** who want to edit one `docker-compose.yml` and start the service. -- **NAS users** who want a low-resource media center for local disks and cloud storage. -- **PT/download users** who want downloads, organization, metadata, and playback in one panel. -- **External-player users** who want to log in to Emby-protocol third-party apps with one MMTL account. -- **Family-sharing users** who want separate user accounts without deploying a separate media server for each person. -- **Developers** who want to study or extend a Go + React self-hosted media app. - ---- - -## Live Demo - -- URL: [https://mgo.3jzs.com](https://mgo.3jzs.com) -- Username: `admin` -- Password: `admin123` - -> The demo is for feature preview only. Do not save private API keys, tracker cookies, or personal data there. +- **Backend**: Go, Gin, GORM, SQLite or PostgreSQL, optional Redis and OpenSearch +- **Frontend**: React 18, Vite, TypeScript, Tailwind CSS, Zustand +- **Deployment**: Standalone Docker Compose templates, amd64/arm64 images, single-binary releases --- ## Quick Start -Docker Compose is the recommended path. Beginners do not need `.env`, bare-metal binaries, or source builds. Use the single-image SQLite template if you want the smallest possible setup. +Docker Compose is the recommended path. The repo ships four **standalone** templates; no `.env` is required. ```bash -mkdir -p MMTL -cd MMTL -# Simplest option: one MMTL container + SQLite -curl -fsSL https://raw.githubusercontent.com/ShukeBta/MMTL/main/docker-compose.simple.yml -o docker-compose.yml -# Or tier 1: MMTL + PostgreSQL -# curl -fsSL https://raw.githubusercontent.com/ShukeBta/MMTL/main/docker-compose.yml -o docker-compose.yml -``` +mkdir -p MeBox && cd MeBox -Edit `docker-compose.yml`: +# Simplest: one container with built-in SQLite +curl -fsSL https://raw.githubusercontent.com/truewhile/MeBox/main/docker-compose.simple.yml -o docker-compose.yml -```bash -vi docker-compose.yml -``` +# Or PostgreSQL tier for multi-user setups +# curl -fsSL https://raw.githubusercontent.com/truewhile/MeBox/main/docker-compose.yml -o docker-compose.yml -Start: - -```bash docker compose up -d ``` @@ -108,478 +82,148 @@ Open: http://SERVER_IP:18080 ``` -Default login: +Default login: `admin` / `admin123` — change the password immediately. + +Image: ```text -Username: admin -Password: admin123 +ghcr.io/truewhile/mebox:latest ``` --- -## Docker Compose Recommended +## Deployment tiers -The repository `docker-compose.yml` is the lightweight recommended template: no `.env` required, and by default it only starts `MMTL + PostgreSQL`. This is the best starting point for most NAS users. +Pick one compose file. Do **not** stack multiple `-f` files. -If you already have an older `./data/mmtl.db`, the first start with the new compose file automatically imports it into PostgreSQL. Keep `./data`; it still stores the JWT secret, runtime data, and the old SQLite migration source. +| Tier | File | Stack | Best for | +| --- | --- | --- | --- | +| Single image | `docker-compose.simple.yml` | MeBox + SQLite | Beginners, single-user, low-resource NAS | +| Tier 1 | `docker-compose.yml` | MeBox + PostgreSQL | Most home NAS deployments | +| Tier 2 | `docker-compose.standard.yml` | + Redis | Multi-user, frequent Emby client refreshes | +| Tier 3 | `docker-compose.search.yml` | + OpenSearch | Very large libraries, advanced full-text search | -### Deployment modes +### Single-image notes -| Mode | Command | Best for | -| --- | --- | --- | -| Single image: SQLite | `docker compose -f docker-compose.simple.yml up -d` | Beginners and single-user setups that want one image only, no PostgreSQL/Redis | -| Lightweight: PG only | `docker compose up -d` | Most NAS devices, lowest resource use | -| Standard: PG + Redis | `docker compose -f docker-compose.standard.yml up -d` | Multi-user use and frequent Emby client refreshes | -| Search enhanced: PG + Redis + OpenSearch | `docker compose -f docker-compose.search.yml up -d` | Huge libraries and future standalone search indexing | +- Only one MeBox container; database lives in `./data/mebox.db` +- Do **not** set `MEBOX_DATABASE_DSN` or it switches to PostgreSQL +- Back up `./data`; `./cache` can be rebuilt -Each compose file is standalone. Do not stack multiple `-f` files together. +### PostgreSQL notes -The single-image `docker-compose.simple.yml` runs only MMTL with a built-in SQLite database — the simplest starting point. Do not set `MMTL_DATABASE_DSN` there, or it switches back to PostgreSQL. Move up to the PostgreSQL modes for multi-user or high-concurrency use (keep `./data` when you switch). Redis and OpenSearch are enhancement layers, not source databases. Do not enable OpenSearch by default on low-memory NAS devices. +- Primary DB: `./postgres`; secrets and runtime files: `./data` +- Existing `./data/mebox.db` migrates automatically on first start +- After migration, point `MEBOX_DATABASE_DB_PATH` at a non-existent file to disable re-checks -### Database Choice And Disabling SQLite +### Backup -The current Docker Compose setup uses PostgreSQL by default. SQLite is no longer the primary database in the recommended Docker deployment. The runtime database is controlled by: - -```yaml -environment: - MMTL_DATABASE_TYPE: postgres - MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable -``` - -`MMTL_DATABASE_DB_PATH` is only used as a one-time migration source for old SQLite data: - -- Fresh installs: `docker compose up -d` uses PostgreSQL and does not create a new SQLite primary database. -- Upgrades: if `./data/mmtl.db` exists, the first start with the new compose file imports it into PostgreSQL. -- Migration fills missing rows by primary key and skips rows that already exist. If it fails partway through, a later start continues the remaining tables. -- After a successful import, PostgreSQL gets a completion marker in the `settings` table, so the old SQLite file is not imported again. -- Redis is a hot cache and OpenSearch is a search index; neither is a source database. - -Recommended SQLite to PostgreSQL upgrade flow: - -```bash -docker compose pull mmtl -docker compose up -d --no-deps mmtl -docker compose logs -f mmtl -``` - -After you see `sqlite data migrated to postgres`, or after the web UI shows your users, libraries, and settings correctly, you can stop using the old SQLite file as a migration source. - -To make the deployment PostgreSQL-only after migration, keep PostgreSQL selected and point the old SQLite migration path at a non-existent file: - -> Only do this after the web UI confirms that users, libraries, settings, and media rows are already present in PostgreSQL. - -```yaml -environment: - MMTL_DATABASE_TYPE: postgres - MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable - MMTL_DATABASE_DB_PATH: /data/disabled-sqlite-migration.db -``` - -Then rename or move the old host-side SQLite file as an offline backup: - -```bash -mv data/mmtl.db data/mmtl.sqlite.bak -``` - -For bare-metal or custom `config.yaml` deployments, use the same idea: - -```yaml -database: - type: postgres - dsn: postgres://mmtl:mmtl@127.0.0.1:5432/mmtl?sslmode=disable - db_path: "" -``` - -Do not delete `./postgres`. After migration, it is the real primary database. Keep `./data` too, because it stores the JWT secret and runtime files. - -### Choose an image source - -Both image sources are supported. Pick one and put it in `image:`: - -| Source | Image | Best for | -| --- | --- | --- | -| GitHub Container Registry (GHCR) | `ghcr.io/shukebta/mmtl:latest` | Recommended default, follows repository releases | -| Docker Hub | `shukbet/mmtl:latest` | Backup source when GHCR is slow or unavailable | - -To pin a version, first confirm the tag exists on the repository Packages page. Use this format: - -```yaml -image: ghcr.io/shukebta/mmtl: -# If GHCR does not have that tag, use Docker Hub as the backup: -# image: shukbet/mmtl:MMTL-v0.0.72 -``` - -For the simplest setup, keep GHCR `latest`. - -Manual pull examples: - -```bash -# GitHub Container Registry -docker pull ghcr.io/shukebta/mmtl:latest - -# Docker Hub backup -docker pull shukbet/mmtl:latest -``` - -Focus on this part: - -```yaml -volumes: - - ./data:/data - - ./cache:/cache - - ./media:/media - - ./downloads:/downloads -``` - -Meaning: - -| Host path | Container path | Purpose | -| --- | --- | --- | -| `./data` | app `/data` | Settings, JWT secret, old SQLite migration source; the primary DB is under `./postgres` | -| `./cache` | app `/cache` | Cache; safe to clean when needed | -| `./media` | `/media` | Media libraries; use `/media/...` in the web UI | -| `./downloads` | `/downloads` | Download directory and organization source | -| `./postgres` | PostgreSQL `/var/lib/postgresql/data` | New default primary database; back this up | -| `./redis` | Redis `/data` | Used only in standard mode; hot cache, rebuildable | -| `./opensearch` | OpenSearch `/usr/share/opensearch/data` | Used only in search-enhanced mode; higher memory use | - -If your NAS paths are: - -```text -/vol1/1000/Media -/vol1/1000/Downloads -``` - -change the compose file to: - -```yaml -volumes: - - ./data:/data - - ./cache:/cache - - /vol1/1000/Media:/media - - /vol1/1000/Downloads:/downloads - -environment: - MMTL_MEDIA_DIR: /vol1/1000/Media - MMTL_DOWNLOAD_DIR: /vol1/1000/Downloads -``` - -Rules: - -- The left side of `volumes` is the real path on your host/NAS. -- The right side is the container path. Keep `/media` and `/downloads` unless you know why you are changing them. -- In the web UI, create libraries with container paths such as `/media/Movies` or `/media/TV`. -- Do not write NAS absolute paths as `./vol1/...`; `./` means a folder under the current compose directory. -- On Windows Docker Desktop, paths like `D:/Media:/media` and `D:/Downloads:/downloads` are fine. -- If you only scan/play existing media and never organize into the library, you may add `:ro`; if you use organize/rename/ingest, the media mount must stay writable. - -### Minimal compose example - -The root `docker-compose.yml` follows this style: - -```yaml -services: - mmtl: - # Pick one image source: - # GitHub Container Registry (GHCR): - image: ghcr.io/shukebta/mmtl:latest - # Docker Hub backup: - # image: shukbet/mmtl:latest - - restart: unless-stopped - init: true - depends_on: - postgres: - condition: service_healthy - - # Browser: http://SERVER_IP:18080 - ports: - - "18080:8080" - - # Let the container reach qBittorrent running on the host: - # qB URL example: http://host.docker.internal:8085 - extra_hosts: - - "host.docker.internal:host-gateway" - - volumes: - # Application data. Back this up before upgrades. - - ./data:/data - - ./cache:/cache - - # Beginners can create ./media and ./downloads. - # NAS users should replace source with real absolute paths. - # create_host_path=false prevents Docker from silently creating an empty - # folder when the host path is wrong. - - type: bind - source: ./media - target: /media - bind: - create_host_path: false - - type: bind - source: ./downloads - target: /downloads - bind: - create_host_path: false - - environment: - TZ: Asia/Shanghai - PUID: "1000" - PGID: "1000" - - MMTL_APP_HOST: 0.0.0.0 - MMTL_APP_PORT: 8080 - MMTL_APP_WEB_DIR: /app/web/dist - MMTL_APP_DATA_DIR: /data - - # Lightweight mode uses PostgreSQL by default. - # Old SQLite data migrates from this path on first start. - MMTL_DATABASE_TYPE: postgres - MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable - # After migration, change this to /data/disabled-sqlite-migration.db to disable the SQLite migration source. - MMTL_DATABASE_DB_PATH: /data/mmtl.db - MMTL_CACHE_CACHE_DIR: /cache - - # Use /media and /downloads in the web UI and downloader by default. - # Only set MMTL_*_DIR to real host paths when migrating old - # libraries/tasks that already stored host paths. - MMTL_MEDIA_DIR: /media - MMTL_MEDIA_CONTAINER_DIR: /media - MMTL_DOWNLOAD_DIR: /downloads - MMTL_DOWNLOAD_CONTAINER_DIR: /downloads - - postgres: - image: postgres:16-alpine - restart: unless-stopped - environment: - POSTGRES_DB: mmtl - POSTGRES_USER: mmtl - POSTGRES_PASSWORD: mmtl - volumes: - - ./postgres:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mmtl -d mmtl"] - interval: 10s - timeout: 5s - retries: 10 - -``` - -> Note: PostgreSQL is the primary database. Lightweight mode still has short in-process caching. Redis is a cross-process hot cache, and OpenSearch is a search enhancement layer; neither is a source database. - ---- - -## First-time Setup - -1. **Create a library** - - Go to the library page. - - Use a container path such as `/media/Movies`. - - Start a scan. - -2. **Connect qBittorrent** - - Go to download client settings. - - If qBittorrent runs on the host, try `http://host.docker.internal:8085`. - -3. **Configure metadata providers** - - Go to system settings / external APIs. - - Add TMDb, Bangumi, TheTVDB, Fanart, Douban, or other providers when needed. - -4. **Use external players** - - Add the server as an Emby/Jellyfin-compatible server. - - Server URL: `http://SERVER_IP:18080`. - - Use the username and password created in MMTL. No separate Emby server is required. - - Admins can create regular users in the web UI or Bot so each person can log in with their own account. - -5. **Use cloud playback** - - Configure OpenList, CloudDrive2, WebDAV, or another provider in storage settings. - - Choose STRMURL or 302 redirect playback in the admin settings. - - The enabled option takes priority. If both are disabled, playback falls back to the normal server playback path. - ---- - -## Update, Backup, Logs +| Path | Notes | +| --- | --- | +| `./data` | JWT secret, config, SQLite DB or migration source | +| `./postgres` | PostgreSQL primary DB | +| `./cache`, `./redis`, `./opensearch` | Rebuildable | ### Update ```bash -docker compose pull mmtl -docker compose up -d --no-deps mmtl +docker compose pull mebox +docker compose up -d --no-deps mebox ``` -### Logs +--- -```bash -docker compose logs -f mmtl -tail -f ./data/logs/app.log -tail -f ./data/logs/error.log +## Path mapping + +The most common Docker mistake is mixing host paths with container paths. + +- Left side of `volumes` = real host/NAS path +- Right side = container path; use `/media/...` in the web UI +- Keep `MEBOX_MEDIA_DIR` / `MEBOX_DOWNLOAD_DIR` aligned with mounts when organizing or ingesting downloads + +Example: + +```yaml +volumes: + - /vol1/1000/Media:/media + - /vol1/1000/Downloads:/downloads +environment: + MEBOX_MEDIA_DIR: /vol1/1000/Media + MEBOX_MEDIA_CONTAINER_DIR: /media + MEBOX_DOWNLOAD_DIR: /vol1/1000/Downloads + MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads ``` -The compose templates keep full application logs in `./data/logs/app.log` and split warnings/errors into `warn.log` and `error.log`. Keep `MMTL_LOGGING_LEVEL=info` while diagnosing subscription, site search, organizer, or STRM generation issues; temporarily switch to `debug` only when deeper tracing is needed. +--- -Use a writable container path for STRM output, such as `/data/strm` or a mounted media path. Deployments that previously saved `/app/data/strm` are migrated automatically to the configured `MMTL_APP_DATA_DIR`, which defaults to `/data`. +## First-time setup -### Backup - -For the default PostgreSQL deployment, back up: - -```text -data/ -postgres/ -``` - -`postgres/` is the primary database and contains users, libraries, settings, and media metadata. `data/` stores the JWT secret, runtime files, and optional old SQLite migration source. - -If you enabled the extended modes, these are optional: - -```text -redis/ # hot cache, safe to rebuild -opensearch/ # search index, rebuildable; backing it up can save reindex time on huge libraries -``` - -`cache/` is usually not important. If you explicitly still use `database.type=sqlite`, the primary database remains `data/mmtl.db`. - -### Stop - -```bash -docker compose down -``` +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) +4. Optionally configure cloud accounts under STRM management +5. Add the server in Emby-compatible players at `http://SERVER_IP:18080` using MeBox credentials --- ## FAQ -### 1. The web page does not open +**Library scan is slow** +Check path mapping and DB tier. Cloud scans also depend on API limits and folder size. -Check the container: +**qBittorrent downloads are not organized** +Ensure the download directory is mounted into the container and env vars match. -```bash -docker ps -docker compose logs --tail=100 mmtl -``` +**Hardlink fails with cross-device link** +Hardlinks require the same filesystem/subvolume; use copy or symlink across disks or cloud mounts. -Then open: - -```text -http://SERVER_IP:18080 -``` - -### 2. The library cannot find files - -Most cases are path mistakes. - -- Docker maps media to `/media`. -- In the web UI, use `/media/Movies`, not the original NAS path. -- Docker maps downloads to `/downloads`; use `/downloads` as the organization source when possible. - -### 3. qBittorrent cannot connect - -If qBittorrent is on the host, try: - -```text -http://host.docker.internal:8085 -``` - -If qBittorrent is on another machine, use that machine's LAN IP. - -### 4. NAS CPU usage is high - -Suggested settings: - -- Set `ffprobe.max_concurrent` to `1`. -- Enable automatic organization, scrape-after-scan, and boot cloud scan only when you really need them. -- Avoid frequent full-library scans on large libraries. Prefer manual scan or scheduled night sync. - -### 5. Should I use `.env`? - -Beginners should not. Editing `docker-compose.yml` directly is easier to understand. - -`.env` is not required by the provided deployment templates. For the single-image template, edit `docker-compose.simple.yml` directly and only adjust the port, volume paths, and optional hardware device mapping. +**External player cannot connect** +Use `http://IP:18080` and a MeBox user account; reverse proxies need correct external URL and HTTPS headers. --- -## Features - -| Area | Features | -| --- | --- | -| Libraries | Movies, TV shows, anime, variety, music, adult content | -| Metadata | NFO, local artwork, TMDb, TheTVDB, Bangumi, Douban, Fanart, JavBus/JavDB | -| Playback | Web playback, HTTP Range, HLS transcoding, direct links, STRMURL, 302 redirect | -| External clients | Emby-protocol compatible APIs; MMTL accounts can log in to third-party players | -| User management | Multi-user accounts, admin/regular users, expiry dates, device management, Bot registration and redeem codes | -| Downloads | qBittorrent, site search, subscriptions, post-download organization | -| File manager | Browse, organize, copy, move, hardlink, symlink | -| Operations | Task queue, recycle bin, duplicate files, notifications, logs | -| AI | OpenAI-compatible API, AI search, recommendations, assistant | - -Directory hardlinks are handled by recreating the directory tree and hardlinking -each contained file. Linux cannot hardlink a directory itself. Hardlinks still -require the source and target files to be on the same filesystem/subvolume from -inside the container; if media and downloads are separate bind mounts, disks, -btrfs subvolumes, or cloud mounts, use copy or symlink instead. - ## Development -Regular users should use Docker. Developers can run: +The backend embeds `web/dist` via `go:embed`. Build the frontend first. ```bash -go run ./cmd/server -``` +npm --prefix web ci +npm --prefix web run build -Frontend: - -```bash -cd web -npm install -npm run dev -``` - -Tests: - -```bash go test ./... -cd web && npm run build +go run ./cmd/server +npm --prefix web run dev ``` ---- - -## Community and Friends - -- Telegram group: -- NodeSeek: [https://www.nodeseek.com/](https://www.nodeseek.com/) -- LINUX DO: [https://linux.do/](https://linux.do/) +Release builds ship single-file binaries for Windows, Linux, and macOS on amd64 and arm64. --- -## Donation +## Acknowledgements -If MMTL saves you time, feel free to buy the author a bowl of noodles. +MeBox is forked from and continues to evolve [MediaStationGo](https://github.com/ShukeBta/MediaStationGo). Thank you to the upstream project for the media-library architecture, Emby-protocol compatibility, and self-hosted foundation. -WeChat Donation QR +Many cloud sync, STRM, and media-organization ideas in this project were also informed by [qmediasync](https://github.com/qicfan/qmediasync). Thank you for the reference implementation and design patterns. + +--- + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md) before opening issues or pull requests. --- ## Star History - + - - - Star History Chart + + + Star History Chart --- -## License and Non-Commercial Statement +## License -This project uses `GPL-3.0` as its base license. See [LICENSE](LICENSE). - -The maintainers also state and request: - -- The project is intended for personal learning, home NAS, self-hosted media, non-commercial research, and community collaboration. -- Without explicit written permission from the author, do not use this project or derivative versions for commercial resale, paid hosting, paid SaaS, pre-installed commercial devices, closed-source redistribution, or other profit-oriented commercial use. -- For commercial cooperation, enterprise deployment, custom development, integrated redistribution, or commercial authorization, contact the author first. -- If there is any interpretive difference between this README and the formal `GPL-3.0` license text, the code license is governed by [LICENSE](LICENSE); commercial usage should additionally obtain author permission. - ---- - -

Made with ❤️ by ShukeBta

+This project is licensed under [GPL-3.0](LICENSE). diff --git a/SECURITY.md b/SECURITY.md index fbdbfda..7f6dedb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,13 +1,13 @@ # 安全策略 -MMTL 是自托管媒体系统,常部署在 NAS、家庭网络、Docker、反向代理和第三方下载器环境中。安全问题通常会同时涉及应用代码、容器配置、路径映射、站点 Cookie / API Key、下载器凭据和外部访问入口。请按本策略报告和处理安全问题。 +MeBox 是自托管媒体系统,常部署在 NAS、家庭网络、Docker、反向代理和第三方下载器环境中。安全问题通常会同时涉及应用代码、容器配置、路径映射、站点 Cookie / API Key、下载器凭据和外部访问入口。请按本策略报告和处理安全问题。 ## 支持范围 我们优先支持以下版本和部署方式的安全修复: - 当前 `main` 分支。 -- 最新发布镜像:`ghcr.io/shukebta/mmtl:latest`。 +- 最新发布镜像:`ghcr.io/truewhile/mebox:latest`。 - README 中推荐的 Docker Compose 第一档、第二档、第三档部署方式。 历史版本、私有魔改镜像、未公开补丁分支和非标准部署仍可报告,但维护者可能要求先在最新 `main` 或最新镜像中复现。 @@ -16,7 +16,7 @@ MMTL 是自托管媒体系统,常部署在 NAS、家庭网络、Docker、反 请不要在公开 Issue、PR、讨论区或群聊中披露可利用细节。优先使用 GitHub Security Advisory 私密报告: - + 如果无法使用 GitHub 私密报告,可以先通过项目 README 中的社区入口联系维护者,说明“需要私下报告安全问题”,不要直接贴出利用细节、密钥或完整日志。 @@ -68,14 +68,14 @@ MMTL 是自托管媒体系统,常部署在 NAS、家庭网络、Docker、反 ## 自托管安全基线 -部署 MMTL 时建议: +部署 MeBox 时建议: - 首次登录后立即修改默认 `admin / admin123`。 - 不要把 PostgreSQL、Redis、OpenSearch、qBittorrent WebUI 暴露到公网。 - 反向代理公网访问时启用 HTTPS,并限制管理后台访问来源。 - 使用强随机的 JWT / 加密密钥,妥善备份 `./data` 和数据库。 - 不要在 Issue、PR、截图或日志中公开站点 Cookie、API Key、Passkey、下载器密码。 -- Docker `volumes` 只挂载 MMTL 需要访问的目录,媒体库目录需要写入时再授予写权限。 +- Docker `volumes` 只挂载 MeBox 需要访问的目录,媒体库目录需要写入时再授予写权限。 - 定期更新镜像,并在升级前备份 `./postgres` 和 `./data`。 ## 安全修复 PR diff --git a/VERSION b/VERSION index a758e3a..1a793f0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.57 +0.0.81 diff --git a/cmd/server/log_rotate.go b/cmd/server/log_rotate.go index 371dd01..d970209 100644 --- a/cmd/server/log_rotate.go +++ b/cmd/server/log_rotate.go @@ -7,7 +7,7 @@ import ( "sync" "time" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) const defaultLogMaxSizeMB = 20 diff --git a/cmd/server/logging.go b/cmd/server/logging.go index 90f18b5..6d32fd3 100644 --- a/cmd/server/logging.go +++ b/cmd/server/logging.go @@ -8,7 +8,7 @@ import ( "go.uber.org/zap" "go.uber.org/zap/zapcore" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) // newLogger 根据 cfg.Logging 构建 Zap。 diff --git a/cmd/server/logging_test.go b/cmd/server/logging_test.go index c49331b..1929f4d 100644 --- a/cmd/server/logging_test.go +++ b/cmd/server/logging_test.go @@ -8,7 +8,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) func TestProductionLoggerWritesConfiguredInfoToAppLogAndSplitsWarnError(t *testing.T) { diff --git a/cmd/server/main.go b/cmd/server/main.go index b810eae..713f7a4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,6 +1,6 @@ -// Package main is the MMTL HTTP server entry point. +// Package main is the MeBox HTTP server entry point. // -// MMTL is a Go rewrite of the legacy Python implementation, +// MeBox is a Go rewrite of the legacy Python implementation, // adopting the same tech stack as cropflre/nowen-video: // // Backend: Go 1.25 + Gin + GORM + PostgreSQL/SQLite + Viper + Zap + JWT @@ -21,10 +21,10 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/database" - "github.com/ShukeBta/MMTL/internal/repository" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/database" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" ) // version is overwritten at build time via -ldflags="-X main.version=...". @@ -35,7 +35,7 @@ func effectiveVersion(buildVersion string) string { if buildVersion != "" && buildVersion != "dev" { return buildVersion } - if envVersion := strings.TrimSpace(os.Getenv("MMTL_VERSION")); envVersion != "" { + if envVersion := strings.TrimSpace(os.Getenv("MEBOX_VERSION")); envVersion != "" { return envVersion } if buildVersion == "" { @@ -59,7 +59,7 @@ func main() { defer func() { _ = logger.Sync() }() appVersion := effectiveVersion(version) - logger.Info("starting MMTL", + logger.Info("starting MeBox", zap.String("version", appVersion), zap.Int("port", cfg.App.Port), zap.String("data_dir", cfg.App.DataDir), @@ -135,5 +135,5 @@ func main() { logger.Error("graceful shutdown failed", zap.Error(err)) } services.Close() - logger.Info("MMTL stopped") + logger.Info("MeBox stopped") } diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index 39620bf..368e149 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -12,23 +12,23 @@ import ( ) func TestEffectiveVersionPrefersBuildVersion(t *testing.T) { - t.Setenv("MMTL_VERSION", "MMTL-v0.1.15") + t.Setenv("MEBOX_VERSION", "MeBox-v0.1.15") - if got := effectiveVersion("MMTL-v0.1.16"); got != "MMTL-v0.1.16" { - t.Fatalf("effectiveVersion = %q, want MMTL-v0.1.16", got) + if got := effectiveVersion("MeBox-v0.1.16"); got != "MeBox-v0.1.16" { + t.Fatalf("effectiveVersion = %q, want MeBox-v0.1.16", got) } } func TestEffectiveVersionUsesEnvWhenBuildVersionIsDev(t *testing.T) { - t.Setenv("MMTL_VERSION", " MMTL-v0.1.16 ") + t.Setenv("MEBOX_VERSION", " MeBox-v0.1.16 ") - if got := effectiveVersion("dev"); got != "MMTL-v0.1.16" { - t.Fatalf("effectiveVersion = %q, want MMTL-v0.1.16", got) + if got := effectiveVersion("dev"); got != "MeBox-v0.1.16" { + t.Fatalf("effectiveVersion = %q, want MeBox-v0.1.16", got) } } func TestEffectiveVersionDefaultsToDev(t *testing.T) { - t.Setenv("MMTL_VERSION", "") + t.Setenv("MEBOX_VERSION", "") if got := effectiveVersion(""); got != "dev" { t.Fatalf("effectiveVersion = %q, want dev", got) @@ -85,7 +85,7 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) { if err := os.WriteFile(filepath.Join(webDir, "assets", "app.js"), []byte("console.log('ok')"), 0o644); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(webDir, "brand", "mmtl-logo.svg"), []byte(""), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(webDir, "brand", "mebox-logo.svg"), []byte(""), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(webDir, "artwork-cache-sw.js"), []byte("self.addEventListener('fetch', () => {})"), 0o644); err != nil { @@ -105,7 +105,7 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) { t.Fatalf("asset Cache-Control = %q, want immutable", got) } - brandReq := httptest.NewRequest(http.MethodGet, "/brand/mmtl-logo.svg", nil) + brandReq := httptest.NewRequest(http.MethodGet, "/brand/mebox-logo.svg", nil) brandResp := httptest.NewRecorder() router.ServeHTTP(brandResp, brandReq) if brandResp.Code != http.StatusOK { diff --git a/cmd/server/router.go b/cmd/server/router.go index a073a8b..7c37bb5 100644 --- a/cmd/server/router.go +++ b/cmd/server/router.go @@ -11,12 +11,12 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/handler" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/handler" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/service" - "github.com/ShukeBta/MMTL/web" + "github.com/truewhile/MeBox/web" ) func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) *gin.Engine { @@ -78,7 +78,7 @@ func serveSPA(r *gin.Engine, root fs.FS) { setNoCacheHeaders(c) data, err := fs.ReadFile(root, "index.html") if err != nil { - c.String(http.StatusNotFound, "MMTL web UI not found") + c.String(http.StatusNotFound, "MeBox web UI not found") return } c.Data(http.StatusOK, "text/html; charset=utf-8", data) diff --git a/cmd/server/server_manager.go b/cmd/server/server_manager.go index a2726bc..02ff1da 100644 --- a/cmd/server/server_manager.go +++ b/cmd/server/server_manager.go @@ -13,8 +13,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/service" ) // tlsPair 记录当前正在服务的证书,用于判断是否需要重新绑定监听。 @@ -27,7 +27,7 @@ type tlsPair struct { version string } -// serverManager 负责 MMTL 的 HTTP/HTTPS 监听。HTTPS 设置保存后调用 Reload, +// serverManager 负责 MeBox 的 HTTP/HTTPS 监听。HTTPS 设置保存后调用 Reload, // 在同一个端口上把明文 HTTP 与 TLS 监听热切换,无需重启进程: // // - 关闭旧监听释放端口(同一进程内 Windows 不允许重复绑定同一端口); diff --git a/cmd/server/server_manager_test.go b/cmd/server/server_manager_test.go index e52376d..24c0412 100644 --- a/cmd/server/server_manager_test.go +++ b/cmd/server/server_manager_test.go @@ -17,7 +17,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) func makeTestPairPEM(t *testing.T) (certPEM, keyPEM string) { diff --git a/cmd/server/startup.go b/cmd/server/startup.go index d14ad09..02760a2 100644 --- a/cmd/server/startup.go +++ b/cmd/server/startup.go @@ -8,7 +8,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) func applyCPUThreadLimit(cfg *config.Config, logger *zap.Logger) { diff --git a/docker-compose.search.yml b/docker-compose.search.yml index 3a5e6c5..0453233 100644 --- a/docker-compose.search.yml +++ b/docker-compose.search.yml @@ -1,7 +1,7 @@ -# MMTL 第三档完整 Docker Compose 部署文件 +# MeBox 第三档完整 Docker Compose 部署文件 # # 组件: -# MMTL + PostgreSQL + Redis + OpenSearch +# MeBox + PostgreSQL + Redis + OpenSearch # # 使用方式二选一: # 1. 保存为 docker-compose.yml 后执行: @@ -19,12 +19,8 @@ # admin / admin123 services: - mmtl: - # 镜像二选一: - # 方式一:GitHub 仓库镜像 GHCR(默认,推荐) - image: ghcr.io/shukebta/mmtl:latest - # 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用) - # image: shukbet/mmtl:latest + mebox: + image: ghcr.io/truewhile/mebox:latest restart: unless-stopped init: true @@ -78,45 +74,45 @@ services: PUID: "1000" PGID: "1000" - MMTL_APP_HOST: 0.0.0.0 - MMTL_APP_PORT: 8080 - MMTL_APP_WEB_DIR: /app/web/dist - MMTL_APP_DATA_DIR: /data - MMTL_LOGGING_LEVEL: info - MMTL_LOGGING_FORMAT: console - MMTL_LOGGING_OUTPUT_PATH: /data/logs - MMTL_LOGGING_MAX_SIZE_MB: "50" - MMTL_LOGGING_MAX_BACKUPS: "20" - MMTL_LOGGING_MAX_AGE_DAYS: "30" + MEBOX_APP_HOST: 0.0.0.0 + MEBOX_APP_PORT: 8080 + MEBOX_APP_WEB_DIR: /app/web/dist + MEBOX_APP_DATA_DIR: /data + MEBOX_LOGGING_LEVEL: info + MEBOX_LOGGING_FORMAT: console + MEBOX_LOGGING_OUTPUT_PATH: /data/logs + MEBOX_LOGGING_MAX_SIZE_MB: "50" + MEBOX_LOGGING_MAX_BACKUPS: "20" + MEBOX_LOGGING_MAX_AGE_DAYS: "30" - MMTL_DATABASE_TYPE: postgres - MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable - MMTL_DATABASE_DB_PATH: /data/mmtl.db + MEBOX_DATABASE_TYPE: postgres + MEBOX_DATABASE_DSN: postgres://mebox:mebox@postgres:5432/mebox?sslmode=disable + MEBOX_DATABASE_DB_PATH: /data/mebox.db - MMTL_CACHE_REDIS_URL: redis://redis:6379/0 - MMTL_CACHE_CACHE_DIR: /cache + MEBOX_CACHE_REDIS_URL: redis://redis:6379/0 + MEBOX_CACHE_CACHE_DIR: /cache # OpenSearch 只做搜索索引,主数据仍以 PostgreSQL 为准。 - MMTL_SEARCH_BACKEND: opensearch - MMTL_SEARCH_OPENSEARCH_URL: http://opensearch:9200 - MMTL_SEARCH_INDEX: mmtl_media + MEBOX_SEARCH_BACKEND: opensearch + MEBOX_SEARCH_OPENSEARCH_URL: http://opensearch:9200 + MEBOX_SEARCH_INDEX: mebox_media - MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest + MEBOX_UPDATE_IMAGE: ghcr.io/truewhile/mebox:latest # 默认推荐在网页里使用容器路径 /media。 # 如果旧媒体库已经保存了宿主机路径 /vol1/1000/Media, # 再把这里改成同一个宿主机真实路径用于旧路径换算。 - MMTL_MEDIA_DIR: /media - MMTL_MEDIA_CONTAINER_DIR: /media - MMTL_DOWNLOAD_DIR: /downloads - MMTL_DOWNLOAD_CONTAINER_DIR: /downloads + MEBOX_MEDIA_DIR: /media + MEBOX_MEDIA_CONTAINER_DIR: /media + MEBOX_DOWNLOAD_DIR: /downloads + MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads - MMTL_TRANSCODER_ENABLED: "true" - MMTL_TRANSCODER_HARDWARE_ACCEL: "false" - MMTL_TRANSCODER_REALTIME: "true" - MMTL_TRANSCODER_THREADS: "2" - MMTL_TRANSCODER_MAX_CONCURRENT: "1" - MMTL_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120" + MEBOX_TRANSCODER_ENABLED: "true" + MEBOX_TRANSCODER_HARDWARE_ACCEL: "false" + MEBOX_TRANSCODER_REALTIME: "true" + MEBOX_TRANSCODER_THREADS: "2" + MEBOX_TRANSCODER_MAX_CONCURRENT: "1" + MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120" healthcheck: test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"] @@ -133,18 +129,18 @@ services: postgres: image: postgres:16-alpine - # 首次部署允许拉取;日常更新请只 pull mmtl。 + # 首次部署允许拉取;日常更新请只 pull mebox。 pull_policy: missing restart: unless-stopped environment: - POSTGRES_DB: mmtl - POSTGRES_USER: mmtl - POSTGRES_PASSWORD: mmtl + POSTGRES_DB: mebox + POSTGRES_USER: mebox + POSTGRES_PASSWORD: mebox TZ: Asia/Shanghai volumes: - ./postgres:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mmtl -d mmtl"] + test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mebox -d mebox"] interval: 10s timeout: 5s retries: 10 diff --git a/docker-compose.simple.yml b/docker-compose.simple.yml index f3bbc01..47d675d 100644 --- a/docker-compose.simple.yml +++ b/docker-compose.simple.yml @@ -1,4 +1,4 @@ -# MMTL 单镜像部署模板(SQLite) +# MeBox 单镜像部署模板(SQLite) # # 适合:新手、单人使用、低配 NAS / 小主机。 # 特点:只有一个镜像,不需要 PostgreSQL / Redis / .env。 @@ -12,12 +12,10 @@ # 首次登录后请立刻修改密码。 services: - mmtl: - image: ghcr.io/shukebta/mmtl:latest - # Docker Hub 备用: - # image: shukbet/mmtl:latest + mebox: + image: ghcr.io/truewhile/mebox:latest - container_name: mmtl + container_name: mebox restart: unless-stopped init: true @@ -33,23 +31,7 @@ services: - ./cache:/cache # 媒体库。网页里添加媒体库时填写 /media 或 /media/子目录。 - # NAS 示例:source: /vol1/1000/Media - # Windows Docker Desktop 示例:source: D:/Media - # create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。 - - type: bind - source: ./media - target: /media - bind: - create_host_path: false - - # 下载目录。qBittorrent / Transmission 的保存目录建议也对齐到 /downloads。 - # NAS 示例:source: /vol1/1000/Downloads - # Windows Docker Desktop 示例:source: D:/Downloads - - type: bind - source: ./downloads - target: /downloads - bind: - create_host_path: false + - ./media:/media # 可选:Intel 核显硬解/转码。需要时取消注释,并在后台开启硬件加速。 # - /dev/dri:/dev/dri @@ -61,32 +43,30 @@ services: TZ: Asia/Shanghai # Linux/NAS 文件权限。写入文件权限异常时,改成宿主机实际 uid/gid。 - PUID: "1000" - PGID: "1000" + PUID: "0" + PGID: "0" - MMTL_APP_HOST: 0.0.0.0 - MMTL_APP_PORT: 8080 - MMTL_APP_WEB_DIR: /app/web/dist - MMTL_APP_DATA_DIR: /data + MEBOX_APP_HOST: 0.0.0.0 + MEBOX_APP_PORT: 8080 + MEBOX_APP_WEB_DIR: /app/web/dist + MEBOX_APP_DATA_DIR: /data - # 单镜像档固定使用 SQLite。主数据库文件:./data/mmtl.db。 - MMTL_DATABASE_TYPE: sqlite - MMTL_DATABASE_DB_PATH: /data/mmtl.db - MMTL_CACHE_CACHE_DIR: /cache + # 单镜像档固定使用 SQLite。主数据库文件:./data/mebox.db。 + MEBOX_DATABASE_TYPE: sqlite + MEBOX_DATABASE_DB_PATH: /data/mebox.db + MEBOX_CACHE_CACHE_DIR: /cache # 路径映射保持容器内统一,网页和下载器里优先使用 /media、/downloads。 - MMTL_MEDIA_DIR: /media - MMTL_MEDIA_CONTAINER_DIR: /media - MMTL_DOWNLOAD_DIR: /downloads - MMTL_DOWNLOAD_CONTAINER_DIR: /downloads + MEBOX_MEDIA_DIR: /media + MEBOX_MEDIA_CONTAINER_DIR: /media # 完整应用日志默认写入 ./data/logs/app.log;排查复杂问题时可临时改成 debug。 - MMTL_LOGGING_LEVEL: info - MMTL_LOGGING_FORMAT: console - MMTL_LOGGING_OUTPUT_PATH: /data/logs - MMTL_LOGGING_MAX_SIZE_MB: "50" - MMTL_LOGGING_MAX_BACKUPS: "20" - MMTL_LOGGING_MAX_AGE_DAYS: "30" + MEBOX_LOGGING_LEVEL: info + MEBOX_LOGGING_FORMAT: console + MEBOX_LOGGING_OUTPUT_PATH: /data/logs + MEBOX_LOGGING_MAX_SIZE_MB: "50" + MEBOX_LOGGING_MAX_BACKUPS: "20" + MEBOX_LOGGING_MAX_AGE_DAYS: "30" extra_hosts: # 容器访问宿主机服务用,例如 qBittorrent: http://host.docker.internal:8085 diff --git a/docker-compose.standard.yml b/docker-compose.standard.yml index d523fe0..0eba48f 100644 --- a/docker-compose.standard.yml +++ b/docker-compose.standard.yml @@ -1,7 +1,7 @@ -# MMTL 第二档完整 Docker Compose 部署文件 +# MeBox 第二档完整 Docker Compose 部署文件 # # 组件: -# MMTL + PostgreSQL + Redis +# MeBox + PostgreSQL + Redis # # 使用方式二选一: # 1. 保存为 docker-compose.yml 后执行: @@ -16,12 +16,8 @@ # admin / admin123 services: - mmtl: - # 镜像二选一: - # 方式一:GitHub 仓库镜像 GHCR(默认,推荐) - image: ghcr.io/shukebta/mmtl:latest - # 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用) - # image: shukbet/mmtl:latest + mebox: + image: ghcr.io/truewhile/mebox:latest restart: unless-stopped init: true @@ -73,39 +69,39 @@ services: PUID: "1000" PGID: "1000" - MMTL_APP_HOST: 0.0.0.0 - MMTL_APP_PORT: 8080 - MMTL_APP_WEB_DIR: /app/web/dist - MMTL_APP_DATA_DIR: /data - MMTL_LOGGING_LEVEL: info - MMTL_LOGGING_FORMAT: console - MMTL_LOGGING_OUTPUT_PATH: /data/logs - MMTL_LOGGING_MAX_SIZE_MB: "50" - MMTL_LOGGING_MAX_BACKUPS: "20" - MMTL_LOGGING_MAX_AGE_DAYS: "30" + MEBOX_APP_HOST: 0.0.0.0 + MEBOX_APP_PORT: 8080 + MEBOX_APP_WEB_DIR: /app/web/dist + MEBOX_APP_DATA_DIR: /data + MEBOX_LOGGING_LEVEL: info + MEBOX_LOGGING_FORMAT: console + MEBOX_LOGGING_OUTPUT_PATH: /data/logs + MEBOX_LOGGING_MAX_SIZE_MB: "50" + MEBOX_LOGGING_MAX_BACKUPS: "20" + MEBOX_LOGGING_MAX_AGE_DAYS: "30" - MMTL_DATABASE_TYPE: postgres - MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable - MMTL_DATABASE_DB_PATH: /data/mmtl.db + MEBOX_DATABASE_TYPE: postgres + MEBOX_DATABASE_DSN: postgres://mebox:mebox@postgres:5432/mebox?sslmode=disable + MEBOX_DATABASE_DB_PATH: /data/mebox.db # Redis 只做热缓存,源数据仍在 PostgreSQL;Redis 丢失可自动重建。 - MMTL_CACHE_REDIS_URL: redis://redis:6379/0 - MMTL_CACHE_CACHE_DIR: /cache + MEBOX_CACHE_REDIS_URL: redis://redis:6379/0 + MEBOX_CACHE_CACHE_DIR: /cache - MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest + MEBOX_UPDATE_IMAGE: ghcr.io/truewhile/mebox:latest # 路径换算配置。左边宿主机真实路径要和 volumes 左边保持一致。 - MMTL_MEDIA_DIR: /media - MMTL_MEDIA_CONTAINER_DIR: /media - MMTL_DOWNLOAD_DIR: /downloads - MMTL_DOWNLOAD_CONTAINER_DIR: /downloads + MEBOX_MEDIA_DIR: /media + MEBOX_MEDIA_CONTAINER_DIR: /media + MEBOX_DOWNLOAD_DIR: /downloads + MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads - MMTL_TRANSCODER_ENABLED: "true" - MMTL_TRANSCODER_HARDWARE_ACCEL: "false" - MMTL_TRANSCODER_REALTIME: "true" - MMTL_TRANSCODER_THREADS: "2" - MMTL_TRANSCODER_MAX_CONCURRENT: "1" - MMTL_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120" + MEBOX_TRANSCODER_ENABLED: "true" + MEBOX_TRANSCODER_HARDWARE_ACCEL: "false" + MEBOX_TRANSCODER_REALTIME: "true" + MEBOX_TRANSCODER_THREADS: "2" + MEBOX_TRANSCODER_MAX_CONCURRENT: "1" + MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120" healthcheck: test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"] @@ -122,18 +118,18 @@ services: postgres: image: postgres:16-alpine - # 首次部署允许拉取;日常更新请只 pull mmtl。 + # 首次部署允许拉取;日常更新请只 pull mebox。 pull_policy: missing restart: unless-stopped environment: - POSTGRES_DB: mmtl - POSTGRES_USER: mmtl - POSTGRES_PASSWORD: mmtl + POSTGRES_DB: mebox + POSTGRES_USER: mebox + POSTGRES_PASSWORD: mebox TZ: Asia/Shanghai volumes: - ./postgres:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mmtl -d mmtl"] + test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mebox -d mebox"] interval: 10s timeout: 5s retries: 10 diff --git a/docker-compose.yml b/docker-compose.yml index 4f06234..b2d0c83 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -# MMTL 最简单 Docker Compose 部署文件 +# MeBox 最简单 Docker Compose 部署文件 # # 新手建议: # 1. 不用 .env。 @@ -15,12 +15,8 @@ # admin / admin123 services: - mmtl: - # 镜像二选一: - # 方式一:GitHub 仓库镜像 GHCR(默认,推荐) - image: ghcr.io/shukebta/mmtl:latest - # 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用) - # image: shukbet/mmtl:latest + mebox: + image: ghcr.io/truewhile/mebox:latest restart: unless-stopped init: true @@ -87,52 +83,52 @@ services: PGID: "1000" # 程序基础配置,通常不用改。 - MMTL_APP_HOST: 0.0.0.0 - MMTL_APP_PORT: 8080 - MMTL_APP_WEB_DIR: /app/web/dist - MMTL_APP_DATA_DIR: /data + MEBOX_APP_HOST: 0.0.0.0 + MEBOX_APP_PORT: 8080 + MEBOX_APP_WEB_DIR: /app/web/dist + MEBOX_APP_DATA_DIR: /data # 详细应用日志会保存在 ./data/logs/app.log,warn/error 也会拆分保存。 # 排查复杂问题时可临时改成 debug。 - MMTL_LOGGING_LEVEL: info - MMTL_LOGGING_FORMAT: console - MMTL_LOGGING_OUTPUT_PATH: /data/logs - MMTL_LOGGING_MAX_SIZE_MB: "50" - MMTL_LOGGING_MAX_BACKUPS: "20" - MMTL_LOGGING_MAX_AGE_DAYS: "30" + MEBOX_LOGGING_LEVEL: info + MEBOX_LOGGING_FORMAT: console + MEBOX_LOGGING_OUTPUT_PATH: /data/logs + MEBOX_LOGGING_MAX_SIZE_MB: "50" + MEBOX_LOGGING_MAX_BACKUPS: "20" + MEBOX_LOGGING_MAX_AGE_DAYS: "30" # 轻量模式默认只使用 PostgreSQL,适合大多数 NAS。 - # 旧版 ./data/mmtl.db 存在时,首次启动会自动迁移到 PostgreSQL。 - MMTL_DATABASE_TYPE: postgres - MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable + # 旧版 ./data/mmtl.db 或 ./data/mebox.db 存在时,首次启动会自动迁移到 PostgreSQL。 + MEBOX_DATABASE_TYPE: postgres + MEBOX_DATABASE_DSN: postgres://mebox:mebox@postgres:5432/mebox?sslmode=disable # SQLite 旧库迁移源: - # - 首次从旧版 ./data/mmtl.db 导入时保持此路径。 + # - 首次从旧版 SQLite(mmtl.db / mebox.db)导入时保持此路径。 # - 确认迁移完成后,建议改成 /data/no-sqlite-migration.db 这类不存在的路径。 - MMTL_DATABASE_DB_PATH: /data/mmtl.db - MMTL_CACHE_CACHE_DIR: /cache + MEBOX_DATABASE_DB_PATH: /data/mebox.db + MEBOX_CACHE_CACHE_DIR: /cache # 管理面板热更新默认拉取此镜像,并用 Watchtower 一次性重建当前容器。 - MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest + MEBOX_UPDATE_IMAGE: ghcr.io/truewhile/mebox:latest # 路径换算配置。 # 默认推荐在网页里使用容器路径 /media。 # 如果旧媒体库已经保存了宿主机路径 /vol1/1000/Media, # 再把这里改成同一个宿主机真实路径用于旧路径换算。 - MMTL_MEDIA_DIR: /media - MMTL_MEDIA_CONTAINER_DIR: /media + MEBOX_MEDIA_DIR: /media + MEBOX_MEDIA_CONTAINER_DIR: /media # 默认推荐下载器保存路径使用 /downloads。 # 如果下载器只能返回宿主机路径,再改成同一个宿主机真实路径。 - MMTL_DOWNLOAD_DIR: /downloads - MMTL_DOWNLOAD_CONTAINER_DIR: /downloads + MEBOX_DOWNLOAD_DIR: /downloads + MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads # NAS 友好的低负载默认值。 - MMTL_TRANSCODER_ENABLED: "true" - MMTL_TRANSCODER_HARDWARE_ACCEL: "false" - MMTL_TRANSCODER_REALTIME: "true" - MMTL_TRANSCODER_THREADS: "2" - MMTL_TRANSCODER_MAX_CONCURRENT: "1" - MMTL_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120" + MEBOX_TRANSCODER_ENABLED: "true" + MEBOX_TRANSCODER_HARDWARE_ACCEL: "false" + MEBOX_TRANSCODER_REALTIME: "true" + MEBOX_TRANSCODER_THREADS: "2" + MEBOX_TRANSCODER_MAX_CONCURRENT: "1" + MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120" healthcheck: test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"] @@ -150,20 +146,20 @@ services: postgres: image: postgres:16-alpine - # 首次部署允许拉取;日常更新请只 pull mmtl。 + # 首次部署允许拉取;日常更新请只 pull mebox。 # 如需升级 PostgreSQL,请先备份 ./postgres 后再手动调整镜像版本并拉取。 pull_policy: missing restart: unless-stopped environment: - POSTGRES_DB: mmtl - POSTGRES_USER: mmtl - POSTGRES_PASSWORD: mmtl + POSTGRES_DB: mebox + POSTGRES_USER: mebox + POSTGRES_PASSWORD: mebox TZ: Asia/Shanghai volumes: # PostgreSQL 主数据目录。升级/重建容器时必须保留。 - ./postgres:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mmtl -d mmtl"] + test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mebox -d mebox"] interval: 10s timeout: 5s retries: 10 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 05ef2c8..be81352 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,8 +1,8 @@ #!/bin/sh set -eu -run_uid="${PUID:-$(id -u mmtl 2>/dev/null || echo 1000)}" -run_gid="${PGID:-$(id -g mmtl 2>/dev/null || echo 1000)}" +run_uid="${PUID:-$(id -u mebox 2>/dev/null || echo 1000)}" +run_gid="${PGID:-$(id -g mebox 2>/dev/null || echo 1000)}" case "$run_uid" in ''|*[!0-9]*) @@ -19,10 +19,10 @@ case "$run_gid" in esac if [ "$run_uid" = "0" ]; then - exec mmtl + exec mebox fi chown -R "$run_uid:$run_gid" /data /cache 2>/dev/null || true chown "$run_uid:$run_gid" /media 2>/dev/null || true -exec su-exec "$run_uid:$run_gid" mmtl +exec su-exec "$run_uid:$run_gid" mebox diff --git a/go.mod b/go.mod index a678106..cb2b967 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/ShukeBta/MMTL +module github.com/truewhile/MeBox go 1.25.0 @@ -14,10 +14,12 @@ require ( github.com/shirou/gopsutil/v3 v3.24.5 github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.9.0 + github.com/ulikunitz/xz v0.5.12 go.uber.org/zap v1.27.0 golang.org/x/crypto v0.21.0 golang.org/x/sys v0.20.0 golang.org/x/time v0.15.0 + gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/postgres v1.5.7 gorm.io/gorm v1.30.0 ) @@ -68,7 +70,6 @@ require ( github.com/tklauser/numcpus v0.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect - github.com/ulikunitz/xz v0.5.12 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.3.0 // indirect @@ -77,7 +78,6 @@ require ( golang.org/x/text v0.20.0 // indirect google.golang.org/protobuf v1.31.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.22.5 // indirect modernc.org/mathutil v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect diff --git a/gui-test-screenshots/t1_player_danmaku.png b/gui-test-screenshots/t1_player_danmaku.png deleted file mode 100644 index 81e4101..0000000 Binary files a/gui-test-screenshots/t1_player_danmaku.png and /dev/null differ diff --git a/gui-test-screenshots/t2_danmaku_live.png b/gui-test-screenshots/t2_danmaku_live.png deleted file mode 100644 index eb05935..0000000 Binary files a/gui-test-screenshots/t2_danmaku_live.png and /dev/null differ diff --git a/internal/config/config.go b/internal/config/config.go index c1a58fd..263c876 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,7 +4,7 @@ // 1. 内置默认值 // 2. 工作目录中的 config.yaml(嵌套格式) // 3. config/*.yaml 分片文件(按模块) -// 4. 以 MMTL_ 为前缀的环境变量 +// 4. 以 MEBOX_ 为前缀的环境变量 package config import ( @@ -17,7 +17,7 @@ import ( ) // EnvPrefix 是所有环境变量驱动的覆盖使用的前缀。 -const EnvPrefix = "MMTL" +const EnvPrefix = "MeBox" // Load 从默认值 / 文件 / 环境读取配置。 // diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5db2549..7121661 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -40,13 +40,13 @@ func TestLoadDefaults(t *testing.T) { if cfg.Database.MaxOpenConns != defaultDatabaseMaxOpenConns { t.Fatalf("expected default MaxOpenConns %d, got %d", defaultDatabaseMaxOpenConns, cfg.Database.MaxOpenConns) } - if cfg.Cache.RedisPrefix != "mmtl" { + if cfg.Cache.RedisPrefix != "mebox" { t.Fatalf("expected default redis prefix, got %q", cfg.Cache.RedisPrefix) } if cfg.Cache.MediaTTLSeconds != 15 { t.Fatalf("expected default media cache ttl 15, got %d", cfg.Cache.MediaTTLSeconds) } - if cfg.Search.Index != "mmtl_media" { + if cfg.Search.Index != "mebox_media" { t.Fatalf("expected default search index, got %q", cfg.Search.Index) } if cfg.Database.MaxIdleConns != defaultDatabaseMaxIdleConns { @@ -74,7 +74,7 @@ func TestLoadDefaults(t *testing.T) { } } -// TestEnvOverride checks that MMTL_* env vars override the defaults. +// TestEnvOverride checks that MEBOX_* env vars override the defaults. func TestEnvOverride(t *testing.T) { dir := t.TempDir() wd, _ := os.Getwd() @@ -83,16 +83,16 @@ func TestEnvOverride(t *testing.T) { t.Fatalf("chdir: %v", err) } - t.Setenv("MMTL_APP_PORT", "9090") - t.Setenv("MMTL_DATABASE_TYPE", "postgres") - t.Setenv("MMTL_DATABASE_DSN", "postgres://mmtl:secret@postgres:5432/mmtl?sslmode=disable") - t.Setenv("MMTL_CACHE_REDIS_URL", "redis://redis:6379/0") - t.Setenv("MMTL_CACHE_MEDIA_TTL_SECONDS", "30") - t.Setenv("MMTL_SEARCH_BACKEND", "opensearch") - t.Setenv("MMTL_SEARCH_OPENSEARCH_URL", "http://opensearch:9200") - t.Setenv("MMTL_LICENSE_SERVER_URL", "https://license.example.com") - t.Setenv("MMTL_LICENSE_HMAC_SECRET", "override-secret") - t.Setenv("MMTL_LICENSE_PUBLIC_KEY", "override-public-key") + t.Setenv("MEBOX_APP_PORT", "9090") + t.Setenv("MEBOX_DATABASE_TYPE", "postgres") + t.Setenv("MEBOX_DATABASE_DSN", "postgres://mebox:secret@postgres:5432/mebox?sslmode=disable") + t.Setenv("MEBOX_CACHE_REDIS_URL", "redis://redis:6379/0") + t.Setenv("MEBOX_CACHE_MEDIA_TTL_SECONDS", "30") + t.Setenv("MEBOX_SEARCH_BACKEND", "opensearch") + t.Setenv("MEBOX_SEARCH_OPENSEARCH_URL", "http://opensearch:9200") + t.Setenv("MEBOX_LICENSE_SERVER_URL", "https://license.example.com") + t.Setenv("MEBOX_LICENSE_HMAC_SECRET", "override-secret") + t.Setenv("MEBOX_LICENSE_PUBLIC_KEY", "override-public-key") cfg, err := Load() if err != nil { t.Fatalf("Load() error: %v", err) diff --git a/internal/config/defaults.go b/internal/config/defaults.go index f64d462..b296f06 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -25,11 +25,11 @@ func setDefaults(v *viper.Viper) { v.SetDefault("app.server_url", "") v.SetDefault("database.type", "auto") - v.SetDefault("database.db_path", "./data/mmtl.db") + v.SetDefault("database.db_path", "./data/mebox.db") v.SetDefault("database.dsn", "") v.SetDefault("database.wal_mode", true) v.SetDefault("database.busy_timeout", 5000) - v.SetDefault("database.cache_size", -20000) + v.SetDefault("database.cache_size", -40000) v.SetDefault("database.max_open_conns", defaultDatabaseMaxOpenConns) v.SetDefault("database.max_idle_conns", defaultDatabaseMaxIdleConns) @@ -43,14 +43,15 @@ func setDefaults(v *viper.Viper) { v.SetDefault("logging.max_backups", 10) v.SetDefault("cache.cache_dir", "./cache") + v.SetDefault("cache.images_max_size_mb", 500) v.SetDefault("cache.cleanup_interval_min", 60) v.SetDefault("cache.redis_url", "") - v.SetDefault("cache.redis_prefix", "mmtl") + v.SetDefault("cache.redis_prefix", "mebox") v.SetDefault("cache.media_ttl_seconds", 15) v.SetDefault("search.backend", "") v.SetDefault("search.opensearch_url", "") - v.SetDefault("search.index", "mmtl_media") + v.SetDefault("search.index", "mebox_media") v.SetDefault("search.username", "") v.SetDefault("search.password", "") @@ -63,7 +64,7 @@ func setDefaults(v *viper.Viper) { v.SetDefault("flaresolverr.enabled", false) v.SetDefault("flaresolverr.url", "http://localhost:8191") - v.SetDefault("flaresolverr.session", "mmtl") + v.SetDefault("flaresolverr.session", "mebox") v.SetDefault("flaresolverr.timeout", 60) v.SetDefault("downloads.smart_classify", true) diff --git a/internal/config/normalize.go b/internal/config/normalize.go index 6eaea21..4d7d1b1 100644 --- a/internal/config/normalize.go +++ b/internal/config/normalize.go @@ -15,7 +15,7 @@ func (c *Config) normalize() error { c.App.DataDir = "./data" } if c.Database.DBPath == "" { - c.Database.DBPath = filepath.Join(c.App.DataDir, "mmtl.db") + c.Database.DBPath = filepath.Join(c.App.DataDir, "mebox.db") } if c.Database.Type == "" { c.Database.Type = "auto" @@ -44,15 +44,18 @@ func (c *Config) normalize() error { if c.Cache.CacheDir == "" { c.Cache.CacheDir = filepath.Join(c.App.DataDir, "cache") } + if c.Cache.ImagesMaxSizeMB < 0 { + c.Cache.ImagesMaxSizeMB = 0 + } if c.Cache.RedisPrefix == "" { - c.Cache.RedisPrefix = "mmtl" + c.Cache.RedisPrefix = "mebox" } if c.Cache.MediaTTLSeconds < 1 { c.Cache.MediaTTLSeconds = 15 } c.Search.Backend = strings.ToLower(strings.TrimSpace(c.Search.Backend)) if c.Search.Index == "" { - c.Search.Index = "mmtl_media" + c.Search.Index = "mebox_media" } if c.Secrets.JWTSecret == "" { // 持久化自动生成的密钥以在操作员忘记配置时保持会话稳定。 diff --git a/internal/config/save_test.go b/internal/config/save_test.go index 261d7e2..65de1a2 100644 --- a/internal/config/save_test.go +++ b/internal/config/save_test.go @@ -14,7 +14,7 @@ func TestSaveDatabaseConfig(t *testing.T) { t.Fatalf("chdir: %v", err) } - dsn := "postgres://admin:pass@127.0.0.1:5432/mmtl?sslmode=disable" + dsn := "postgres://admin:pass@127.0.0.1:5432/mebox?sslmode=disable" if err := SaveDatabaseConfig("postgres", dsn); err != nil { t.Fatalf("SaveDatabaseConfig error: %v", err) } diff --git a/internal/config/types.go b/internal/config/types.go index 94c67b8..42a0409 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -44,11 +44,11 @@ type TranscoderConfig struct { // AppConfig 保存运行时应用参数。 type AppConfig struct { - Port int `mapstructure:"port"` - Debug bool `mapstructure:"debug"` - Env string `mapstructure:"env"` - DataDir string `mapstructure:"data_dir"` - WebDir string `mapstructure:"web_dir"` + Port int `mapstructure:"port"` + Debug bool `mapstructure:"debug"` + Env string `mapstructure:"env"` + DataDir string `mapstructure:"data_dir"` + WebDir string `mapstructure:"web_dir"` // HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置 // SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。 HTTPSEnabled bool `mapstructure:"https_enabled"` @@ -59,7 +59,7 @@ type AppConfig struct { // SSLCertPath 是 SSL 证书文件路径;非空时优先于 SSLCert 从文件读取。 SSLCertPath string `mapstructure:"ssl_cert_path"` // SSLKeyPath 是 SSL 私钥文件路径;非空时优先于 SSLKey 从文件读取。 - SSLKeyPath string `mapstructure:"ssl_key_path"` + SSLKeyPath string `mapstructure:"ssl_key_path"` FFmpegPath string `mapstructure:"ffmpeg_path"` FFprobePath string `mapstructure:"ffprobe_path"` // FFprobeMaxConcurrent limits concurrent ffprobe/ffmpeg metadata probes. @@ -116,6 +116,7 @@ type LoggingConfig struct { // CacheConfig 控制磁盘转码/刮削缓存。 type CacheConfig struct { CacheDir string `mapstructure:"cache_dir"` + ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"` MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"` TTLHours int `mapstructure:"ttl_hours"` AutoCleanup bool `mapstructure:"auto_cleanup"` @@ -151,7 +152,7 @@ type AIConfig struct { MaxConcurrent int `mapstructure:"max_concurrent"` } -// LicenseConfig configures the optional MMTL license server bridge. +// LicenseConfig configures the optional MeBox license server bridge. type LicenseConfig struct { ServerURL string `mapstructure:"server_url"` HMACSecret string `mapstructure:"hmac_secret"` diff --git a/internal/database/database.go b/internal/database/database.go index 96aff92..39103ec 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -13,7 +13,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/logger" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) // Open initialises the configured GORM database. database.type=auto chooses diff --git a/internal/database/database_admin.go b/internal/database/database_admin.go index 6b97032..dd02d65 100644 --- a/internal/database/database_admin.go +++ b/internal/database/database_admin.go @@ -12,8 +12,8 @@ import ( "gorm.io/gorm" "gorm.io/gorm/logger" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" ) // DatabaseStatus describes the currently active database engine and runtime metrics. diff --git a/internal/database/database_admin_test.go b/internal/database/database_admin_test.go index bb1acb2..6c784ca 100644 --- a/internal/database/database_admin_test.go +++ b/internal/database/database_admin_test.go @@ -6,8 +6,8 @@ import ( "github.com/glebarez/sqlite" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" ) func TestMaskDSN(t *testing.T) { @@ -16,16 +16,16 @@ func TestMaskDSN(t *testing.T) { want string }{ { - in: "postgres://admin:secret123@localhost:5432/mmtl?sslmode=disable", - want: "postgres://admin:******@localhost:5432/mmtl?sslmode=disable", + in: "postgres://admin:secret123@localhost:5432/mebox?sslmode=disable", + want: "postgres://admin:******@localhost:5432/mebox?sslmode=disable", }, { - in: "host=localhost port=5432 user=admin password=secret dbname=mmtl sslmode=disable", - want: "host=localhost port=5432 user=admin password=****** dbname=mmtl sslmode=disable", + in: "host=localhost port=5432 user=admin password=secret dbname=mebox sslmode=disable", + want: "host=localhost port=5432 user=admin password=****** dbname=mebox sslmode=disable", }, { - in: "sqlite://data/mmtl.db", - want: "sqlite://data/mmtl.db", + in: "sqlite://data/mebox.db", + want: "sqlite://data/mebox.db", }, { in: "", @@ -53,7 +53,7 @@ func TestInspectDatabaseStatus(t *testing.T) { cfg := &config.Config{} cfg.Database.Type = "sqlite" - cfg.Database.DBPath = "./data/mmtl.db" + cfg.Database.DBPath = "./data/mebox.db" st := InspectDatabaseStatus(db, cfg) if st == nil { @@ -62,7 +62,7 @@ func TestInspectDatabaseStatus(t *testing.T) { if st.Type != "sqlite" { t.Fatalf("expected sqlite, got %s", st.Type) } - if st.DBPath != "./data/mmtl.db" { + if st.DBPath != "./data/mebox.db" { t.Fatalf("expected db_path, got %s", st.DBPath) } if st.TableCounts["users"] != 1 { diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 73fb690..f81ccf4 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -8,8 +8,8 @@ import ( "github.com/glebarez/sqlite" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" ) func TestOpenRequiresConfig(t *testing.T) { @@ -28,7 +28,7 @@ func TestOpenRequiresConfig(t *testing.T) { func TestOpenSQLiteWithNilLoggerConfiguresPool(t *testing.T) { cfg := &config.Config{} cfg.Database.Type = "sqlite" - cfg.Database.DBPath = filepath.Join(t.TempDir(), "mmtl.db") + cfg.Database.DBPath = filepath.Join(t.TempDir(), "mebox.db") cfg.Database.WALMode = true cfg.Database.BusyTimeout = 5000 cfg.Database.CacheSize = -2000 @@ -278,7 +278,7 @@ func TestSQLiteMigrationCompleteMarker(t *testing.T) { func TestSQLiteMigrationFallsBackToDataDirDefaultPath(t *testing.T) { dir := t.TempDir() - sqlitePath := filepath.Join(dir, "mmtl.db") + sqlitePath := filepath.Join(dir, "mebox.db") src, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{}) if err != nil { t.Fatal(err) @@ -362,7 +362,7 @@ func TestSQLiteMigrationFallsBackToDataDirDefaultPath(t *testing.T) { func TestOpenSQLiteMigrationSourceUsesFallbackSourcePath(t *testing.T) { dir := t.TempDir() - sqlitePath := filepath.Join(dir, "mmtl.db") + sqlitePath := filepath.Join(dir, "mebox.db") src, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{}) if err != nil { t.Fatal(err) @@ -408,13 +408,13 @@ func TestOpenSQLiteMigrationSourceUsesFallbackSourcePath(t *testing.T) { _ = sqlDB2.Close() } }() - _, copied, err := copyModelTables(src2, dst, 2) - if err != nil { - t.Fatal(err) - } - if copied != 2 { - t.Fatalf("copied rows = %d, want 2", copied) - } + _, copied, err := copyModelTables(src2, dst, 2) + if err != nil { + t.Fatal(err) + } + if copied != 2 { + t.Fatalf("copied rows = %d, want 2", copied) + } var userCount int64 if err := dst.Model(&model.User{}).Where("username = ?", "real-admin").Count(&userCount).Error; err != nil { t.Fatal(err) diff --git a/internal/database/schema_library_roots.go b/internal/database/schema_library_roots.go index 2dd2b10..76a9bd1 100644 --- a/internal/database/schema_library_roots.go +++ b/internal/database/schema_library_roots.go @@ -6,7 +6,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func ensureLibraryRootsCompatibility(db *gorm.DB) error { diff --git a/internal/database/schema_migration.go b/internal/database/schema_migration.go index c335b78..424b5e6 100644 --- a/internal/database/schema_migration.go +++ b/internal/database/schema_migration.go @@ -3,7 +3,7 @@ package database import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // AutoMigrate creates tables for every model registered in the model package. @@ -20,12 +20,23 @@ func AutoMigrate(db *gorm.DB) error { if err := ensureLibraryRootsCompatibility(db); err != nil { return err } + if err := ensureEmbyMountsCompatibility(db); err != nil { + return err + } if isSQLite(db) { - return ensureMediaSearchIndex(db) + if err := ensureMediaSearchIndex(db); err != nil { + return err + } + return ensureSQLiteQueryOptimizer(db) } return nil } +func ensureSQLiteQueryOptimizer(db *gorm.DB) error { + // Refresh planner statistics so indexes on large media tables are used. + return db.Exec("ANALYZE").Error +} + func ensurePostgresColumnCompatibility(db *gorm.DB) error { if !isPostgres(db) { return nil @@ -77,3 +88,25 @@ func ensurePerformanceIndexes(db *gorm.DB) error { } return nil } + +func ensureEmbyMountsCompatibility(db *gorm.DB) error { + if !db.Migrator().HasTable(&model.EmbyMount{}) { + return nil + } + if !db.Migrator().HasColumn(&model.EmbyMount{}, "sort_order") { + if err := db.Migrator().AddColumn(&model.EmbyMount{}, "sort_order"); err != nil { + return err + } + } + // 针对已有数据:如果存在多个 sort_order=0/NULL 的记录,按创建时间顺序赋予稳定递增的序号 + var zeroCount int64 + if err := db.Model(&model.EmbyMount{}).Where("sort_order = 0 OR sort_order IS NULL").Count(&zeroCount).Error; err == nil && zeroCount > 1 { + var mounts []model.EmbyMount + if err := db.Order("created_at asc, id asc").Find(&mounts).Error; err == nil { + for i, m := range mounts { + _ = db.Exec("UPDATE emby_mounts SET sort_order = ? WHERE id = ?", i, m.ID).Error + } + } + } + return nil +} diff --git a/internal/database/schema_migration_emby_mount_test.go b/internal/database/schema_migration_emby_mount_test.go new file mode 100644 index 0000000..0e2d6c7 --- /dev/null +++ b/internal/database/schema_migration_emby_mount_test.go @@ -0,0 +1,62 @@ +package database + +import ( + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "github.com/truewhile/MeBox/internal/model" +) + +func TestEnsureEmbyMountsCompatibility(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + + // Create a table without sort_order simulating an older schema + if err := db.Exec(`CREATE TABLE emby_mounts ( + id varchar(36) PRIMARY KEY, + created_at datetime, + updated_at datetime, + deleted_at datetime, + account_id text, + remote_view_id text, + remote_view_name text, + collection_type text, + name text, + proxy_play numeric DEFAULT false, + enabled numeric DEFAULT true + )`).Error; err != nil { + t.Fatal(err) + } + + // Insert older rows + now := time.Now() + _ = db.Exec("INSERT INTO emby_mounts (id, name, created_at) VALUES (?, ?, ?)", "m1", "Mount 1", now.Add(-2*time.Hour)).Error + _ = db.Exec("INSERT INTO emby_mounts (id, name, created_at) VALUES (?, ?, ?)", "m2", "Mount 2", now.Add(-1*time.Hour)).Error + + // Run compatibility migration + if err := ensureEmbyMountsCompatibility(db); err != nil { + t.Fatalf("ensureEmbyMountsCompatibility failed: %v", err) + } + + // Verify column sort_order exists and values are initialized sequentially + if !db.Migrator().HasColumn(&model.EmbyMount{}, "sort_order") { + t.Fatal("expected sort_order column to be added") + } + + var m1, m2 model.EmbyMount + if err := db.Where("id = ?", "m1").First(&m1).Error; err != nil { + t.Fatal(err) + } + if err := db.Where("id = ?", "m2").First(&m2).Error; err != nil { + t.Fatal(err) + } + + if m1.SortOrder != 0 || m2.SortOrder != 1 { + t.Fatalf("unexpected sort orders: m1=%d, m2=%d", m1.SortOrder, m2.SortOrder) + } +} diff --git a/internal/database/sqlite_migration.go b/internal/database/sqlite_migration.go index f5a274e..cba494a 100644 --- a/internal/database/sqlite_migration.go +++ b/internal/database/sqlite_migration.go @@ -7,7 +7,7 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) // MigrateSQLiteToCurrentIfNeeded copies an existing SQLite database into diff --git a/internal/database/sqlite_migration_bootstrap.go b/internal/database/sqlite_migration_bootstrap.go index 604a3bd..6b5e68f 100644 --- a/internal/database/sqlite_migration_bootstrap.go +++ b/internal/database/sqlite_migration_bootstrap.go @@ -9,7 +9,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src, target *gorm.DB, log *zap.Logger) error { diff --git a/internal/database/sqlite_migration_copy.go b/internal/database/sqlite_migration_copy.go index 4053c7c..748af90 100644 --- a/internal/database/sqlite_migration_copy.go +++ b/internal/database/sqlite_migration_copy.go @@ -10,7 +10,7 @@ import ( "gorm.io/gorm/clause" "gorm.io/gorm/schema" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func copyModelTables(src, target *gorm.DB, batchSize int) (map[string]int64, int64, error) { diff --git a/internal/database/sqlite_migration_source.go b/internal/database/sqlite_migration_source.go index 8bb1171..a950cd3 100644 --- a/internal/database/sqlite_migration_source.go +++ b/internal/database/sqlite_migration_source.go @@ -12,7 +12,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/logger" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) func openSQLiteMigrationSource(cfg *config.Config, sqlitePath string) (*gorm.DB, error) { @@ -36,23 +36,30 @@ func sqliteMigrationSourcePath(cfg *config.Config, log *zap.Logger) (string, err } } - fallback := filepath.Join(strings.TrimSpace(cfg.App.DataDir), "mmtl.db") - if fallback == "" || sameCleanPath(configured, fallback) { - return "", nil + // Prefer the new default filename, then fall back to legacy MMTL SQLite files. + candidates := []string{ + filepath.Join(strings.TrimSpace(cfg.App.DataDir), "mebox.db"), + filepath.Join(strings.TrimSpace(cfg.App.DataDir), "mmtl.db"), } - exists, err := regularFileExists(fallback) - if err != nil { - return "", fmt.Errorf("stat default sqlite migration source: %w", err) + for _, fallback := range candidates { + if fallback == "" || sameCleanPath(configured, fallback) { + continue + } + exists, err := regularFileExists(fallback) + if err != nil { + return "", fmt.Errorf("stat default sqlite migration source: %w", err) + } + if !exists { + continue + } + if log != nil && configured != "" { + log.Warn("configured sqlite migration source not found; using data-dir default", + zap.String("configured", configured), + zap.String("fallback", fallback)) + } + return fallback, nil } - if !exists { - return "", nil - } - if log != nil && configured != "" { - log.Warn("configured sqlite migration source not found; using data-dir default", - zap.String("configured", configured), - zap.String("fallback", fallback)) - } - return fallback, nil + return "", nil } func regularFileExists(path string) (bool, error) { diff --git a/internal/database/sqlite_runtime.go b/internal/database/sqlite_runtime.go index a0ce354..df0c21f 100644 --- a/internal/database/sqlite_runtime.go +++ b/internal/database/sqlite_runtime.go @@ -8,14 +8,14 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) func installSQLiteWriteGate(db *gorm.DB) { if db == nil { return } - const lockedKey = "mmtl:sqlite_write_locked" + const lockedKey = "mebox:sqlite_write_locked" gate := newSQLiteWriteGate() lock := func(tx *gorm.DB) { ctx := context.Background() @@ -39,14 +39,14 @@ func installSQLiteWriteGate(db *gorm.DB) { } lock(tx) } - _ = db.Callback().Create().Before("gorm:create").Register("mmtl:sqlite_write_lock", lock) - _ = db.Callback().Create().After("gorm:create").Register("mmtl:sqlite_write_unlock", unlock) - _ = db.Callback().Update().Before("gorm:update").Register("mmtl:sqlite_write_lock", lock) - _ = db.Callback().Update().After("gorm:update").Register("mmtl:sqlite_write_unlock", unlock) - _ = db.Callback().Delete().Before("gorm:delete").Register("mmtl:sqlite_write_lock", lock) - _ = db.Callback().Delete().After("gorm:delete").Register("mmtl:sqlite_write_unlock", unlock) - _ = db.Callback().Raw().Before("gorm:raw").Register("mmtl:sqlite_write_lock", rawLock) - _ = db.Callback().Raw().After("gorm:raw").Register("mmtl:sqlite_write_unlock", unlock) + _ = db.Callback().Create().Before("gorm:create").Register("mebox:sqlite_write_lock", lock) + _ = db.Callback().Create().After("gorm:create").Register("mebox:sqlite_write_unlock", unlock) + _ = db.Callback().Update().Before("gorm:update").Register("mebox:sqlite_write_lock", lock) + _ = db.Callback().Update().After("gorm:update").Register("mebox:sqlite_write_unlock", unlock) + _ = db.Callback().Delete().Before("gorm:delete").Register("mebox:sqlite_write_lock", lock) + _ = db.Callback().Delete().After("gorm:delete").Register("mebox:sqlite_write_unlock", unlock) + _ = db.Callback().Raw().Before("gorm:raw").Register("mebox:sqlite_write_lock", rawLock) + _ = db.Callback().Raw().After("gorm:raw").Register("mebox:sqlite_write_unlock", unlock) } func isReadOnlySQL(sql string) bool { @@ -114,7 +114,10 @@ func buildSQLiteDSN(cfg *config.Config) string { if cfg.Database.CacheSize != 0 { dsn += fmt.Sprintf("&_pragma=cache_size(%d)", cfg.Database.CacheSize) } - dsn += "&_pragma=temp_store(MEMORY)&_pragma=mmap_size(268435456)" + dsn += "&_pragma=temp_store(MEMORY)&_pragma=mmap_size(536870912)" + if cfg.Database.WALMode { + dsn += "&_pragma=wal_autocheckpoint(1000)" + } return dsn } diff --git a/internal/handler/active_user.go b/internal/handler/active_user.go index 61fdd93..d8638e6 100644 --- a/internal/handler/active_user.go +++ b/internal/handler/active_user.go @@ -6,8 +6,8 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/service" ) const embyCtxUserName = "emby_user_name" diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 5aa66fe..91c78ff 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -10,8 +10,8 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service" ) func listUsersHandler(svc *service.Container) gin.HandlerFunc { @@ -31,7 +31,66 @@ func listUsersHandler(svc *service.Container) gin.HandlerFunc { for i := range users { users[i].PopulateComputedFields() } - c.JSON(http.StatusOK, users) + maxUsers, err := service.LoadMaxUsers(c.Request.Context(), svc.Repo) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "users": users, + "max_users": maxUsers, + "current_users": len(users), + }) + } +} + +type updateUserLimitReq struct { + MaxUsers int `json:"max_users" binding:"required"` +} + +func getUserLimitHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + maxUsers, err := service.LoadMaxUsers(c.Request.Context(), svc.Repo) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + currentUsers, err := svc.Repo.User.Count(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "max_users": maxUsers, + "current_users": currentUsers, + }) + } +} + +func updateUserLimitHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req updateUserLimitReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := service.SaveMaxUsers(c.Request.Context(), svc.Repo, req.MaxUsers); err != nil { + if errors.Is(err, service.ErrInvalidMaxUsers) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + currentUsers, err := svc.Repo.User.Count(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "max_users": req.MaxUsers, + "current_users": currentUsers, + }) } } @@ -192,77 +251,77 @@ func updateUserStatusHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - if req.IsActive { - _ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, false) - } else { - _ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, true) - } - updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - updated.PopulateComputedFields() - c.JSON(http.StatusOK, updated) + if req.IsActive { + _ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, false) + } else { + _ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, true) } + updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + updated.PopulateComputedFields() + c.JSON(http.StatusOK, updated) } +} - type adminUpdateUserLibrariesReq struct { - AllowedLibraryIDs *[]string `json:"allowed_library_ids"` - } +type adminUpdateUserLibrariesReq struct { + AllowedLibraryIDs *[]string `json:"allowed_library_ids"` +} - func updateUserLibrariesHandler(svc *service.Container) gin.HandlerFunc { - return func(c *gin.Context) { - var req adminUpdateUserLibrariesReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - userID := c.Param("id") - user, err := svc.Repo.User.FindByID(c.Request.Context(), userID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - if user == nil { - c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) - return - } +func updateUserLibrariesHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req adminUpdateUserLibrariesReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + userID := c.Param("id") + user, err := svc.Repo.User.FindByID(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if user == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } - var rawJSON string - if req.AllowedLibraryIDs != nil && len(*req.AllowedLibraryIDs) > 0 { - var cleanIDs []string - for _, id := range *req.AllowedLibraryIDs { - trimmed := strings.TrimSpace(id) - if trimmed != "" { - cleanIDs = append(cleanIDs, trimmed) - } - } - if len(cleanIDs) > 0 { - data, err := json.Marshal(cleanIDs) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - rawJSON = string(data) + var rawJSON string + if req.AllowedLibraryIDs != nil && len(*req.AllowedLibraryIDs) > 0 { + var cleanIDs []string + for _, id := range *req.AllowedLibraryIDs { + trimmed := strings.TrimSpace(id) + if trimmed != "" { + cleanIDs = append(cleanIDs, trimmed) } } - - updates := map[string]any{"allowed_library_ids": rawJSON} - if err := svc.Repo.User.UpdateFields(c.Request.Context(), userID, updates); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return + if len(cleanIDs) > 0 { + data, err := json.Marshal(cleanIDs) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + rawJSON = string(data) } - - updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID) - if err != nil || updated == nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload user"}) - return - } - updated.PopulateComputedFields() - c.JSON(http.StatusOK, updated) } + + updates := map[string]any{"allowed_library_ids": rawJSON} + if err := svc.Repo.User.UpdateFields(c.Request.Context(), userID, updates); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID) + if err != nil || updated == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload user"}) + return + } + updated.PopulateComputedFields() + c.JSON(http.StatusOK, updated) } +} func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error { firstAdmin, err := svc.Repo.User.FirstAdmin(ctx) @@ -288,7 +347,12 @@ func writeUserMutationError(c *gin.Context, svc *service.Container, err error) { case errors.Is(err, service.ErrUsernameTaken): c.JSON(http.StatusConflict, gin.H{"error": "username already taken"}) case errors.Is(err, service.ErrUserLimitReached): - c.JSON(http.StatusBadRequest, gin.H{"error": "user limit reached", "max_users": service.UserLimit}) + maxUsers, loadErr := service.LoadMaxUsers(c.Request.Context(), svc.Repo) + if loadErr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": loadErr.Error()}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": "user limit reached", "max_users": maxUsers}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } diff --git a/internal/handler/admin_database.go b/internal/handler/admin_database.go index 6b1fbdc..1f77253 100644 --- a/internal/handler/admin_database.go +++ b/internal/handler/admin_database.go @@ -8,7 +8,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) type DatabaseConnectionPayload struct { @@ -38,7 +38,7 @@ func (p *DatabaseConnectionPayload) BuildDSN() string { user := strings.TrimSpace(p.User) dbname := strings.TrimSpace(p.DBName) if dbname == "" { - dbname = "mmtl" + dbname = "mebox" } sslmode := strings.TrimSpace(p.SSLMode) if sslmode == "" { diff --git a/internal/handler/admin_database_test.go b/internal/handler/admin_database_test.go index b948a85..53e4d55 100644 --- a/internal/handler/admin_database_test.go +++ b/internal/handler/admin_database_test.go @@ -9,8 +9,8 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/service" ) func TestBuildDSN(t *testing.T) { @@ -30,10 +30,10 @@ func TestBuildDSN(t *testing.T) { Port: 5432, User: "postgres", Password: "secretpassword", - DBName: "mmtl_prod", + DBName: "mebox_prod", SSLMode: "disable", }, - want: "postgres://postgres:secretpassword@127.0.0.1:5432/mmtl_prod?sslmode=disable", + want: "postgres://postgres:secretpassword@127.0.0.1:5432/mebox_prod?sslmode=disable", }, } @@ -49,7 +49,7 @@ func TestGetDatabaseStatusHandler(t *testing.T) { gin.SetMode(gin.TestMode) cfg := &config.Config{} cfg.Database.Type = "sqlite" - cfg.Database.DBPath = "./data/mmtl.db" + cfg.Database.DBPath = "./data/mebox.db" svc := &service.Container{ Database: service.NewDatabaseAdminService(cfg, nil, nil, nil), @@ -89,7 +89,7 @@ func TestSaveDatabaseConfigHandler(t *testing.T) { r := gin.New() r.POST("/api/admin/database/save-config", saveDatabaseConfigHandler(svc)) - body := bytes.NewBufferString(`{"type":"postgres","host":"localhost","port":5432,"user":"admin","password":"pwd","dbname":"mmtl"}`) + body := bytes.NewBufferString(`{"type":"postgres","host":"localhost","port":5432,"user":"admin","password":"pwd","dbname":"mebox"}`) req := httptest.NewRequest(http.MethodPost, "/api/admin/database/save-config", body) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() diff --git a/internal/handler/admin_settings.go b/internal/handler/admin_settings.go index 1171af1..7498ebc 100644 --- a/internal/handler/admin_settings.go +++ b/internal/handler/admin_settings.go @@ -10,8 +10,8 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service" ) type settingReq struct { @@ -64,6 +64,9 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc { if req.Key == "transcode.hw_enabled" || req.Key == "transcode.hw_accel" || req.Key == "transcoder.hardware_accel" || req.Key == "transcoder.encoder" { svc.Transcoder.StopAll() } + if req.Key == "cache.images_max_size_mb" && svc.Scheduler != nil { + _ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup") + } c.Status(http.StatusNoContent) } } diff --git a/internal/handler/admin_test.go b/internal/handler/admin_test.go index 0690001..3ecddc6 100644 --- a/internal/handler/admin_test.go +++ b/internal/handler/admin_test.go @@ -11,9 +11,9 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" ) func TestDeleteUserRefusesRecentRealtimeSession(t *testing.T) { @@ -44,10 +44,10 @@ func TestDeleteUserRefusesRecentRealtimeSession(t *testing.T) { if w.Code != http.StatusConflict { t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) } - if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil { - t.Fatal("recent realtime user should not be deleted") - } + if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil { + t.Fatal("recent realtime user should not be deleted") } +} func TestUpdateUserLibraries(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/internal/handler/admin_user_limit_test.go b/internal/handler/admin_user_limit_test.go new file mode 100644 index 0000000..849f046 --- /dev/null +++ b/internal/handler/admin_user_limit_test.go @@ -0,0 +1,55 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" +) + +func TestUserLimitHandlers(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(model.AllModels()...); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + cfg := &service.Container{Repo: repos, Auth: service.NewAuthService(nil, zap.NewNop(), repos, service.NewTokenService(nil, zap.NewNop(), repos), service.NewPermissionService(zap.NewNop(), repos))} + router := gin.New() + router.GET("/admin/users/limit", getUserLimitHandler(cfg)) + router.PUT("/admin/users/limit", updateUserLimitHandler(cfg)) + + req := httptest.NewRequest(http.MethodGet, "/admin/users/limit", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET limit status = %d body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), `"max_users":20`) { + t.Fatalf("expected default max_users=20, got %s", w.Body.String()) + } + + req = httptest.NewRequest(http.MethodPut, "/admin/users/limit", strings.NewReader(`{"max_users":42}`)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("PUT limit status = %d body=%s", w.Code, w.Body.String()) + } + got, err := service.LoadMaxUsers(t.Context(), repos) + if err != nil || got != 42 { + t.Fatalf("stored max users = %d err=%v, want 42", got, err) + } +} diff --git a/internal/handler/api_config.go b/internal/handler/api_config.go index f80746f..a5f4a19 100644 --- a/internal/handler/api_config.go +++ b/internal/handler/api_config.go @@ -9,7 +9,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) func listAPIConfigsHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/handler/api_config_handler.go b/internal/handler/api_config_handler.go index f59888b..8c7a5b6 100644 --- a/internal/handler/api_config_handler.go +++ b/internal/handler/api_config_handler.go @@ -7,7 +7,7 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) // ApiConfigHandler API 配置 HTTP 处理。 diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 5db3c49..2be8c0c 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -7,8 +7,8 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/service" ) type loginReq struct { @@ -68,6 +68,15 @@ func registerHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusConflict, gin.H{"error": "username taken"}) return } + if errors.Is(err, service.ErrUserLimitReached) { + maxUsers, loadErr := service.LoadMaxUsers(c.Request.Context(), svc.Repo) + if loadErr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": loadErr.Error()}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": "user limit reached", "max_users": maxUsers}) + return + } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } diff --git a/internal/handler/auth_cookie.go b/internal/handler/auth_cookie.go index a4ae747..fa9a39f 100644 --- a/internal/handler/auth_cookie.go +++ b/internal/handler/auth_cookie.go @@ -7,8 +7,8 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/service" ) func setAccessTokenCookie(c *gin.Context, token string, maxAgeSeconds int) { diff --git a/internal/handler/auth_cookie_test.go b/internal/handler/auth_cookie_test.go index 7494217..040cb59 100644 --- a/internal/handler/auth_cookie_test.go +++ b/internal/handler/auth_cookie_test.go @@ -7,7 +7,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/middleware" + "github.com/truewhile/MeBox/internal/middleware" ) func TestSetAccessTokenCookie(t *testing.T) { diff --git a/internal/handler/auth_extra.go b/internal/handler/auth_extra.go index 542f803..07e7303 100644 --- a/internal/handler/auth_extra.go +++ b/internal/handler/auth_extra.go @@ -10,8 +10,8 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/service" ) // refreshHandler returns a fresh token signed for the current user. diff --git a/internal/handler/backup.go b/internal/handler/backup.go index 7c6f03e..37d9957 100644 --- a/internal/handler/backup.go +++ b/internal/handler/backup.go @@ -6,7 +6,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) func createBackupHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/handler/danmaku.go b/internal/handler/danmaku.go index afd136c..c048bad 100644 --- a/internal/handler/danmaku.go +++ b/internal/handler/danmaku.go @@ -6,7 +6,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) // getDanmakuHandler returns danmaku for a media. ?kw= optionally overrides the diff --git a/internal/handler/dlna.go b/internal/handler/dlna.go index 964d841..08b2e3e 100644 --- a/internal/handler/dlna.go +++ b/internal/handler/dlna.go @@ -6,7 +6,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) func dlnaListHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/handler/dlna_extra.go b/internal/handler/dlna_extra.go index 1e561fc..9b48889 100644 --- a/internal/handler/dlna_extra.go +++ b/internal/handler/dlna_extra.go @@ -11,7 +11,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) // dlnaControlPath maps the action name to the AVTransport SOAP body. diff --git a/internal/handler/emby_auth.go b/internal/handler/emby_auth.go index feeb5d8..5704f6a 100644 --- a/internal/handler/emby_auth.go +++ b/internal/handler/emby_auth.go @@ -7,8 +7,8 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/service" ) // embyError 返回 Emby 风格的错误(顶层 Code/Message)。 diff --git a/internal/handler/emby_auth_test.go b/internal/handler/emby_auth_test.go index 1940e98..f606207 100644 --- a/internal/handler/emby_auth_test.go +++ b/internal/handler/emby_auth_test.go @@ -14,10 +14,10 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" ) func TestParseEmbyAuthByNameReqAcceptsLowercaseJSON(t *testing.T) { diff --git a/internal/handler/emby_authed_loop_test.go b/internal/handler/emby_authed_loop_test.go index 6d199e2..5b44b6f 100644 --- a/internal/handler/emby_authed_loop_test.go +++ b/internal/handler/emby_authed_loop_test.go @@ -13,10 +13,10 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" ) // TestEmbyLoginThenAuthedRequest simulates the exact flow an Emby-compatible diff --git a/internal/handler/emby_discovery_routes_test.go b/internal/handler/emby_discovery_routes_test.go index 6dcecb8..0c9df36 100644 --- a/internal/handler/emby_discovery_routes_test.go +++ b/internal/handler/emby_discovery_routes_test.go @@ -12,10 +12,10 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" ) func TestEmbyWithRequestAddressUsesHost(t *testing.T) { @@ -24,7 +24,7 @@ func TestEmbyWithRequestAddressUsesHost(t *testing.T) { c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodGet, "http://192.168.1.4:18080/System/Info/Public", nil) - payload := embyWithRequestAddress(c, map[string]any{"Id": "mmtl-001"}) + payload := embyWithRequestAddress(c, map[string]any{"Id": "mebox-001"}) if payload["LocalAddress"] != "http://192.168.1.4:18080" { t.Fatalf("unexpected LocalAddress: %#v", payload["LocalAddress"]) @@ -42,7 +42,7 @@ func TestEmbyWithRequestAddressHonorsForwardedHeaders(t *testing.T) { c.Request.Header.Set("X-Forwarded-Proto", "https") c.Request.Header.Set("X-Forwarded-Host", "media.example.test") - payload := embyWithRequestAddress(c, map[string]any{"Id": "mmtl-001"}) + payload := embyWithRequestAddress(c, map[string]any{"Id": "mebox-001"}) if payload["LocalAddress"] != "https://media.example.test" { t.Fatalf("unexpected LocalAddress: %#v", payload["LocalAddress"]) diff --git a/internal/handler/emby_images.go b/internal/handler/emby_images.go index 11538c3..c279e9b 100644 --- a/internal/handler/emby_images.go +++ b/internal/handler/emby_images.go @@ -9,7 +9,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) var embyPlaceholderPNG = []byte{ diff --git a/internal/handler/emby_items_handlers.go b/internal/handler/emby_items_handlers.go index 6c178a9..55d2bda 100644 --- a/internal/handler/emby_items_handlers.go +++ b/internal/handler/emby_items_handlers.go @@ -7,7 +7,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) func parseEmbyItemsParams(c *gin.Context) service.ItemsParams { diff --git a/internal/handler/emby_items_test.go b/internal/handler/emby_items_test.go index f673c8b..89747da 100644 --- a/internal/handler/emby_items_test.go +++ b/internal/handler/emby_items_test.go @@ -14,10 +14,10 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" ) func TestEmbyItemImageServesWithoutAPIAuth(t *testing.T) { diff --git a/internal/handler/emby_misc_routes_test.go b/internal/handler/emby_misc_routes_test.go index 5a08b59..d8a4101 100644 --- a/internal/handler/emby_misc_routes_test.go +++ b/internal/handler/emby_misc_routes_test.go @@ -13,9 +13,9 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" ) func TestEmbyVirtualFoldersRouteReturnsJSON(t *testing.T) { diff --git a/internal/handler/emby_mounted_resume_test.go b/internal/handler/emby_mounted_resume_test.go new file mode 100644 index 0000000..2643c93 --- /dev/null +++ b/internal/handler/emby_mounted_resume_test.go @@ -0,0 +1,148 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/golang-jwt/jwt/v5" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" +) + +func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(model.AllModels()...); err != nil { + t.Fatalf("migrate: %v", err) + } + + repos := repository.New(db) + user := &model.User{ + Base: model.Base{ID: "user-1"}, + Username: "test_viewer", + PasswordHash: "x", + Role: "user", + Tier: "free", + IsActive: true, + } + if err := repos.User.Create(t.Context(), user); err != nil { + t.Fatalf("create user: %v", err) + } + + cfg := &config.Config{} + logger := zap.NewNop() + svc := &service.Container{ + Repo: repos, + Emby: service.NewEmbyService(cfg, logger, repos), + Sessions: service.NewSessionTrackerService(logger), + Playback: service.NewPlaybackService(logger, repos), + } + + router := gin.New() + // 注册带认证的路由,模拟已登录用户 + router.Use(func(c *gin.Context) { + c.Set(middleware.CtxUserID, user.ID) + c.Next() + }) + router.POST("/Sessions/Playing/Progress", embyPlayingProgressHandler(svc)) + router.GET("/Items", embyItemsHandler(svc)) + router.GET("/Users/:userId/Items/Resume", embyResumeItemsHandler(svc)) + router.GET("/Sessions", embySessionsHandler(svc)) + + remoteMediaID := service.EncodeEmbyRemoteID("mount-1", "remote-item-123") + + // 1. 测试上报进度:客户端使用小写 query 参数 itemId / positionTicks + progressReq := httptest.NewRequest( + http.MethodPost, + "/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000", + nil, + ) + wProgress := httptest.NewRecorder() + router.ServeHTTP(wProgress, progressReq) + if wProgress.Code != http.StatusNoContent { + t.Fatalf("progress status = %d, body = %s", wProgress.Code, wProgress.Body.String()) + } + + // 验证已持久化到 PlaybackHistory + var hist model.PlaybackHistory + if err := db.Where("user_id = ? AND media_id = ?", user.ID, remoteMediaID).First(&hist).Error; err != nil { + t.Fatalf("playback history not saved: %v", err) + } + if hist.PositionMs != 30000 { + t.Fatalf("expected position_ms = 30000, got %d", hist.PositionMs) + } + + // 2. 测试 Filters=IsResumable 能够包含该远程条目 + resumableReq := httptest.NewRequest( + http.MethodGet, + "/Items?Filters=IsResumable", + nil, + ) + wResumable := httptest.NewRecorder() + router.ServeHTTP(wResumable, resumableReq) + if wResumable.Code != http.StatusOK { + t.Fatalf("items resumable status = %d, body = %s", wResumable.Code, wResumable.Body.String()) + } + var resumableEnvelope map[string]any + if err := json.Unmarshal(wResumable.Body.Bytes(), &resumableEnvelope); err != nil { + t.Fatalf("decode resumable: %v", err) + } + // 因为没有配置真实的远程客户端连接,该远程条目在当前离线测试中不会 panic 崩溃,并且正常响应 Envelope + if resumableEnvelope["TotalRecordCount"] == nil { + t.Fatalf("missing TotalRecordCount in resumable envelope") + } + + // 3. 测试 /Users/:userId/Items/Resume 别名路由 + resumeAliasReq := httptest.NewRequest( + http.MethodGet, + "/Users/"+user.ID+"/Items/Resume", + nil, + ) + wResumeAlias := httptest.NewRecorder() + router.ServeHTTP(wResumeAlias, resumeAliasReq) + if wResumeAlias.Code != http.StatusOK { + t.Fatalf("resume alias status = %d, body = %s", wResumeAlias.Code, wResumeAlias.Body.String()) + } + + // 4. 测试 /Sessions 返回 NowPlayingItem + sessionsReq := httptest.NewRequest(http.MethodGet, "/Sessions", nil) + wSessions := httptest.NewRecorder() + router.ServeHTTP(wSessions, sessionsReq) + if wSessions.Code != http.StatusOK { + t.Fatalf("sessions status = %d, body = %s", wSessions.Code, wSessions.Body.String()) + } + var sessionsList []map[string]any + if err := json.Unmarshal(wSessions.Body.Bytes(), &sessionsList); err != nil { + t.Fatalf("decode sessions: %v", err) + } + if len(sessionsList) == 0 { + t.Fatalf("expected at least 1 session") + } + nowPlaying, ok := sessionsList[0]["NowPlayingItem"].(map[string]any) + if !ok || nowPlaying["Id"] != remoteMediaID { + t.Fatalf("expected NowPlayingItem with id %q, got %#v", remoteMediaID, sessionsList[0]["NowPlayingItem"]) + } +} + +func signMockToken(secret, userID string) string { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": userID, + "exp": time.Now().Add(time.Hour).Unix(), + }) + s, _ := token.SignedString([]byte(secret)) + return s +} diff --git a/internal/handler/emby_mounts.go b/internal/handler/emby_mounts.go index c1b6a85..7bb40b2 100644 --- a/internal/handler/emby_mounts.go +++ b/internal/handler/emby_mounts.go @@ -8,8 +8,8 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service" ) // embyMountView 挂载的对外 JSON(附带账号信息)。 @@ -195,4 +195,28 @@ func deleteEmbyMountHandler(svc *service.Container) gin.HandlerFunc { } c.JSON(http.StatusOK, gin.H{"ok": true}) } -} \ No newline at end of file +} + +type reorderEmbyMountsReq struct { + IDs []string `json:"ids" binding:"required"` +} + +// reorderEmbyMountsHandler 批量重排挂载媒体库顺序。 +func reorderEmbyMountsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req reorderEmbyMountsReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if svc.EmbyRemote == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "emby remote service not available"}) + return + } + if err := svc.EmbyRemote.ReorderMounts(c.Request.Context(), req.IDs); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) + } +} diff --git a/internal/handler/emby_mounts_reorder_test.go b/internal/handler/emby_mounts_reorder_test.go new file mode 100644 index 0000000..0e5e24b --- /dev/null +++ b/internal/handler/emby_mounts_reorder_test.go @@ -0,0 +1,65 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/truewhile/MeBox/internal/database" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" +) + +func TestReorderEmbyMountsHandler(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := database.AutoMigrate(db); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + ctx := t.Context() + + m1 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-1", Name: "Mount 1"} + m2 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-2", Name: "Mount 2"} + _ = repos.EmbyMount.Create(ctx, m1) + _ = repos.EmbyMount.Create(ctx, m2) + + svc := &service.Container{ + Repo: repos, + EmbyRemote: service.NewEmbyRemoteService(nil, zap.NewNop(), repos, nil), + } + + router := gin.New() + router.PUT("/admin/emby/mounts/reorder", reorderEmbyMountsHandler(svc)) + + body, _ := json.Marshal(map[string]any{ + "ids": []string{m2.ID, m1.ID}, + }) + req := httptest.NewRequest(http.MethodPut, "/admin/emby/mounts/reorder", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + list, err := repos.EmbyMount.List(ctx) + if err != nil { + t.Fatal(err) + } + if len(list) != 2 || list[0].ID != m2.ID || list[1].ID != m1.ID { + t.Fatalf("expected order [m2, m1], got [m%s, m%s]", list[0].ID, list[1].ID) + } +} diff --git a/internal/handler/emby_playback.go b/internal/handler/emby_playback.go index e720980..af26ab2 100644 --- a/internal/handler/emby_playback.go +++ b/internal/handler/emby_playback.go @@ -8,7 +8,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/handler/emby_playback_info_routes_test.go b/internal/handler/emby_playback_info_routes_test.go index 31ab9e1..894c3d3 100644 --- a/internal/handler/emby_playback_info_routes_test.go +++ b/internal/handler/emby_playback_info_routes_test.go @@ -13,10 +13,10 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" ) func TestEmbyLowercasePlaybackInfoRouteReturnsJSON(t *testing.T) { diff --git a/internal/handler/emby_playback_routes_test.go b/internal/handler/emby_playback_routes_test.go index 30afae7..80db660 100644 --- a/internal/handler/emby_playback_routes_test.go +++ b/internal/handler/emby_playback_routes_test.go @@ -12,10 +12,10 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" ) func TestEmbyLowercaseVideoStreamRouteServesMedia(t *testing.T) { diff --git a/internal/handler/emby_playstate_handlers.go b/internal/handler/emby_playstate_handlers.go index f9a3ee2..eeed839 100644 --- a/internal/handler/emby_playstate_handlers.go +++ b/internal/handler/emby_playstate_handlers.go @@ -7,13 +7,18 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) type embyPlayingReq struct { ItemId string `json:"ItemId"` + ItemIDLower string `json:"itemId"` + ID string `json:"Id"` + IDLower string `json:"id"` PositionTicks int64 `json:"PositionTicks"` + PositionLower int64 `json:"positionTicks"` RunTimeTicks int64 `json:"RunTimeTicks"` + RunTimeLower int64 `json:"runTimeTicks"` } func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc { @@ -25,16 +30,25 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc { } var req embyPlayingReq _ = c.ShouldBindJSON(&req) - if req.ItemId == "" { - req.ItemId = c.Query("ItemId") + itemID := embyFirstNonEmptyString(req.ItemId, req.ItemIDLower, req.ID, req.IDLower) + if itemID == "" { + itemID = embyFirstNonEmptyString(firstQueryValue(c, "ItemId", "itemId", "Id", "id")) } - if req.PositionTicks == 0 { - req.PositionTicks, _ = strconv.ParseInt(c.Query("PositionTicks"), 10, 64) + pos := req.PositionTicks + if pos == 0 { + pos = req.PositionLower } - if req.RunTimeTicks == 0 { - req.RunTimeTicks, _ = strconv.ParseInt(c.Query("RunTimeTicks"), 10, 64) + if pos == 0 { + pos, _ = strconv.ParseInt(firstQueryValue(c, "PositionTicks", "positionTicks"), 10, 64) } - if req.ItemId == "" { + runTime := req.RunTimeTicks + if runTime == 0 { + runTime = req.RunTimeLower + } + if runTime == 0 { + runTime, _ = strconv.ParseInt(firstQueryValue(c, "RunTimeTicks", "runTimeTicks"), 10, 64) + } + if itemID == "" { c.Status(http.StatusOK) return } @@ -43,7 +57,10 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc { c.Status(http.StatusUnauthorized) return } - _ = svc.Emby.RecordProgress(c.Request.Context(), uid, req.ItemId, req.PositionTicks, req.RunTimeTicks) + if err := svc.Emby.RecordProgress(c.Request.Context(), uid, itemID, pos, runTime); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } stopped := strings.Contains(strings.ToLower(c.FullPath()+" "+c.Request.URL.Path), "stopped") if svc.Sessions != nil { svc.Sessions.RecordPlayback(c.Request.Context(), uid, "", @@ -51,9 +68,9 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc { clientInfo.DeviceName, clientInfo.Client, c.ClientIP(), - req.ItemId, - req.PositionTicks, - req.RunTimeTicks, + itemID, + pos, + runTime, stopped) } if svc.Device != nil && !stopped { diff --git a/internal/handler/emby_routes.go b/internal/handler/emby_routes.go index f29590e..f231828 100644 --- a/internal/handler/emby_routes.go +++ b/internal/handler/emby_routes.go @@ -5,8 +5,8 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/service" ) // registerEmbyRoutes 在 r 上挂双前缀("" + "/emby")的 Emby 兼容路由。 @@ -161,6 +161,8 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con auth.GET("/Users/:userId/Items/Counts", embyItemsCountsHandler(svc)) auth.GET("/Items/Latest", embyLatestItemsHandler(svc)) auth.GET("/Items/Resume", embyResumeItemsHandler(svc)) + auth.GET("/Users/:userId/Items/Resume", embyResumeItemsHandler(svc)) + auth.GET("/UserItems/Resume", embyResumeItemsHandler(svc)) auth.GET("/Items/:id", embyItemByIDHandler(svc)) auth.GET("/Users/:userId/Items/:id", embyUserItemByIDHandler(svc)) auth.GET("/Shows/:id/Seasons", embyShowSeasonsHandler(svc)) diff --git a/internal/handler/emby_routes_lowercase.go b/internal/handler/emby_routes_lowercase.go index 9246b25..2b5370a 100644 --- a/internal/handler/emby_routes_lowercase.go +++ b/internal/handler/emby_routes_lowercase.go @@ -3,7 +3,7 @@ package handler import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) func registerLowercaseEmbyAuthRoutes(auth *gin.RouterGroup, svc *service.Container) { @@ -32,6 +32,8 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain auth.GET("/users/:userId/items/counts", embyItemsCountsHandler(svc)) auth.GET("/items/latest", embyLatestItemsHandler(svc)) auth.GET("/items/resume", embyResumeItemsHandler(svc)) + auth.GET("/users/:userId/items/resume", embyResumeItemsHandler(svc)) + auth.GET("/useritems/resume", embyResumeItemsHandler(svc)) auth.GET("/items/:id", embyItemByIDHandler(svc)) auth.GET("/users/:userId/items/:id", embyUserItemByIDHandler(svc)) auth.GET("/shows/:id/seasons", embyShowSeasonsHandler(svc)) diff --git a/internal/handler/emby_session_routes_test.go b/internal/handler/emby_session_routes_test.go index 7a5ebe9..a85f237 100644 --- a/internal/handler/emby_session_routes_test.go +++ b/internal/handler/emby_session_routes_test.go @@ -14,10 +14,10 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" ) func TestEmbyMarkPlayedRefreshesPlaybackDevice(t *testing.T) { @@ -213,10 +213,13 @@ func TestEmbyAuthenticatedRequestRefreshesRealtimeUserActivity(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("admin users status: %d body=%s", w.Code, w.Body.String()) } - var users []model.User - if err := json.Unmarshal(w.Body.Bytes(), &users); err != nil { + var payload struct { + Users []model.User `json:"users"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode users: %v", err) } + users := payload.Users if len(users) != 1 { t.Fatalf("users = %#v", users) } diff --git a/internal/handler/emby_sessions.go b/internal/handler/emby_sessions.go index d36ca8a..b89358d 100644 --- a/internal/handler/emby_sessions.go +++ b/internal/handler/emby_sessions.go @@ -7,8 +7,8 @@ import ( "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/service" ) func embySessionsHandler(svc *service.Container) gin.HandlerFunc { @@ -29,7 +29,7 @@ func embySessionsHandler(svc *service.Container) gin.HandlerFunc { } row := gin.H{ "Id": sess.ID, - "ServerId": "mmtl-001", + "ServerId": "mebox-001", "Client": sess.Client, "DeviceId": sess.DeviceID, "DeviceName": sess.DeviceName, @@ -41,7 +41,17 @@ func embySessionsHandler(svc *service.Container) gin.HandlerFunc { "SupportsRemoteControl": true, } if itemID != "" && sess.IsPlaying { - row["NowPlayingItem"] = gin.H{"Id": itemID} + nowPlaying := gin.H{"Id": itemID} + if svc.Emby != nil { + if item, _ := svc.Emby.Item(c.Request.Context(), itemID, sess.UserID); item != nil { + for _, key := range []string{"Name", "Type", "RunTimeTicks", "PrimaryImageItemId", "ImageTags", "SeriesName", "SeasonName", "IndexNumber", "ParentIndexNumber"} { + if val, ok := item[key]; ok && val != nil { + nowPlaying[key] = val + } + } + } + } + row["NowPlayingItem"] = nowPlaying } out = append(out, row) } diff --git a/internal/handler/emby_sessions_test.go b/internal/handler/emby_sessions_test.go index 3353cb9..0b240ca 100644 --- a/internal/handler/emby_sessions_test.go +++ b/internal/handler/emby_sessions_test.go @@ -10,7 +10,7 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) func TestEmbySessionsReturnsRealtimeSession(t *testing.T) { diff --git a/internal/handler/emby_static.go b/internal/handler/emby_static.go index 2312520..7597f04 100644 --- a/internal/handler/emby_static.go +++ b/internal/handler/emby_static.go @@ -7,7 +7,7 @@ import ( "github.com/gin-gonic/gin" "github.com/gorilla/websocket" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) func embyNoContentHandler(_ *service.Container) gin.HandlerFunc { diff --git a/internal/handler/emby_subtitle_routes_test.go b/internal/handler/emby_subtitle_routes_test.go index 82bf05d..6c0f203 100644 --- a/internal/handler/emby_subtitle_routes_test.go +++ b/internal/handler/emby_subtitle_routes_test.go @@ -14,10 +14,10 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" ) // TestEmbySubtitleOfficialRouteServesRawASS verifies that the official Emby diff --git a/internal/handler/emby_system.go b/internal/handler/emby_system.go index 092fe56..50bbfe3 100644 --- a/internal/handler/emby_system.go +++ b/internal/handler/emby_system.go @@ -6,7 +6,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) func embySystemInfoHandler(svc *service.Container) gin.HandlerFunc { @@ -84,9 +84,9 @@ func embyPublicSystemInfoPayload(c *gin.Context, svc *service.Container) map[str return embyWithRequestAddress(c, svc.Emby.SystemInfoPublic()) } return embyWithRequestAddress(c, map[string]any{ - "Id": "mmtl-001", - "ServerId": "mmtl-001", - "ServerName": "MMTL", + "Id": "mebox-001", + "ServerId": "mebox-001", + "ServerName": "MeBox", "Version": "4.8.10.0", "ServerVersion": "4.8.10.0", "ProductName": "Emby Server", diff --git a/internal/handler/emby_test_helpers_test.go b/internal/handler/emby_test_helpers_test.go index fd76b3e..6890308 100644 --- a/internal/handler/emby_test_helpers_test.go +++ b/internal/handler/emby_test_helpers_test.go @@ -6,7 +6,7 @@ import ( "github.com/golang-jwt/jwt/v5" - "github.com/ShukeBta/MMTL/internal/middleware" + "github.com/truewhile/MeBox/internal/middleware" ) func signedTestToken(t *testing.T, secret string) string { @@ -23,7 +23,7 @@ func signedTestTokenWithPurpose(t *testing.T, secret, purpose string) string { Purpose: purpose, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), - Issuer: "mmtl-test", + Issuer: "mebox-test", Subject: "user-1", }, } diff --git a/internal/handler/emby_users.go b/internal/handler/emby_users.go index b0e779d..e810202 100644 --- a/internal/handler/emby_users.go +++ b/internal/handler/emby_users.go @@ -6,7 +6,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) // ─── Users / Auth ──────────────────────────────────────────────────────────── @@ -67,7 +67,7 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc { embyRememberCompatSession(c, accessToken) c.JSON(http.StatusOK, gin.H{ "AccessToken": accessToken, - "ServerId": "mmtl-001", + "ServerId": "mebox-001", "User": userPayload, "SessionInfo": gin.H{ "Id": resp.User.ID, @@ -150,12 +150,12 @@ func embyGetUserByIDHandler(svc *service.Container) gin.HandlerFunc { func embyFallbackUser(id string) gin.H { if strings.TrimSpace(id) == "" { - id = "mmtl-user" + id = "mebox-user" } return gin.H{ "Id": id, - "Name": "MMTL", - "ServerId": "mmtl-001", + "Name": "MeBox", + "ServerId": "mebox-001", "HasPassword": true, "HasConfiguredPassword": true, "HasConfiguredEasyPassword": false, diff --git a/internal/handler/emby_views_handlers.go b/internal/handler/emby_views_handlers.go index dab3dd8..093963b 100644 --- a/internal/handler/emby_views_handlers.go +++ b/internal/handler/emby_views_handlers.go @@ -5,7 +5,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) func embyViewsHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/handler/filemanager.go b/internal/handler/filemanager.go index 96e9cb2..075ef8f 100644 --- a/internal/handler/filemanager.go +++ b/internal/handler/filemanager.go @@ -10,7 +10,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) type fileFolderReq struct { diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 509c8c0..f8e3fc0 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -8,8 +8,8 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/service" ) // Register attaches every API route to the engine. @@ -46,7 +46,7 @@ func healthCheck(c *gin.Context) { } func versionInfo(c *gin.Context) { - c.JSON(200, gin.H{"name": "MMTL", "version": "0.1.0"}) + c.JSON(200, gin.H{"name": "MeBox", "version": "0.1.0"}) } // ─── 权限 Handler 包装 ──────────────────────────────────────────────────────── diff --git a/internal/handler/manual_scrape.go b/internal/handler/manual_scrape.go index d053e61..31a0f85 100644 --- a/internal/handler/manual_scrape.go +++ b/internal/handler/manual_scrape.go @@ -8,7 +8,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) type manualScrapeApplyReq struct { diff --git a/internal/handler/media.go b/internal/handler/media.go index 9a46e51..ef13e2f 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -7,31 +7,32 @@ import ( "net/http" "strconv" "strings" + "sync" "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service" ) type createLibraryReq struct { - Name string `json:"name"` - Path string `json:"path"` - Paths []string `json:"paths"` - Roots []service.LibraryRootInput `json:"roots"` - Type string `json:"type"` - CoverURL string `json:"cover_url"` - CreatePerSubfolder bool `json:"create_per_subfolder"` + Name string `json:"name"` + Path string `json:"path"` + Paths []string `json:"paths"` + Roots []service.LibraryRootInput `json:"roots"` + Type string `json:"type"` + CoverURL string `json:"cover_url"` + CreatePerSubfolder bool `json:"create_per_subfolder"` } // webLibraryPayload 是 /api/libraries 返回的库条目:本地库与远程 Emby 挂载库 // 统一结构(远程库附加 is_remote_emby / remote_source 只读标记)。 type webLibraryPayload struct { model.Library - IsRemoteEmby bool `json:"is_remote_emby,omitempty"` - RemoteSource string `json:"remote_source,omitempty"` - Total int64 `json:"total,omitempty"` + IsRemoteEmby bool `json:"is_remote_emby,omitempty"` + RemoteSource string `json:"remote_source,omitempty"` + Total int64 `json:"total,omitempty"` Cards []service.SeriesCard `json:"cards,omitempty"` } @@ -96,22 +97,42 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { // 远程 Emby 挂载库追加在本地库之后。 if svc.EmbyRemote != nil { if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil { - for _, v := range views { - wl := webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName} - if withPreview { - if acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID); acct != nil { + remotePayloads := make([]webLibraryPayload, len(views)) + for i, v := range views { + remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName} + } + if withPreview && len(views) > 0 { + const maxRemotePreviewWorkers = 6 + sem := make(chan struct{}, maxRemotePreviewWorkers) + var wg sync.WaitGroup + for i, v := range views { + i, v := i, v + wg.Add(1) + go func() { + defer wg.Done() + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-ctx.Done(): + return + } + acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID) + if acct == nil { + return + } tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}} itemTypes := remoteLibraryItemTypes(v.CollectionType) if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil { - wl.Total = total + remotePayloads[i].Total = total } if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil { - wl.Cards = cards + remotePayloads[i].Cards = cards } - } + }() } - out = append(out, wl) + wg.Wait() } + out = append(out, remotePayloads...) } } c.JSON(http.StatusOK, out) @@ -171,38 +192,38 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc { } } if len(roots) == 0 && strings.TrimSpace(req.Path) != "" { -roots = append(roots, service.LibraryRootInput{Path: req.Path}) - } - var l *model.Library - if req.CreatePerSubfolder { - parent := "" - if len(roots) > 0 { - parent = roots[0].Path - } else if strings.TrimSpace(req.Path) != "" { - parent = req.Path + roots = append(roots, service.LibraryRootInput{Path: req.Path}) } - created, err := svc.Media.CreateLibrariesPerSubfolder(c.Request.Context(), parent, req.Type, req.CoverURL) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - uid, _ := c.Get("ctx_user_id") - for i := range created { - lib := &created[i] - svc.Audit.Record(c.Request.Context(), toString(uid), "library.create", lib.ID, c.ClientIP(), lib.Path) - if svc.Watcher != nil { - go func() { _ = svc.Watcher.Refresh(context.Background()) }() + var l *model.Library + if req.CreatePerSubfolder { + parent := "" + if len(roots) > 0 { + parent = roots[0].Path + } else if strings.TrimSpace(req.Path) != "" { + parent = req.Path } - for _, root := range lib.Roots { - if root.Enabled { - queueLibraryRootScan(svc, lib.ID, root.ID) + created, err := svc.Media.CreateLibrariesPerSubfolder(c.Request.Context(), parent, req.Type, req.CoverURL) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + uid, _ := c.Get("ctx_user_id") + for i := range created { + lib := &created[i] + svc.Audit.Record(c.Request.Context(), toString(uid), "library.create", lib.ID, c.ClientIP(), lib.Path) + if svc.Watcher != nil { + go func() { _ = svc.Watcher.Refresh(context.Background()) }() + } + for _, root := range lib.Roots { + if root.Enabled { + queueLibraryRootScan(svc, lib.ID, root.ID) + } } } + c.JSON(http.StatusCreated, gin.H{"libraries": created}) + return } - c.JSON(http.StatusCreated, gin.H{"libraries": created}) - return - } - l, err := svc.Media.CreateLibraryWithRootsAndCover(c.Request.Context(), req.Name, req.Type, req.CoverURL, roots) + l, err := svc.Media.CreateLibraryWithRootsAndCover(c.Request.Context(), req.Name, req.Type, req.CoverURL, roots) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -488,8 +509,11 @@ func streamHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { ctx := c.Request.Context() id := c.Param("id") - // 远程 Emby 条目:按挂载代理配置分流——代理走 MMTL 反代,否则 302 直连。 + // 远程 Emby 条目:按挂载代理配置分流——代理走 MeBox 反代,否则 302 直连。 if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) { + if !enforceScopedPlaybackToken(c, id) { + return + } mountID, remoteID, _ := service.DecodeEmbyRemoteID(id) mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID) if mount == nil || acct == nil { @@ -509,6 +533,7 @@ func streamHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } + setRedirectNoStoreHeaders(c) c.Redirect(http.StatusFound, target) return } diff --git a/internal/handler/media_extra.go b/internal/handler/media_extra.go index 9f99bfc..6ba0d02 100644 --- a/internal/handler/media_extra.go +++ b/internal/handler/media_extra.go @@ -8,8 +8,8 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service" ) func recentMediaHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/handler/media_favorite.go b/internal/handler/media_favorite.go index dd133af..70e3448 100644 --- a/internal/handler/media_favorite.go +++ b/internal/handler/media_favorite.go @@ -12,9 +12,9 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service" ) // addMediaFavoriteHandler ensures the (user, media) row exists. If it diff --git a/internal/handler/media_roots.go b/internal/handler/media_roots.go index 608bdc2..fc0faf8 100644 --- a/internal/handler/media_roots.go +++ b/internal/handler/media_roots.go @@ -6,7 +6,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) func listLibraryRootsHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/handler/media_scan.go b/internal/handler/media_scan.go index 035f7eb..8c36d6f 100644 --- a/internal/handler/media_scan.go +++ b/internal/handler/media_scan.go @@ -7,7 +7,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) func scanLibraryHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/handler/media_test.go b/internal/handler/media_test.go index 3fc3f1b..410d941 100644 --- a/internal/handler/media_test.go +++ b/internal/handler/media_test.go @@ -18,11 +18,11 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" ) func TestQueueLibraryRootScanImportsNewLibraryMedia(t *testing.T) { diff --git a/internal/handler/metadata_enrich.go b/internal/handler/metadata_enrich.go index 956c26b..f727966 100644 --- a/internal/handler/metadata_enrich.go +++ b/internal/handler/metadata_enrich.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) var metadataNoiseRE = regexp.MustCompile(`(?i)(自动订阅|订阅|全集|合集|complete|batch|season\s*\d+|s\d{1,2}|s\d{1,2}e\d{1,3}|第\s*\d+\s*季|第\s*\d+\s*[集话話期]|2160p|1080p|720p|4k|uhd|bluray|blu-ray|web-?dl|hdtv|remux|x26[45]|h\.?26[45]|hevc|avc|hdr10?\+?|dovi|dv|atmos|aac|ddp?5\.1|truehd|flac)`) diff --git a/internal/handler/nfo.go b/internal/handler/nfo.go index be2e0c4..83db407 100644 --- a/internal/handler/nfo.go +++ b/internal/handler/nfo.go @@ -6,7 +6,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) func exportNFOHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/handler/organizer.go b/internal/handler/organizer.go index 270d971..9d5b058 100644 --- a/internal/handler/organizer.go +++ b/internal/handler/organizer.go @@ -7,7 +7,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) // organizeReq carries optional per-request overrides. 留空则沿用系统设置。 diff --git a/internal/handler/permission_handler.go b/internal/handler/permission_handler.go index 6ad0e4a..ca518ad 100644 --- a/internal/handler/permission_handler.go +++ b/internal/handler/permission_handler.go @@ -7,9 +7,9 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service" ) // PermissionHandler 权限相关 HTTP 处理。 diff --git a/internal/handler/permissions.go b/internal/handler/permissions.go index a140a03..daa568a 100644 --- a/internal/handler/permissions.go +++ b/internal/handler/permissions.go @@ -11,9 +11,9 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service" ) func requirePermission(svc *service.Container, key string) gin.HandlerFunc { diff --git a/internal/handler/play_profile.go b/internal/handler/play_profile.go index dee5fed..4f7f23e 100644 --- a/internal/handler/play_profile.go +++ b/internal/handler/play_profile.go @@ -11,8 +11,8 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/service" ) type verifyPlayProfilePINReq struct { diff --git a/internal/handler/playback.go b/internal/handler/playback.go index 13c8001..e40ad1d 100644 --- a/internal/handler/playback.go +++ b/internal/handler/playback.go @@ -6,8 +6,8 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/service" ) // ─── History ──────────────────────────────────────────────────────────────── diff --git a/internal/handler/playback_extra.go b/internal/handler/playback_extra.go index 3b012c0..94ae453 100644 --- a/internal/handler/playback_extra.go +++ b/internal/handler/playback_extra.go @@ -14,26 +14,45 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service" ) +func findMediaForPlaybackEndpoint(c *gin.Context, svc *service.Container, id string) (*model.Media, error) { + ctx := c.Request.Context() + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) { + mountID, remoteID, _ := service.DecodeEmbyRemoteID(id) + mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID) + if mount == nil || acct == nil { + return nil, nil + } + return svc.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID) + } + return svc.Repo.Media.FindByID(ctx, id) +} + // playbackInfoHandler returns the media row + a `stream_url` the React // player can hit. Mirrors the Python project's surface. func playbackInfoHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id")) + id := c.Param("id") + m, err := findMediaForPlaybackEndpoint(c, svc, id) if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) { c.JSON(http.StatusNotFound, gin.H{"error": "media not found"}) return } token := externalPlaybackToken(c, svc, m.ID, m.DurationSec) profileQuery := externalProfileQuery(c) + hlsURL := "/api/hls/" + m.ID + "/index.m3u8?token=" + url.QueryEscape(token) + profileQuery + if service.IsEmbyRemoteID(m.ID) || service.IsStrmMediaRow(m) { + // Emby 远程挂载与 STRM 媒体一样,默认直连播放,不提供转码地址 + hlsURL = "" + } c.JSON(http.StatusOK, gin.H{ "media": m, "stream_url": "/api/stream/" + m.ID + "?token=" + url.QueryEscape(token) + profileQuery, - "hls_url": "/api/hls/" + m.ID + "/index.m3u8?token=" + url.QueryEscape(token) + profileQuery, + "hls_url": hlsURL, }) } } @@ -63,12 +82,37 @@ func playbackProgressHandler(svc *service.Container) gin.HandlerFunc { } } +func playbackResumeHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + uid, _ := c.Get(middleware.CtxUserID) + row, err := svc.Playback.GetProgress(c.Request.Context(), toString(uid), c.Param("id")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if row == nil { + c.JSON(http.StatusOK, gin.H{ + "position_ms": 0, + "duration_ms": 0, + "completed": false, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "position_ms": row.PositionMs, + "duration_ms": row.DurationMs, + "completed": row.Completed, + }) + } +} + // externalPlayersHandler returns the list of external player URI // schemes the UI can offer the user. We lookup the media row to // produce the per-player launch URL. func externalPlayersHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id")) + id := c.Param("id") + m, err := findMediaForPlaybackEndpoint(c, svc, id) if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) { c.JSON(http.StatusNotFound, gin.H{"error": "media not found"}) return @@ -93,7 +137,8 @@ func externalPlayersHandler(svc *service.Container) gin.HandlerFunc { // token query string the external player needs. func externalURLHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id")) + id := c.Param("id") + m, err := findMediaForPlaybackEndpoint(c, svc, id) if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) { c.JSON(http.StatusNotFound, gin.H{"error": "media not found"}) return @@ -141,7 +186,7 @@ func externalPlaybackURL(c *gin.Context, svc *service.Container, path string) st if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { return path } - headerOrigin := sanitizedPublicOrigin(c.GetHeader("X-MMTL-Public-Origin")) + headerOrigin := sanitizedPublicOrigin(c.GetHeader("X-MeBox-Public-Origin")) if headerOrigin != "" && !isLocalPublicOrigin(headerOrigin) { return joinOriginPath(headerOrigin, path) } diff --git a/internal/handler/playback_extra_test.go b/internal/handler/playback_extra_test.go index c135d64..449ba0f 100644 --- a/internal/handler/playback_extra_test.go +++ b/internal/handler/playback_extra_test.go @@ -15,11 +15,11 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" ) func TestExternalURLUsesMediaScopedPlaybackToken(t *testing.T) { @@ -109,7 +109,7 @@ func TestExternalURLPrefersBrowserPublicOriginOverForwardedSource(t *testing.T) req.Header.Set("Authorization", "Bearer "+loginToken) req.Header.Set("X-Forwarded-Proto", "https") req.Header.Set("X-Forwarded-Host", "media.v6.agonyz.dpdns.org") - req.Header.Set("X-MMTL-Public-Origin", "https://media.agonyz.dpdns.org") + req.Header.Set("X-MeBox-Public-Origin", "https://media.agonyz.dpdns.org") w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -140,7 +140,7 @@ func TestExternalPlayersSanitizeBrowserPublicOrigin(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://origin.internal/api/playback/media-1/external-players", nil) req.Header.Set("Authorization", "Bearer "+loginToken) - req.Header.Set("X-MMTL-Public-Origin", "https://user:pass@media.agonyz.dpdns.org/sneaky/path?x=1#frag") + req.Header.Set("X-MeBox-Public-Origin", "https://user:pass@media.agonyz.dpdns.org/sneaky/path?x=1#frag") w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -184,7 +184,7 @@ func TestExternalURLFallsBackToConfiguredPublicServerURL(t *testing.T) { req.Header.Set("Authorization", "Bearer "+loginToken) req.Header.Set("X-Forwarded-Proto", "https") req.Header.Set("X-Forwarded-Host", "source.example.test") - req.Header.Set("X-MMTL-Public-Origin", "javascript:alert(1)") + req.Header.Set("X-MeBox-Public-Origin", "javascript:alert(1)") w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -211,7 +211,7 @@ func TestExternalURLPrefersConfiguredPublicServerURLOverLocalBrowserOrigin(t *te req := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8080/api/playback/media-1/external-url", nil) req.Header.Set("Authorization", "Bearer "+loginToken) - req.Header.Set("X-MMTL-Public-Origin", "http://127.0.0.1:8080") + req.Header.Set("X-MeBox-Public-Origin", "http://127.0.0.1:8080") w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -404,8 +404,77 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container, router := gin.New() api := router.Group("/api") api.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret)) + api.GET("/playback/:id/info", playbackInfoHandler(svc)) api.GET("/playback/:id/external-url", externalURLHandler(svc)) api.GET("/playback/:id/external-players", externalPlayersHandler(svc)) api.GET("/stream/:id", streamHandler(svc)) + api.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc)) + api.GET("/media/:id/subtitles", listSubtitlesHandler(svc)) return router, svc, cfg.Secrets.JWTSecret } + +func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) { + router, _, secret := newPlaybackScopeTestRouter(t) + loginToken := signedTestToken(t, secret) + + req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-1/info", nil) + req.Header.Set("Authorization", "Bearer "+loginToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) + } + var payload struct { + StreamURL string `json:"stream_url"` + HlsURL string `json:"hls_url"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode: %v", err) + } + if payload.StreamURL == "" { + t.Fatalf("expected non-empty stream_url") + } + if payload.HlsURL != "" { + t.Fatalf("expected empty hls_url for STRM media, got %q", payload.HlsURL) + } +} + +func TestHLSPlaylistForRemoteEmbyMediaDisabled(t *testing.T) { + router, svc, secret := newPlaybackScopeTestRouter(t) + svc.EmbyRemote = &service.EmbyRemoteService{} + loginToken := signedTestToken(t, secret) + + req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/hls/embyremote~acct1~item1/index.m3u8", nil) + req.Header.Set("Authorization", "Bearer "+loginToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Fatalf("status = %d, want %d (409 StatusConflict)", w.Code, http.StatusConflict) + } +} + +func TestListSubtitlesForRemoteEmbyMediaReturnsEmptyTracks(t *testing.T) { + router, svc, secret := newPlaybackScopeTestRouter(t) + svc.EmbyRemote = &service.EmbyRemoteService{} + loginToken := signedTestToken(t, secret) + + req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/media/embyremote~acct1~item1/subtitles", nil) + req.Header.Set("Authorization", "Bearer "+loginToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 OK", w.Code) + } + var payload struct { + Tracks []any `json:"tracks"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode: %v", err) + } + if payload.Tracks == nil || len(payload.Tracks) != 0 { + t.Fatalf("expected empty tracks array, got %v", payload.Tracks) + } +} diff --git a/internal/handler/playback_scope.go b/internal/handler/playback_scope.go index 2343da8..0a40c13 100644 --- a/internal/handler/playback_scope.go +++ b/internal/handler/playback_scope.go @@ -7,9 +7,9 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service" ) func enforceScopedPlaybackToken(c *gin.Context, mediaID string) bool { diff --git a/internal/handler/playlist_extra.go b/internal/handler/playlist_extra.go index 7091aa3..525d73a 100644 --- a/internal/handler/playlist_extra.go +++ b/internal/handler/playlist_extra.go @@ -7,8 +7,8 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service" ) type reorderReq struct { diff --git a/internal/handler/profile.go b/internal/handler/profile.go index 1c4458d..309e0f8 100644 --- a/internal/handler/profile.go +++ b/internal/handler/profile.go @@ -9,8 +9,8 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/service" ) func updateProfileHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/handler/profile_test.go b/internal/handler/profile_test.go index 2ba5d3e..8264281 100644 --- a/internal/handler/profile_test.go +++ b/internal/handler/profile_test.go @@ -6,9 +6,9 @@ import ( "github.com/glebarez/sqlite" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" ) func TestProfileHideAdultRequiresPasswordOnlyWhenChanged(t *testing.T) { diff --git a/internal/handler/public_ui.go b/internal/handler/public_ui.go index 81333a0..1d53eb6 100644 --- a/internal/handler/public_ui.go +++ b/internal/handler/public_ui.go @@ -5,7 +5,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) const hideCommunityLinksSettingKey = "ui.hide_community_links_for_users" diff --git a/internal/handler/public_ui_test.go b/internal/handler/public_ui_test.go index cc75814..a2c1802 100644 --- a/internal/handler/public_ui_test.go +++ b/internal/handler/public_ui_test.go @@ -11,9 +11,9 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" ) func TestPublicUIConfigExposesCommunityLinkVisibility(t *testing.T) { diff --git a/internal/handler/recognition_words.go b/internal/handler/recognition_words.go index a1cc7cb..34752ba 100644 --- a/internal/handler/recognition_words.go +++ b/internal/handler/recognition_words.go @@ -5,7 +5,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) func recognitionWordsService(svc *service.Container) *service.RecognitionWordsService { diff --git a/internal/handler/recycle.go b/internal/handler/recycle.go index 003cb9e..63eeb9a 100644 --- a/internal/handler/recycle.go +++ b/internal/handler/recycle.go @@ -6,7 +6,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) func deleteMediaHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/handler/refresh_handler.go b/internal/handler/refresh_handler.go index c0645d8..f366d9a 100644 --- a/internal/handler/refresh_handler.go +++ b/internal/handler/refresh_handler.go @@ -7,7 +7,7 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) // RefreshHandler 令牌刷新 HTTP 处理。 diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index de2a76b..351bb35 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -4,9 +4,9 @@ package handler import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/service" ) func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.Container) { @@ -51,6 +51,7 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) { admin.POST("/emby/accounts/:id/full-mount", fullMountEmbyAccountHandler(svc)) admin.GET("/emby/mounts", listEmbyMountsHandler(svc)) admin.POST("/emby/mounts", createEmbyMountsHandler(svc)) + admin.PUT("/emby/mounts/reorder", reorderEmbyMountsHandler(svc)) admin.PUT("/emby/mounts/:id", updateEmbyMountHandler(svc)) admin.DELETE("/emby/mounts/:id", deleteEmbyMountHandler(svc)) @@ -93,11 +94,16 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) { admin.POST("/strm/uploads/:id/retry", retryStrmUploadHandler(svc)) admin.DELETE("/strm/uploads/:id", deleteStrmUploadHandler(svc)) admin.POST("/strm/uploads/batch", batchActionUploadsHandler(svc)) - admin.POST("/strm/uploads/cancel-pending", cancelPendingUploadsHandler(svc)) + admin.POST("/strm/uploads/clear-done", clearDoneUploadsHandler(svc)) + admin.POST("/strm/uploads/clear-finished", clearFinishedUploadsHandler(svc)) admin.POST("/strm/uploads/clear-canceled", clearCanceledUploadsHandler(svc)) + admin.POST("/strm/uploads/retry-failed", retryAllFailedUploadsHandler(svc)) + admin.POST("/strm/uploads/cancel-pending", cancelPendingUploadsHandler(svc)) } func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) { + admin.GET("/users/limit", getUserLimitHandler(svc)) + admin.PUT("/users/limit", updateUserLimitHandler(svc)) admin.GET("/users", listUsersHandler(svc)) admin.POST("/users", createUserHandler(svc)) admin.PATCH("/users/:id", updateUserHandler(svc)) diff --git a/internal/handler/routes_admin_test.go b/internal/handler/routes_admin_test.go index c38b934..bab3a43 100644 --- a/internal/handler/routes_admin_test.go +++ b/internal/handler/routes_admin_test.go @@ -6,8 +6,8 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/service" ) func TestAdminRouteSurfacesAreRegistered(t *testing.T) { diff --git a/internal/handler/routes_api_config.go b/internal/handler/routes_api_config.go index 818d365..a64433e 100644 --- a/internal/handler/routes_api_config.go +++ b/internal/handler/routes_api_config.go @@ -4,9 +4,9 @@ package handler import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/service" ) func registerAPIConfigRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.Container) { diff --git a/internal/handler/routes_authenticated.go b/internal/handler/routes_authenticated.go index 9b5290e..a9de929 100644 --- a/internal/handler/routes_authenticated.go +++ b/internal/handler/routes_authenticated.go @@ -4,9 +4,9 @@ package handler import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/service" ) func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.Container) { diff --git a/internal/handler/routes_authenticated_core.go b/internal/handler/routes_authenticated_core.go index 666add4..88d4433 100644 --- a/internal/handler/routes_authenticated_core.go +++ b/internal/handler/routes_authenticated_core.go @@ -3,8 +3,8 @@ package handler import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/service" ) func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Container) { diff --git a/internal/handler/routes_authenticated_extras.go b/internal/handler/routes_authenticated_extras.go index 4bf7e73..80e3c52 100644 --- a/internal/handler/routes_authenticated_extras.go +++ b/internal/handler/routes_authenticated_extras.go @@ -3,7 +3,7 @@ package handler import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) func registerAuthedUISurfaceRoutes(authed *gin.RouterGroup, svc *service.Container) { @@ -71,6 +71,7 @@ func registerAuthedFavoriteAndMediaActionRoutes(authed *gin.RouterGroup, svc *se func registerAuthedPlaybackExtraRoutes(authed *gin.RouterGroup, svc *service.Container) { authed.GET("/playback/:id/info", playbackInfoHandler(svc)) + authed.GET("/playback/:id/resume", playbackResumeHandler(svc)) authed.POST("/playback/:id/progress", playbackProgressHandler(svc)) authed.GET("/playback/:id/external-players", externalPlayersHandler(svc)) authed.GET("/playback/:id/external-url", externalURLHandler(svc)) diff --git a/internal/handler/routes_authenticated_features.go b/internal/handler/routes_authenticated_features.go index 06c1ad0..8e18efb 100644 --- a/internal/handler/routes_authenticated_features.go +++ b/internal/handler/routes_authenticated_features.go @@ -3,8 +3,8 @@ package handler import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/service" ) func registerAuthedFileRoutes(authed *gin.RouterGroup, svc *service.Container) { diff --git a/internal/handler/routes_authenticated_test.go b/internal/handler/routes_authenticated_test.go index 8acc5c1..e138d9f 100644 --- a/internal/handler/routes_authenticated_test.go +++ b/internal/handler/routes_authenticated_test.go @@ -6,8 +6,8 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/service" ) func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) { diff --git a/internal/handler/routes_public.go b/internal/handler/routes_public.go index 0992361..c9a8359 100644 --- a/internal/handler/routes_public.go +++ b/internal/handler/routes_public.go @@ -7,8 +7,8 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/service" ) func registerPublicAuthRoutes(api *gin.RouterGroup, svc *service.Container, log *zap.Logger) { diff --git a/internal/handler/scrape_reclassify.go b/internal/handler/scrape_reclassify.go index 7678ce8..ff49389 100644 --- a/internal/handler/scrape_reclassify.go +++ b/internal/handler/scrape_reclassify.go @@ -3,7 +3,7 @@ package handler import ( "context" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) // 刮削元数据时不再自动跨库纠偏或自动创建新媒体库,保持在当前媒体库内仅更新元数据 diff --git a/internal/handler/scraper_queue.go b/internal/handler/scraper_queue.go index 5b6a21e..7f870db 100644 --- a/internal/handler/scraper_queue.go +++ b/internal/handler/scraper_queue.go @@ -6,7 +6,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) func listScrapeQueueHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/handler/search_extra.go b/internal/handler/search_extra.go index 8b42f40..439adb0 100644 --- a/internal/handler/search_extra.go +++ b/internal/handler/search_extra.go @@ -10,7 +10,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) // searchUnifiedHandler is the basic /api/search endpoint. diff --git a/internal/handler/series.go b/internal/handler/series.go index f0e39b7..04984c9 100644 --- a/internal/handler/series.go +++ b/internal/handler/series.go @@ -13,8 +13,8 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service" ) // seasonGroup is the JSON returned to the React UI per season. diff --git a/internal/handler/stats_user.go b/internal/handler/stats_user.go index ee0b156..ded5cd8 100644 --- a/internal/handler/stats_user.go +++ b/internal/handler/stats_user.go @@ -8,9 +8,9 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service" ) // statsUserHandler returns a watch-time summary for one user. diff --git a/internal/handler/storage.go b/internal/handler/storage.go index 5dfc638..170ae5e 100644 --- a/internal/handler/storage.go +++ b/internal/handler/storage.go @@ -5,7 +5,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) func storageBreakdownHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/handler/storage_test.go b/internal/handler/storage_test.go index c136e59..b315b5a 100644 --- a/internal/handler/storage_test.go +++ b/internal/handler/storage_test.go @@ -6,8 +6,8 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/service" ) func TestStorageRouteIsRegistered(t *testing.T) { diff --git a/internal/handler/streaming.go b/internal/handler/streaming.go index 23ba58c..9137906 100644 --- a/internal/handler/streaming.go +++ b/internal/handler/streaming.go @@ -8,12 +8,18 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id")) + id := c.Param("id") + // 远程 Emby 挂载媒体与 STRM 一样,默认直连播放,不进行转码。 + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) { + c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"}) + return + } + m, err := svc.Media.GetMedia(c.Request.Context(), id) if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return @@ -43,7 +49,12 @@ func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc { func hlsSegmentHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id")) + id := c.Param("id") + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) { + c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"}) + return + } + m, err := svc.Media.GetMedia(c.Request.Context(), id) if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return diff --git a/internal/handler/strm.go b/internal/handler/strm.go index ead666f..e92a03b 100644 --- a/internal/handler/strm.go +++ b/internal/handler/strm.go @@ -8,11 +8,12 @@ import ( "net/url" "strconv" "strings" + "time" "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service" ) // ─── 网盘账号 ────────────────────────────────────────────────────────────────── @@ -28,8 +29,13 @@ type strmAccountView struct { model.StrmAccount HasCredential bool `json:"has_credential"` ProviderLabel string `json:"provider_label"` - // ProxyPlay 仅远程 Emby 挂载账号返回:播放流量是否经过 MMTL 代理(编辑回显用)。 + // ConfigPreview 非敏感配置字段,供编辑表单回显。 + ConfigPreview service.StrmAccountConfigPreview `json:"config_preview,omitempty"` + // ProxyPlay 仅远程 Emby 挂载账号返回:播放流量是否经过 MeBox 代理(编辑回显用)。 ProxyPlay *bool `json:"proxy_play,omitempty"` + // EmbyLines 仅远程 Emby 挂载账号返回:多线路配置(不含凭据)。 + EmbyLines []service.EmbyRemoteLine `json:"emby_lines,omitempty"` + EmbyActiveLine int `json:"emby_active_line,omitempty"` } func strmAccountViews(svc *service.Container, accounts []model.StrmAccount) []strmAccountView { @@ -41,10 +47,17 @@ func strmAccountViews(svc *service.Container, accounts []model.StrmAccount) []st HasCredential: service.HasStrmAccountCredential(&a), ProviderLabel: providerLabelOf(a.Provider), } + if svc != nil && svc.Strm != nil { + view.ConfigPreview = svc.Strm.StrmAccountConfigPreviewOf(&a) + } if a.Provider == model.StrmProviderEmbyRemote && svc != nil && svc.EmbyRemote != nil { if proxyPlay, err := svc.EmbyRemote.ProxyPlayOf(&a); err == nil { view.ProxyPlay = &proxyPlay } + if lines, activeLine, err := svc.EmbyRemote.LinesOf(&a); err == nil { + view.EmbyLines = lines + view.EmbyActiveLine = activeLine + } } out = append(out, view) } @@ -117,13 +130,33 @@ func deleteStrmAccountHandler(svc *service.Container) gin.HandlerFunc { func testStrmAccountHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - acct := svc.Strm.TestStrmAccount(c.Request.Context(), c.Param("id")) - if acct == nil { + id := c.Param("id") + acct, err := svc.Repo.StrmAccount.FindByID(c.Request.Context(), id) + if err != nil || acct == nil { c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"}) return } - views := strmAccountViews(svc, []model.StrmAccount{*acct}) - c.JSON(http.StatusOK, views[0]) + now := time.Now() + acct.LastTestAt = &now + if acct.Provider == model.StrmProviderEmbyRemote && svc.EmbyRemote != nil { + if err := svc.EmbyRemote.TestConnection(c.Request.Context(), acct); err != nil { + acct.LastTestResult = err.Error() + acct.LastTestOK = false + } else { + acct.LastTestResult = "ok" + acct.LastTestOK = true + } + } else { + acct = svc.Strm.TestStrmAccount(c.Request.Context(), id) + if acct == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"}) + return + } + c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0]) + return + } + _ = svc.Repo.StrmAccount.Update(c.Request.Context(), acct) + c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0]) } } @@ -504,6 +537,28 @@ func clearCanceledUploadsHandler(svc *service.Container) gin.HandlerFunc { } } +func clearDoneUploadsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + n, err := svc.Strm.ClearDoneUploadTasks(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"deleted": n}) + } +} + +func clearFinishedUploadsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + n, err := svc.Strm.ClearFinishedUploadTasks(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"deleted": n}) + } +} + func retryAllFailedDownloadsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { n, err := svc.Strm.RetryAllFailedDownloadTasks(c.Request.Context()) @@ -515,6 +570,17 @@ func retryAllFailedDownloadsHandler(svc *service.Container) gin.HandlerFunc { } } +func retryAllFailedUploadsHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + n, err := svc.Strm.RetryAllFailedUploadTasks(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"retried": n}) + } +} + func cancelPendingDownloadsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { n, err := svc.Strm.CancelPendingDownloadTasks(c.Request.Context()) diff --git a/internal/handler/strm_routes_test.go b/internal/handler/strm_routes_test.go index ff7eed0..b9c4c9f 100644 --- a/internal/handler/strm_routes_test.go +++ b/internal/handler/strm_routes_test.go @@ -11,10 +11,10 @@ import ( "gorm.io/gorm" "gorm.io/gorm/logger" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" ) // TestStrmAdminRoutes 冒烟测试 STRM 管理端点注册与 401 拦截。 @@ -50,16 +50,19 @@ func TestStrmAdminRoutesAreRegistered(t *testing.T) { "GET /api/admin/strm/downloads", "POST /api/admin/strm/downloads/:id/cancel", "POST /api/admin/strm/downloads/:id/retry", - "POST /api/admin/strm/downloads/clear-finished", - "POST /api/admin/strm/downloads/clear-canceled", - "POST /api/admin/strm/downloads/retry-failed", - "POST /api/admin/strm/downloads/cancel-pending", - "GET /api/admin/strm/uploads", - "POST /api/admin/strm/uploads/:id/cancel", - "POST /api/admin/strm/uploads/:id/retry", - "POST /api/admin/strm/uploads/cancel-pending", - "POST /api/admin/strm/uploads/clear-canceled", - "GET /api/strm/play/:provider/:file", + "POST /api/admin/strm/downloads/clear-finished", + "POST /api/admin/strm/downloads/clear-canceled", + "POST /api/admin/strm/downloads/retry-failed", + "POST /api/admin/strm/downloads/cancel-pending", + "GET /api/admin/strm/uploads", + "POST /api/admin/strm/uploads/:id/cancel", + "POST /api/admin/strm/uploads/:id/retry", + "POST /api/admin/strm/uploads/clear-done", + "POST /api/admin/strm/uploads/clear-finished", + "POST /api/admin/strm/uploads/clear-canceled", + "POST /api/admin/strm/uploads/retry-failed", + "POST /api/admin/strm/uploads/cancel-pending", + "GET /api/strm/play/:provider/:file", } { if !routes[want] { t.Fatalf("%s route is not registered", want) diff --git a/internal/handler/subtitles.go b/internal/handler/subtitles.go index fff5b33..1e78a4a 100644 --- a/internal/handler/subtitles.go +++ b/internal/handler/subtitles.go @@ -6,12 +6,17 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - tracks, err := svc.Subtitle.Discover(c.Request.Context(), c.Param("id")) + id := c.Param("id") + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) { + c.JSON(http.StatusOK, gin.H{"tracks": []service.SubtitleTrack{}}) + return + } + tracks, err := svc.Subtitle.Discover(c.Request.Context(), id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return diff --git a/internal/handler/system_extra.go b/internal/handler/system_extra.go index 0e93cbd..5540e63 100644 --- a/internal/handler/system_extra.go +++ b/internal/handler/system_extra.go @@ -10,9 +10,9 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service" ) // listSystemConfigHandler is the non-admin alias for /admin/settings. diff --git a/internal/handler/system_meta.go b/internal/handler/system_meta.go index daf3fbf..81e64f6 100644 --- a/internal/handler/system_meta.go +++ b/internal/handler/system_meta.go @@ -15,7 +15,7 @@ import ( "github.com/shirou/gopsutil/v3/disk" "github.com/shirou/gopsutil/v3/mem" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) // startedAt is captured at first call so /system/status can report uptime @@ -31,7 +31,7 @@ func systemInfoHandler(svc *service.Container) gin.HandlerFunc { } } c.JSON(http.StatusOK, gin.H{ - "name": "MMTL", + "name": "MeBox", "version": "0.1.0", "go": runtime.Version(), "os": runtime.GOOS, diff --git a/internal/handler/system_update.go b/internal/handler/system_update.go index b49300a..6c43ebe 100644 --- a/internal/handler/system_update.go +++ b/internal/handler/system_update.go @@ -6,7 +6,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) func systemUpdateStatusHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/handler/task_helpers.go b/internal/handler/task_helpers.go index 88b4f27..c793d4f 100644 --- a/internal/handler/task_helpers.go +++ b/internal/handler/task_helpers.go @@ -1,6 +1,6 @@ package handler -import "github.com/ShukeBta/MMTL/internal/service" +import "github.com/truewhile/MeBox/internal/service" func finishHTTPTask(task *service.TaskHandle, err error, stage, message string, metrics map[string]int64, details []string) { if task == nil { diff --git a/internal/handler/tools_ffmpeg.go b/internal/handler/tools_ffmpeg.go index 7912c01..bfdc1f1 100644 --- a/internal/handler/tools_ffmpeg.go +++ b/internal/handler/tools_ffmpeg.go @@ -6,7 +6,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) // ffToolsStatusHandler 返回 ffmpeg/ffprobe 当前安装状态 diff --git a/internal/handler/visibility.go b/internal/handler/visibility.go index 85513c8..21c3616 100644 --- a/internal/handler/visibility.go +++ b/internal/handler/visibility.go @@ -11,9 +11,9 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service" ) func mediaVisibilityForRequest(c *gin.Context, svc *service.Container) service.MediaVisibility { diff --git a/internal/handler/watch_history.go b/internal/handler/watch_history.go index a5e3e08..44ecdfa 100644 --- a/internal/handler/watch_history.go +++ b/internal/handler/watch_history.go @@ -17,9 +17,9 @@ import ( "github.com/gin-gonic/gin" - "github.com/ShukeBta/MMTL/internal/middleware" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service" ) // historyListHandler returns the caller's history rows joined with the @@ -125,6 +125,27 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc { for _, r := range rows { m, ok := mIdx[r.MediaID] if !ok { + if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) { + mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID) + if mount, acct, _ := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID); mount != nil && acct != nil { + if rm, err := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID); err == nil && rm != nil { + if mediaVisibleForRequest(c, svc, rm) { + out = append(out, gin.H{ + "history": r, + "media": *rm, + }) + } + continue + } + } + } + fallback := fallbackHistoryMedia(r.MediaID) + if fallback != nil { + out = append(out, gin.H{ + "history": r, + "media": *fallback, + }) + } continue } out = append(out, gin.H{ @@ -136,6 +157,20 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc { } } +func fallbackHistoryMedia(mediaID string) *model.Media { + if mediaID == "" { + return nil + } + title := "媒体" + if service.IsEmbyRemoteID(mediaID) { + title = "远程媒体" + } + return &model.Media{ + Base: model.Base{ID: mediaID}, + Title: title, + } +} + // historyDeleteHandler removes one or all history rows for the caller. // // DELETE /api/watch-history?media_id=xxx → delete just that media's row @@ -163,7 +198,7 @@ func historyDeleteHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": "status must be completed or incomplete"}) return } - res := q.Unscoped().Delete(&model.PlaybackHistory{}) + res := q.Unscoped().Delete(&model.PlaybackHistory{}) if err := res.Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/internal/handler/ws.go b/internal/handler/ws.go index 187f0eb..fb1e358 100644 --- a/internal/handler/ws.go +++ b/internal/handler/ws.go @@ -15,7 +15,7 @@ import ( "github.com/google/uuid" "github.com/gorilla/websocket" - "github.com/ShukeBta/MMTL/internal/service" + "github.com/truewhile/MeBox/internal/service" ) var wsUpgrader = websocket.Upgrader{ diff --git a/internal/model/emby_mount.go b/internal/model/emby_mount.go index 5b81806..47dbcc2 100644 --- a/internal/model/emby_mount.go +++ b/internal/model/emby_mount.go @@ -2,18 +2,19 @@ // // 远程 Emby 账号(StrmAccount.Provider = emby_remote)只是一个服务器连接; // 「挂载」才决定把该服务器的哪个媒体库(View)暴露到本项目的媒体库中。 -// 这样同一个 Emby 服务器可以按库选择挂载,且每个挂载独立控制是否由 MMTL +// 这样同一个 Emby 服务器可以按库选择挂载,且每个挂载独立控制是否由 MeBox // 代理播放流量。 package model // EmbyMount 是远程 Emby 服务器上一个媒体库(View)的挂载配置。 type EmbyMount struct { Base - AccountID string `gorm:"size:36;index" json:"account_id"` // StrmAccount.ID(provider=emby_remote) - RemoteViewID string `gorm:"size:128" json:"remote_view_id"` // 远程 Emby 的 View Id - RemoteViewName string `gorm:"size:255" json:"remote_view_name"` // 远程媒体库原名(展示冗余) - CollectionType string `gorm:"size:32" json:"collection_type"` // movies / tvshows / music ... - Name string `gorm:"size:255" json:"name,omitempty"` // 覆盖显示名(可选,默认「账号 · 库名」) - ProxyPlay bool `gorm:"default:false" json:"proxy_play"` // 该挂载播放流量是否经 MMTL 反向代理 - Enabled bool `gorm:"default:true" json:"enabled"` // 是否在媒体库中展示 -} \ No newline at end of file + AccountID string `gorm:"size:36;index" json:"account_id"` // StrmAccount.ID(provider=emby_remote) + RemoteViewID string `gorm:"size:128" json:"remote_view_id"` // 远程 Emby 的 View Id + RemoteViewName string `gorm:"size:255" json:"remote_view_name"` // 远程媒体库原名(展示冗余) + CollectionType string `gorm:"size:32" json:"collection_type"` // movies / tvshows / music ... + Name string `gorm:"size:255" json:"name,omitempty"` // 覆盖显示名(可选,默认「账号 · 库名」) + SortOrder int `gorm:"default:0;index" json:"sort_order"` // 手动排序用,越小越靠前 + ProxyPlay bool `gorm:"default:false" json:"proxy_play"` // 该挂载播放流量是否经 MeBox 反向代理 + Enabled bool `gorm:"default:true" json:"enabled"` // 是否在媒体库中展示 +} diff --git a/internal/model/library_media.go b/internal/model/library_media.go index 677325d..de46ec7 100644 --- a/internal/model/library_media.go +++ b/internal/model/library_media.go @@ -8,8 +8,8 @@ type Library struct { Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"` Enabled bool `gorm:"default:true" json:"enabled"` - SortOrder int `gorm:"index;default:0" json:"sort_order"` // 手动拖拽排序用,越小越靠前 - CarouselEnabled bool `gorm:"default:false" json:"carousel_enabled"` // 是否参与首页海报轮播(默认不参与) + SortOrder int `gorm:"index;default:0" json:"sort_order"` // 手动拖拽排序用,越小越靠前 + CarouselEnabled bool `gorm:"default:false" json:"carousel_enabled"` // 是否参与首页海报轮播(默认不参与) Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"` } diff --git a/internal/model/model.go b/internal/model/model.go index 36c02aa..dfc1c76 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -1,5 +1,5 @@ // Package model 定义 GORM 数据模型和自动迁移使用的注册表。 -// 每个子系统在 MMTL 中拥有一个表切片;AllModels 返回联合以供 db.AutoMigrate 使用。 +// 每个子系统在 MeBox 中拥有一个表切片;AllModels 返回联合以供 db.AutoMigrate 使用。 package model import ( @@ -54,10 +54,10 @@ func AllModels() []interface{} { &StrmAccount{}, &StrmSyncPath{}, &StrmSyncRecord{}, - &StrmDownloadTask{}, - &StrmUploadTask{}, - &StrmDirCache{}, - &ScrapeTask{}, - &EmbyMount{}, - } + &StrmDownloadTask{}, + &StrmUploadTask{}, + &StrmDirCache{}, + &ScrapeTask{}, + &EmbyMount{}, + } } diff --git a/internal/model/scrape_task.go b/internal/model/scrape_task.go index 9df33e8..8c829e8 100644 --- a/internal/model/scrape_task.go +++ b/internal/model/scrape_task.go @@ -18,8 +18,8 @@ type ScrapeTask struct { LibraryName string `gorm:"size:128" json:"library_name"` MediaTitle string `gorm:"size:255;not null" json:"media_title"` MediaPath string `gorm:"size:1024;not null" json:"media_path"` - MediaType string `gorm:"size:16" json:"media_type"` // movie / tv / anime / adult - Provider string `gorm:"size:32" json:"provider"` // tmdb / douban / bangumi / thetvdb / metatube + MediaType string `gorm:"size:16" json:"media_type"` // movie / tv / anime / adult + Provider string `gorm:"size:32" json:"provider"` // tmdb / douban / bangumi / thetvdb / metatube MatchedTitle string `gorm:"size:255" json:"matched_title"` MatchedYear int `json:"matched_year"` PosterURL string `gorm:"size:1024" json:"poster_url"` diff --git a/internal/model/strm.go b/internal/model/strm.go index add4d67..67e90ad 100644 --- a/internal/model/strm.go +++ b/internal/model/strm.go @@ -38,17 +38,17 @@ type StrmSyncPath struct { RemotePath string `gorm:"size:1024" json:"remote_path"` // 远端目录:115=目录ID,OpenList/CD2=路径,local=源目录 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 - MetaExt string `gorm:"size:512" json:"meta_ext"` // 逗号分隔,覆盖 strm.meta_ext - ExcludeName string `gorm:"size:512" json:"exclude_name"` // 逗号分隔,文件名包含即跳过 - MinVideoSizeMB int64 `json:"min_video_size_mb"` // 小于该大小(MB)的视频不生成 STRM - AddPath int `json:"add_path"` // STRM 链接 path 参数:1=完整远端路径 2=仅文件名 3=不带 - DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕) - UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端 - DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录 - Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选) - EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步 + StrmBaseURL string `gorm:"size:512" json:"strm_base_url"` // 覆盖 strm.base_url + VideoExt string `gorm:"size:512" json:"video_ext"` // 逗号分隔,覆盖 strm.video_ext + MetaExt string `gorm:"size:512" json:"meta_ext"` // 逗号分隔,覆盖 strm.meta_ext + ExcludeName string `gorm:"size:512" json:"exclude_name"` // 逗号分隔,文件名包含即跳过 + MinVideoSizeMB int64 `json:"min_video_size_mb"` // 小于该大小(MB)的视频不生成 STRM + AddPath int `json:"add_path"` // STRM 链接 path 参数:1=完整远端路径 2=仅文件名 3=不带 + DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕) + UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端 + DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录 + Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选) + EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步 SyncMode string `gorm:"size:32;default:'incremental'" json:"sync_mode"` // 默认同步模式:incremental / full Enabled bool `gorm:"default:true" json:"enabled"` LastSyncAt *time.Time `json:"last_sync_at"` @@ -140,4 +140,3 @@ type StrmDirCache struct { DirID string `gorm:"size:128;index:idx_strm_dir_cache,priority:2" json:"dir_id"` Path string `gorm:"size:1024" json:"path"` // 相对根目录的路径 } - diff --git a/internal/model/user.go b/internal/model/user.go index f5084f0..a24861c 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -23,8 +23,8 @@ type User struct { LastLoginAt *time.Time `json:"last_login_at,omitempty"` // AllowedLibraryIDs 存储管理员为该用户指定的受限可访问媒体库 ID 列表(JSON 字符串)。 // 为空时代表不限制(全库可访问)。 - AllowedLibraryIDs string `gorm:"type:text" json:"-"` - AllowedLibraryList []string `gorm:"-" json:"allowed_library_ids,omitempty"` + AllowedLibraryIDs string `gorm:"type:text" json:"-"` + AllowedLibraryList []string `gorm:"-" json:"allowed_library_ids,omitempty"` // ExpiredAt is the account expiry time. Nil means the account never // expires. When set and in the past, the account is treated as expired // (login blocked) until an admin or a redemption code renews it. diff --git a/internal/repository/access_log_repository.go b/internal/repository/access_log_repository.go index 1485842..6b21e63 100644 --- a/internal/repository/access_log_repository.go +++ b/internal/repository/access_log_repository.go @@ -5,7 +5,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // AccessLogRepository persists model.AccessLog records. diff --git a/internal/repository/api_config_repository.go b/internal/repository/api_config_repository.go index 1a1f624..3e4993e 100644 --- a/internal/repository/api_config_repository.go +++ b/internal/repository/api_config_repository.go @@ -7,7 +7,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // ApiConfigRepository persists model.ApiConfig records. diff --git a/internal/repository/bot_repo.go b/internal/repository/bot_repo.go index e2b98b6..9f866ae 100644 --- a/internal/repository/bot_repo.go +++ b/internal/repository/bot_repo.go @@ -7,7 +7,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // ─── RegistrationCode ───────────────────────────────────────────────────────── diff --git a/internal/repository/emby_mount_repository.go b/internal/repository/emby_mount_repository.go index 9de20b0..8b9af27 100644 --- a/internal/repository/emby_mount_repository.go +++ b/internal/repository/emby_mount_repository.go @@ -7,7 +7,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // EmbyMountRepository 持久化远程 Emby 媒体库挂载。 @@ -15,7 +15,14 @@ type EmbyMountRepository struct{ db *gorm.DB } func (r *EmbyMountRepository) Create(ctx context.Context, m *model.EmbyMount) error { return withSQLiteBusyRetry(ctx, func() error { - return r.db.WithContext(ctx).Create(m).Error + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if m != nil && m.SortOrder == 0 { + var maxSort int + _ = tx.Model(&model.EmbyMount{}).Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort) + m.SortOrder = maxSort + 1 + } + return tx.Create(m).Error + }) }) } @@ -27,7 +34,17 @@ func (r *EmbyMountRepository) CreateInBatches(ctx context.Context, mounts []*mod batchSize = 50 } return withSQLiteBusyRetry(ctx, func() error { - return r.db.WithContext(ctx).CreateInBatches(mounts, batchSize).Error + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var maxSort int + _ = tx.Model(&model.EmbyMount{}).Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort) + for _, m := range mounts { + if m != nil && m.SortOrder == 0 { + maxSort++ + m.SortOrder = maxSort + } + } + return tx.CreateInBatches(mounts, batchSize).Error + }) }) } @@ -45,16 +62,33 @@ func (r *EmbyMountRepository) FindByID(ctx context.Context, id string) (*model.E func (r *EmbyMountRepository) List(ctx context.Context) ([]model.EmbyMount, error) { var rows []model.EmbyMount - err := r.db.WithContext(ctx).Order("created_at desc").Find(&rows).Error + err := r.db.WithContext(ctx).Order("sort_order asc, created_at asc").Find(&rows).Error return rows, err } func (r *EmbyMountRepository) ListByAccountID(ctx context.Context, accountID string) ([]model.EmbyMount, error) { var rows []model.EmbyMount - err := r.db.WithContext(ctx).Where("account_id = ?", accountID).Order("created_at asc").Find(&rows).Error + err := r.db.WithContext(ctx).Where("account_id = ?", accountID).Order("sort_order asc, created_at asc").Find(&rows).Error return rows, err } +func (r *EmbyMountRepository) SetSortOrder(ctx context.Context, ids []string) error { + if len(ids) == 0 { + return nil + } + return withSQLiteBusyRetry(ctx, func() error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for i, id := range ids { + if err := tx.Model(&model.EmbyMount{}).Where("id = ?", id). + Update("sort_order", i).Error; err != nil { + return err + } + } + return nil + }) + }) +} + func (r *EmbyMountRepository) CountByAccountID(ctx context.Context, accountID string) (int64, error) { var count int64 err := r.db.WithContext(ctx).Model(&model.EmbyMount{}).Where("account_id = ?", accountID).Count(&count).Error @@ -90,4 +124,4 @@ func (r *EmbyMountRepository) DeleteByAccountID(ctx context.Context, accountID s return res.Error }) return count, err -} \ No newline at end of file +} diff --git a/internal/repository/emby_mount_test.go b/internal/repository/emby_mount_test.go new file mode 100644 index 0000000..97aba84 --- /dev/null +++ b/internal/repository/emby_mount_test.go @@ -0,0 +1,74 @@ +package repository + +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "github.com/truewhile/MeBox/internal/database" + "github.com/truewhile/MeBox/internal/model" +) + +func TestEmbyMountSortOrderAndReorder(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := database.AutoMigrate(db); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := New(db) + ctx := t.Context() + + // 1. Create mounts and verify auto-assigned sort_order + m1 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-1", Name: "Mount 1"} + m2 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-2", Name: "Mount 2"} + m3 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-3", Name: "Mount 3"} + + if err := repos.EmbyMount.Create(ctx, m1); err != nil { + t.Fatalf("create m1: %v", err) + } + if err := repos.EmbyMount.Create(ctx, m2); err != nil { + t.Fatalf("create m2: %v", err) + } + if err := repos.EmbyMount.Create(ctx, m3); err != nil { + t.Fatalf("create m3: %v", err) + } + + if m1.SortOrder >= m2.SortOrder || m2.SortOrder >= m3.SortOrder { + t.Fatalf("expected ascending sort order on create: m1=%d, m2=%d, m3=%d", + m1.SortOrder, m2.SortOrder, m3.SortOrder) + } + + // 2. Query list and verify initial order + list, err := repos.EmbyMount.List(ctx) + if err != nil { + t.Fatalf("list mounts: %v", err) + } + if len(list) != 3 || list[0].ID != m1.ID || list[1].ID != m2.ID || list[2].ID != m3.ID { + t.Fatalf("unexpected list order: %+v", list) + } + + // 3. Reorder: m3, m1, m2 + if err := repos.EmbyMount.SetSortOrder(ctx, []string{m3.ID, m1.ID, m2.ID}); err != nil { + t.Fatalf("SetSortOrder failed: %v", err) + } + + // 4. Query list again and verify updated order + reordered, err := repos.EmbyMount.List(ctx) + if err != nil { + t.Fatalf("list mounts after reorder: %v", err) + } + if len(reordered) != 3 { + t.Fatalf("expected 3 mounts, got %d", len(reordered)) + } + if reordered[0].ID != m3.ID || reordered[1].ID != m1.ID || reordered[2].ID != m2.ID { + t.Fatalf("expected order [m3, m1, m2], got: %s, %s, %s", + reordered[0].ID, reordered[1].ID, reordered[2].ID) + } + if reordered[0].SortOrder != 0 || reordered[1].SortOrder != 1 || reordered[2].SortOrder != 2 { + t.Fatalf("unexpected sort orders: %d, %d, %d", + reordered[0].SortOrder, reordered[1].SortOrder, reordered[2].SortOrder) + } +} diff --git a/internal/repository/favorite_repository.go b/internal/repository/favorite_repository.go index 40862ad..48e1b65 100644 --- a/internal/repository/favorite_repository.go +++ b/internal/repository/favorite_repository.go @@ -6,7 +6,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // FavoriteRepository persists model.Favorite records. @@ -23,7 +23,7 @@ func (r *FavoriteRepository) Toggle(ctx context.Context, userID, mediaID string) if err != nil { return false, err } - return false, r.db.WithContext(ctx).Unscoped().Delete(&f).Error + return false, r.db.WithContext(ctx).Unscoped().Delete(&f).Error } // ListByUser returns all favourite media IDs for a user. diff --git a/internal/repository/history_repository.go b/internal/repository/history_repository.go index 132fc94..d7c7314 100644 --- a/internal/repository/history_repository.go +++ b/internal/repository/history_repository.go @@ -6,7 +6,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // HistoryRepository persists model.PlaybackHistory entries. The application @@ -26,7 +26,9 @@ func (r *HistoryRepository) Upsert(ctx context.Context, h *model.PlaybackHistory return err } existing.PositionMs = h.PositionMs - existing.DurationMs = h.DurationMs + if h.DurationMs > 0 { + existing.DurationMs = h.DurationMs + } existing.WatchedAt = h.WatchedAt existing.Completed = h.Completed return r.db.WithContext(ctx).Save(&existing).Error diff --git a/internal/repository/library_repository.go b/internal/repository/library_repository.go index 44a2311..68a9f5f 100644 --- a/internal/repository/library_repository.go +++ b/internal/repository/library_repository.go @@ -7,7 +7,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // LibraryRepository persists model.Library records. diff --git a/internal/repository/media_repository.go b/internal/repository/media_repository.go index d3905c2..256b063 100644 --- a/internal/repository/media_repository.go +++ b/internal/repository/media_repository.go @@ -3,11 +3,13 @@ package repository import ( "context" "errors" + "fmt" + "strings" "sync" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // MediaRepository persists model.Media records. @@ -87,6 +89,18 @@ func (r *MediaRepository) ListByLibraryFiltered(ctx context.Context, libraryID s } func (r *MediaRepository) ListByLibrariesFiltered(ctx context.Context, libraryIDs []string, offset, limit int, filter MediaQueryFilter) ([]model.Media, int64, error) { + items, total, err := r.listByLibrariesFiltered(ctx, libraryIDs, offset, limit, filter, true) + return items, total, err +} + +// ListByLibrariesFilteredNoCount skips the COUNT query when the caller already +// knows totals or only needs a bounded slice (e.g. home-page previews). +func (r *MediaRepository) ListByLibrariesFilteredNoCount(ctx context.Context, libraryIDs []string, offset, limit int, filter MediaQueryFilter) ([]model.Media, error) { + items, _, err := r.listByLibrariesFiltered(ctx, libraryIDs, offset, limit, filter, false) + return items, err +} + +func (r *MediaRepository) listByLibrariesFiltered(ctx context.Context, libraryIDs []string, offset, limit int, filter MediaQueryFilter, withCount bool) ([]model.Media, int64, error) { var items []model.Media var total int64 if len(libraryIDs) == 0 { @@ -99,8 +113,10 @@ func (r *MediaRepository) ListByLibrariesFiltered(ctx context.Context, libraryID q = q.Where("library_id IN ?", libraryIDs) } q = applyMediaQueryFilter(q, filter) - if err := q.Count(&total).Error; err != nil { - return nil, 0, err + if withCount { + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } } // 多级排序消除"随机"观感: // 1. release_date desc — 精确上映/首播日期新→旧 @@ -114,6 +130,75 @@ func (r *MediaRepository) ListByLibrariesFiltered(ctx context.Context, libraryID return items, total, err } +type rankedMediaRow struct { + model.Media + MmtlRN int `gorm:"column:mebox_rn"` +} + +// ListRecentByLibraries returns up to perLibrary recent items for each library +// in a single query using a window function (avoids N+1 on home preview). +func (r *MediaRepository) ListRecentByLibraries(ctx context.Context, libraryIDs []string, perLibrary int, filter MediaQueryFilter) (map[string][]model.Media, error) { + out := make(map[string][]model.Media, len(libraryIDs)) + if len(libraryIDs) == 0 || perLibrary <= 0 { + return out, nil + } + + var libClause string + var args []interface{} + if len(libraryIDs) == 1 { + libClause = "library_id = ?" + args = append(args, libraryIDs[0]) + } else { + libClause = "library_id IN ?" + args = append(args, libraryIDs) + } + where := "deleted_at IS NULL AND " + libClause + if filterSQL, filterArgs := mediaQueryFilterSQL(filter); filterSQL != "" { + where += " AND " + filterSQL + args = append(args, filterArgs...) + } + args = append(args, perLibrary) + + sql := fmt.Sprintf(` + SELECT * FROM ( + SELECT *, ROW_NUMBER() OVER ( + PARTITION BY library_id + ORDER BY release_date DESC, year DESC, updated_at DESC, created_at DESC, id DESC + ) AS mebox_rn + FROM media + WHERE %s + ) ranked + WHERE mebox_rn <= ? + `, where) + + var rows []rankedMediaRow + if err := r.db.WithContext(ctx).Raw(sql, args...).Scan(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + out[row.LibraryID] = append(out[row.LibraryID], row.Media) + } + return out, nil +} + +func mediaQueryFilterSQL(filter MediaQueryFilter) (string, []interface{}) { + var parts []string + var args []interface{} + if !filter.IncludeNSFW { + parts = append(parts, "nsfw = ?") + args = append(args, false) + } + if len(filter.HiddenLibraryIDs) > 0 { + parts = append(parts, "library_id NOT IN ?") + args = append(args, filter.HiddenLibraryIDs) + } + if len(filter.AllowedLibraryIDs) > 0 { + parts = append(parts, "library_id IN ?") + args = append(args, filter.AllowedLibraryIDs) + } + return strings.Join(parts, " AND "), args +} + type libraryCountRow struct { LibraryID string `gorm:"column:library_id"` Total int64 `gorm:"column:total"` diff --git a/internal/repository/media_repository_preview_test.go b/internal/repository/media_repository_preview_test.go new file mode 100644 index 0000000..ce6a1c8 --- /dev/null +++ b/internal/repository/media_repository_preview_test.go @@ -0,0 +1,72 @@ +package repository + +import ( + "fmt" + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "github.com/truewhile/MeBox/internal/database" + "github.com/truewhile/MeBox/internal/model" +) + +func TestListRecentByLibraries(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := database.AutoMigrate(db); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := New(db) + + lib1 := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib1); err != nil { + t.Fatal(err) + } + lib2 := model.Library{Name: "动漫", Path: "/media/anime", Type: "anime", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib2); err != nil { + t.Fatal(err) + } + + now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) + var rows []model.Media + for i := 1; i <= 5; i++ { + rows = append(rows, model.Media{ + Base: model.Base{ID: fmt.Sprintf("movie-%02d", i), CreatedAt: now.Add(time.Duration(i) * time.Hour)}, + LibraryID: lib1.ID, + Title: fmt.Sprintf("电影%d", i), + Path: fmt.Sprintf("/media/movies/电影%d/movie%d.mp4", i, i), + }) + } + for i := 1; i <= 8; i++ { + rows = append(rows, model.Media{ + Base: model.Base{ID: fmt.Sprintf("anime-ep-%02d", i), CreatedAt: now.Add(time.Duration(i) * time.Minute)}, + LibraryID: lib2.ID, + Title: fmt.Sprintf("某动漫 第%d集", i), + Path: fmt.Sprintf("/media/anime/某动漫/Season 01/某动漫.S01E%02d.mp4", i), + SeasonNum: 1, + EpisodeNum: i, + }) + } + if err := repos.DB.Create(&rows).Error; err != nil { + t.Fatal(err) + } + + filter := MediaQueryFilter{IncludeNSFW: true} + got, err := repos.Media.ListRecentByLibraries(t.Context(), []string{lib1.ID, lib2.ID}, 3, filter) + if err != nil { + t.Fatalf("ListRecentByLibraries failed: %v", err) + } + if len(got[lib1.ID]) != 3 { + t.Fatalf("lib1 recent count = %d, want 3", len(got[lib1.ID])) + } + if len(got[lib2.ID]) != 3 { + t.Fatalf("lib2 recent count = %d, want 3", len(got[lib2.ID])) + } + if got[lib1.ID][0].ID != "movie-05" { + t.Fatalf("lib1 newest = %q, want movie-05", got[lib1.ID][0].ID) + } +} diff --git a/internal/repository/media_repository_upsert.go b/internal/repository/media_repository_upsert.go index 49f1df1..21d70bf 100644 --- a/internal/repository/media_repository_upsert.go +++ b/internal/repository/media_repository_upsert.go @@ -7,7 +7,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // Upsert inserts or updates a media row keyed by Path (unique index). diff --git a/internal/repository/media_search_repository.go b/internal/repository/media_search_repository.go index d781cf6..85ac738 100644 --- a/internal/repository/media_search_repository.go +++ b/internal/repository/media_search_repository.go @@ -7,7 +7,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // Search runs a LIKE search against the title field. Empty query returns the diff --git a/internal/repository/opensearch.go b/internal/repository/opensearch.go index 4c81ee7..9b8ecdc 100644 --- a/internal/repository/opensearch.go +++ b/internal/repository/opensearch.go @@ -11,8 +11,8 @@ import ( "strings" "time" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" ) type OpenSearchMediaBackend struct { @@ -29,7 +29,7 @@ func NewOpenSearchMediaBackend(cfg config.SearchConfig) *OpenSearchMediaBackend } index := strings.TrimSpace(cfg.Index) if index == "" { - index = "mmtl_media" + index = "mebox_media" } return &OpenSearchMediaBackend{ baseURL: strings.TrimRight(strings.TrimSpace(cfg.OpenSearchURL), "/"), diff --git a/internal/repository/opensearch_test.go b/internal/repository/opensearch_test.go index bb2d4cc..c3bd806 100644 --- a/internal/repository/opensearch_test.go +++ b/internal/repository/opensearch_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "testing" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) func TestOpenSearchMediaBackendSearchesIDs(t *testing.T) { diff --git a/internal/repository/permission_repository.go b/internal/repository/permission_repository.go index fc126af..805ddfd 100644 --- a/internal/repository/permission_repository.go +++ b/internal/repository/permission_repository.go @@ -6,7 +6,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // PermissionRepository persists model.UserPermission records. diff --git a/internal/repository/play_profile_repo.go b/internal/repository/play_profile_repo.go index 0cf9b86..91b883b 100644 --- a/internal/repository/play_profile_repo.go +++ b/internal/repository/play_profile_repo.go @@ -6,7 +6,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // PlayProfileRepository persists model.PlayProfile records. diff --git a/internal/repository/playlist_repository.go b/internal/repository/playlist_repository.go index 14d854c..2f7a9ad 100644 --- a/internal/repository/playlist_repository.go +++ b/internal/repository/playlist_repository.go @@ -5,7 +5,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // PlaylistRepository persists model.Playlist + PlaylistItem. diff --git a/internal/repository/refresh_token_repository.go b/internal/repository/refresh_token_repository.go index 33136de..d0c7d89 100644 --- a/internal/repository/refresh_token_repository.go +++ b/internal/repository/refresh_token_repository.go @@ -9,7 +9,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // RefreshTokenRepository persists model.RefreshToken records. diff --git a/internal/repository/repository.go b/internal/repository/repository.go index cc8d738..0cf8b79 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -30,11 +30,11 @@ type Container struct { StrmSyncPath *StrmSyncPathRepository StrmSyncRecord *StrmSyncRecordRepository StrmDownload *StrmDownloadTaskRepository - StrmUpload *StrmUploadTaskRepository - StrmDirCache *StrmDirCacheRepository - ScrapeTask *ScrapeTaskRepository + StrmUpload *StrmUploadTaskRepository + StrmDirCache *StrmDirCacheRepository + ScrapeTask *ScrapeTaskRepository EmbyMount *EmbyMountRepository - } +} // New 将每个 repository 连接到单个 *gorm.DB。 func New(db *gorm.DB) *Container { diff --git a/internal/repository/repository_test.go b/internal/repository/repository_test.go index 66f843c..b9ddb84 100644 --- a/internal/repository/repository_test.go +++ b/internal/repository/repository_test.go @@ -9,8 +9,8 @@ import ( "github.com/glebarez/sqlite" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/database" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/database" + "github.com/truewhile/MeBox/internal/model" ) func TestMediaUpsertSkipsUnchangedExistingRow(t *testing.T) { diff --git a/internal/repository/scrape_task_repository.go b/internal/repository/scrape_task_repository.go index bf6a793..6b50714 100644 --- a/internal/repository/scrape_task_repository.go +++ b/internal/repository/scrape_task_repository.go @@ -9,7 +9,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) var scrapeClaimMu sync.Mutex diff --git a/internal/repository/series_repository.go b/internal/repository/series_repository.go index c7ad824..5fecafb 100644 --- a/internal/repository/series_repository.go +++ b/internal/repository/series_repository.go @@ -6,7 +6,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // SeriesRepository persists model.Series records. diff --git a/internal/repository/setting_repository.go b/internal/repository/setting_repository.go index c3a7368..528bb48 100644 --- a/internal/repository/setting_repository.go +++ b/internal/repository/setting_repository.go @@ -6,7 +6,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // SettingRepository persists key/value preferences. diff --git a/internal/repository/storage_config_repo.go b/internal/repository/storage_config_repo.go index 81e7e2b..363d593 100644 --- a/internal/repository/storage_config_repo.go +++ b/internal/repository/storage_config_repo.go @@ -8,7 +8,7 @@ import ( "github.com/google/uuid" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // StorageConfigRepository persists model.StorageConfig records. diff --git a/internal/repository/strm_repository.go b/internal/repository/strm_repository.go index bdfacb2..fe3de4e 100644 --- a/internal/repository/strm_repository.go +++ b/internal/repository/strm_repository.go @@ -8,7 +8,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) var strmClaimMu sync.Mutex @@ -675,6 +675,29 @@ func (r *StrmUploadTaskRepository) CancelBatch(ctx context.Context, ids []string return count, err } +// ClearDone 清空全部已完成上传任务。 +func (r *StrmUploadTaskRepository) ClearDone(ctx context.Context) (int64, error) { + var count int64 + err := withSQLiteBusyRetry(ctx, func() error { + res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.StrmTaskDone).Delete(&model.StrmUploadTask{}) + count = res.RowsAffected + return res.Error + }) + return count, err +} + +// ClearFinished 清空全部已完成与失败上传任务(包括已完成、失败及取消)。 +func (r *StrmUploadTaskRepository) ClearFinished(ctx context.Context) (int64, error) { + var count int64 + err := withSQLiteBusyRetry(ctx, func() error { + res := r.db.WithContext(ctx).Unscoped().Where("status IN ?", []string{model.StrmTaskDone, model.StrmTaskFailed, model.StrmTaskCanceled}). + Delete(&model.StrmUploadTask{}) + count = res.RowsAffected + return res.Error + }) + return count, err +} + // ClearCanceled 清空全部已取消上传任务。 func (r *StrmUploadTaskRepository) ClearCanceled(ctx context.Context) (int64, error) { var count int64 @@ -686,6 +709,27 @@ func (r *StrmUploadTaskRepository) ClearCanceled(ctx context.Context) (int64, er return count, err } +// RetryAllFailed 把所有失败任务重置回待处理,清空错误与重试计数。 +func (r *StrmUploadTaskRepository) RetryAllFailed(ctx context.Context) (int64, error) { + var count int64 + err := withSQLiteBusyRetry(ctx, func() error { + res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}). + Where("status = ?", model.StrmTaskFailed). + Updates(map[string]any{ + "status": model.StrmTaskPending, + "error": "", + "retry_count": 0, + "next_try_at": nil, + "started_at": nil, + "finished_at": nil, + "updated_at": time.Now(), + }) + count = res.RowsAffected + return res.Error + }) + return count, err +} + // CancelPending 批量取消所有排队中和进行中的任务。 func (r *StrmUploadTaskRepository) CancelPending(ctx context.Context) (int64, error) { now := time.Now() @@ -777,5 +821,3 @@ func (r *StrmDirCacheRepository) DeleteBySyncPathID(ctx context.Context, syncPat return r.db.WithContext(ctx).Unscoped().Where("sync_path_id = ?", syncPathID).Delete(&model.StrmDirCache{}).Error }) } - - diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go index ae6cf52..50d3487 100644 --- a/internal/repository/user_repository.go +++ b/internal/repository/user_repository.go @@ -9,7 +9,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // UserRepository persists model.User records. diff --git a/internal/service/adult_scraper.go b/internal/service/adult_scraper.go index 92862cf..134921a 100644 --- a/internal/service/adult_scraper.go +++ b/internal/service/adult_scraper.go @@ -13,7 +13,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/repository" ) var ( diff --git a/internal/service/adult_scraper_routing_test.go b/internal/service/adult_scraper_routing_test.go index 5612404..1d71b6d 100644 --- a/internal/service/adult_scraper_routing_test.go +++ b/internal/service/adult_scraper_routing_test.go @@ -11,8 +11,8 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestAdultProviderRouting(t *testing.T) { diff --git a/internal/service/adult_scraper_test.go b/internal/service/adult_scraper_test.go index 6e35212..430e4f7 100644 --- a/internal/service/adult_scraper_test.go +++ b/internal/service/adult_scraper_test.go @@ -8,8 +8,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestNormalizeAdultCode(t *testing.T) { diff --git a/internal/service/api_config.go b/internal/service/api_config.go index 622a40e..6e12c36 100644 --- a/internal/service/api_config.go +++ b/internal/service/api_config.go @@ -5,7 +5,7 @@ // read so values stored on disk are useless without the JWT secret. // // On first read it seeds the table with the providers supported by -// MMTL today (TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban). +// MeBox today (TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban). package service import ( @@ -17,8 +17,8 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // APIConfigService coordinates third-party API key storage. diff --git a/internal/service/api_config_connection.go b/internal/service/api_config_connection.go index 940068a..c983bc0 100644 --- a/internal/service/api_config_connection.go +++ b/internal/service/api_config_connection.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // TestConnection 测试 API 连接。 diff --git a/internal/service/api_config_helpers.go b/internal/service/api_config_helpers.go index f63dc98..be0c36e 100644 --- a/internal/service/api_config_helpers.go +++ b/internal/service/api_config_helpers.go @@ -5,7 +5,7 @@ import ( "net/url" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // GetEffectiveConfig 获取生效的 API 配置(数据库配置优先于配置文件)。 diff --git a/internal/service/api_config_svc.go b/internal/service/api_config_svc.go index c76f1c1..893217a 100644 --- a/internal/service/api_config_svc.go +++ b/internal/service/api_config_svc.go @@ -7,9 +7,9 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // ApiConfigService 负责第三方 API 配置的 CRUD 和加密管理。 diff --git a/internal/service/audit.go b/internal/service/audit.go index 10bed8f..8fc30b4 100644 --- a/internal/service/audit.go +++ b/internal/service/audit.go @@ -12,8 +12,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // AuditService is the only sanctioned writer for the access_logs table. diff --git a/internal/service/auth.go b/internal/service/auth.go index 4704ce0..0048f3e 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -12,9 +12,9 @@ import ( "go.uber.org/zap" "golang.org/x/crypto/bcrypt" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // AuthService handles registration, login, and JWT issuance. @@ -40,14 +40,8 @@ var ( ErrUserExpired = errors.New("user account has expired") ) -// MaxUsers 是单实例允许的最大用户数(开源版本固定上限)。 -const MaxUsers = OpenSourceUserLimit - -// OpenSourceUserLimit 是开源版本的用户数上限。 -const OpenSourceUserLimit = 20 - -// UserLimit 是注册/邀请码发放时的固定用户数上限(授权管理已移除,固定为开源上限)。 -const UserLimit = OpenSourceUserLimit +// MaxUsers is kept for tests that seed up to the default cap. +const MaxUsers = DefaultMaxUsers // SeedAdmin makes sure at least one admin user exists. It mirrors the // legacy default behaviour: if no admin row is found we create @@ -103,9 +97,13 @@ func (s *AuthService) Register(ctx context.Context, username, password string) ( if err := s.repo.User.ReleaseDeletedUsername(ctx, username); err != nil { return nil, nil, err } + limit, err := LoadMaxUsers(ctx, s.repo) + if err != nil { + return nil, nil, err + } if n, err := s.repo.User.Count(ctx); err != nil { return nil, nil, err - } else if n >= UserLimit { + } else if n >= int64(limit) { return nil, nil, ErrUserLimitReached } hash, err := hashPassword(password) diff --git a/internal/service/auth_tokens.go b/internal/service/auth_tokens.go index 8833cfa..cb7cef9 100644 --- a/internal/service/auth_tokens.go +++ b/internal/service/auth_tokens.go @@ -9,7 +9,7 @@ import ( "github.com/golang-jwt/jwt/v5" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // IssueToken signs a JWT for the given user (60min validity, includes tier). @@ -21,7 +21,7 @@ func (s *AuthService) IssueToken(u *model.User) (string, error) { RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(time.Now().Add(60 * time.Minute)), - Issuer: "mmtl", + Issuer: "mebox", Subject: u.ID, }, } @@ -68,7 +68,7 @@ func (s *AuthService) IssueExternalPlaybackToken(u *model.User, mediaID string, RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(time.Now().Add(ExternalPlaybackTokenDurationForMedia(durationSec))), - Issuer: "mmtl", + Issuer: "mebox", Subject: u.ID, }, } @@ -95,7 +95,7 @@ func (s *AuthService) IssueEmbyToken(u *model.User) (string, error) { RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(time.Now().Add(EmbyTokenDuration)), - Issuer: "mmtl", + Issuer: "mebox", Subject: u.ID, }, } diff --git a/internal/service/auth_user_limits_test.go b/internal/service/auth_user_limits_test.go index 247f854..5478c07 100644 --- a/internal/service/auth_user_limits_test.go +++ b/internal/service/auth_user_limits_test.go @@ -12,10 +12,10 @@ import ( "go.uber.org/zap" "golang.org/x/crypto/bcrypt" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/database" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/database" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func newAuthTestServices(t *testing.T) (*repository.Container, *AuthService, *ProfileService, *PermissionService) { diff --git a/internal/service/backup.go b/internal/service/backup.go index b3ee59e..6e1d071 100644 --- a/internal/service/backup.go +++ b/internal/service/backup.go @@ -19,7 +19,7 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) // BackupService manages database backups. @@ -72,7 +72,7 @@ func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) { return nil, err } ts := time.Now().Format("20060102_150405") - dst := filepath.Join(dir, fmt.Sprintf("mmtl_%s.db", ts)) + dst := filepath.Join(dir, fmt.Sprintf("mebox_%s.db", ts)) // VACUUM INTO creates a clean, non-WAL copy atomically. if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", dst).Error; err != nil { diff --git a/internal/service/bangumi.go b/internal/service/bangumi.go index 7326ebc..082b668 100644 --- a/internal/service/bangumi.go +++ b/internal/service/bangumi.go @@ -23,7 +23,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) // BangumiProvider talks to https://api.bgm.tv. @@ -181,7 +181,7 @@ func (b *BangumiProvider) getJSON(ctx context.Context, u string, out any) error if err != nil { return err } - req.Header.Set("User-Agent", "MMTL/0.1 (https://github.com/ShukeBta/MMTL)") + req.Header.Set("User-Agent", "MeBox/0.1 (https://github.com/truewhile/MeBox)") if t := strings.TrimSpace(b.cfg.Secrets.BangumiToken); t != "" { req.Header.Set("Authorization", "Bearer "+t) } diff --git a/internal/service/bot_settings.go b/internal/service/bot_settings.go index fa81393..eec56e1 100644 --- a/internal/service/bot_settings.go +++ b/internal/service/bot_settings.go @@ -7,8 +7,8 @@ import ( "strconv" "strings" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // Bot / 设备管控相关的设置键。全部存储在 settings 表,由管理员通过 diff --git a/internal/service/cache_cleanup.go b/internal/service/cache_cleanup.go index afc190c..bf83ec9 100644 --- a/internal/service/cache_cleanup.go +++ b/internal/service/cache_cleanup.go @@ -4,8 +4,11 @@ package service import ( + "errors" "os" "path/filepath" + "sort" + "strings" "time" ) @@ -42,3 +45,96 @@ func walkAndPrune(root string, cutoff time.Time) error { } return nil } + +// PruneImageCacheResult holds stats from an image cache prune operation. +type PruneImageCacheResult struct { + TotalFilesBefore int + TotalBytesBefore int64 + DeletedFiles int + FreedBytes int64 + RemainingBytes int64 +} + +type imageCacheFileEntry struct { + path string + size int64 + modTime time.Time +} + +// PruneImageCache scans imagesDir for cached image files. If the total disk usage +// exceeds maxSizeBytes, it removes files starting from the oldest (by ModTime) +// until disk usage falls to or below targetSizeBytes (80% of maxSizeBytes). +// +// In-flight temporary files (*.tmp) are skipped to avoid corrupting concurrent writes. +// Empty subdirectories left behind are best-effort removed. +func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResult, error) { + var result PruneImageCacheResult + if imagesDir == "" || maxSizeBytes <= 0 { + return result, nil + } + if _, err := os.Stat(imagesDir); err != nil { + return result, nil + } + + var ( + dirs []string + entries []imageCacheFileEntry + ) + + _ = filepath.Walk(imagesDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + if path != imagesDir { + dirs = append(dirs, path) + } + return nil + } + // Skip temporary files created during image download. + name := info.Name() + if strings.HasSuffix(name, ".tmp") || strings.HasPrefix(name, "img-") && strings.Contains(name, ".tmp") { + return nil + } + size := info.Size() + result.TotalFilesBefore++ + result.TotalBytesBefore += size + entries = append(entries, imageCacheFileEntry{ + path: path, + size: size, + modTime: info.ModTime(), + }) + return nil + }) + + result.RemainingBytes = result.TotalBytesBefore + if result.TotalBytesBefore <= maxSizeBytes { + return result, nil + } + + // High/Low watermark: prune down to 80% of max size to leave headroom + // and prevent disk thrashing on consecutive writes. + targetSizeBytes := maxSizeBytes * 80 / 100 + + sort.Slice(entries, func(i, j int) bool { + return entries[i].modTime.Before(entries[j].modTime) + }) + + for _, entry := range entries { + if result.RemainingBytes <= targetSizeBytes { + break + } + if err := os.Remove(entry.path); err == nil || errors.Is(err, os.ErrNotExist) { + result.DeletedFiles++ + result.FreedBytes += entry.size + result.RemainingBytes -= entry.size + } + } + + // Clean up emptied subdirectories from deepest to shallowest. + for i := len(dirs) - 1; i >= 0; i-- { + _ = os.Remove(dirs[i]) + } + + return result, nil +} diff --git a/internal/service/cache_cleanup_test.go b/internal/service/cache_cleanup_test.go new file mode 100644 index 0000000..9b45c69 --- /dev/null +++ b/internal/service/cache_cleanup_test.go @@ -0,0 +1,162 @@ +package service + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "go.uber.org/zap" +) + +func TestPruneImageCache_UnderLimit(t *testing.T) { + dir := t.TempDir() + + file1 := filepath.Join(dir, "img1") + file2 := filepath.Join(dir, "img2") + if err := os.WriteFile(file1, make([]byte, 100), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file2, make([]byte, 200), 0o600); err != nil { + t.Fatal(err) + } + + // Max limit is 500 bytes, total is 300 bytes -> no prune + res, err := PruneImageCache(dir, 500) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.DeletedFiles != 0 { + t.Fatalf("expected 0 deleted files, got %d", res.DeletedFiles) + } + if res.TotalFilesBefore != 2 || res.TotalBytesBefore != 300 || res.RemainingBytes != 300 { + t.Fatalf("unexpected stats: %+v", res) + } +} + +func TestPruneImageCache_OverLimitLRU(t *testing.T) { + dir := t.TempDir() + + now := time.Now() + // Create 4 files of 100 bytes each, with distinct mtime + fOldest := filepath.Join(dir, "oldest") + fMidOld := filepath.Join(dir, "mid_old") + fMidNew := filepath.Join(dir, "mid_new") + fNewest := filepath.Join(dir, "newest") + + for _, f := range []string{fOldest, fMidOld, fMidNew, fNewest} { + if err := os.WriteFile(f, make([]byte, 100), 0o600); err != nil { + t.Fatal(err) + } + } + + _ = os.Chtimes(fOldest, now.Add(-4*time.Hour), now.Add(-4*time.Hour)) + _ = os.Chtimes(fMidOld, now.Add(-3*time.Hour), now.Add(-3*time.Hour)) + _ = os.Chtimes(fMidNew, now.Add(-2*time.Hour), now.Add(-2*time.Hour)) + _ = os.Chtimes(fNewest, now.Add(-1*time.Hour), now.Add(-1*time.Hour)) + + // Total = 400 bytes. Max limit = 300 bytes. + // Target = 300 * 80 / 100 = 240 bytes. + // Deleting oldest (100) brings total to 300 (> 240). + // Deleting mid_old (100) brings total to 200 (<= 240). + // Total deleted = 2 files (200 bytes), remaining = 200 bytes. + res, err := PruneImageCache(dir, 300) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.DeletedFiles != 2 { + t.Fatalf("expected 2 deleted files, got %d", res.DeletedFiles) + } + if res.FreedBytes != 200 { + t.Fatalf("expected 200 freed bytes, got %d", res.FreedBytes) + } + if res.RemainingBytes != 200 { + t.Fatalf("expected 200 remaining bytes, got %d", res.RemainingBytes) + } + + // Verify oldest and mid_old were deleted, mid_new and newest still exist + if _, err := os.Stat(fOldest); !os.IsNotExist(err) { + t.Fatalf("expected oldest file to be deleted, got err=%v", err) + } + if _, err := os.Stat(fMidOld); !os.IsNotExist(err) { + t.Fatalf("expected mid_old file to be deleted, got err=%v", err) + } + if _, err := os.Stat(fMidNew); err != nil { + t.Fatalf("expected mid_new file to exist, got err=%v", err) + } + if _, err := os.Stat(fNewest); err != nil { + t.Fatalf("expected newest file to exist, got err=%v", err) + } +} + +func TestPruneImageCache_SkipsTmpFiles(t *testing.T) { + dir := t.TempDir() + + fTmp := filepath.Join(dir, "img-123.tmp") + fImg := filepath.Join(dir, "cached_img") + + if err := os.WriteFile(fTmp, make([]byte, 500), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fImg, make([]byte, 100), 0o600); err != nil { + t.Fatal(err) + } + + // Limit is 200 bytes. fTmp (500) is ignored, only fImg (100) is counted <= 200. + res, err := PruneImageCache(dir, 200) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.DeletedFiles != 0 { + t.Fatalf("expected 0 deleted files, got %d", res.DeletedFiles) + } + if _, err := os.Stat(fTmp); err != nil { + t.Fatalf("expected tmp file to remain untouched, got %v", err) + } +} + +func TestPruneImageCache_ZeroOrNegativeLimit(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "img") + if err := os.WriteFile(f, make([]byte, 100), 0o600); err != nil { + t.Fatal(err) + } + + res, err := PruneImageCache(dir, 0) + if err != nil || res.DeletedFiles != 0 { + t.Fatalf("expected no-op for 0 limit, got %+v, err=%v", res, err) + } + + res, err = PruneImageCache(dir, -10) + if err != nil || res.DeletedFiles != 0 { + t.Fatalf("expected no-op for negative limit, got %+v, err=%v", res, err) + } +} + +func TestSchedulerJobCleanImageCache(t *testing.T) { + cacheRoot := t.TempDir() + imagesDir := filepath.Join(cacheRoot, "images") + if err := os.MkdirAll(imagesDir, 0o750); err != nil { + t.Fatal(err) + } + + f := filepath.Join(imagesDir, "old_poster") + if err := os.WriteFile(f, make([]byte, 2*1024*1024), 0o600); err != nil { + t.Fatal(err) + } + + scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, cacheRoot) + // Set limit to 1MB; our file is 2MB -> should be pruned + scheduler.SetImagesMaxSizeMBProvider(func() int { + return 1 + }) + + if err := scheduler.jobCleanImageCache(context.Background()); err != nil { + t.Fatalf("jobCleanImageCache failed: %v", err) + } + + if _, err := os.Stat(f); !os.IsNotExist(err) { + t.Fatalf("expected file to be pruned, got err=%v", err) + } +} diff --git a/internal/service/cloud/cloud.go b/internal/service/cloud/cloud.go index 9c4cdd1..8c30b5b 100644 --- a/internal/service/cloud/cloud.go +++ b/internal/service/cloud/cloud.go @@ -38,11 +38,11 @@ var ErrUnsupported = errors.New("unsupported cloud provider") // FileEntry is one item in a cloud directory listing. type FileEntry struct { - ID string `json:"id"` // provider-native file id - Name string `json:"name"` - IsDir bool `json:"is_dir"` - Size int64 `json:"size"` - MTime int64 `json:"mtime,omitempty"` + ID string `json:"id"` // provider-native file id + Name string `json:"name"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size"` + MTime int64 `json:"mtime,omitempty"` // PickCode is 115-specific; other providers use ID directly. PickCode string `json:"pick_code,omitempty"` } diff --git a/internal/service/cloud/cloud_115_test.go b/internal/service/cloud/cloud_115_test.go index 19f0a11..d05b22f 100644 --- a/internal/service/cloud/cloud_115_test.go +++ b/internal/service/cloud/cloud_115_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/ShukeBta/MMTL/internal/service/cloud115" + "github.com/truewhile/MeBox/internal/service/cloud115" ) // 115 开放平台(openapi)驱动测试:mock proapi 的列目录/直链接口。 diff --git a/internal/service/cloud/clouddrive2.go b/internal/service/cloud/clouddrive2.go index a77d112..f92d73c 100644 --- a/internal/service/cloud/clouddrive2.go +++ b/internal/service/cloud/clouddrive2.go @@ -13,7 +13,7 @@ import ( // cloudDrive2Provider bridges CloudDrive2 through its WebDAV endpoint. // // CloudDrive2 integrates many cloud disks (115 / 123 / Aliyun and more). -// Treating it as a WebDAV-backed cloud provider lets MMTL +// Treating it as a WebDAV-backed cloud provider lets MeBox // browse, mount and upload to those disks without carrying every provider's // private chunk-upload protocol in this project. type cloudDrive2Provider struct { diff --git a/internal/service/cloud/emby.go b/internal/service/cloud/emby.go index b2677ee..c4308a3 100644 --- a/internal/service/cloud/emby.go +++ b/internal/service/cloud/emby.go @@ -21,7 +21,7 @@ import ( // embyProvider implements Provider against a remote Emby server using an // api_key (token) for authentication. DirectLink.Resolve returns the remote -// stream URL; whether MMTL reverse-proxies the bytes is decided by the +// stream URL; whether MeBox reverse-proxies the bytes is decided by the // emby.proxy_play account config (defaults to off). type embyProvider struct { base string // e.g. http://host:8096(自动补 /emby 前缀) @@ -159,7 +159,7 @@ func (p *embyProvider) ensureToken(ctx context.Context) (string, error) { return "", err } req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Emby-Authorization", `MediaBrowser Client="MMTL", Device="MMTL-Federated", DeviceId="mmtl-federated", Version="1.0"`) + req.Header.Set("X-Emby-Authorization", `MediaBrowser Client="MeBox", Device="MeBox-Federated", DeviceId="mebox-federated", Version="1.0"`) resp, err := p.client.Do(req) if err != nil { return "", err @@ -184,12 +184,12 @@ func (p *embyProvider) ensureToken(ctx context.Context) (string, error) { // embyItemSummary 目录浏览所需的最小 Emby 条目字段。 type embyItemSummary struct { - Id string `json:"Id"` - Name string `json:"Name"` - Type string `json:"Type"` - IsFolder bool `json:"IsFolder"` - ChildCount int `json:"ChildCount"` - RunTimeTicks int64 `json:"RunTimeTicks"` + Id string `json:"Id"` + Name string `json:"Name"` + Type string `json:"Type"` + IsFolder bool `json:"IsFolder"` + ChildCount int `json:"ChildCount"` + RunTimeTicks int64 `json:"RunTimeTicks"` } type embyItemListResponse struct { @@ -234,7 +234,7 @@ func (p *embyProvider) List(ctx context.Context, dirID string) ([]FileEntry, err } // Resolve 返回远程 Emby 直链。Proxy=true 时由调用方(StrmService.ProxyDirect) -// 反向代理流量;false 时 302 到直链。默认不代理(播放字节不经过 MMTL)。 +// 反向代理流量;false 时 302 到直链。默认不代理(播放字节不经过 MeBox)。 func (p *embyProvider) Resolve(ctx context.Context, fileRef string) (*DirectLink, error) { token, err := p.ensureToken(ctx) if err != nil { @@ -243,4 +243,4 @@ func (p *embyProvider) Resolve(ctx context.Context, fileRef string) (*DirectLink u := p.apiBase() + "/Videos/" + url.PathEscape(fileRef) + "/stream" u += "?api_key=" + url.QueryEscape(token) + "&Static=true&MediaSourceId=" + url.QueryEscape(fileRef) return &DirectLink{URL: u, Headers: map[string]string{"X-Emby-Token": token}, Proxy: p.proxyPlay}, nil -} \ No newline at end of file +} diff --git a/internal/service/cloud/emby_test.go b/internal/service/cloud/emby_test.go index f17e693..3d15a4f 100644 --- a/internal/service/cloud/emby_test.go +++ b/internal/service/cloud/emby_test.go @@ -59,8 +59,8 @@ func TestEmbyProviderListViewsAndChildren(t *testing.T) { defer srv.Close() p, err := New(TypeEmbyRemote, map[string]any{ - "url": srv.URL, - "api_key": "fixed-token", + "url": srv.URL, + "api_key": "fixed-token", "remote_user_id": "user-9", }, srv.Client()) if err != nil { @@ -87,8 +87,8 @@ func TestEmbyProviderResolveDirectURLByDefault(t *testing.T) { defer srv.Close() p, err := New(TypeEmbyRemote, map[string]any{ - "url": srv.URL, - "api_key": "fixed-token", + "url": srv.URL, + "api_key": "fixed-token", "remote_user_id": "user-9", }, srv.Client()) if err != nil { @@ -115,10 +115,10 @@ func TestEmbyProviderResolveProxyWhenConfigured(t *testing.T) { defer srv.Close() p, err := New(TypeEmbyRemote, map[string]any{ - "url": srv.URL, - "api_key": "fixed-token", + "url": srv.URL, + "api_key": "fixed-token", "remote_user_id": "user-9", - "proxy_play": "true", + "proxy_play": "true", }, srv.Client()) if err != nil { t.Fatal(err) @@ -133,4 +133,4 @@ func TestEmbyProviderResolveProxyWhenConfigured(t *testing.T) { if link.URL == "" { t.Fatal("proxy link must still carry the remote URL") } -} \ No newline at end of file +} diff --git a/internal/service/cloud/pan115_openapi.go b/internal/service/cloud/pan115_openapi.go index 6731550..e81d169 100644 --- a/internal/service/cloud/pan115_openapi.go +++ b/internal/service/cloud/pan115_openapi.go @@ -22,7 +22,7 @@ import ( "path/filepath" "strings" - "github.com/ShukeBta/MMTL/internal/service/cloud115" + "github.com/truewhile/MeBox/internal/service/cloud115" ) // OpenAPI115Provider 暴露 115 开放平台驱动接口。 @@ -112,7 +112,7 @@ func (p *openAPI115Provider) OpenClient() *cloud115.OpenClient { return p.c } // io.Reader 无法携带文件名,因此走独立的 named 上传接口。将内容落为临时文件后 // 重命名为目标文件名,再交给 115 上传(/open/upload/init 的 file_name 取真实文件名)。 func (p *openAPI115Provider) PutFileNamed(ctx context.Context, parentCID, fileName string, r io.Reader) error { - tmp, err := os.CreateTemp("", "mmtl-upload-*") + tmp, err := os.CreateTemp("", "mebox-upload-*") if err != nil { return fmt.Errorf("115: 创建临时文件失败:%w", err) } diff --git a/internal/service/cloud115/const.go b/internal/service/cloud115/const.go index c42231f..d82df66 100644 --- a/internal/service/cloud115/const.go +++ b/internal/service/cloud115/const.go @@ -39,7 +39,7 @@ const ( ) // DefaultUA 请求 UA(标准 API 客户端格式,避免伪装浏览器触发 115 WAF 拦截与 405 错误)。 -const DefaultUA = "MMTL-115-GoClient/1.0" +const DefaultUA = "MeBox-115-GoClient/1.0" // OpenAPIError 保留 115 开放平台返回的原始错误信息。 type OpenAPIError struct { diff --git a/internal/service/cloud_library_display_stubs.go b/internal/service/cloud_library_display_stubs.go index 455d7b9..223daf1 100644 --- a/internal/service/cloud_library_display_stubs.go +++ b/internal/service/cloud_library_display_stubs.go @@ -6,8 +6,8 @@ import ( "path" "strings" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // CloudMountInfo 是云盘挂载库的规范标识(因网盘后端已移除,仅保留类型以兼容 diff --git a/internal/service/danmaku_credentials.go b/internal/service/danmaku_credentials.go index 6b66984..9ade4b5 100644 --- a/internal/service/danmaku_credentials.go +++ b/internal/service/danmaku_credentials.go @@ -29,12 +29,12 @@ import ( // dandanplayObfuscationKey 是解开内置凭据的 XOR 混淆密钥。它与密文 // 同处一个二进制,仅作提取门槛,不作安全边界。 -const dandanplayObfuscationKey = "MMTL-Danmaku#2026!v2" +const dandanplayObfuscationKey = "MeBox-Danmaku#2026!v2" // 内置回退凭据(XOR 混淆后的 hex 编码)。 const ( - danmakuEmbeddedAppIDHex = "352c24755f7c115d0a52" - danmakuEmbeddedAppKeyHex = "74390d751832375917150d4717586578586620652a05043d5e320f0d0b120d38" + danmakuEmbeddedAppIDHex = "350432560a153452095e" + danmakuEmbeddedAppKeyHex = "74111b564d5b1256141907594149677a5c717721550535331c0e4327071d0b2c" ) // danmakuEmbeddedCredentials 解出内置回退凭据。 @@ -62,4 +62,4 @@ func xorDecode(hexStr string) string { func dandanplaySignature(appID, appSecret string, ts int64, path string) string { sum := sha256.Sum256([]byte(appID + strconv.FormatInt(ts, 10) + path + appSecret)) return base64.StdEncoding.EncodeToString(sum[:]) -} \ No newline at end of file +} diff --git a/internal/service/danmaku_credentials_test.go b/internal/service/danmaku_credentials_test.go index 05eca79..e5ed8e1 100644 --- a/internal/service/danmaku_credentials_test.go +++ b/internal/service/danmaku_credentials_test.go @@ -81,4 +81,4 @@ func TestDanmakuCredentialsSelection(t *testing.T) { require.False(t, ok) require.Empty(t, id) require.Empty(t, key) -} \ No newline at end of file +} diff --git a/internal/service/danmaku_match_test.go b/internal/service/danmaku_match_test.go index 8825ed2..095fc4a 100644 --- a/internal/service/danmaku_match_test.go +++ b/internal/service/danmaku_match_test.go @@ -16,8 +16,8 @@ import ( "github.com/stretchr/testify/require" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service/cloud" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service/cloud" ) // overrideDanmakuOfficialBase points the "official" endpoint (match + fallback) @@ -34,7 +34,7 @@ func overrideDanmakuOfficialBase(t *testing.T, base string) { // since the file is smaller than the 16MB prefix). func writeDanmakuTestVideo(t *testing.T, name string) (path, wantHash string) { t.Helper() - content := bytes.Repeat([]byte("MMTL-danmaku-hash-test-0123456789"), 500) + content := bytes.Repeat([]byte("MeBox-danmaku-hash-test-0123456789"), 500) path = filepath.Join(t.TempDir(), name) require.NoError(t, os.WriteFile(path, content, 0o644)) sum := md5.Sum(content) @@ -278,69 +278,171 @@ func TestDanmakuSameBase(t *testing.T) { require.False(t, sameDanmakuBase("", "https://api.dandanplay.net")) } - // fetchCommentWithFallback:配置源与官方同源时不重复请求; - // 全失败时带出最后一跳错误。 - func TestDanmakuFetchCommentWithFallback(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - t.Cleanup(srv.Close) +// fetchCommentWithFallback:配置源与官方同源时不重复请求; +// 全失败时带出最后一跳错误。 +func TestDanmakuFetchCommentWithFallback(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) - svc := newDanmakuTestService(t) - ctx := context.Background() - raw, st, err := svc.fetchCommentWithFallback(ctx, srv.URL, srv.URL, "25484") - require.Error(t, err) - require.Empty(t, raw) - require.Equal(t, "auto", st) - } + svc := newDanmakuTestService(t) + ctx := context.Background() + raw, st, err := svc.fetchCommentWithFallback(ctx, srv.URL, srv.URL, "25484") + require.Error(t, err) + require.Empty(t, raw) + require.Equal(t, "auto", st) +} - // 视频即便能命中 Hash 自动识别,当用户传入手动搜索关键词时应跳过 Hash 匹配,走关键词搜索。 - func TestDanmakuFetchHashMatchSkippedOnManualKeyword(t *testing.T) { - videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv") +// 视频即便能命中 Hash 自动识别,当用户传入手动搜索关键词时应跳过 Hash 匹配,走关键词搜索。 +func TestDanmakuFetchHashMatchSkippedOnManualKeyword(t *testing.T) { + videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv") - // 官方服务同时提供 match 和 search: - // match 会返回 episodeId=25484(动画A) - // search 会根据关键词返回 episodeId=99999(动画B) - mux := http.NewServeMux() - var matchCalled bool - mux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) { - matchCalled = true - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"自动识别动画A","episodeTitle":"第1话"}]}`) - }) - mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":2002,"animeTitle":"手动搜索动画B","episodes":[{"episodeId":99999,"episodeTitle":"第1话"}]}]}`) - }) - mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/xml") - fmt.Fprint(w, `自动识别弹幕`) - }) - mux.HandleFunc("/api/v2/comment/99999", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/xml") - fmt.Fprint(w, `手动搜索弹幕`) - }) - official := httptest.NewServer(mux) - t.Cleanup(official.Close) - overrideDanmakuOfficialBase(t, official.URL) + // 官方服务同时提供 match 和 search: + // match 会返回 episodeId=25484(动画A) + // search 会根据关键词返回 episodeId=99999(动画B) + mux := http.NewServeMux() + var matchCalled bool + mux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) { + matchCalled = true + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"自动识别动画A","episodeTitle":"第1话"}]}`) + }) + mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":2002,"animeTitle":"手动搜索动画B","episodes":[{"episodeId":99999,"episodeTitle":"第1话"}]}]}`) + }) + mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + fmt.Fprint(w, `自动识别弹幕`) + }) + mux.HandleFunc("/api/v2/comment/99999", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + fmt.Fprint(w, `手动搜索弹幕`) + }) + official := httptest.NewServer(mux) + t.Cleanup(official.Close) + overrideDanmakuOfficialBase(t, official.URL) - svc := newDanmakuTestService(t) - ctx := context.Background() - seedDanmakuVideoMedia(t, svc, "mManual", "自动识别动画A", videoPath, 32000, 1) + svc := newDanmakuTestService(t) + ctx := context.Background() + seedDanmakuVideoMedia(t, svc, "mManual", "自动识别动画A", videoPath, 32000, 1) - // 1) 默认自动识别:命中 Hash 识别 - resAuto, err := svc.Fetch(ctx, "mManual", "", "") - require.NoError(t, err) - require.True(t, matchCalled) - require.Equal(t, "hash", resAuto.MatchMode) - require.Equal(t, int64(25484), resAuto.EpisodeID) - require.Contains(t, resAuto.Raw, "自动识别弹幕") + // 1) 默认自动识别:命中 Hash 识别 + resAuto, err := svc.Fetch(ctx, "mManual", "", "") + require.NoError(t, err) + require.True(t, matchCalled) + require.Equal(t, "hash", resAuto.MatchMode) + require.Equal(t, int64(25484), resAuto.EpisodeID) + require.Contains(t, resAuto.Raw, "自动识别弹幕") - // 2) 用户传入手动搜索关键词:跳过 Hash 识别,命中搜索结果动画B - resManual, err := svc.Fetch(ctx, "mManual", "手动搜索动画B", "") - require.NoError(t, err) - require.Equal(t, "search", resManual.MatchMode) - require.Equal(t, int64(99999), resManual.EpisodeID) - require.Equal(t, "手动搜索动画B", resManual.AnimeTitle) - require.Contains(t, resManual.Raw, "手动搜索弹幕") - } \ No newline at end of file + // 2) 用户传入手动搜索关键词:跳过 Hash 识别,命中搜索结果动画B + resManual, err := svc.Fetch(ctx, "mManual", "手动搜索动画B", "") + require.NoError(t, err) + require.Equal(t, "search", resManual.MatchMode) + require.Equal(t, int64(99999), resManual.EpisodeID) + require.Equal(t, "手动搜索动画B", resManual.AnimeTitle) + require.Contains(t, resManual.Raw, "手动搜索弹幕") +} + +// Emby 远程挂载条目:通过伪装 ID 解析出流直链,通过 Range 提取 16MB 前缀计算 hash 并匹配弹幕。 +func TestDanmakuFetchEmbyRemoteHashViaDirectLink(t *testing.T) { + content := bytes.Repeat([]byte("emby-remote-video-bytes-9876543210"), 300) + sum := md5.Sum(content) + wantHash := hex.EncodeToString(sum[:]) + + var gotRange string + var rangeHits int + rangeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rangeHits++ + gotRange = r.Header.Get("Range") + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(content) + })) + t.Cleanup(rangeSrv.Close) + + var seen string + official := danmakuOfficialServer(t, + `{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":2001,"animeTitle":"芙莉莲","episodeTitle":"第1话"}]}`, + `Emby远程弹幕命中`, + &seen) + overrideDanmakuOfficialBase(t, official.URL) + + remoteMediaID := EncodeEmbyRemoteID("mount-123", "remote-item-456") + svc := newDanmakuTestService(t) + svc.SetRemoteMediaResolver(func(_ context.Context, encodedID string) (*model.Media, string, error) { + require.Equal(t, remoteMediaID, encodedID) + return &model.Media{ + Base: model.Base{ID: remoteMediaID}, + Title: "葬送的芙莉莲", + EpisodeTitle: "第1话", + EpisodeNum: 1, + Path: "/mnt/emby/anime/Frieren/S01E01.mkv", + SizeBytes: int64(len(content)), + DurationSec: 1400, + }, rangeSrv.URL, nil + }) + + ctx := context.Background() + res, err := svc.Fetch(ctx, remoteMediaID, "", "") + require.NoError(t, err) + require.True(t, res.Enabled) + require.Equal(t, "hash", res.MatchMode) + require.Equal(t, int64(25484), res.EpisodeID) + require.Equal(t, "芙莉莲", res.AnimeTitle) + require.Contains(t, res.Raw, "Emby远程弹幕命中") + require.Contains(t, gotRange, "bytes=0-") + require.Contains(t, seen, `"fileHash":"`+wantHash+`"`) + require.Contains(t, seen, `"fileName":"`+url.QueryEscape("S01E01")+`"`) + require.Equal(t, 1, rangeHits) + + // 第二次拉取验证 hashCache 命中,不重复请求 rangeSrv + res2, err := svc.Fetch(ctx, remoteMediaID, "", "") + require.NoError(t, err) + require.Equal(t, "hash", res2.MatchMode) + require.Equal(t, 1, rangeHits) +} + +// Emby 远程直链拉取失败时(如网络异常),能平滑降级走番剧原名/标题关键词搜索。 +func TestDanmakuFetchEmbyRemoteStreamFailedFallsBackToSearch(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // 文件名搜索 ep01 时无结果,模拟文件名未匹配 + if r.URL.Query().Get("anime") == "ep01" { + fmt.Fprint(w, `{"hasMore":false,"animes":[]}`) + return + } + // 降级到番剧名搜索命中 + fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":3001,"animeTitle":"降级搜索番剧","episodes":[{"episodeId":7799,"episodeTitle":"第1话"}]}]}`) + }) + mux.HandleFunc("/api/v2/comment/7799", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + fmt.Fprint(w, `降级搜索弹幕`) + }) + official := httptest.NewServer(mux) + t.Cleanup(official.Close) + overrideDanmakuOfficialBase(t, official.URL) + + remoteMediaID := EncodeEmbyRemoteID("mount-123", "remote-item-789") + svc := newDanmakuTestService(t) + // 返回一个不存在的流服务地址模拟 Range 拉取失败 + svc.SetRemoteMediaResolver(func(_ context.Context, encodedID string) (*model.Media, string, error) { + return &model.Media{ + Base: model.Base{ID: remoteMediaID}, + Title: "降级搜索番剧", + EpisodeNum: 1, + Path: "/mnt/emby/anime/fallback/ep01.mkv", + DurationSec: 1200, + }, "http://127.0.0.1:1/invalid-stream", nil + }) + + ctx := context.Background() + res, err := svc.Fetch(ctx, remoteMediaID, "", "") + require.NoError(t, err) + require.True(t, res.Enabled) + require.Equal(t, "search", res.MatchMode) + require.Equal(t, int64(7799), res.EpisodeID) + require.Equal(t, "降级搜索番剧", res.AnimeTitle) + require.Contains(t, res.Raw, "降级搜索弹幕") +} diff --git a/internal/service/danmaku_service.go b/internal/service/danmaku_service.go index a221efd..9a1d928 100644 --- a/internal/service/danmaku_service.go +++ b/internal/service/danmaku_service.go @@ -20,8 +20,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // Danmaku setting keys, managed through the admin settings UI (PUT @@ -94,6 +94,10 @@ type DanmakuEpisode struct { EpisodeTitle string `json:"episodeTitle"` } +// DanmakuRemoteMediaResolver resolves an Emby remote pseudo-ID (e.g. embyremote~mount~id) +// into a memory model.Media and a direct stream URL. +type DanmakuRemoteMediaResolver func(ctx context.Context, encodedID string) (*model.Media, string, error) + // DanmakuService fetches danmaku for a media item through the dandanplay // protocol: match by 16MB-prefix hash, then search for an episode id by the // video's name, then fetch the comment library XML. The React player parses @@ -108,6 +112,10 @@ type DanmakuService struct { // StrmService.ResolvePlay; nil means strm sources are skipped. strmResolve func(ctx context.Context, provider string, q url.Values) (*StrmPlayResult, error) + // remoteResolve resolves an Emby remote pseudo-ID into *model.Media and + // direct stream URL for range hashing. + remoteResolve DanmakuRemoteMediaResolver + hashCacheMu sync.Mutex hashCache map[string]string // stamp → 16MB-prefix MD5 } @@ -136,6 +144,14 @@ func (s *DanmakuService) SetStrmResolver(resolve func(ctx context.Context, provi } } +// SetRemoteMediaResolver wires the resolver used to fetch metadata and direct +// stream URLs for Emby remote mounted media. +func (s *DanmakuService) SetRemoteMediaResolver(resolve DanmakuRemoteMediaResolver) { + if s != nil { + s.remoteResolve = resolve + } +} + // Config reads danmaku settings from the runtime settings table. func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig { cfg := DanmakuRenderConfig{ @@ -190,90 +206,94 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID configured := strings.TrimRight(strings.TrimSpace(res.Source), "/") official := danmakuOfficialBase - // 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。 - if target := strings.TrimSpace(episodeID); target != "" { - raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target) - if err != nil { - s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err)) - return res, err - } - res.Raw, res.SourceType = raw, st - if id, parseErr := strconv.ParseInt(target, 10, 64); parseErr == nil { - res.EpisodeID = id - } - res.MatchMode = "manual" - return res, nil - } - - term, media, err := s.searchTerms(ctx, mediaID) + // 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。 + if target := strings.TrimSpace(episodeID); target != "" { + raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target) if err != nil { + s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err)) return res, err } - manualKeyword := strings.TrimSpace(keyword) != "" - if kw := strings.TrimSpace(keyword); kw != "" { - term.name = kw + res.Raw, res.SourceType = raw, st + if id, parseErr := strconv.ParseInt(target, 10, 64); parseErr == nil { + res.EpisodeID = id } - if strings.TrimSpace(term.name) == "" { + res.MatchMode = "manual" + return res, nil + } + + term, media, err := s.searchTerms(ctx, mediaID) + if err != nil { + return res, err + } + manualKeyword := strings.TrimSpace(keyword) != "" + if kw := strings.TrimSpace(keyword); kw != "" { + term.name = kw + } + if strings.TrimSpace(term.name) == "" { + return res, nil + } + + target := "" + + // 1) hash 识别:始终走官方 /api/v2/match(keyword 手动覆盖时跳过,直接走第 3 层)。 + if target == "" && !manualKeyword && media != nil && (media.Path != "" || IsEmbyRemoteID(media.ID)) { + if hash, ok := s.mediaHash(ctx, media); ok { + fileSize := media.SizeBytes + if media.Path != "" && strings.EqualFold(filepath.Ext(media.Path), ".strm") { + fileSize = 0 // strm 行的 SizeBytes 是文本大小,不是视频大小 + } + matchName := danmakuMatchFileName(media.Path) + if matchName == "" { + matchName = term.name + } + matches, err := s.matchOfficial(ctx, matchName, hash, fileSize, media.DurationSec) + if err != nil { + s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err)) + } else if len(matches) > 0 { + target = fmt.Sprintf("%d", matches[0].EpisodeID) + res.AnimeTitle = matches[0].AnimeTitle + res.EpisodeTitle = matches[0].EpisodeTitle + res.EpisodeID = matches[0].EpisodeID + res.MatchMode = "hash" + } + } + } + + // 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。 + if target == "" && !manualKeyword && media != nil && media.Path != "" { + if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name { + if candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, fileName, term.episode); err == nil && + len(candidates) == 1 && len(candidates[0].Episodes) > 0 { + target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID) + res.AnimeTitle = candidates[0].AnimeTitle + res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle + res.EpisodeID = candidates[0].Episodes[0].EpisodeID + res.MatchMode = "filename" + } + } + } + + // 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数, + // 多结果返回候选列表交给播放器(歧义处理)。 + if target == "" { + candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode) + if err != nil { + s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err)) + return res, err + } + if len(candidates) != 1 { + res.Candidates = candidates return res, nil } - - target := "" - - // 1) hash 识别:始终走官方 /api/v2/match(keyword 手动覆盖时跳过,直接走第 3 层)。 - if target == "" && !manualKeyword && media != nil && media.Path != "" { - if hash, ok := s.mediaHash(ctx, media); ok { - fileSize := media.SizeBytes - if strings.EqualFold(filepath.Ext(media.Path), ".strm") { - fileSize = 0 // strm 行的 SizeBytes 是文本大小,不是视频大小 - } - matches, err := s.matchOfficial(ctx, danmakuMatchFileName(media.Path), hash, fileSize, media.DurationSec) - if err != nil { - s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err)) - } else if len(matches) > 0 { - target = fmt.Sprintf("%d", matches[0].EpisodeID) - res.AnimeTitle = matches[0].AnimeTitle - res.EpisodeTitle = matches[0].EpisodeTitle - res.EpisodeID = matches[0].EpisodeID - res.MatchMode = "hash" - } - } - } - - // 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。 - if target == "" && !manualKeyword && media != nil && media.Path != "" { - if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name { - if candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, fileName, term.episode); err == nil && - len(candidates) == 1 && len(candidates[0].Episodes) > 0 { - target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID) - res.AnimeTitle = candidates[0].AnimeTitle - res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle - res.EpisodeID = candidates[0].Episodes[0].EpisodeID - res.MatchMode = "filename" - } - } - } - - // 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数, - // 多结果返回候选列表交给播放器(歧义处理)。 - if target == "" { - candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode) - if err != nil { - s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err)) - return res, err - } - if len(candidates) != 1 { - res.Candidates = candidates - return res, nil - } - if len(candidates[0].Episodes) == 0 { - return res, errors.New("no danmaku library found for this video") - } - target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID) - res.AnimeTitle = candidates[0].AnimeTitle - res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle - res.EpisodeID = candidates[0].Episodes[0].EpisodeID - res.MatchMode = "search" + if len(candidates[0].Episodes) == 0 { + return res, errors.New("no danmaku library found for this video") } + target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID) + res.AnimeTitle = candidates[0].AnimeTitle + res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle + res.EpisodeID = candidates[0].Episodes[0].EpisodeID + res.MatchMode = "search" + } raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target) if err != nil { @@ -311,6 +331,29 @@ type danmakuSearchTerms struct { // (movies / unknown) is left empty so the search does not filter by episode. func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danmakuSearchTerms, *model.Media, error) { var term danmakuSearchTerms + if IsEmbyRemoteID(mediaID) { + if s == nil || s.remoteResolve == nil { + return term, nil, errors.New("remote emby resolver unavailable") + } + m, _, err := s.remoteResolve(ctx, mediaID) + if err != nil || m == nil { + if err != nil { + return term, nil, err + } + return term, nil, errors.New("media not found") + } + if name := strings.TrimSpace(m.OriginalName); name != "" { + term.name = name + } else if name := strings.TrimSpace(m.Title); name != "" { + term.name = name + } else { + term.name = danmakuMatchFileName(m.Path) + } + if m.EpisodeNum > 0 { + term.episode = strconv.Itoa(m.EpisodeNum) + } + return term, m, nil + } if s == nil || s.repo == nil || s.repo.Media == nil { return term, nil, errors.New("media repository unavailable") } @@ -396,7 +439,7 @@ func (s *DanmakuService) fetchBody(ctx context.Context, sourceURL string, follow if err != nil { return "", err } - req.Header.Set("User-Agent", "MMTL/danmaku (+https://github.com/ShukeBta/MMTL)") + req.Header.Set("User-Agent", "MeBox/danmaku (+https://github.com/truewhile/MeBox)") req.Header.Set("Accept", "application/json, application/xml, */*") if appID, appKey, ok := s.danmakuCredentials(ctx, sourceURL); ok { // 签名认证:base64(sha256(AppId+Timestamp+Path+Secret)),密钥不出服务器。 @@ -479,12 +522,19 @@ func (s *DanmakuService) hashCachePut(stamp, hash string) { } // danmakuMatchFileName derives the /api/v2/match fileName: base name without -// the final extension. .strm items are covered too — MMTL strm files drop the +// the final extension. .strm items are covered too — MeBox strm files drop the // video extension ("xxx.strm") while pre-existing ones may keep it // ("xxx.mkv.strm") — so a second strip removes a real video extension only // (filepath.Ext would misread names like "xxx.第01话" as having an extension). func danmakuMatchFileName(path string) string { - base := filepath.Base(path) + if path == "" { + return "" + } + clean := strings.ReplaceAll(path, "\\", "/") + if idx := strings.LastIndex(clean, "/"); idx >= 0 { + clean = clean[idx+1:] + } + base := filepath.Base(clean) if ext := filepath.Ext(base); ext != "" { base = strings.TrimSuffix(base, ext) } @@ -493,14 +543,20 @@ func danmakuMatchFileName(path string) string { base = strings.TrimSuffix(base, filepath.Ext(base)) } } - return base + return strings.TrimSpace(base) } // mediaHash returns the dandanplay match hash (MD5 of the first 16MB of the -// video). Local videos are hashed straight from disk; .strm indirections are -// resolved (local path / direct link) and only the 16MB prefix is downloaded. +// video). Local videos are hashed straight from disk; .strm indirections and +// remote Emby streams are range-fetched and only the 16MB prefix is downloaded. func (s *DanmakuService) mediaHash(ctx context.Context, media *model.Media) (string, bool) { - if media == nil || media.Path == "" { + if media == nil { + return "", false + } + if IsEmbyRemoteID(media.ID) { + return s.hashEmbyRemote(ctx, media) + } + if media.Path == "" { return "", false } if strings.EqualFold(filepath.Ext(media.Path), ".strm") { @@ -517,6 +573,43 @@ func (s *DanmakuService) mediaHash(ctx context.Context, media *model.Media) (str return s.hashLocalFile(media.Path) } +// hashEmbyRemote computes the 16MB-prefix MD5 of a remote Emby stream via HTTP Range. +func (s *DanmakuService) hashEmbyRemote(ctx context.Context, media *model.Media) (string, bool) { + if media == nil || media.ID == "" { + return "", false + } + if h, ok := s.hashCacheGet("e|" + media.ID); ok { + return h, true + } + if s.remoteResolve == nil { + return "", false + } + _, streamURL, err := s.remoteResolve(ctx, media.ID) + if err != nil || strings.TrimSpace(streamURL) == "" { + if err != nil { + s.log.Warn("danmaku emby stream url resolve failed, hash layer skipped", + zap.String("media_id", media.ID), zap.Error(err)) + } + return "", false + } + body, err := s.openRangeBody(ctx, streamURL, nil) + if err != nil || body == nil { + if err != nil { + s.log.Warn("danmaku emby range fetch failed, hash layer skipped", + zap.String("media_id", media.ID), zap.Error(err)) + } + return "", false + } + defer body.Close() + h := md5.New() + if _, err := io.Copy(h, io.LimitReader(body, danmakuHashPrefixBytes)); err != nil { + return "", false + } + hash := hex.EncodeToString(h.Sum(nil)) + s.hashCachePut("e|"+media.ID, hash) + return hash, true +} + // hashLocalFile computes the MD5 of the first 16MB of a local video, cached // by path+size+mtime so repeated danmaku loads skip the disk read. func (s *DanmakuService) hashLocalFile(path string) (string, bool) { @@ -543,7 +636,7 @@ func (s *DanmakuService) hashLocalFile(path string) (string, bool) { } // hashStrmTarget computes the video hash behind a .strm indirection: -// MMTL-internal /api/strm/play URLs are resolved through strmResolve (local +// MeBox-internal /api/strm/play URLs are resolved through strmResolve (local // path read directly, cloud links range-fetched); plain http(s) links are // fetched directly. Only the 16MB prefix is ever downloaded. func (s *DanmakuService) hashStrmTarget(ctx context.Context, raw string) (string, bool) { @@ -614,7 +707,7 @@ func (s *DanmakuService) openRangeBody(ctx context.Context, target string, heade if err != nil { return nil, err } - req.Header.Set("User-Agent", "MMTL/danmaku (+https://github.com/ShukeBta/MMTL)") + req.Header.Set("User-Agent", "MeBox/danmaku (+https://github.com/truewhile/MeBox)") req.Header.Set("Range", fmt.Sprintf("bytes=0-%d", danmakuHashPrefixBytes-1)) for k, v := range headers { req.Header.Set(k, v) @@ -694,7 +787,7 @@ func (s *DanmakuService) matchOfficial(ctx context.Context, fileName, fileHash s return nil, fmt.Errorf("danmaku match returned HTTP %d", resp.StatusCode) } var out struct { - Success bool `json:"success"` + Success bool `json:"success"` Matches []danmakuMatch `json:"matches"` } if err := json.Unmarshal(raw, &out); err != nil { diff --git a/internal/service/danmaku_service_test.go b/internal/service/danmaku_service_test.go index 608b674..c77f49f 100644 --- a/internal/service/danmaku_service_test.go +++ b/internal/service/danmaku_service_test.go @@ -13,8 +13,8 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func newDanmakuTestService(t *testing.T) *DanmakuService { diff --git a/internal/service/database_admin.go b/internal/service/database_admin.go index dc0719c..eebb99b 100644 --- a/internal/service/database_admin.go +++ b/internal/service/database_admin.go @@ -8,9 +8,9 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/database" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/database" + "github.com/truewhile/MeBox/internal/repository" ) // DatabaseAdminService manages database configuration, connectivity testing, and migration. diff --git a/internal/service/db_errors.go b/internal/service/db_errors.go index 9e146f6..a81c1f0 100644 --- a/internal/service/db_errors.go +++ b/internal/service/db_errors.go @@ -1,6 +1,6 @@ package service -import "github.com/ShukeBta/MMTL/internal/repository" +import "github.com/truewhile/MeBox/internal/repository" func IsTransientDatabaseLock(err error) bool { return repository.IsSQLiteBusyError(err) diff --git a/internal/service/device_cleanup.go b/internal/service/device_cleanup.go index 97d090f..d4558aa 100644 --- a/internal/service/device_cleanup.go +++ b/internal/service/device_cleanup.go @@ -8,7 +8,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // SweepInactiveUsers is kept for compatibility with the existing scheduler; it diff --git a/internal/service/device_listing.go b/internal/service/device_listing.go index 0d4442a..7697605 100644 --- a/internal/service/device_listing.go +++ b/internal/service/device_listing.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // KickDevice marks a device as kicked so the next request from it is rejected diff --git a/internal/service/device_service.go b/internal/service/device_service.go index 5cca7bd..ab2427a 100644 --- a/internal/service/device_service.go +++ b/internal/service/device_service.go @@ -10,8 +10,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // DeviceService implements device/session tracking and the two decoupled diff --git a/internal/service/dlna.go b/internal/service/dlna.go index b17b708..81c3c5f 100644 --- a/internal/service/dlna.go +++ b/internal/service/dlna.go @@ -6,7 +6,7 @@ // service to start playback of an HTTP URL. // // We do NOT mediate the renderer ↔ client traffic; the renderer pulls -// the bytes directly from MMTL's /api/stream endpoint, so +// the bytes directly from MeBox's /api/stream endpoint, so // the cast call only ever transports a URL string. package service diff --git a/internal/service/douban.go b/internal/service/douban.go index fa1cf86..d3800a4 100644 --- a/internal/service/douban.go +++ b/internal/service/douban.go @@ -27,7 +27,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) // DoubanProvider talks to the unofficial Douban movie API. diff --git a/internal/service/emby_artwork.go b/internal/service/emby_artwork.go index 57e1318..d943f59 100644 --- a/internal/service/emby_artwork.go +++ b/internal/service/emby_artwork.go @@ -5,8 +5,8 @@ import ( "strings" "time" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // ImageURL returns artwork for a media/series/season item id. diff --git a/internal/service/emby_artwork_test.go b/internal/service/emby_artwork_test.go index 7144cb8..18280eb 100644 --- a/internal/service/emby_artwork_test.go +++ b/internal/service/emby_artwork_test.go @@ -3,7 +3,7 @@ package service import ( "testing" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func TestImageURLFallsBackToLibraryCoverURL(t *testing.T) { diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index a775e12..c557a69 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -3,7 +3,7 @@ // EmbyService produces JSON envelopes shaped like the most-consumed // Emby-API endpoints so existing players (Infuse / Yamby / Hills / // Senplayer / Kodi NextPVR extension / iOS native clients) can talk to -// MMTL without a custom plugin. +// MeBox without a custom plugin. // // The shim is read-mostly: items, images, playback are fully covered; // 播放进度上报 / 收藏切换 是写路径但走我们自己的 PlaybackHistory / @@ -17,14 +17,14 @@ import ( "sync" "time" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/repository" "go.uber.org/zap" ) // 用一个固定的 ServerId 字符串。Emby 客户端会缓存这个 id,第一次见到 // 该 id 后会把所有派生数据(cookie/收藏/历史)和它绑定。 -const embyServerID = "mmtl-001" +const embyServerID = "mebox-001" // embyCompatVersion deliberately reports an Emby 4.x server. Official Emby // clients reject Jellyfin-style 10.x identities as unsupported/too old during @@ -153,7 +153,14 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any, if mount == nil || acct == nil { return emptyItemsEnvelope(p.StartIndex), nil } - return e.remote.RemoteItems(ctx, mount, acct, p) + out, err := e.remote.RemoteItems(ctx, mount, acct, p) + if err != nil { + return nil, err + } + if err := e.mergeRemoteUserData(ctx, p.UserID, out); err != nil { + return nil, err + } + return out, nil } // 全局搜索:无 ParentId 且带搜索词 → 聚合本地 + 全部远程。 if p.ParentID == "" && p.SearchTerm != "" { @@ -161,6 +168,10 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any, } } + if containsEmbyFilter(p.Filters, "IsResumable") { + return e.resumableItems(ctx, p) + } + if len(p.IDs) > 0 { items := make([]map[string]any, 0, len(p.IDs)) for _, id := range p.IDs { @@ -274,6 +285,9 @@ func (e *EmbyService) aggregatedSearch(ctx context.Context, p ItemsParams) (map[ } continue } + if err := e.mergeRemoteUserData(ctx, p.UserID, remote); err != nil { + return nil, err + } if raw, ok := remote["Items"].([]any); ok { results = append(results, remoteResult{items: raw}) } else if rawMap, ok := remote["Items"].([]map[string]any); ok { diff --git a/internal/service/emby_compat_test.go b/internal/service/emby_compat_test.go index 04ff9b4..500d505 100644 --- a/internal/service/emby_compat_test.go +++ b/internal/service/emby_compat_test.go @@ -6,9 +6,9 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func newTestEmbyService(t *testing.T) *EmbyService { diff --git a/internal/service/emby_counts.go b/internal/service/emby_counts.go index e64c5b0..764bc66 100644 --- a/internal/service/emby_counts.go +++ b/internal/service/emby_counts.go @@ -4,7 +4,7 @@ import ( "context" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" "gorm.io/gorm" ) diff --git a/internal/service/emby_items_detail.go b/internal/service/emby_items_detail.go index 8182b03..53df106 100644 --- a/internal/service/emby_items_detail.go +++ b/internal/service/emby_items_detail.go @@ -6,11 +6,14 @@ import ( "strings" "time" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // Item 单条目详情。 func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) { + if e == nil { + return nil, nil + } // 远程 Emby 条目:不查本地库,直接向远程转发(保持远程最新元数据)。 if e.remote != nil && IsEmbyRemoteID(mediaID) { mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID) @@ -18,7 +21,14 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str if mount == nil || acct == nil { return nil, nil } - return e.remote.RemoteItem(ctx, mount, acct, remoteID) + out, err := e.remote.RemoteItem(ctx, mount, acct, remoteID) + if err != nil || out == nil { + return out, err + } + if err := e.mergeRemoteUserData(ctx, userID, out); err != nil { + return nil, err + } + return out, nil } if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != nil { return nil, err @@ -91,7 +101,14 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, if mount == nil || acct == nil { return nil, nil } - return e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit) + out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit) + if err != nil { + return nil, err + } + if err := e.mergeRemoteUserData(ctx, userID, out); err != nil { + return nil, err + } + return out, nil } cacheKey := e.embyLatestCacheKey(userID, parentID, limit) var cached embyLatestCacheValue @@ -160,41 +177,88 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l // ResumeItems 列出有未完成播放进度的媒体。 func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) { - if limit <= 0 || limit > 100 { - limit = 20 + return e.resumableItems(ctx, ItemsParams{UserID: userID, Limit: limit}) +} + +// resumableItems 返回未完成播放进度的媒体(包含本地媒体与挂载的远程媒体),支持分页。 +func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[string]any, error) { + if p.Limit <= 0 || p.Limit > 100 { + p.Limit = 50 } + if p.StartIndex < 0 { + p.StartIndex = 0 + } + if strings.TrimSpace(p.UserID) == "" { + return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil + } + var hist []model.PlaybackHistory if err := e.repo.DB.WithContext(ctx). - Where("user_id = ? AND completed = ? AND position_ms > 0", userID, false). - Order("watched_at desc").Limit(limit).Find(&hist).Error; err != nil { + Where("user_id = ? AND completed = ? AND position_ms > 0", p.UserID, false). + Order("watched_at desc").Find(&hist).Error; err != nil { return nil, err } if len(hist) == 0 { - return map[string]any{"Items": []any{}, "TotalRecordCount": 0}, nil + return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil } - ids := make([]string, 0, len(hist)) - posByID := map[string]int64{} + + localIDs := make([]string, 0, len(hist)) for _, h := range hist { - ids = append(ids, h.MediaID) - posByID[h.MediaID] = h.PositionMs - } - var medias []model.Media - q := e.repo.DB.WithContext(ctx).Where("id IN ?", ids) - q = e.applyUserMediaVisibility(ctx, q, userID) - if err := q.Find(&medias).Error; err != nil { - return nil, err + if !IsEmbyRemoteID(h.MediaID) { + localIDs = append(localIDs, h.MediaID) + } } byID := map[string]*model.Media{} - for i := range medias { - byID[medias[i].ID] = &medias[i] + if len(localIDs) > 0 { + var medias []model.Media + q := e.repo.DB.WithContext(ctx).Where("id IN ?", localIDs) + q = e.applyUserMediaVisibility(ctx, q, p.UserID) + if err := q.Find(&medias).Error; err != nil { + return nil, err + } + for i := range medias { + byID[medias[i].ID] = &medias[i] + } } + items := make([]map[string]any, 0, len(hist)) for _, h := range hist { if m, ok := byID[h.MediaID]; ok { - items = append(items, e.itemPayload(ctx, m, false, posByID[h.MediaID])) + if p.ParentID != "" && m.LibraryID != p.ParentID && m.SeriesID != p.ParentID { + continue + } + items = append(items, e.itemPayload(ctx, m, false, h.PositionMs)) + continue } + if e.remote == nil || !IsEmbyRemoteID(h.MediaID) { + continue + } + mountID, remoteID, _ := DecodeEmbyRemoteID(h.MediaID) + mount, acct, err := e.remote.ResolveMount(ctx, mountID) + if err != nil || mount == nil || acct == nil { + continue + } + item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID) + if err != nil || item == nil { + continue + } + if p.ParentID != "" { + parentID, _ := item["ParentId"].(string) + seriesID, _ := item["SeriesId"].(string) + if parentID != p.ParentID && seriesID != p.ParentID && mountID != p.ParentID { + continue + } + } + item["UserData"] = mergedRemoteUserData(item["UserData"], &h) + items = append(items, item) } - return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil + + total := int64(len(items)) + if p.StartIndex >= len(items) { + return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil + } + end := minInt(p.StartIndex+p.Limit, len(items)) + return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil } func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64) map[string]any { diff --git a/internal/service/emby_items_list.go b/internal/service/emby_items_list.go index 686d10c..fc60aef 100644 --- a/internal/service/emby_items_list.go +++ b/internal/service/emby_items_list.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string]any, error) { diff --git a/internal/service/emby_media_sources.go b/internal/service/emby_media_sources.go index 2f4cdca..741a08c 100644 --- a/internal/service/emby_media_sources.go +++ b/internal/service/emby_media_sources.go @@ -7,7 +7,7 @@ import ( "sort" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func (e *EmbyService) mediaSourcesForItem(ctx context.Context, m *model.Media, asEmbedded, directOnly bool) []map[string]any { diff --git a/internal/service/emby_movie_items.go b/internal/service/emby_movie_items.go index 625a274..5337945 100644 --- a/internal/service/emby_movie_items.go +++ b/internal/service/emby_movie_items.go @@ -8,7 +8,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // movieLibraryHasEpisodicContent 报告电影类型库里是否混入了「剧集结构」内容 diff --git a/internal/service/emby_movie_library_test.go b/internal/service/emby_movie_library_test.go index 9419d6f..ee1e401 100644 --- a/internal/service/emby_movie_library_test.go +++ b/internal/service/emby_movie_library_test.go @@ -4,7 +4,7 @@ import ( "testing" "time" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func TestEmbyMovieLibrarySeasonNumbersStayMovies(t *testing.T) { diff --git a/internal/service/emby_playback.go b/internal/service/emby_playback.go index b97d927..6e42bdc 100644 --- a/internal/service/emby_playback.go +++ b/internal/service/emby_playback.go @@ -11,12 +11,12 @@ import ( "strings" "time" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // PlaybackInfo returns a PlaybackInfoResponse usable by Emby clients. // 远程 Emby 条目直接转发远程 PlaybackInfo,并按账号 proxy_play 配置决定 -// 播放地址指向远程(直连)还是 MMTL 本地代理端点。 +// 播放地址指向远程(直连)还是 MeBox 本地代理端点。 func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string) (map[string]any, error) { if e.remote != nil && IsEmbyRemoteID(mediaID) { mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID) @@ -31,6 +31,9 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string) if out == nil { return nil, ErrEmbyRemoteNotFound } + if err := e.mergeRemoteUserData(ctx, userID, out); err != nil { + return nil, err + } out["PlaySessionId"] = fmt.Sprintf("remote-%s-%d", mountID, time.Now().Unix()) return out, nil } diff --git a/internal/service/emby_playback_test.go b/internal/service/emby_playback_test.go index 3072d2c..03ebf2f 100644 --- a/internal/service/emby_playback_test.go +++ b/internal/service/emby_playback_test.go @@ -4,7 +4,7 @@ import ( "testing" "time" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func TestEmbyRootItemsExposeLibraries(t *testing.T) { diff --git a/internal/service/emby_remote.go b/internal/service/emby_remote.go index 31424fa..c868c6f 100644 --- a/internal/service/emby_remote.go +++ b/internal/service/emby_remote.go @@ -1,13 +1,13 @@ // EmbyRemoteService 是「远程 Emby 联邦聚合」核心:把挂载的远程 Emby 服务器 -// 作为外部媒体源,通过 MMTL 的 Emby 兼容 API 透出。 +// 作为外部媒体源,通过 MeBox 的 Emby 兼容 API 透出。 // // 设计要点: // - 远程媒体的元数据完全不落库:每次请求实时向远程 Emby 拉取; // - 条目 ID 用 embyremote~{accountID}~{remoteID} 伪装(见 emby_remote_ids.go), // 客户端拿伪装 ID 回来时按账号路由回远程; // - 播放分流由账号级 proxy_play 配置决定: -// 不代理(默认)= MediaSource 下发热门远程绝对 URL,播放字节完全不经过 MMTL; -// 代理 = 下发 MMTL 本地 /Videos/{encoded} 端点,由 ProxyVideoStream 反向拉流。 +// 不代理(默认)= MediaSource 下发热门远程绝对 URL,播放字节完全不经过 MeBox; +// 代理 = 下发 MeBox 本地 /Videos/{encoded} 端点,由 ProxyVideoStream 反向拉流。 // // 配置复用 STRM 账号体系(StrmAccount.Provider = emby_remote),CRUD/加密/连通 // 测试全部走既有 /admin/strm/accounts 接口,不需要新增数据表。 @@ -15,6 +15,8 @@ package service import ( "context" + "crypto/sha1" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -28,9 +30,9 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // embyRemoteHTTPTimeout 远程 Emby 常规 API 请求超时(流式代理不在此列)。 @@ -54,12 +56,14 @@ func (t *embyRemoteTransport) RoundTrip(req *http.Request) (*http.Response, erro // EmbyRemoteConfig 是一个远程 Emby 账号的解密配置。 type EmbyRemoteConfig struct { - BaseURL string // http://host:8096(无需 /emby 后缀) + BaseURL string // 当前生效线路地址(http://host:8096,无需 /emby 后缀) + Lines []EmbyRemoteLine // 全部线路,按优先级排列 + ActiveLine int // 当前生效线路下标 Username string Password string Token string // api_key(手动填写或自动认证获得) RemoteUserID string // 远程用户 Id(自动认证后回填) - ProxyPlay bool // true=播放流量经 MMTL 反向代理;false=客户端直连远程 + ProxyPlay bool // true=播放流量经 MeBox 反向代理;false=客户端直连远程 } // EmbyRemoteService 提供对远程 Emby 服务器的读写封装。 @@ -69,6 +73,7 @@ type EmbyRemoteService struct { repo *repository.Container crypto *CryptoService http *http.Client + cache *RuntimeCacheService } // NewEmbyRemoteService 构造远程 Emby 聚合服务。 @@ -85,6 +90,32 @@ func NewEmbyRemoteService(cfg *config.Config, log *zap.Logger, repo *repository. } } +func (r *EmbyRemoteService) SetRuntimeCache(cache *RuntimeCacheService) *EmbyRemoteService { + if r != nil { + r.cache = cache + } + return r +} + +func (r *EmbyRemoteService) remoteMediaCacheTTL() time.Duration { + seconds := 15 + if r != nil && r.cfg != nil && r.cfg.Cache.MediaTTLSeconds > 0 { + seconds = r.cfg.Cache.MediaTTLSeconds + } + return time.Duration(seconds) * time.Second +} + +func (r *EmbyRemoteService) remoteCacheKey(parts ...string) string { + sum := sha1.Sum([]byte(strings.Join(parts, "|"))) + return "media:embyremote:" + hex.EncodeToString(sum[:]) +} + +func (r *EmbyRemoteService) invalidateRemoteMediaCache(ctx context.Context) { + if r != nil && r.cache != nil { + r.cache.DeletePrefix(ctx, "media:embyremote:") + } +} + // ListAccounts 返回全部启用的远程 Emby 挂载账号。 func (r *EmbyRemoteService) ListAccounts(ctx context.Context) ([]model.StrmAccount, error) { accounts, err := r.repo.StrmAccount.List(ctx) @@ -143,6 +174,7 @@ func (r *EmbyRemoteService) CreateMount(ctx context.Context, m *model.EmbyMount) if err := r.repo.EmbyMount.Create(ctx, m); err != nil { return nil, err } + r.invalidateRemoteMediaCache(ctx) return m, nil } @@ -172,6 +204,7 @@ func (r *EmbyRemoteService) CreateMounts(ctx context.Context, mounts []*model.Em if err := r.repo.EmbyMount.CreateInBatches(ctx, fresh, 50); err != nil { return 0, err } + r.invalidateRemoteMediaCache(ctx) return len(fresh), nil } @@ -187,12 +220,29 @@ func (r *EmbyRemoteService) UpdateMount(ctx context.Context, id string, m *model if err := r.repo.EmbyMount.Update(ctx, existing); err != nil { return nil, err } + r.invalidateRemoteMediaCache(ctx) return existing, nil } // DeleteMount 删除挂载。 func (r *EmbyRemoteService) DeleteMount(ctx context.Context, id string) error { - return r.repo.EmbyMount.Delete(ctx, id) + err := r.repo.EmbyMount.Delete(ctx, id) + if err == nil { + r.invalidateRemoteMediaCache(ctx) + } + return err +} + +// ReorderMounts 批量重排挂载媒体库顺序。 +func (r *EmbyRemoteService) ReorderMounts(ctx context.Context, ids []string) error { + if len(ids) == 0 { + return nil + } + if err := r.repo.EmbyMount.SetSortOrder(ctx, ids); err != nil { + return err + } + r.invalidateRemoteMediaCache(ctx) + return nil } // FullMountAccount 把账号的全部远程媒体库(View)挂载进来(幂等,已存在跳过)。 @@ -274,8 +324,14 @@ func (r *EmbyRemoteService) configOf(acct *model.StrmAccount) (*EmbyRemoteConfig return nil, fmt.Errorf("decode emby account config: %w", err) } } + lines, activeLine, err := ParseEmbyRemoteLines(raw) + if err != nil { + return nil, err + } cfg := &EmbyRemoteConfig{ - BaseURL: strings.TrimRight(strings.TrimSpace(raw["url"]), "/"), + BaseURL: lines[activeLine].URL, + Lines: lines, + ActiveLine: activeLine, Username: strings.TrimSpace(raw["username"]), Password: r.crypto.Decrypt(raw["password"]), Token: firstNonEmptyStr(r.crypto.Decrypt(raw["api_key"]), r.crypto.Decrypt(raw["token"])), @@ -285,9 +341,6 @@ func (r *EmbyRemoteService) configOf(acct *model.StrmAccount) (*EmbyRemoteConfig if cfg.BaseURL == "" { return nil, errors.New("缺少 Emby 地址") } - if !strings.HasPrefix(cfg.BaseURL, "http://") && !strings.HasPrefix(cfg.BaseURL, "https://") { - return nil, errors.New("Emby 地址必须以 http:// 或 https:// 开头") - } return cfg, nil } @@ -318,6 +371,29 @@ func (r *EmbyRemoteService) ensureToken(ctx context.Context, acct *model.StrmAcc if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { return errors.New("缺少 Emby 凭据:请填写 api_key 或 用户名/密码") } + var lastErr error + for _, lineIdx := range r.lineOrder(cfg) { + lineCfg := r.withLine(cfg, lineIdx) + err := r.ensureTokenOnLine(ctx, acct, lineCfg) + if err == nil { + cfg.Token = lineCfg.Token + cfg.RemoteUserID = lineCfg.RemoteUserID + cfg.BaseURL = lineCfg.BaseURL + r.adoptWorkingLine(ctx, acct, cfg, lineIdx) + return r.persistToken(ctx, acct, cfg) + } + lastErr = err + if !isEmbyLineFailoverError(err) { + return err + } + } + if lastErr != nil { + return lastErr + } + return errors.New("Emby 线路认证失败") +} + +func (r *EmbyRemoteService) ensureTokenOnLine(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig) error { body, _ := json.Marshal(map[string]string{"Username": cfg.Username, "Pw": cfg.Password}) endpoint := r.embyBase(cfg) + "/Users/AuthenticateByName" req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(body))) @@ -325,7 +401,7 @@ func (r *EmbyRemoteService) ensureToken(ctx context.Context, acct *model.StrmAcc return err } req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Emby-Authorization", `MediaBrowser Client="MMTL", Device="MMTL-Federated", DeviceId="mmtl-federated", Version="1.0"`) + req.Header.Set("X-Emby-Authorization", `MediaBrowser Client="MeBox", Device="MeBox-Federated", DeviceId="mebox-federated", Version="1.0"`) resp, err := r.http.Do(req) if err != nil { return fmt.Errorf("连接远程 Emby 失败: %w", err) @@ -351,7 +427,7 @@ func (r *EmbyRemoteService) ensureToken(ctx context.Context, acct *model.StrmAcc if login.User.Id != "" { cfg.RemoteUserID = login.User.Id } - return r.persistToken(ctx, acct, cfg) + return nil } // persistToken 把认证得到的 token / user id 加密写回账号配置(下次请求免登录)。 @@ -377,12 +453,36 @@ func (r *EmbyRemoteService) persistToken(ctx context.Context, acct *model.StrmAc } // doGet 向远程 Emby 发起带 api_key 的 GET,把响应 JSON 解码到 out。 -// 401 时自动重认证一次再重试(凭据过期场景)。 +// 401 时自动重认证一次再重试(凭据过期场景)。连接失败时按线路优先级自动切换。 func (r *EmbyRemoteService) doGet(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig, path string, q url.Values, out any) error { - for attempt := 0; attempt < 2; attempt++ { - if err := r.ensureToken(ctx, acct, cfg); err != nil { + var lastErr error + for _, lineIdx := range r.lineOrder(cfg) { + lineCfg := r.withLine(cfg, lineIdx) + err := r.doGetOnLine(ctx, acct, cfg, lineCfg, path, q, out) + if err == nil { + r.adoptWorkingLine(ctx, acct, cfg, lineIdx) + return nil + } + lastErr = err + if !isEmbyLineFailoverError(err) { return err } + } + if lastErr != nil { + return lastErr + } + return errors.New("远程 Emby 请求失败") +} + +func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAccount, master *EmbyRemoteConfig, cfg *EmbyRemoteConfig, path string, q url.Values, out any) error { + for attempt := 0; attempt < 2; attempt++ { + if strings.TrimSpace(cfg.Token) == "" { + if err := r.ensureTokenOnLine(ctx, acct, cfg); err != nil { + return err + } + master.Token = cfg.Token + master.RemoteUserID = cfg.RemoteUserID + } endpoint := r.embyBase(cfg) + path if q != nil { endpoint += "?" + q.Encode() @@ -404,15 +504,15 @@ func (r *EmbyRemoteService) doGet(ctx context.Context, acct *model.StrmAccount, return readErr } if resp.StatusCode == http.StatusUnauthorized && attempt == 0 { - // token 失效:清空后重认证重试一次。 cfg.Token = "" + master.Token = "" if acct != nil { -raw := map[string]string{} - _ = json.Unmarshal([]byte(acct.Config), &raw) - delete(raw, "api_key") - enc, _ := json.Marshal(raw) - acct.Config = string(enc) - _ = r.repo.StrmAccount.Update(ctx, acct) + raw := map[string]string{} + _ = json.Unmarshal([]byte(acct.Config), &raw) + delete(raw, "api_key") + enc, _ := json.Marshal(raw) + acct.Config = string(enc) + _ = r.repo.StrmAccount.Update(ctx, acct) } continue } @@ -457,6 +557,11 @@ func (r *EmbyRemoteService) RemoteViews(ctx context.Context, acct *model.StrmAcc if err != nil { return nil, err } + cacheKey := r.remoteCacheKey("views", acct.ID, r.remoteUserID(cfg)) + var cached []map[string]any + if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) { + return cached, nil + } q := url.Values{"api_key": {cfg.Token}} var body struct { Items []map[string]any `json:"Items"` @@ -464,6 +569,12 @@ func (r *EmbyRemoteService) RemoteViews(ctx context.Context, acct *model.StrmAcc if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Views", q, &body); err != nil { return nil, err } + if body.Items == nil { + body.Items = []map[string]any{} + } + if r.cache != nil { + r.cache.SetJSON(ctx, cacheKey, body.Items, r.remoteMediaCacheTTL()) + } return body.Items, nil } @@ -588,7 +699,7 @@ func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyM } // RemotePlaybackInfo 拉取远程 PlaybackInfo,并按挂载的 proxy_play 配置重写 -// 播放 URL:不代理=指向远程绝对地址(播放字节不过 MMTL);代理=指向 MMTL +// 播放 URL:不代理=指向远程绝对地址(播放字节不过 MeBox);代理=指向 MeBox // 本地 /Videos/{encodedID} 端点(由 ProxyVideoStream 反代)。 func (r *EmbyRemoteService) RemotePlaybackInfo(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID, userID string) (map[string]any, error) { cfg, err := r.configOf(acct) @@ -608,7 +719,7 @@ func (r *EmbyRemoteService) RemotePlaybackInfo(ctx context.Context, mount *model // rewritePlayURLs 按挂载代理模式重写载荷内 MediaSources 的播放地址。 // 远程 Emby 的 PlaybackInfo 通常不返回 DirectStreamUrl(客户端靠它拼 -// /Videos/{Id}/stream),因此这里总是强制构造播放地址,完全由 MMTL 掌控 +// /Videos/{Id}/stream),因此这里总是强制构造播放地址,完全由 MeBox 掌控 // 直连(远程绝对 URL)或代理(本地 /Videos/{encoded})的最终去向。 func (r *EmbyRemoteService) rewritePlayURLs(value any, mount *model.EmbyMount, cfg *EmbyRemoteConfig, remoteID string) { encoded := EncodeEmbyRemoteID(mount.ID, remoteID) @@ -724,9 +835,39 @@ func (r *EmbyRemoteService) ProxyVideoStream(ctx context.Context, w http.Respons if err != nil { return err } - if err := r.ensureToken(ctx, acct, cfg); err != nil { - return err + var lastErr error + for _, lineIdx := range r.lineOrder(cfg) { + lineCfg := r.withLine(cfg, lineIdx) + lineCfg.Token = cfg.Token + lineCfg.RemoteUserID = cfg.RemoteUserID + if strings.TrimSpace(lineCfg.Token) == "" { + if err := r.ensureToken(ctx, acct, lineCfg); err != nil { + lastErr = err + if isEmbyLineFailoverError(err) { + continue + } + return err + } + cfg.Token = lineCfg.Token + cfg.RemoteUserID = lineCfg.RemoteUserID + } + err := r.proxyVideoStreamOnLine(ctx, w, req, lineCfg, remoteID) + if err == nil { + r.adoptWorkingLine(ctx, acct, cfg, lineIdx) + return nil + } + lastErr = err + if !isEmbyLineFailoverError(err) { + return err + } } + if lastErr != nil { + return lastErr + } + return errors.New("远程 Emby 视频流请求失败") +} + +func (r *EmbyRemoteService) proxyVideoStreamOnLine(ctx context.Context, w http.ResponseWriter, req *http.Request, cfg *EmbyRemoteConfig, remoteID string) error { endpoint := r.embyBase(cfg) + "/Videos/" + url.PathEscape(remoteID) + "/stream" q := url.Values{} if mediaSourceID := strings.TrimSpace(req.URL.Query().Get("MediaSourceId")); mediaSourceID != "" { @@ -778,9 +919,39 @@ func (r *EmbyRemoteService) ProxySubtitle(ctx context.Context, w http.ResponseWr if err != nil { return err } - if err := r.ensureToken(ctx, acct, cfg); err != nil { - return err + var lastErr error + for _, lineIdx := range r.lineOrder(cfg) { + lineCfg := r.withLine(cfg, lineIdx) + lineCfg.Token = cfg.Token + lineCfg.RemoteUserID = cfg.RemoteUserID + if strings.TrimSpace(lineCfg.Token) == "" { + if err := r.ensureToken(ctx, acct, lineCfg); err != nil { + lastErr = err + if isEmbyLineFailoverError(err) { + continue + } + return err + } + cfg.Token = lineCfg.Token + cfg.RemoteUserID = lineCfg.RemoteUserID + } + err := r.proxySubtitleOnLine(ctx, w, lineCfg, remoteID, index) + if err == nil { + r.adoptWorkingLine(ctx, acct, cfg, lineIdx) + return nil + } + lastErr = err + if !isEmbyLineFailoverError(err) { + return err + } } + if lastErr != nil { + return lastErr + } + return errors.New("远程 Emby 字幕流请求失败") +} + +func (r *EmbyRemoteService) proxySubtitleOnLine(ctx context.Context, w http.ResponseWriter, cfg *EmbyRemoteConfig, remoteID, index string) error { endpoint := r.embyBase(cfg) + "/Videos/" + url.PathEscape(remoteID) + "/Subtitles/" + url.PathEscape(index) + "/Stream" endpoint += "?api_key=" + url.QueryEscape(cfg.Token) upstream, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) @@ -808,7 +979,7 @@ func (r *EmbyRemoteService) ProxySubtitle(ctx context.Context, w http.ResponseWr // ─── 播放状态透传 ────────────────────────────────────────────────────────────── -// ProxySetPlayed 把「已看/未看」状态透传到远程 Emby(MMTL 本地不落库)。 +// ProxySetPlayed 把「已看/未看」状态透传到远程 Emby(MeBox 本地不落库)。 func (r *EmbyRemoteService) ProxySetPlayed(ctx context.Context, acct *model.StrmAccount, remoteID string, played bool) error { cfg, err := r.configOf(acct) if err != nil { @@ -843,6 +1014,39 @@ func (r *EmbyRemoteService) ProxySetFavorite(ctx context.Context, acct *model.St } func (r *EmbyRemoteService) doMutate(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig, method, path string) error { + var lastErr error + for _, lineIdx := range r.lineOrder(cfg) { + lineCfg := r.withLine(cfg, lineIdx) + lineCfg.Token = cfg.Token + lineCfg.RemoteUserID = cfg.RemoteUserID + if strings.TrimSpace(lineCfg.Token) == "" { + if err := r.ensureToken(ctx, acct, lineCfg); err != nil { + lastErr = err + if isEmbyLineFailoverError(err) { + continue + } + return err + } + cfg.Token = lineCfg.Token + cfg.RemoteUserID = lineCfg.RemoteUserID + } + err := r.doMutateOnLine(ctx, lineCfg, method, path) + if err == nil { + r.adoptWorkingLine(ctx, acct, cfg, lineIdx) + return nil + } + lastErr = err + if !isEmbyLineFailoverError(err) { + return err + } + } + if lastErr != nil { + return lastErr + } + return errors.New("远程 Emby 状态同步失败") +} + +func (r *EmbyRemoteService) doMutateOnLine(ctx context.Context, cfg *EmbyRemoteConfig, method, path string) error { endpoint := r.embyBase(cfg) + path + "?api_key=" + url.QueryEscape(cfg.Token) req, err := http.NewRequestWithContext(ctx, method, endpoint, nil) if err != nil { @@ -859,4 +1063,4 @@ func (r *EmbyRemoteService) doMutate(ctx context.Context, acct *model.StrmAccoun return fmt.Errorf("远程 Emby 状态同步失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))) } return nil -} \ No newline at end of file +} diff --git a/internal/service/emby_remote_ids.go b/internal/service/emby_remote_ids.go index 342d39c..e12ef67 100644 --- a/internal/service/emby_remote_ids.go +++ b/internal/service/emby_remote_ids.go @@ -1,6 +1,6 @@ // 远程 Emby 聚合的 ID 伪装。 // -// MMTL 作为 Emby 联邦网关把多个远程 Emby 服务器的媒体库透明聚合到自身的 +// MeBox 作为 Emby 联邦网关把多个远程 Emby 服务器的媒体库透明聚合到自身的 // Emby API 之下,远程条目完全不落库。为了把本地 ID 与多个远程服务器的 ID // 隔离开,远程条目在返回给客户端之前统一被改写为: // @@ -42,7 +42,7 @@ func DecodeEmbyRemoteID(id string) (accountID, remoteID string, ok bool) { } // embyRemoteStringIDs 是条目 JSON 中需要伪装(编码)成远程 ID 的字符串字段。 -// 图片 / 详情 / 播放请求都会以这些字段的值作为 ID 回指 MMTL。 +// 图片 / 详情 / 播放请求都会以这些字段的值作为 ID 回指 MeBox。 var embyRemoteStringIDs = []string{ "Id", "ParentId", @@ -106,4 +106,4 @@ func rewriteEmbyRemoteIDsMap(m map[string]any, accountID string) { if items, ok := m["Items"]; ok { RewriteEmbyRemoteIDs(items, accountID) } -} \ No newline at end of file +} diff --git a/internal/service/emby_remote_ids_test.go b/internal/service/emby_remote_ids_test.go index 473f315..3dd256a 100644 --- a/internal/service/emby_remote_ids_test.go +++ b/internal/service/emby_remote_ids_test.go @@ -42,11 +42,11 @@ func TestDecodeEmbyRemoteIDRejectsLocalUUIDs(t *testing.T) { func TestRewriteEmbyRemoteIDs(t *testing.T) { payload := map[string]any{ - "Id": "item-1", - "ParentId": "folder-1", - "SeriesId": "series-1", - "SeasonId": "season-1", - "PrimaryImageItemId": "item-1", + "Id": "item-1", + "ParentId": "folder-1", + "SeriesId": "series-1", + "SeasonId": "season-1", + "PrimaryImageItemId": "item-1", "DisplayPreferencesId": "folder-1", "ImageTags": map[string]any{ "Primary": "item-1", @@ -58,8 +58,8 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) { // MediaSource 的 Id 保持原样(客户端仅作为 MediaSourceId 查询参数)。 "MediaSources": []any{ map[string]any{ - "Id": "ms-9", - "DirectStreamUrl": "/Videos/item-1/stream", + "Id": "ms-9", + "DirectStreamUrl": "/Videos/item-1/stream", "MediaStreams": []any{ map[string]any{"Type": "Subtitle", "DeliveryUrl": "/Videos/item-1/Subtitles/2/Stream.srt"}, }, @@ -99,4 +99,4 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) { if ms["DirectStreamUrl"] != "/Videos/item-1/stream" { t.Fatalf("DirectStreamUrl must stay raw, got %v", ms["DirectStreamUrl"]) } -} \ No newline at end of file +} diff --git a/internal/service/emby_remote_lines.go b/internal/service/emby_remote_lines.go new file mode 100644 index 0000000..e46e13b --- /dev/null +++ b/internal/service/emby_remote_lines.go @@ -0,0 +1,185 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/truewhile/MeBox/internal/model" +) + +// EmbyRemoteLine 表示同一 Emby 服务器的一条接入线路(内网/外网/CDN 等)。 +type EmbyRemoteLine struct { + Name string `json:"name"` + URL string `json:"url"` +} + +func normalizeEmbyRemoteURL(raw string) string { + u := strings.TrimRight(strings.TrimSpace(raw), "/") + if u == "" { + return "" + } + if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { + return "" + } + return u +} + +// NormalizeEmbyRemoteLines 去掉空 URL,并规范每条线路地址。 +func NormalizeEmbyRemoteLines(lines []EmbyRemoteLine) []EmbyRemoteLine { + out := make([]EmbyRemoteLine, 0, len(lines)) + for _, line := range lines { + u := normalizeEmbyRemoteURL(line.URL) + if u == "" { + continue + } + out = append(out, EmbyRemoteLine{ + Name: strings.TrimSpace(line.Name), + URL: u, + }) + } + return out +} + +// ParseEmbyRemoteLines 从账号配置 JSON 中解析线路列表。 +// 兼容旧版单字段 url;urls 为 JSON 数组字符串。 +func ParseEmbyRemoteLines(raw map[string]string) ([]EmbyRemoteLine, int, error) { + active := 0 + if v := strings.TrimSpace(raw["active_line"]); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + active = n + } + } + if urlsJSON := strings.TrimSpace(raw["urls"]); urlsJSON != "" { + var lines []EmbyRemoteLine + if err := json.Unmarshal([]byte(urlsJSON), &lines); err != nil { + return nil, 0, fmt.Errorf("解析 Emby 线路列表: %w", err) + } + lines = NormalizeEmbyRemoteLines(lines) + if len(lines) == 0 { + return nil, 0, errors.New("至少配置一条 Emby 线路") + } + if active >= len(lines) { + active = 0 + } + return lines, active, nil + } + if u := normalizeEmbyRemoteURL(raw["url"]); u != "" { + return []EmbyRemoteLine{{URL: u}}, 0, nil + } + return nil, 0, errors.New("缺少 Emby 地址") +} + +// EncodeEmbyRemoteLines 序列化线路列表,并返回兼容旧版的 primary URL。 +func EncodeEmbyRemoteLines(lines []EmbyRemoteLine) (urlsJSON string, primaryURL string, err error) { + lines = NormalizeEmbyRemoteLines(lines) + if len(lines) == 0 { + return "", "", errors.New("至少配置一条 Emby 线路") + } + data, err := json.Marshal(lines) + if err != nil { + return "", "", err + } + return string(data), lines[0].URL, nil +} + +func (r *EmbyRemoteService) withLine(cfg *EmbyRemoteConfig, index int) *EmbyRemoteConfig { + if cfg == nil { + return nil + } + copy := *cfg + if index >= 0 && index < len(cfg.Lines) { + copy.BaseURL = normalizeEmbyRemoteURL(cfg.Lines[index].URL) + copy.ActiveLine = index + } + return © +} + +func (r *EmbyRemoteService) lineOrder(cfg *EmbyRemoteConfig) []int { + if cfg == nil || len(cfg.Lines) == 0 { + return []int{0} + } + if len(cfg.Lines) == 1 { + return []int{0} + } + active := cfg.ActiveLine + if active < 0 || active >= len(cfg.Lines) { + active = 0 + } + order := []int{active} + for i := range cfg.Lines { + if i != active { + order = append(order, i) + } + } + return order +} + +func isEmbyLineFailoverError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "登录失败") || + strings.Contains(msg, "未返回 accesstoken") || + strings.Contains(msg, "缺少 emby 凭据") || + strings.Contains(msg, "认证重试失败") { + return false + } + var urlErr *url.Error + if errors.As(err, &urlErr) { + return true + } + return strings.Contains(msg, "连接远程 emby") || + strings.Contains(msg, "请求远程 emby 失败") || + strings.Contains(msg, "dial tcp") || + strings.Contains(msg, "connection refused") || + strings.Contains(msg, "no such host") || + strings.Contains(msg, "timeout") || + strings.Contains(msg, "i/o timeout") +} + +func (r *EmbyRemoteService) persistActiveLine(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig, lineIndex int) error { + if acct == nil || cfg == nil || lineIndex < 0 || lineIndex >= len(cfg.Lines) { + return nil + } + raw := map[string]string{} + if strings.TrimSpace(acct.Config) != "" { + _ = json.Unmarshal([]byte(acct.Config), &raw) + } + raw["active_line"] = strconv.Itoa(lineIndex) + raw["url"] = cfg.Lines[lineIndex].URL + data, err := json.Marshal(raw) + if err != nil { + return err + } + acct.Config = string(data) + cfg.ActiveLine = lineIndex + cfg.BaseURL = normalizeEmbyRemoteURL(cfg.Lines[lineIndex].URL) + return r.repo.StrmAccount.Update(ctx, acct) +} + +func (r *EmbyRemoteService) adoptWorkingLine(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig, lineIndex int) { + if cfg == nil || lineIndex < 0 || lineIndex >= len(cfg.Lines) { + return + } + if lineIndex == cfg.ActiveLine { + return + } + _ = r.persistActiveLine(ctx, acct, cfg, lineIndex) +} + +// LinesOf 返回账号已配置的线路(供管理界面回显,不含敏感信息)。 +func (r *EmbyRemoteService) LinesOf(acct *model.StrmAccount) ([]EmbyRemoteLine, int, error) { + raw := map[string]string{} + if acct != nil && strings.TrimSpace(acct.Config) != "" { + if err := json.Unmarshal([]byte(acct.Config), &raw); err != nil { + return nil, 0, err + } + } + return ParseEmbyRemoteLines(raw) +} diff --git a/internal/service/emby_remote_lines_test.go b/internal/service/emby_remote_lines_test.go new file mode 100644 index 0000000..87a1a49 --- /dev/null +++ b/internal/service/emby_remote_lines_test.go @@ -0,0 +1,66 @@ +package service + +import ( + "testing" +) + +func TestParseEmbyRemoteLinesLegacyURL(t *testing.T) { + lines, active, err := ParseEmbyRemoteLines(map[string]string{ + "url": "http://192.168.1.10:8096/", + }) + if err != nil { + t.Fatalf("ParseEmbyRemoteLines() error = %v", err) + } + if active != 0 || len(lines) != 1 || lines[0].URL != "http://192.168.1.10:8096" { + t.Fatalf("unexpected lines: %#v active=%d", lines, active) + } +} + +func TestParseEmbyRemoteLinesMulti(t *testing.T) { + lines, active, err := ParseEmbyRemoteLines(map[string]string{ + "urls": `[{"name":"内网","url":"http://10.0.0.8:8096"},{"name":"外网","url":"https://emby.example.com"}]`, + "active_line": "1", + }) + if err != nil { + t.Fatalf("ParseEmbyRemoteLines() error = %v", err) + } + if active != 1 || len(lines) != 2 { + t.Fatalf("unexpected lines: %#v active=%d", lines, active) + } + if lines[1].Name != "外网" || lines[1].URL != "https://emby.example.com" { + t.Fatalf("unexpected second line: %#v", lines[1]) + } +} + +func TestEncodeEmbyRemoteLines(t *testing.T) { + jsonText, primary, err := EncodeEmbyRemoteLines([]EmbyRemoteLine{ + {Name: "内网", URL: "http://10.0.0.8:8096/"}, + {Name: "外网", URL: "https://emby.example.com"}, + }) + if err != nil { + t.Fatalf("EncodeEmbyRemoteLines() error = %v", err) + } + if primary != "http://10.0.0.8:8096" { + t.Fatalf("primary = %q", primary) + } + lines, active, err := ParseEmbyRemoteLines(map[string]string{"urls": jsonText}) + if err != nil || active != 0 || len(lines) != 2 { + t.Fatalf("roundtrip failed: %#v active=%d err=%v", lines, active, err) + } +} + +func TestEmbyRemoteServiceLineOrder(t *testing.T) { + svc := &EmbyRemoteService{} + cfg := &EmbyRemoteConfig{ + Lines: []EmbyRemoteLine{ + {URL: "http://a"}, + {URL: "http://b"}, + {URL: "http://c"}, + }, + ActiveLine: 1, + } + order := svc.lineOrder(cfg) + if len(order) != 3 || order[0] != 1 || order[1] != 0 || order[2] != 2 { + t.Fatalf("unexpected order: %#v", order) + } +} diff --git a/internal/service/emby_remote_playurl_test.go b/internal/service/emby_remote_playurl_test.go index 5b377e1..681659e 100644 --- a/internal/service/emby_remote_playurl_test.go +++ b/internal/service/emby_remote_playurl_test.go @@ -2,6 +2,8 @@ package service import ( "testing" + + "github.com/truewhile/MeBox/internal/model" ) // rewriteSubtitleDeliveryURLs 只应改动字幕轨道的 DeliveryUrl,其余媒体流不动。 @@ -51,4 +53,47 @@ func TestRewriteSubtitleDeliveryURLsFallsBackIndexOne(t *testing.T) { if got := streams[0].(map[string]any)["DeliveryUrl"]; got != want { t.Fatalf("subtitle DeliveryUrl = %v, want %v", got, want) } -} \ No newline at end of file +} + +func TestMapRemoteItemToMediaExtractsCodecsAndContainer(t *testing.T) { + r := &EmbyRemoteService{} + item := map[string]any{ + "Id": "item-100", + "Name": "Test Movie", + "Container": "mkv", + "MediaStreams": []any{ + map[string]any{ + "Type": "Video", + "Codec": "h264", + "Width": 1920, + "Height": 1080, + }, + map[string]any{ + "Type": "Audio", + "Codec": "aac", + }, + }, + "MediaSources": []any{ + map[string]any{ + "Container": "mkv", + "Size": int64(104857600), + }, + }, + } + media := r.MapRemoteItemToMedia(t.Context(), nil, &model.StrmAccount{Base: model.Base{ID: "acct-1"}}, &EmbyRemoteConfig{}, item) + if media.Container != "mkv" { + t.Fatalf("media.Container = %v, want mkv", media.Container) + } + if media.VideoCodec != "h264" { + t.Fatalf("media.VideoCodec = %v, want h264", media.VideoCodec) + } + if media.AudioCodec != "aac" { + t.Fatalf("media.AudioCodec = %v, want aac", media.AudioCodec) + } + if media.Width != 1920 || media.Height != 1080 { + t.Fatalf("resolution = %dx%d, want 1920x1080", media.Width, media.Height) + } + if media.SizeBytes != 104857600 { + t.Fatalf("size = %d, want 104857600", media.SizeBytes) + } +} diff --git a/internal/service/emby_remote_web.go b/internal/service/emby_remote_web.go index 301f96d..f1da252 100644 --- a/internal/service/emby_remote_web.go +++ b/internal/service/emby_remote_web.go @@ -13,10 +13,11 @@ import ( "sort" "strconv" "strings" + "time" "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // RemoteLibraryView 是一个网页端可见的远程媒体库(对应一个挂载的远程 View)。 @@ -36,23 +37,28 @@ func (r *EmbyRemoteService) RemoteLibraries(ctx context.Context) ([]RemoteLibrar if err != nil || len(mounts) == 0 { return nil, err } - // 按账号分组,每账号拉一次 Views 做匹配。 - byAccount := map[string][]*model.EmbyMount{} + type accountData struct { + acct *model.StrmAccount + cfg *EmbyRemoteConfig + viewByName map[string]map[string]any + } + acctData := map[string]*accountData{} for i := range mounts { - m := mounts[i] + m := &mounts[i] if !m.Enabled { continue } - byAccount[m.AccountID] = append(byAccount[m.AccountID], &mounts[i]) - } - out := make([]RemoteLibraryView, 0, len(mounts)) - for accountID, accountMounts := range byAccount { - acct := r.AccountByID(ctx, accountID) + if _, ok := acctData[m.AccountID]; ok { + continue + } + acct := r.AccountByID(ctx, m.AccountID) if acct == nil { + acctData[m.AccountID] = nil continue } cfg, cfgErr := r.configOf(acct) if cfgErr != nil { + acctData[m.AccountID] = nil continue } views, viewErr := r.RemoteViews(ctx, acct) @@ -61,31 +67,47 @@ func (r *EmbyRemoteService) RemoteLibraries(ctx context.Context) ([]RemoteLibrar r.log.Warn("web remote emby views failed", zap.String("account", acct.Name), zap.Error(viewErr)) } + acctData[m.AccountID] = nil continue } viewByName := map[string]map[string]any{} for _, v := range views { viewByName[remoteItemString(v, "Id")] = v } - for _, mount := range accountMounts { - v, ok := viewByName[mount.RemoteViewID] - if !ok { - continue // 远程已删除该媒体库 - } - lib := r.mapRemoteMountToLibrary(mount, acct, cfg, v) - if lib == nil { - continue - } - out = append(out, RemoteLibraryView{ - Library: *lib, - MountID: mount.ID, - AccountID: acct.ID, - RemoteID: mount.RemoteViewID, - CollectionType: mount.CollectionType, - AccountName: acct.Name, - }) + acctData[m.AccountID] = &accountData{ + acct: acct, + cfg: cfg, + viewByName: viewByName, } } + + out := make([]RemoteLibraryView, 0, len(mounts)) + for i := range mounts { + m := &mounts[i] + if !m.Enabled { + continue + } + data := acctData[m.AccountID] + if data == nil || data.viewByName == nil { + continue + } + v, ok := data.viewByName[m.RemoteViewID] + if !ok { + continue // 远程已删除该媒体库 + } + lib := r.mapRemoteMountToLibrary(m, data.acct, data.cfg, v) + if lib == nil { + continue + } + out = append(out, RemoteLibraryView{ + Library: *lib, + MountID: m.ID, + AccountID: data.acct.ID, + RemoteID: m.RemoteViewID, + CollectionType: m.CollectionType, + AccountName: data.acct.Name, + }) + } return out, nil } @@ -130,7 +152,7 @@ func (r *EmbyRemoteService) mapRemoteMountToLibrary(mount *model.EmbyMount, acct Name: name, Type: libType, Enabled: true, - SortOrder: 1000, // 远程库排在本地库之后 + SortOrder: 1000 + mount.SortOrder, // 远程库排在本地库之后,且保持挂载库排序 } // 远程媒体库封面只有真实存在图片标签才下发。 if remoteItemHasImageTag(item, "Primary") { @@ -156,17 +178,25 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod if _, rid, ok := DecodeEmbyRemoteID(seriesID); ok { seriesID = rid } + rating := remoteItemFloat(item, "CommunityRating") + if rating == 0 { + rating = remoteItemFloat(item, "CriticRating") + } media := model.Media{ Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)}, Title: remoteItemString(item, "Name"), OriginalName: remoteItemString(item, "OriginalTitle"), Overview: remoteItemString(item, "Overview"), Year: remoteItemInt(item, "ProductionYear"), - Rating: float32(remoteItemFloat(item, "CommunityRating")), + Rating: float32(rating), Path: remoteItemString(item, "Path"), Genres: remoteItemGenres(item), ScrapeStatus: "done", } + if date, ok := parseEmbyRemoteDate(remoteItemString(item, "DateCreated")); ok { + media.CreatedAt = date + media.UpdatedAt = date + } // 只有远程明确存在图片标签才下发图片 URL。 if remoteItemHasImageTag(item, "Primary") { media.PosterURL = r.remoteItemImageURL(cfg, remoteID, "Primary") @@ -177,8 +207,16 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod if ticks := remoteItemInt64(item, "RunTimeTicks"); ticks > 0 { media.DurationSec = int(ticks / 10_000_000) } - if date, ok := embyPremiereDate(remoteItemString(item, "PremiereDate")); ok { + if date, ok := parseEmbyRemoteDate(remoteItemString(item, "PremiereDate")); ok { media.ReleaseDate = date.Format("2006-01-02") + if media.Year == 0 { + media.Year = date.Year() + } + } else if date, ok := embyPremiereDate(remoteItemString(item, "PremiereDate")); ok { + media.ReleaseDate = date.Format("2006-01-02") + if media.Year == 0 { + media.Year = date.Year() + } } if providerIDs, ok := item["ProviderIds"].(map[string]any); ok { if v := anyString(providerIDs["Tmdb"]); v != "" { @@ -191,6 +229,59 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod media.DoubanID = v } } + media.Container = remoteItemString(item, "Container") + media.Width = remoteItemInt(item, "Width") + media.Height = remoteItemInt(item, "Height") + + extractStreamInfo := func(streams []any) { + for _, s := range streams { + sm, ok := s.(map[string]any) + if !ok { + continue + } + typ := remoteItemString(sm, "Type") + if strings.EqualFold(typ, "Video") { + if media.VideoCodec == "" { + media.VideoCodec = remoteItemString(sm, "Codec") + } + if media.Width == 0 { + media.Width = remoteItemInt(sm, "Width") + } + if media.Height == 0 { + media.Height = remoteItemInt(sm, "Height") + } + } else if strings.EqualFold(typ, "Audio") { + if media.AudioCodec == "" { + media.AudioCodec = remoteItemString(sm, "Codec") + } + } + } + } + + if streams, ok := item["MediaStreams"].([]any); ok { + extractStreamInfo(streams) + } else if streams, ok := item["MediaStreams"].([]map[string]any); ok { + anyStreams := make([]any, len(streams)) + for i, v := range streams { + anyStreams[i] = v + } + extractStreamInfo(anyStreams) + } + + if sources, ok := item["MediaSources"].([]any); ok && len(sources) > 0 { + if sourceMap, ok := sources[0].(map[string]any); ok { + if media.Container == "" { + media.Container = remoteItemString(sourceMap, "Container") + } + if media.SizeBytes == 0 { + media.SizeBytes = remoteItemInt64(sourceMap, "Size") + } + if streams, ok := sourceMap["MediaStreams"].([]any); ok && (media.VideoCodec == "" || media.AudioCodec == "") { + extractStreamInfo(streams) + } + } + } + switch remoteItemString(item, "Type") { case "Episode": media.SeasonNum = remoteItemInt(item, "ParentIndexNumber") @@ -209,6 +300,11 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod media.SeasonNum = 0 media.EpisodeNum = 0 } + if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" { + libID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID) + media.DisplayLibraryID = libID + media.LibraryID = libID + } return media } @@ -221,13 +317,21 @@ func (r *EmbyRemoteService) RemoteLibraryMedia(ctx context.Context, mount *model if itemTypes == "" { itemTypes = "Movie,Series" // 未知类型时两者都取(前端自行按 episode-like 分组) } + cacheKey := r.remoteCacheKey("library-media", acct.ID, mount.ID, remoteViewID, itemTypes, strconv.Itoa(offset), strconv.Itoa(limit)) + var cached struct { + Items []model.Media `json:"items"` + TotalRecordCount int64 `json:"total_record_count"` + } + if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) { + return cached.Items, cached.TotalRecordCount, nil + } q := url.Values{} q.Set("ParentId", remoteViewID) q.Set("IncludeItemTypes", itemTypes) q.Set("Recursive", "false") q.Set("StartIndex", strconv.Itoa(offset)) q.Set("Limit", strconv.Itoa(limit)) - q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage") + q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating") var body struct { Items []map[string]any `json:"Items"` TotalRecordCount int64 `json:"TotalRecordCount"` @@ -240,6 +344,12 @@ func (r *EmbyRemoteService) RemoteLibraryMedia(ctx context.Context, mount *model RewriteEmbyRemoteIDs(it, mount.ID) // 嵌套/关联 ID 一并伪装 items = append(items, r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)) } + if r.cache != nil { + r.cache.SetJSON(ctx, cacheKey, struct { + Items []model.Media `json:"items"` + TotalRecordCount int64 `json:"total_record_count"` + }{Items: items, TotalRecordCount: body.TotalRecordCount}, r.remoteMediaCacheTTL()) + } return items, body.TotalRecordCount, nil } @@ -250,7 +360,7 @@ func (r *EmbyRemoteService) RemoteMediaDetail(ctx context.Context, mount *model. return nil, err } path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/" + url.PathEscape(remoteID) - path += "?Fields=Overview,Genres,ProviderIds,People,Studios,Path" + path += "?Fields=Overview,Genres,ProviderIds,People,Studios,Path,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating" var out map[string]any if err := r.doGet(ctx, acct, cfg, path, nil, &out); err != nil { return nil, err @@ -311,7 +421,7 @@ func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, mount *model.E q.Set("Recursive", "true") q.Set("StartIndex", "0") q.Set("Limit", "500") - q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage") + q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating") var body struct { Items []map[string]any `json:"Items"` TotalRecordCount int64 `json:"TotalRecordCount"` @@ -334,13 +444,18 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model. if err != nil { return nil, err } + cacheKey := r.remoteCacheKey("series-cards", acct.ID, mount.ID, remoteViewID) + var cached []SeriesCard + if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) { + return cached, nil + } q := url.Values{} q.Set("ParentId", remoteViewID) q.Set("IncludeItemTypes", "Series") q.Set("Recursive", "false") q.Set("StartIndex", "0") q.Set("Limit", "1000") - q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage") + q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating") var body struct { Items []map[string]any `json:"Items"` } @@ -361,6 +476,9 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model. } cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count}) } + if r.cache != nil { + r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL()) + } return cards, nil } @@ -370,6 +488,11 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model. if err != nil { return nil, err } + cacheKey := r.remoteCacheKey("latest-cards", acct.ID, mount.ID, remoteViewID, strconv.Itoa(limit)) + var cached []SeriesCard + if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) { + return cached, nil + } items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit) if err != nil { return nil, err @@ -379,6 +502,9 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model. m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it) cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: 0}) } + if r.cache != nil { + r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL()) + } return cards, nil } @@ -449,6 +575,8 @@ func remoteItemInt(item map[string]any, key string) int { return int(v) case int: return v + case int64: + return int(v) case string: n, _ := strconv.Atoi(v) return n @@ -463,6 +591,8 @@ func remoteItemInt64(item map[string]any, key string) int64 { switch v := item[key].(type) { case float64: return int64(v) + case int64: + return v case int: return int64(v) case string: @@ -572,6 +702,7 @@ func remoteItemTypeOf(m *model.Media) string { } return "Movie" } + // ─── 供 handler 层使用的远程 View 条目取值(导出薄封装) ────────────────────── // RemoteItemIDString 提取远程 View 条目的 Id。 @@ -581,7 +712,29 @@ func RemoteItemIDString(item map[string]any) string { return remoteItemString(it func RemoteItemNameString(item map[string]any) string { return remoteItemString(item, "Name") } // RemoteItemCollectionType 提取远程 View 条目的 CollectionType。 -func RemoteItemCollectionType(item map[string]any) string { return remoteItemString(item, "CollectionType") } +func RemoteItemCollectionType(item map[string]any) string { + return remoteItemString(item, "CollectionType") +} // RemoteItemChildCount 提取远程 View 条目的 ChildCount。 func RemoteItemChildCount(item map[string]any) int { return remoteItemInt(item, "ChildCount") } + +func parseEmbyRemoteDate(s string) (time.Time, bool) { + s = strings.TrimSpace(s) + if s == "" { + return time.Time{}, false + } + for _, layout := range []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02T15:04:05.9999999Z", + "2006-01-02T15:04:05.9999999", + "2006-01-02T15:04:05", + "2006-01-02", + } { + if t, err := time.Parse(layout, s); err == nil { + return t, true + } + } + return time.Time{}, false +} diff --git a/internal/service/emby_remote_web_test.go b/internal/service/emby_remote_web_test.go new file mode 100644 index 0000000..51579ec --- /dev/null +++ b/internal/service/emby_remote_web_test.go @@ -0,0 +1,67 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/truewhile/MeBox/internal/model" +) + +func TestMapRemoteItemToMediaSortingFields(t *testing.T) { + svc := &EmbyRemoteService{} + mount := &model.EmbyMount{Base: model.Base{ID: "mount-1"}} + acct := &model.StrmAccount{Base: model.Base{ID: "acct-1"}} + cfg := &EmbyRemoteConfig{BaseURL: "http://localhost:8096"} + + item := map[string]any{ + "Id": "item-1", + "Name": "测试电影", + "OriginalTitle": "Test Movie", + "ProductionYear": 2023, + "CommunityRating": 8.5, + "PremiereDate": "2023-05-12T00:00:00.0000000Z", + "DateCreated": "2024-01-15T08:30:00.0000000Z", + } + + media := svc.MapRemoteItemToMedia(context.Background(), mount, acct, cfg, item) + + if media.ReleaseDate != "2023-05-12" { + t.Fatalf("ReleaseDate = %q, want %q", media.ReleaseDate, "2023-05-12") + } + if media.Year != 2023 { + t.Fatalf("Year = %d, want 2023", media.Year) + } + if media.Rating != 8.5 { + t.Fatalf("Rating = %f, want 8.5", media.Rating) + } + expectedCreated, _ := time.Parse(time.RFC3339, "2024-01-15T08:30:00Z") + if !media.CreatedAt.Equal(expectedCreated) { + t.Fatalf("CreatedAt = %v, want %v", media.CreatedAt, expectedCreated) + } + if !media.UpdatedAt.Equal(expectedCreated) { + t.Fatalf("UpdatedAt = %v, want %v", media.UpdatedAt, expectedCreated) + } +} + +func TestMapRemoteItemToMediaCriticRatingFallback(t *testing.T) { + svc := &EmbyRemoteService{} + mount := &model.EmbyMount{Base: model.Base{ID: "mount-1"}} + acct := &model.StrmAccount{Base: model.Base{ID: "acct-1"}} + cfg := &EmbyRemoteConfig{BaseURL: "http://localhost:8096"} + + item := map[string]any{ + "Id": "item-2", + "Name": "评分测试", + "CriticRating": 9.2, + "PremiereDate": "2022-10-01", + } + + media := svc.MapRemoteItemToMedia(context.Background(), mount, acct, cfg, item) + if media.Rating != 9.2 { + t.Fatalf("Rating = %f, want 9.2 from CriticRating", media.Rating) + } + if media.Year != 2022 { + t.Fatalf("Year = %d, want 2022 from PremiereDate", media.Year) + } +} diff --git a/internal/service/emby_series.go b/internal/service/emby_series.go index e680126..6b67428 100644 --- a/internal/service/emby_series.go +++ b/internal/service/emby_series.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) type embySeriesGroup struct { diff --git a/internal/service/emby_series_hierarchy_test.go b/internal/service/emby_series_hierarchy_test.go index 4ff9a68..5775210 100644 --- a/internal/service/emby_series_hierarchy_test.go +++ b/internal/service/emby_series_hierarchy_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func TestEmbyItemsExposeSeriesSeasonEpisodeHierarchy(t *testing.T) { diff --git a/internal/service/emby_series_ids.go b/internal/service/emby_series_ids.go index 4c59e95..3c9a8c6 100644 --- a/internal/service/emby_series_ids.go +++ b/internal/service/emby_series_ids.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func (e *EmbyService) seriesIDForMedia(m *model.Media) string { diff --git a/internal/service/emby_subtitle_test.go b/internal/service/emby_subtitle_test.go index b5b23e1..d8b4175 100644 --- a/internal/service/emby_subtitle_test.go +++ b/internal/service/emby_subtitle_test.go @@ -9,8 +9,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" ) // writeTempVideoWithSubtitle creates a temp directory with a fake video and a diff --git a/internal/service/emby_system.go b/internal/service/emby_system.go index a3f92b4..7f72328 100644 --- a/internal/service/emby_system.go +++ b/internal/service/emby_system.go @@ -5,7 +5,7 @@ import ( "strings" "time" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // SystemInfo returns the full Emby identity payload. @@ -13,7 +13,7 @@ func (e *EmbyService) SystemInfo() map[string]any { return map[string]any{ "Id": embyServerID, "ServerId": embyServerID, - "ServerName": "MMTL", + "ServerName": "MeBox", "Version": embyCompatVersion, "ServerVersion": embyCompatVersion, "ProductName": "Emby Server", @@ -42,7 +42,7 @@ func (e *EmbyService) SystemInfoPublic() map[string]any { return map[string]any{ "Id": embyServerID, "ServerId": embyServerID, - "ServerName": "MMTL", + "ServerName": "MeBox", "Version": embyCompatVersion, "ServerVersion": embyCompatVersion, "ProductName": "Emby Server", @@ -85,7 +85,7 @@ func (e *EmbyService) userPayload(u *model.User) map[string]any { "Id": u.ID, "Name": u.Username, "ServerId": embyServerID, - "ServerName": "MMTL", + "ServerName": "MeBox", "HasPassword": true, "HasConfiguredPassword": true, "HasConfiguredEasyPassword": false, diff --git a/internal/service/emby_user_data.go b/internal/service/emby_user_data.go index c656f85..395f3d1 100644 --- a/internal/service/emby_user_data.go +++ b/internal/service/emby_user_data.go @@ -9,7 +9,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // SetFavorite 把 mediaID 标为 userID 的收藏。远程 Emby 条目直接透传到对应 @@ -49,9 +49,13 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl return nil } if !played { - return e.repo.DB.WithContext(ctx). + err := e.repo.DB.WithContext(ctx). Where("user_id = ? AND media_id = ?", userID, mediaID). Delete(&model.PlaybackHistory{}).Error + if err == nil { + e.invalidateEmbyItemsCache(ctx) + } + return err } m, err := e.repo.Media.FindByID(ctx, mediaID) if err != nil || m == nil { @@ -61,7 +65,7 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl if dur <= 0 { dur = 1 } - return e.repo.History.Upsert(ctx, &model.PlaybackHistory{ + err = e.repo.History.Upsert(ctx, &model.PlaybackHistory{ UserID: userID, MediaID: mediaID, PositionMs: dur, @@ -69,6 +73,10 @@ func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, pl WatchedAt: time.Now(), Completed: true, }) + if err == nil { + e.invalidateEmbyItemsCache(ctx) + } + return err } // RecordProgress 记录播放进度(来自 Emby 客户端的 /Sessions/Playing/Progress)。 @@ -79,10 +87,27 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string // runtimeTicks 缺失时回退到 media.DurationSec if m, _ := e.repo.Media.FindByID(ctx, mediaID); m != nil { dur = int64(m.DurationSec) * 1000 + } else if IsEmbyRemoteID(mediaID) { + // 远程挂载条目:尝试从既有历史记录或远程详情补齐时长 + var oldHist model.PlaybackHistory + if err := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id = ?", userID, mediaID).First(&oldHist).Error; err == nil && oldHist.DurationMs > 0 { + dur = oldHist.DurationMs + } else if e.remote != nil { + mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID) + if mount, acct, _ := e.remote.ResolveMount(ctx, mountID); mount != nil && acct != nil { + if item, _ := e.remote.RemoteItem(ctx, mount, acct, remoteID); item != nil { + if ticks, ok := item["RunTimeTicks"].(float64); ok && ticks > 0 { + dur = int64(ticks) / 10_000 + } else if ticks, ok := item["RunTimeTicks"].(int64); ok && ticks > 0 { + dur = ticks / 10_000 + } + } + } + } } } completed := dur > 0 && pos >= dur*9/10 - return e.repo.History.Upsert(ctx, &model.PlaybackHistory{ + err := e.repo.History.Upsert(ctx, &model.PlaybackHistory{ UserID: userID, MediaID: mediaID, PositionMs: pos, @@ -90,6 +115,115 @@ func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string WatchedAt: time.Now(), Completed: completed, }) + if err == nil { + e.invalidateEmbyItemsCache(ctx) + } + return err +} + +// mergeRemoteUserData applies the current MeBox user's locally recorded playback +// state to remote Emby payloads. Remote metadata remains authoritative unless the +// user has played the item through MeBox. +func (e *EmbyService) mergeRemoteUserData(ctx context.Context, userID string, payload any) error { + if strings.TrimSpace(userID) == "" || payload == nil { + return nil + } + items := remoteItemMaps(payload) + ids := make([]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + id, _ := item["Id"].(string) + if !IsEmbyRemoteID(id) { + continue + } + if _, ok := seen[id]; !ok { + ids = append(ids, id) + seen[id] = struct{}{} + } + } + if len(ids) == 0 { + return nil + } + var histories []model.PlaybackHistory + if err := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id IN ?", userID, ids).Find(&histories).Error; err != nil { + return err + } + byMediaID := make(map[string]*model.PlaybackHistory, len(histories)) + for i := range histories { + byMediaID[histories[i].MediaID] = &histories[i] + } + for _, item := range items { + id, _ := item["Id"].(string) + if h := byMediaID[id]; h != nil { + item["UserData"] = mergedRemoteUserData(item["UserData"], h) + } + } + return nil +} + +func remoteItemMaps(payload any) []map[string]any { + items := make([]map[string]any, 0) + var visit func(any) + visit = func(value any) { + switch typed := value.(type) { + case map[string]any: + if _, ok := typed["Id"].(string); ok { + items = append(items, typed) + } + if nested, ok := typed["Items"]; ok { + visit(nested) + } + case []any: + for _, value := range typed { + visit(value) + } + case []map[string]any: + for _, value := range typed { + visit(value) + } + } + } + visit(payload) + return items +} + +func mergedRemoteUserData(raw any, history *model.PlaybackHistory) map[string]any { + userData := map[string]any{} + if existing, ok := raw.(map[string]any); ok { + for key, value := range existing { + userData[key] = value + } + } + duration := history.DurationMs + position := history.PositionMs + percentage := float64(0) + if duration > 0 { + percentage = float64(position) / float64(duration) * 100 + } + userData["PlaybackPositionTicks"] = position * 10_000 + userData["Played"] = history.Completed + userData["PlayedPercentage"] = percentage + if history.Completed { + playCount := 0 + switch value := userData["PlayCount"].(type) { + case int: + playCount = value + case int64: + playCount = int(value) + case float64: + playCount = int(value) + } + if playCount < 1 { + userData["PlayCount"] = 1 + } + } + return userData +} + +func (e *EmbyService) invalidateEmbyItemsCache(ctx context.Context) { + if e.cache != nil { + e.cache.DeletePrefix(ctx, "media:emby:") + } } func splitCSV(s string) []string { diff --git a/internal/service/emby_user_data_test.go b/internal/service/emby_user_data_test.go new file mode 100644 index 0000000..372026b --- /dev/null +++ b/internal/service/emby_user_data_test.go @@ -0,0 +1,110 @@ +package service + +import ( + "testing" + + "github.com/truewhile/MeBox/internal/model" +) + +func TestMergedRemoteUserData(t *testing.T) { + tests := []struct { + name string + raw any + history model.PlaybackHistory + position int64 + played bool + percent float64 + count int + preserve any + }{ + { + name: "in-progress preserves remote fields", + raw: map[string]any{ + "PlayCount": 2, + "Custom": "remote-value", + }, + history: model.PlaybackHistory{PositionMs: 25_000, DurationMs: 100_000}, + position: 250_000_000, + played: false, + percent: 25, + count: 2, + preserve: "remote-value", + }, + { + name: "completed ensures a play count", + raw: map[string]any{"PlayCount": 0}, + history: model.PlaybackHistory{PositionMs: 100_000, DurationMs: 100_000, Completed: true}, + position: 1_000_000_000, + played: true, + percent: 100, + count: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := mergedRemoteUserData(tt.raw, &tt.history) + if got := out["PlaybackPositionTicks"]; got != tt.position { + t.Fatalf("PlaybackPositionTicks = %#v, want %d", got, tt.position) + } + if got := out["Played"]; got != tt.played { + t.Fatalf("Played = %#v, want %t", got, tt.played) + } + if got := out["PlayedPercentage"]; got != tt.percent { + t.Fatalf("PlayedPercentage = %#v, want %v", got, tt.percent) + } + if got := out["PlayCount"]; got != tt.count { + t.Fatalf("PlayCount = %#v, want %d", got, tt.count) + } + if tt.preserve != nil && out["Custom"] != tt.preserve { + t.Fatalf("Custom = %#v, want %#v", out["Custom"], tt.preserve) + } + }) + } +} + +func TestRemoteItemMapsFindsEnvelopeItems(t *testing.T) { + remoteID := EncodeEmbyRemoteID("mount-1", "item-1") + payload := map[string]any{ + "Items": []any{ + map[string]any{"Id": remoteID}, + map[string]any{"Id": "local-item"}, + }, + } + items := remoteItemMaps(payload) + if len(items) != 2 { + t.Fatalf("item count = %d, want 2", len(items)) + } + if items[0]["Id"] != remoteID { + t.Fatalf("first item ID = %#v, want %q", items[0]["Id"], remoteID) + } +} + +func TestRecordProgressFallbacksToExistingHistoryDuration(t *testing.T) { + svc := newTestEmbyService(t) + remoteID := EncodeEmbyRemoteID("mount-test", "item-999") + user := &model.User{Username: "resume_test_user", Role: "user", Tier: "free", IsActive: true} + if err := svc.repo.User.Create(t.Context(), user); err != nil { + t.Fatalf("create user: %v", err) + } + + // 先以有 runtimeTicks 写入首次进度 + if err := svc.RecordProgress(t.Context(), user.ID, remoteID, 10_000_000, 100_000_000); err != nil { + t.Fatalf("first record progress: %v", err) + } + // 再次上报,但某些客户端此时发了 0 runtimeTicks + if err := svc.RecordProgress(t.Context(), user.ID, remoteID, 95_000_000, 0); err != nil { + t.Fatalf("second record progress: %v", err) + } + + var hist model.PlaybackHistory + if err := svc.repo.DB.Where("user_id = ? AND media_id = ?", user.ID, remoteID).First(&hist).Error; err != nil { + t.Fatalf("find hist: %v", err) + } + if hist.DurationMs != 10_000 { + t.Fatalf("expected duration 10000ms, got %d", hist.DurationMs) + } + if !hist.Completed { + t.Fatalf("expected 95%% progress to be completed") + } +} diff --git a/internal/service/emby_versions_test.go b/internal/service/emby_versions_test.go index 9c6aec2..52c2a61 100644 --- a/internal/service/emby_versions_test.go +++ b/internal/service/emby_versions_test.go @@ -4,7 +4,7 @@ import ( "testing" "time" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func TestEmbyLatestItemsCollapsesMovieVersions(t *testing.T) { diff --git a/internal/service/emby_visibility.go b/internal/service/emby_visibility.go index 1769cd2..30c93b2 100644 --- a/internal/service/emby_visibility.go +++ b/internal/service/emby_visibility.go @@ -7,7 +7,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func (e *EmbyService) applyUserMediaVisibility(ctx context.Context, q *gorm.DB, userID string) *gorm.DB { diff --git a/internal/service/episode_metadata_cleanup.go b/internal/service/episode_metadata_cleanup.go index e5d82b5..bec7a0b 100644 --- a/internal/service/episode_metadata_cleanup.go +++ b/internal/service/episode_metadata_cleanup.go @@ -11,7 +11,7 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // pollutedEpisodeCleanupSettingKey marks that the one-time normalization has run. diff --git a/internal/service/episode_metadata_cleanup_test.go b/internal/service/episode_metadata_cleanup_test.go index 8320b1b..6a41d3b 100644 --- a/internal/service/episode_metadata_cleanup_test.go +++ b/internal/service/episode_metadata_cleanup_test.go @@ -6,8 +6,8 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func newCleanupTestContainer(t *testing.T) (*Container, *gorm.DB) { diff --git a/internal/service/fanart.go b/internal/service/fanart.go index 02fd77e..c5cc3e2 100644 --- a/internal/service/fanart.go +++ b/internal/service/fanart.go @@ -21,7 +21,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) // FanartProvider talks to https://webservice.fanart.tv. @@ -133,7 +133,7 @@ func (f *FanartProvider) getJSON(ctx context.Context, u string, out any) error { if err != nil { return err } - req.Header.Set("User-Agent", "MMTL/0.1") + req.Header.Set("User-Agent", "MeBox/0.1") resp, err := f.client.Do(req) if err != nil { return err diff --git a/internal/service/ffmpeg_auto_install.go b/internal/service/ffmpeg_auto_install.go index 1d0ca6c..bd14f77 100644 --- a/internal/service/ffmpeg_auto_install.go +++ b/internal/service/ffmpeg_auto_install.go @@ -16,7 +16,7 @@ import ( "github.com/ulikunitz/xz" "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) // ffmpegDownloadTarget 描述某个平台对应的官方构建下载源。 @@ -121,7 +121,7 @@ func installFFmpegTools(ctx context.Context, log *zap.Logger, cfg *config.Config return "", "", fmt.Errorf("创建安装目录失败: %w", err) } - tempDir, err := os.MkdirTemp("", "mmtl-ffmpeg-*") + tempDir, err := os.MkdirTemp("", "mebox-ffmpeg-*") if err != nil { return "", "", fmt.Errorf("创建临时目录失败: %w", err) } @@ -202,7 +202,7 @@ func downloadFFmpegFile(ctx context.Context, log *zap.Logger, url, dest string) if err != nil { return err } - req.Header.Set("User-Agent", "MMTL/ffmpeg-installer ("+runtime.GOOS+"/"+runtime.GOARCH+")") + req.Header.Set("User-Agent", "MeBox/ffmpeg-installer ("+runtime.GOOS+"/"+runtime.GOARCH+")") resp, err := http.DefaultClient.Do(req) if err != nil { return err diff --git a/internal/service/ffmpeg_install_archive.go b/internal/service/ffmpeg_install_archive.go index 1bf43fa..c87703b 100644 --- a/internal/service/ffmpeg_install_archive.go +++ b/internal/service/ffmpeg_install_archive.go @@ -111,4 +111,3 @@ func safeZipTarget(destRoot, name string) (string, error) { } return targetAbs, nil } - diff --git a/internal/service/ffmpeg_tools.go b/internal/service/ffmpeg_tools.go index d8e7106..056bde0 100644 --- a/internal/service/ffmpeg_tools.go +++ b/internal/service/ffmpeg_tools.go @@ -20,8 +20,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/repository" ) // FFmpegToolsService 管理 ffmpeg/ffprobe 的自动下载安装状态(单飞:同一时间 diff --git a/internal/service/ffmpeg_tools_test.go b/internal/service/ffmpeg_tools_test.go index 5e6488a..77db7e3 100644 --- a/internal/service/ffmpeg_tools_test.go +++ b/internal/service/ffmpeg_tools_test.go @@ -8,7 +8,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) func TestFFmpegTargetForPlatform(t *testing.T) { @@ -78,4 +78,3 @@ func TestStartInstallRejectsConcurrent(t *testing.T) { svc.running = false svc.mu.Unlock() } - diff --git a/internal/service/ffprobe.go b/internal/service/ffprobe.go index f293754..b332064 100644 --- a/internal/service/ffprobe.go +++ b/internal/service/ffprobe.go @@ -18,7 +18,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) // FFprobeService wraps the external ffprobe binary. diff --git a/internal/service/ffprobe_test.go b/internal/service/ffprobe_test.go index d689af8..3bffd65 100644 --- a/internal/service/ffprobe_test.go +++ b/internal/service/ffprobe_test.go @@ -7,7 +7,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) func TestFFprobeServiceDefaultsToSingleConcurrentProbe(t *testing.T) { diff --git a/internal/service/filemanager.go b/internal/service/filemanager.go index f5e2059..dd2e17e 100644 --- a/internal/service/filemanager.go +++ b/internal/service/filemanager.go @@ -16,8 +16,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/repository" ) // FileManagerService browses and mutates the server-side filesystem. diff --git a/internal/service/filemanager_roots.go b/internal/service/filemanager_roots.go index 3a4118a..8573892 100644 --- a/internal/service/filemanager_roots.go +++ b/internal/service/filemanager_roots.go @@ -67,8 +67,8 @@ func (s *FileManagerService) allowedRoots() (map[string]string, error) { add("movies", s.cfg.Media.MoviesDir) add("tv", s.cfg.Media.TVDir) add("anime", s.cfg.Media.AnimeDir) - add("downloads", envOrDefault("MMTL_DOWNLOAD_CONTAINER_DIR", "/downloads")) - add("media", envOrDefault("MMTL_MEDIA_CONTAINER_DIR", "/media")) + add("downloads", envOrDefault("MEBOX_DOWNLOAD_CONTAINER_DIR", "/downloads")) + add("media", envOrDefault("MEBOX_MEDIA_CONTAINER_DIR", "/media")) if s.repo != nil && s.repo.Setting != nil { addSetting := func(label, key string) { if value, err := s.repo.Setting.Get(context.Background(), key); err == nil { diff --git a/internal/service/filemanager_test.go b/internal/service/filemanager_test.go index b2af25e..0963532 100644 --- a/internal/service/filemanager_test.go +++ b/internal/service/filemanager_test.go @@ -8,9 +8,9 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func newFileManagerTestServiceWithRepo(t *testing.T, root string) (*FileManagerService, *repository.Container) { diff --git a/internal/service/https_test.go b/internal/service/https_test.go index 6f443a3..4e56317 100644 --- a/internal/service/https_test.go +++ b/internal/service/https_test.go @@ -50,11 +50,11 @@ func TestResolveSSLMaterial(t *testing.T) { } cases := []struct { - name string + name string content string - path string - want string - err bool + path string + want string + err bool }{ {name: "content only", content: certPEM, want: certPEM}, {name: "path only", path: path, want: certPEM}, diff --git a/internal/service/image_proxy.go b/internal/service/image_proxy.go index 6b68dd7..67dd99d 100644 --- a/internal/service/image_proxy.go +++ b/internal/service/image_proxy.go @@ -2,7 +2,7 @@ // // Some deployments cannot reach image.tmdb.org directly (GFW, internal-only // networks). ImageProxy fronts a remote image URL so the browser only ever -// talks to the MMTL origin. The proxy: +// talks to the MeBox origin. The proxy: // // - validates the URL scheme is http/https, // - streams bytes through with a small disk cache under cache/images, @@ -20,7 +20,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) // ImageProxy fetches and caches remote images on behalf of the browser. @@ -84,3 +84,12 @@ func (p *ImageProxy) libraryRoots() []string { p.libRootsAt = time.Now() return p.libRootsCache } + +// Prune removes oldest cached images until disk usage is within the configured limit. +func (p *ImageProxy) Prune() (PruneImageCacheResult, error) { + if p.cfg == nil || p.cfg.Cache.ImagesMaxSizeMB <= 0 { + return PruneImageCacheResult{}, nil + } + maxBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024 + return PruneImageCache(p.cacheDir, maxBytes) +} diff --git a/internal/service/image_proxy_remote_test.go b/internal/service/image_proxy_remote_test.go index 80939d4..dd0a8e7 100644 --- a/internal/service/image_proxy_remote_test.go +++ b/internal/service/image_proxy_remote_test.go @@ -14,7 +14,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) func TestImageProxyDoesNotNegativeCacheFailedRemoteImageFetch(t *testing.T) { diff --git a/internal/service/image_proxy_test.go b/internal/service/image_proxy_test.go index 0568f49..cb723d7 100644 --- a/internal/service/image_proxy_test.go +++ b/internal/service/image_proxy_test.go @@ -10,7 +10,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) var testJPEG = []byte{ diff --git a/internal/service/local_availability.go b/internal/service/local_availability.go index e446c07..fc31e89 100644 --- a/internal/service/local_availability.go +++ b/internal/service/local_availability.go @@ -6,8 +6,8 @@ import ( "regexp" "strings" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) var availabilityNoiseRE = regexp.MustCompile(`(?i)(自动订阅|订阅|全集|合集|complete|batch|s\d{1,2}e\d{1,3}|season\s*\d+|s\d{1,2}|第\s*\d+\s*季|第\s*\d+\s*[集话話期]|\(\d{4}\)|\b\d{4}\b|2160p|1080p|720p|4k|uhd|bluray|blu-ray|web-?dl|hdtv|remux|x26[45]|h\.?26[45]|hevc|avc|hdr10?\+?|dovi|dv|atmos|aac|ddp?5\.1|truehd|flac)`) diff --git a/internal/service/local_metadata_apply.go b/internal/service/local_metadata_apply.go index 1a640fe..bd17d30 100644 --- a/internal/service/local_metadata_apply.go +++ b/internal/service/local_metadata_apply.go @@ -1,6 +1,6 @@ package service -import "github.com/ShukeBta/MMTL/internal/model" +import "github.com/truewhile/MeBox/internal/model" func applyLocalMetadata(m *model.Media, local *LocalMetadata) { applyLocalIdentityMetadata(m, local) diff --git a/internal/service/local_metadata_apply_test.go b/internal/service/local_metadata_apply_test.go index 7228e71..ff63059 100644 --- a/internal/service/local_metadata_apply_test.go +++ b/internal/service/local_metadata_apply_test.go @@ -3,7 +3,7 @@ package service import ( "testing" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func TestApplyLocalMetadataPreservesPathHintAsPending(t *testing.T) { diff --git a/internal/service/manual_scrape.go b/internal/service/manual_scrape.go index 0c5ddb9..59fc5f3 100644 --- a/internal/service/manual_scrape.go +++ b/internal/service/manual_scrape.go @@ -5,7 +5,7 @@ import ( "errors" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) type ManualScrapeRequest struct { diff --git a/internal/service/manual_scrape_providers.go b/internal/service/manual_scrape_providers.go index e15294c..3b30133 100644 --- a/internal/service/manual_scrape_providers.go +++ b/internal/service/manual_scrape_providers.go @@ -4,7 +4,7 @@ import ( "context" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) type manualTMDbCandidate struct { diff --git a/internal/service/manual_scrape_search.go b/internal/service/manual_scrape_search.go index 794f90e..9f50f68 100644 --- a/internal/service/manual_scrape_search.go +++ b/internal/service/manual_scrape_search.go @@ -5,7 +5,7 @@ import ( "errors" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func (s *ScraperService) ManualSearch(ctx context.Context, media *model.Media, query, provider, mediaType string) ([]ExternalMediaResult, error) { diff --git a/internal/service/manual_scrape_test.go b/internal/service/manual_scrape_test.go index ca3326d..355b8ac 100644 --- a/internal/service/manual_scrape_test.go +++ b/internal/service/manual_scrape_test.go @@ -13,9 +13,9 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestManualRequestMatchFallsBackToCandidatePayload(t *testing.T) { @@ -179,9 +179,9 @@ func TestManualSearchFallsBackToMovieFolderForGenericQuery(t *testing.T) { if len(results) != 1 || results[0].TMDbID != 27205 { t.Fatalf("manual search results=%#v, want folder fallback candidate; queries=%v", results, queries) } - if len(queries) < 2 || queries[0] != "00000" || queries[len(queries)-1] != "inception" { - t.Fatalf("manual search queries=%v, want explicit query then folder fallback", queries) - } + if len(queries) < 2 || queries[0] != "00000" || queries[len(queries)-1] != "inception" { + t.Fatalf("manual search queries=%v, want explicit query then folder fallback", queries) + } } func TestManualSearchReturnsMovieFallbackForTVTypedTMDbSearch(t *testing.T) { diff --git a/internal/service/media.go b/internal/service/media.go index 6e834cc..da9baea 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -4,9 +4,9 @@ package service import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // MediaService offers high-level CRUD over libraries and media items. diff --git a/internal/service/media_cache.go b/internal/service/media_cache.go index 32d69b2..b42fe7b 100644 --- a/internal/service/media_cache.go +++ b/internal/service/media_cache.go @@ -8,8 +8,8 @@ import ( "sort" "strings" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) type mediaListCacheValue struct { @@ -34,6 +34,40 @@ func (s *MediaService) mediaListCacheKey(libraryID string, libraryIDs []string, return "media:list:" + hex.EncodeToString(sum[:]) } +func (s *MediaService) libraryPreviewCacheKey(libraries []model.Library, cardLimit int, filter repository.MediaQueryFilter) string { + libIDs := make([]string, len(libraries)) + for i, lib := range libraries { + libIDs[i] = lib.ID + } + sort.Strings(libIDs) + allowed := append([]string(nil), filter.AllowedLibraryIDs...) + hidden := append([]string(nil), filter.HiddenLibraryIDs...) + sort.Strings(allowed) + sort.Strings(hidden) + sum := sha1.Sum([]byte(strings.Join([]string{ + "preview", + strings.Join(libIDs, ","), + fmt.Sprintf("%d:%t", cardLimit, filter.IncludeNSFW), + strings.Join(allowed, ","), + strings.Join(hidden, ","), + }, "|"))) + return "media:preview:" + hex.EncodeToString(sum[:]) +} + +func (s *MediaService) seriesCardsCacheKey(libraryID string, visibility MediaVisibility) string { + allowed := append([]string(nil), visibility.AllowedLibraryIDs...) + hidden := append([]string(nil), visibility.HiddenLibraryIDs...) + sort.Strings(allowed) + sort.Strings(hidden) + sum := sha1.Sum([]byte(strings.Join([]string{ + libraryID, + fmt.Sprintf("%t", visibility.IncludeNSFW), + strings.Join(allowed, ","), + strings.Join(hidden, ","), + }, "|"))) + return "media:series-cards:" + hex.EncodeToString(sum[:]) +} + func (s *MediaService) mediaCacheTTLSeconds() int { if s == nil || s.cfg == nil || s.cfg.Cache.MediaTTLSeconds < 1 { return 15 diff --git a/internal/service/media_classifier.go b/internal/service/media_classifier.go index 6d5769b..e9909bb 100644 --- a/internal/service/media_classifier.go +++ b/internal/service/media_classifier.go @@ -5,7 +5,7 @@ import ( "regexp" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) var ( diff --git a/internal/service/media_display_library.go b/internal/service/media_display_library.go index f85d7f9..2b1c668 100644 --- a/internal/service/media_display_library.go +++ b/internal/service/media_display_library.go @@ -4,8 +4,8 @@ import ( "context" "strings" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func (s *MediaService) attachLibraryMetadata(ctx context.Context, items []model.Media) { diff --git a/internal/service/media_library.go b/internal/service/media_library.go index fd7a23e..edead2d 100644 --- a/internal/service/media_library.go +++ b/internal/service/media_library.go @@ -2,9 +2,10 @@ package service import ( "context" + "time" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" "gorm.io/gorm" ) @@ -14,6 +15,10 @@ type LibraryPreviewItem struct { Cards []SeriesCard `json:"cards"` } +type libraryPreviewCacheValue struct { + Items []LibraryPreviewItem `json:"items"` +} + // ListLibraries returns every library configured on the server. func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, error) { return s.repo.Library.List(ctx) @@ -29,6 +34,18 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [ return out, nil } + visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility) + filter := repository.MediaQueryFilter{ + IncludeNSFW: visibility.IncludeNSFW, + AllowedLibraryIDs: visibility.AllowedLibraryIDs, + HiddenLibraryIDs: visibility.HiddenLibraryIDs, + } + cacheKey := s.libraryPreviewCacheKey(libraries, cardLimit, filter) + var cached libraryPreviewCacheValue + if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) { + return cached.Items, nil + } + libIDs := make([]string, 0, len(libraries)) for i, lib := range libraries { out[i] = LibraryPreviewItem{ @@ -39,13 +56,6 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [ libIDs = append(libIDs, lib.ID) } - visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility) - filter := repository.MediaQueryFilter{ - IncludeNSFW: visibility.IncludeNSFW, - AllowedLibraryIDs: visibility.AllowedLibraryIDs, - HiddenLibraryIDs: visibility.HiddenLibraryIDs, - } - counts, err := s.repo.Media.CountByLibraries(ctx, libIDs, filter) if err != nil { return nil, err @@ -64,15 +74,32 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [ fetchCount = 200 } + recentByLibrary, err := s.repo.Media.ListRecentByLibraries(ctx, libIDs, fetchCount, filter) + if err != nil { + return nil, err + } + + allPreviewItems := make([]model.Media, 0, len(libIDs)*fetchCount) for i := range out { if out[i].Total == 0 { continue } - items, _, err := s.repo.Media.ListByLibrariesFiltered(ctx, []string{out[i].ID}, 0, fetchCount, filter) - if err != nil { + items := recentByLibrary[out[i].ID] + if len(items) == 0 { + continue + } + allPreviewItems = append(allPreviewItems, items...) + } + s.attachLibraryMetadata(ctx, allPreviewItems) + + for i := range out { + if out[i].Total == 0 { + continue + } + items := recentByLibrary[out[i].ID] + if len(items) == 0 { continue } - s.attachLibraryMetadata(ctx, items) cards := groupMediaSeriesCards(items) if len(cards) > cardLimit { cards = cards[:cardLimit] @@ -83,6 +110,10 @@ func (s *MediaService) ListLibrariesWithPreview(ctx context.Context, libraries [ out[i].Cards = cards } + if s.cache != nil { + s.cache.SetJSON(ctx, cacheKey, libraryPreviewCacheValue{Items: out}, time.Duration(s.mediaCacheTTLSeconds())*time.Second) + } + return out, nil } @@ -117,10 +148,10 @@ func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error { if err := tx.Unscoped().Where("library_id = ?", id).Delete(&model.Media{}).Error; err != nil { return err } - if err := hardDeleteLibraryRoots(ctx, tx, id); err != nil { - return err - } - return tx.Unscoped().Delete(&model.Library{}, "id = ?", id).Error + if err := hardDeleteLibraryRoots(ctx, tx, id); err != nil { + return err + } + return tx.Unscoped().Delete(&model.Library{}, "id = ?", id).Error }) if err == nil { s.invalidateMediaCache(ctx) diff --git a/internal/service/media_library_roots.go b/internal/service/media_library_roots.go index 33b6d90..9f42912 100644 --- a/internal/service/media_library_roots.go +++ b/internal/service/media_library_roots.go @@ -8,7 +8,7 @@ import ( "path/filepath" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) type LibraryRootInput struct { diff --git a/internal/service/media_library_roots_test.go b/internal/service/media_library_roots_test.go index d643354..20c8e8e 100644 --- a/internal/service/media_library_roots_test.go +++ b/internal/service/media_library_roots_test.go @@ -6,9 +6,9 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestCreateLibraryWithRootsAppendsToExistingLogicalLibrary(t *testing.T) { diff --git a/internal/service/media_library_test.go b/internal/service/media_library_test.go index b7e48e9..ac7c9c0 100644 --- a/internal/service/media_library_test.go +++ b/internal/service/media_library_test.go @@ -7,9 +7,9 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestListLibrariesWithPreview(t *testing.T) { diff --git a/internal/service/media_listing.go b/internal/service/media_listing.go index 59776cf..5b0ad78 100644 --- a/internal/service/media_listing.go +++ b/internal/service/media_listing.go @@ -6,8 +6,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // ListMedia paginates media items inside a library. diff --git a/internal/service/media_metadata.go b/internal/service/media_metadata.go index 0e8b33c..3a95a51 100644 --- a/internal/service/media_metadata.go +++ b/internal/service/media_metadata.go @@ -5,7 +5,7 @@ import ( "errors" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) type MediaMetadataUpdate struct { diff --git a/internal/service/media_metadata_test.go b/internal/service/media_metadata_test.go index 97665e4..859401d 100644 --- a/internal/service/media_metadata_test.go +++ b/internal/service/media_metadata_test.go @@ -5,9 +5,9 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestUpdateMediaMetadataMarksManualMatch(t *testing.T) { diff --git a/internal/service/media_paths.go b/internal/service/media_paths.go index 44939a8..6fbf49d 100644 --- a/internal/service/media_paths.go +++ b/internal/service/media_paths.go @@ -69,8 +69,8 @@ func embeddedContainerMarkerCandidates(input string) []string { part string container string }{ - {part: "/media", container: envOrDefault("MMTL_MEDIA_CONTAINER_DIR", "/media")}, - {part: "/downloads", container: envOrDefault("MMTL_DOWNLOAD_CONTAINER_DIR", "/downloads")}, + {part: "/media", container: envOrDefault("MEBOX_MEDIA_CONTAINER_DIR", "/media")}, + {part: "/downloads", container: envOrDefault("MEBOX_DOWNLOAD_CONTAINER_DIR", "/downloads")}, } { part := strings.TrimRight(marker.part, "/") container := strings.TrimRight(filepath.ToSlash(marker.container), "/") @@ -83,17 +83,17 @@ func embeddedContainerMarkerCandidates(input string) []string { // describeUnresolvedLibraryPath 把「路径解析失败」变成可操作的诊断信息:列出尝试过的 // 候选路径与当前的宿主机→容器映射状态。旧库存的是宿主机路径,在新版/容器内常因为缺少 -// MMTL_MEDIA_DIR 映射而扫不出媒体——这条诊断帮助用户直接定位到底哪一步断了。 +// MEBOX_MEDIA_DIR 映射而扫不出媒体——这条诊断帮助用户直接定位到底哪一步断了。 // 仅用于日志与扫描错误提示,不改变解析逻辑本身。 func describeUnresolvedLibraryPath(rawPath string) string { rawPath = strings.TrimSpace(rawPath) candidates := mappedPathCandidates(rawPath) - mediaHost := strings.TrimSpace(os.Getenv("MMTL_MEDIA_DIR")) - mediaContainer := envOrDefault("MMTL_MEDIA_CONTAINER_DIR", "/media") + mediaHost := strings.TrimSpace(os.Getenv("MEBOX_MEDIA_DIR")) + mediaContainer := envOrDefault("MEBOX_MEDIA_CONTAINER_DIR", "/media") var b strings.Builder fmt.Fprintf(&b, "媒体库路径无法解析为可访问目录:%s(已尝试候选:%s)", rawPath, strings.Join(candidates, " | ")) if mediaHost == "" { - b.WriteString(";未配置 MMTL_MEDIA_DIR / MMTL_MEDIA_CONTAINER_DIR。若为 Docker 部署且此库为旧宿主机路径,请设置这两个变量把宿主机路径映射到容器内路径,并确认对应 volume 已挂载。") + b.WriteString(";未配置 MEBOX_MEDIA_DIR / MEBOX_MEDIA_CONTAINER_DIR。若为 Docker 部署且此库为旧宿主机路径,请设置这两个变量把宿主机路径映射到容器内路径,并确认对应 volume 已挂载。") } else { fmt.Fprintf(&b, ";当前映射 %s → %s。请确认该库路径位于此宿主机目录下,或补充对应的 volume 与路径映射。", mediaHost, mediaContainer) } @@ -204,8 +204,8 @@ func dockerVolumePathCandidates(path string) []string { env string container string }{ - {env: "MMTL_MEDIA_DIR", container: envOrDefault("MMTL_MEDIA_CONTAINER_DIR", "/media")}, - {env: "MMTL_DOWNLOAD_DIR", container: envOrDefault("MMTL_DOWNLOAD_CONTAINER_DIR", "/downloads")}, + {env: "MEBOX_MEDIA_DIR", container: envOrDefault("MEBOX_MEDIA_CONTAINER_DIR", "/media")}, + {env: "MEBOX_DOWNLOAD_DIR", container: envOrDefault("MEBOX_DOWNLOAD_CONTAINER_DIR", "/downloads")}, } { container := cleanPathForVolumeMapping(mapping.container) if container == "." || container == "" || strings.HasPrefix(container, ".") { @@ -247,8 +247,8 @@ func dockerVolumePathCandidates(path string) []string { part string container string }{ - {part: "/media", container: envOrDefault("MMTL_MEDIA_CONTAINER_DIR", "/media")}, - {part: "/downloads", container: envOrDefault("MMTL_DOWNLOAD_CONTAINER_DIR", "/downloads")}, + {part: "/media", container: envOrDefault("MEBOX_MEDIA_CONTAINER_DIR", "/media")}, + {part: "/downloads", container: envOrDefault("MEBOX_DOWNLOAD_CONTAINER_DIR", "/downloads")}, } { part := strings.TrimRight(marker.part, "/") container := strings.TrimRight(filepath.ToSlash(marker.container), "/") diff --git a/internal/service/media_recycle.go b/internal/service/media_recycle.go index d90404d..ec39eb3 100644 --- a/internal/service/media_recycle.go +++ b/internal/service/media_recycle.go @@ -5,7 +5,7 @@ import ( "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) const maxRecycleBinRecords = 200 diff --git a/internal/service/media_recycle_test.go b/internal/service/media_recycle_test.go index 86deecb..201f0e7 100644 --- a/internal/service/media_recycle_test.go +++ b/internal/service/media_recycle_test.go @@ -9,9 +9,9 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestListRecycleBinPrunesOldRowsOverLimit(t *testing.T) { diff --git a/internal/service/media_release_sort.go b/internal/service/media_release_sort.go index 8078420..689fe94 100644 --- a/internal/service/media_release_sort.go +++ b/internal/service/media_release_sort.go @@ -4,7 +4,7 @@ import ( "fmt" "time" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func mediaReleaseSortTime(media model.Media) time.Time { diff --git a/internal/service/media_search.go b/internal/service/media_search.go index d41b5c3..b6787f6 100644 --- a/internal/service/media_search.go +++ b/internal/service/media_search.go @@ -3,8 +3,8 @@ package service import ( "context" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // SearchMedia performs a simple LIKE search across titles. diff --git a/internal/service/media_series.go b/internal/service/media_series.go index 2065b07..423e5a3 100644 --- a/internal/service/media_series.go +++ b/internal/service/media_series.go @@ -9,9 +9,14 @@ import ( "strings" "time" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) +type seriesCardsCacheValue struct { + Cards []SeriesCard `json:"cards"` + Total int64 `json:"total"` +} + type SeriesCard struct { Key string `json:"key"` Rep model.Media `json:"rep"` @@ -25,12 +30,25 @@ type seriesCardGroup struct { } func (s *MediaService) ListLibrarySeriesCards(ctx context.Context, libraryID string, visibility MediaVisibility) ([]SeriesCard, int64, error) { + visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility) + cacheKey := s.seriesCardsCacheKey(libraryID, visibility) + var cached seriesCardsCacheValue + if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) { + return cached.Cards, cached.Total, nil + } rows, _, err := s.listAllMediaVisible(ctx, libraryID, visibility) if err != nil { return nil, 0, err } cards := groupMediaSeriesCards(rows) - return cards, int64(len(cards)), nil + if cards == nil { + cards = []SeriesCard{} + } + total := int64(len(cards)) + if s.cache != nil { + s.cache.SetJSON(ctx, cacheKey, seriesCardsCacheValue{Cards: cards, Total: total}, time.Duration(s.mediaCacheTTLSeconds())*time.Second) + } + return cards, total, nil } func (s *MediaService) ListRecentSeriesCards(ctx context.Context, limit int, visibility MediaVisibility) ([]SeriesCard, error) { diff --git a/internal/service/media_series_key.go b/internal/service/media_series_key.go index 0aa7731..b9bb584 100644 --- a/internal/service/media_series_key.go +++ b/internal/service/media_series_key.go @@ -6,7 +6,7 @@ import ( "regexp" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]`) diff --git a/internal/service/media_series_resolver.go b/internal/service/media_series_resolver.go index a4ce7c0..7037855 100644 --- a/internal/service/media_series_resolver.go +++ b/internal/service/media_series_resolver.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) type mediaSeriesKeyResolver struct { diff --git a/internal/service/media_series_test.go b/internal/service/media_series_test.go index 67c1ffb..4a8e54f 100644 --- a/internal/service/media_series_test.go +++ b/internal/service/media_series_test.go @@ -7,9 +7,9 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestListRecentSeriesCardsCountsAllEpisodesInSeries(t *testing.T) { diff --git a/internal/service/media_test.go b/internal/service/media_test.go index feccd94..e504591 100644 --- a/internal/service/media_test.go +++ b/internal/service/media_test.go @@ -14,8 +14,8 @@ func TestResolveAccessibleLibraryPathMapsConfiguredHostMediaDir(t *testing.T) { if err := os.MkdirAll(containerLibrary, 0o755); err != nil { t.Fatal(err) } - t.Setenv("MMTL_MEDIA_DIR", hostRoot) - t.Setenv("MMTL_MEDIA_CONTAINER_DIR", containerRoot) + t.Setenv("MEBOX_MEDIA_DIR", hostRoot) + t.Setenv("MEBOX_MEDIA_CONTAINER_DIR", containerRoot) got, err := resolveAccessibleLibraryPath(filepath.Join(hostRoot, "电视剧", "国产剧")) if err != nil { @@ -33,8 +33,8 @@ func TestResolveAccessibleLibraryPathMapsWindowsDriveBeforeLinuxAbs(t *testing.T if err := os.MkdirAll(containerLibrary, 0o755); err != nil { t.Fatal(err) } - t.Setenv("MMTL_MEDIA_DIR", `Q:\media`) - t.Setenv("MMTL_MEDIA_CONTAINER_DIR", containerRoot) + t.Setenv("MEBOX_MEDIA_DIR", `Q:\media`) + t.Setenv("MEBOX_MEDIA_CONTAINER_DIR", containerRoot) for _, input := range []string{ `Q:\media\电视剧\国产剧`, @@ -61,8 +61,8 @@ func TestResolveAccessibleLibraryPathRecoversDockerPollutedWindowsDrive(t *testi if err := os.MkdirAll(containerLibrary, 0o755); err != nil { t.Fatal(err) } - t.Setenv("MMTL_MEDIA_DIR", `F:\media`) - t.Setenv("MMTL_MEDIA_CONTAINER_DIR", containerRoot) + t.Setenv("MEBOX_MEDIA_DIR", `F:\media`) + t.Setenv("MEBOX_MEDIA_CONTAINER_DIR", containerRoot) got, err := resolveAccessibleLibraryPath(`/app/F:\media\电视剧\国产剧`) if err != nil { @@ -95,7 +95,7 @@ func TestResolveAccessibleLibraryPathMapsRelativeDockerMediaMarker(t *testing.T) if err := os.MkdirAll(containerLibrary, 0o755); err != nil { t.Fatal(err) } - t.Setenv("MMTL_MEDIA_CONTAINER_DIR", containerRoot) + t.Setenv("MEBOX_MEDIA_CONTAINER_DIR", containerRoot) got, err := resolveAccessibleLibraryPath(filepath.Join("media", "电视剧", "国产剧")) if err != nil { @@ -116,7 +116,7 @@ func TestResolveAccessibleLibraryPathMapsEmbeddedHostMediaMarker(t *testing.T) { if err := os.MkdirAll(containerLibrary, 0o755); err != nil { t.Fatal(err) } - t.Setenv("MMTL_MEDIA_CONTAINER_DIR", containerRoot) + t.Setenv("MEBOX_MEDIA_CONTAINER_DIR", containerRoot) got, err := resolveAccessibleLibraryPath("/vol1/1000/Docker/moviepilot-v2/media/电视剧/国产剧") if err != nil { @@ -134,7 +134,7 @@ func TestResolveAccessibleMappedPathMapsEmbeddedHostDownloadMarker(t *testing.T) if err := os.MkdirAll(containerItem, 0o755); err != nil { t.Fatal(err) } - t.Setenv("MMTL_DOWNLOAD_CONTAINER_DIR", containerDownloads) + t.Setenv("MEBOX_DOWNLOAD_CONTAINER_DIR", containerDownloads) got, _, err := resolveAccessibleMappedPath("/vol1/1000/Docker/qbittorrent/downloads/国产剧") if err != nil { @@ -180,7 +180,7 @@ func TestMappedPathCandidatesMapWindowsDriveDownloadMarker(t *testing.T) { root := t.TempDir() containerDownloads := filepath.Join(root, "container", "downloads") containerLibrary := filepath.Join(containerDownloads, "国产剧") - t.Setenv("MMTL_DOWNLOAD_CONTAINER_DIR", containerDownloads) + t.Setenv("MEBOX_DOWNLOAD_CONTAINER_DIR", containerDownloads) want := filepath.Clean(containerLibrary) for _, got := range mappedPathCandidates(`F:\downloads\国产剧`) { @@ -197,8 +197,8 @@ func TestResolveMappedDestinationPathPrefersConfiguredContainerMapping(t *testin if err := os.MkdirAll(containerMedia, 0o755); err != nil { t.Fatal(err) } - t.Setenv("MMTL_MEDIA_DIR", `Q:\media`) - t.Setenv("MMTL_MEDIA_CONTAINER_DIR", containerMedia) + t.Setenv("MEBOX_MEDIA_DIR", `Q:\media`) + t.Setenv("MEBOX_MEDIA_CONTAINER_DIR", containerMedia) for _, input := range []string{`Q:\media`, `Q:/media`, `/app/Q:\media`} { t.Run(input, func(t *testing.T) { @@ -221,7 +221,7 @@ func TestResolveMappedDestinationPathPrefersContainerForRelativeMediaMarker(t *t t.Fatal(err) } t.Chdir(workDir) - t.Setenv("MMTL_MEDIA_CONTAINER_DIR", containerMedia) + t.Setenv("MEBOX_MEDIA_CONTAINER_DIR", containerMedia) got := resolveMappedDestinationPath(filepath.Join("media", "电视剧", "国产剧")) want := filepath.Join(containerMedia, "电视剧", "国产剧") @@ -237,8 +237,8 @@ func TestResolveAccessibleMappedPathMapsWindowsDownloadVariants(t *testing.T) { if err := os.MkdirAll(containerSource, 0o755); err != nil { t.Fatal(err) } - t.Setenv("MMTL_DOWNLOAD_DIR", `Q:\downloads`) - t.Setenv("MMTL_DOWNLOAD_CONTAINER_DIR", containerDownloads) + t.Setenv("MEBOX_DOWNLOAD_DIR", `Q:\downloads`) + t.Setenv("MEBOX_DOWNLOAD_CONTAINER_DIR", containerDownloads) for _, input := range []string{`Q:\downloads\国产剧`, `Q:/downloads/国产剧`, `/app/Q:\downloads\国产剧`} { t.Run(input, func(t *testing.T) { diff --git a/internal/service/media_versions.go b/internal/service/media_versions.go index 10c5420..e2bbc9e 100644 --- a/internal/service/media_versions.go +++ b/internal/service/media_versions.go @@ -5,7 +5,7 @@ import ( "sort" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) type MediaItem struct { diff --git a/internal/service/media_versions_test.go b/internal/service/media_versions_test.go index 2515d26..d5bc044 100644 --- a/internal/service/media_versions_test.go +++ b/internal/service/media_versions_test.go @@ -6,9 +6,9 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestGroupMediaVersionsMergesEpisodeByExternalIDAcrossLibraries(t *testing.T) { diff --git a/internal/service/media_visibility_test.go b/internal/service/media_visibility_test.go index 1264ada..58a315b 100644 --- a/internal/service/media_visibility_test.go +++ b/internal/service/media_visibility_test.go @@ -5,9 +5,9 @@ import ( "slices" "testing" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" "go.uber.org/zap" ) diff --git a/internal/service/metatube.go b/internal/service/metatube.go index 002c88b..59a2de0 100644 --- a/internal/service/metatube.go +++ b/internal/service/metatube.go @@ -273,7 +273,7 @@ func (p *MetaTubeProvider) applyAuthHeader(req *http.Request, token string) { req.Header.Set("X-Token", token) } req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", "MMTL/1.0 (MetaTube Client)") + req.Header.Set("User-Agent", "MeBox/1.0 (MetaTube Client)") } func (p *MetaTubeProvider) convertSearchResultToMatch(query string, res *MetaTubeSearchResult) *Match { diff --git a/internal/service/nfo.go b/internal/service/nfo.go index a78b75a..10905c5 100644 --- a/internal/service/nfo.go +++ b/internal/service/nfo.go @@ -22,8 +22,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // NFOService is the entry point used by the admin "导出 NFO" action. diff --git a/internal/service/nfo_test.go b/internal/service/nfo_test.go index db70eeb..3db603a 100644 --- a/internal/service/nfo_test.go +++ b/internal/service/nfo_test.go @@ -6,13 +6,13 @@ import ( "strings" "testing" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func TestWriteMediaNFOUsesMappedDestinationPath(t *testing.T) { root := t.TempDir() - t.Setenv("MMTL_MEDIA_DIR", root) - t.Setenv("MMTL_MEDIA_CONTAINER_DIR", "/media") + t.Setenv("MEBOX_MEDIA_DIR", root) + t.Setenv("MEBOX_MEDIA_CONTAINER_DIR", "/media") mediaPath := filepath.Join(root, "电影", "测试电影.mkv") if err := os.MkdirAll(filepath.Dir(mediaPath), 0o755); err != nil { t.Fatal(err) diff --git a/internal/service/organize_naming_test.go b/internal/service/organize_naming_test.go index f511edc..3ab8359 100644 --- a/internal/service/organize_naming_test.go +++ b/internal/service/organize_naming_test.go @@ -8,8 +8,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" ) // TestOrganizeNaming locks in the rename pipeline used by OrganizeDirectory: diff --git a/internal/service/organize_pipeline.go b/internal/service/organize_pipeline.go index b22f2f2..69428b1 100644 --- a/internal/service/organize_pipeline.go +++ b/internal/service/organize_pipeline.go @@ -8,7 +8,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/repository" ) type OrganizeScope string diff --git a/internal/service/organize_pipeline_audit.go b/internal/service/organize_pipeline_audit.go index 9be6a8e..2acd2f5 100644 --- a/internal/service/organize_pipeline_audit.go +++ b/internal/service/organize_pipeline_audit.go @@ -7,7 +7,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func organizeFatalResultError(res *OrganizeResult) error { diff --git a/internal/service/organize_pipeline_sync.go b/internal/service/organize_pipeline_sync.go index 47c22ce..fabee1b 100644 --- a/internal/service/organize_pipeline_sync.go +++ b/internal/service/organize_pipeline_sync.go @@ -7,7 +7,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func (p *OrganizePipelineService) syncScrapedOrganizedNames(ctx context.Context, opts OrganizeOptions, res *OrganizeResult, path string) { diff --git a/internal/service/organize_pipeline_test.go b/internal/service/organize_pipeline_test.go index b712f46..5d456f7 100644 --- a/internal/service/organize_pipeline_test.go +++ b/internal/service/organize_pipeline_test.go @@ -8,7 +8,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func TestOrganizeScanRootUsesActualOrganizedTarget(t *testing.T) { diff --git a/internal/service/organizer.go b/internal/service/organizer.go index a8d7c68..359e136 100644 --- a/internal/service/organizer.go +++ b/internal/service/organizer.go @@ -20,9 +20,9 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // OrganizerService moves/renames files into library structures. diff --git a/internal/service/organizer_classification_test.go b/internal/service/organizer_classification_test.go index 67d8509..dcffab4 100644 --- a/internal/service/organizer_classification_test.go +++ b/internal/service/organizer_classification_test.go @@ -11,8 +11,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" ) func TestOrganizeDirectoryClassifiesScraperMatchBeforeRename(t *testing.T) { diff --git a/internal/service/organizer_classify.go b/internal/service/organizer_classify.go index 0a87852..904d1cf 100644 --- a/internal/service/organizer_classify.go +++ b/internal/service/organizer_classify.go @@ -5,7 +5,7 @@ import ( "path/filepath" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // isSmartClassifyEnabled checks database settings first, then config.yaml. diff --git a/internal/service/organizer_directory_classification_test.go b/internal/service/organizer_directory_classification_test.go index 7f209e1..49a7657 100644 --- a/internal/service/organizer_directory_classification_test.go +++ b/internal/service/organizer_directory_classification_test.go @@ -7,8 +7,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" ) func TestOrganizeDirectoryUsesDownloadCategoryLayout(t *testing.T) { diff --git a/internal/service/organizer_directory_dedup_test.go b/internal/service/organizer_directory_dedup_test.go index 4f1dcaa..39f6210 100644 --- a/internal/service/organizer_directory_dedup_test.go +++ b/internal/service/organizer_directory_dedup_test.go @@ -7,8 +7,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" ) // TestOrganizeDirectoryDedup verifies that media already present in the diff --git a/internal/service/organizer_directory_libraries.go b/internal/service/organizer_directory_libraries.go index a3582f3..97bf54a 100644 --- a/internal/service/organizer_directory_libraries.go +++ b/internal/service/organizer_directory_libraries.go @@ -8,7 +8,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func (o *OrganizerService) organizeLibraryRootForLayout(ctx context.Context, destRoot, mediaType, category string) (string, bool) { diff --git a/internal/service/organizer_directory_metadata.go b/internal/service/organizer_directory_metadata.go index 36c7575..5a33187 100644 --- a/internal/service/organizer_directory_metadata.go +++ b/internal/service/organizer_directory_metadata.go @@ -7,7 +7,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sourceRoot, mediaType, title string, year, season, episode int, cache map[string]*Match) *Match { diff --git a/internal/service/organizer_directory_metadata_persist.go b/internal/service/organizer_directory_metadata_persist.go index 80f8dff..a46e259 100644 --- a/internal/service/organizer_directory_metadata_persist.go +++ b/internal/service/organizer_directory_metadata_persist.go @@ -8,7 +8,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func (o *OrganizerService) persistOrganizedSourceMetadata(ctx context.Context, plan organizeSourceFilePlan) { diff --git a/internal/service/organizer_directory_source.go b/internal/service/organizer_directory_source.go index 5ec59be..8a53950 100644 --- a/internal/service/organizer_directory_source.go +++ b/internal/service/organizer_directory_source.go @@ -6,7 +6,7 @@ import ( "path/filepath" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) type organizeDirectoryLayout struct { diff --git a/internal/service/organizer_directory_source_test.go b/internal/service/organizer_directory_source_test.go index d8ea801..c436ee8 100644 --- a/internal/service/organizer_directory_source_test.go +++ b/internal/service/organizer_directory_source_test.go @@ -7,7 +7,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) func TestOrganizeDirectoryUsesConfiguredSourceWhenRequestSourceEmpty(t *testing.T) { @@ -42,10 +42,10 @@ func TestOrganizeDirectoryMapsConfiguredHostPathsToContainerPaths(t *testing.T) containerSource := filepath.Join(containerDownloads, "国产剧") writeOrgFile(t, filepath.Join(containerSource, "Some Show S01E01 2024 1080p.mkv"), "show-e01") - t.Setenv("MMTL_DOWNLOAD_DIR", hostDownloads) - t.Setenv("MMTL_DOWNLOAD_CONTAINER_DIR", containerDownloads) - t.Setenv("MMTL_MEDIA_DIR", hostMedia) - t.Setenv("MMTL_MEDIA_CONTAINER_DIR", containerMedia) + t.Setenv("MEBOX_DOWNLOAD_DIR", hostDownloads) + t.Setenv("MEBOX_DOWNLOAD_CONTAINER_DIR", containerDownloads) + t.Setenv("MEBOX_MEDIA_DIR", hostMedia) + t.Setenv("MEBOX_MEDIA_CONTAINER_DIR", containerMedia) repos := newOrganizerTestRepo(t) for key, value := range map[string]string{ @@ -106,8 +106,8 @@ func TestOrganizeSourceCandidatesOnlyReturnAccessibleDirectories(t *testing.T) { if err := os.MkdirAll(downloadDir, 0o755); err != nil { t.Fatal(err) } - t.Setenv("MMTL_DOWNLOAD_CONTAINER_DIR", downloadDir) - t.Setenv("MMTL_MEDIA_CONTAINER_DIR", filepath.Join(root, "missing-media")) + t.Setenv("MEBOX_DOWNLOAD_CONTAINER_DIR", downloadDir) + t.Setenv("MEBOX_MEDIA_CONTAINER_DIR", filepath.Join(root, "missing-media")) repos := newOrganizerTestRepo(t) if err := repos.Setting.Set(t.Context(), "organize.source_dir", configuredDir); err != nil { @@ -128,7 +128,7 @@ func TestOrganizeSourceCandidatesOnlyReturnAccessibleDirectories(t *testing.T) { if err := os.MkdirAll(mediaDir, 0o755); err != nil { t.Fatal(err) } - t.Setenv("MMTL_MEDIA_CONTAINER_DIR", mediaDir) + t.Setenv("MEBOX_MEDIA_CONTAINER_DIR", mediaDir) candidates = org.OrganizeSourceCandidates(t.Context()) if len(candidates) != 3 { t.Fatalf("candidates = %#v, want configured source, download and media dirs", candidates) diff --git a/internal/service/organizer_directory_sources.go b/internal/service/organizer_directory_sources.go index e8de025..cbbd596 100644 --- a/internal/service/organizer_directory_sources.go +++ b/internal/service/organizer_directory_sources.go @@ -41,8 +41,8 @@ func (o *OrganizerService) OrganizeSourceCandidates(ctx context.Context) []Organ } add("默认整理源", o.settingValue(ctx, "organize.source_dir"), "source") add("下载器保存目录", o.settingValue(ctx, "qbittorrent.savepath"), "download") - add("下载目录", envOrDefault("MMTL_DOWNLOAD_CONTAINER_DIR", "/downloads"), "download") - add("媒体目录", envOrDefault("MMTL_MEDIA_CONTAINER_DIR", "/media"), "media") + add("下载目录", envOrDefault("MEBOX_DOWNLOAD_CONTAINER_DIR", "/downloads"), "download") + add("媒体目录", envOrDefault("MEBOX_MEDIA_CONTAINER_DIR", "/media"), "media") return out } @@ -69,7 +69,7 @@ func (o *OrganizerService) defaultSourceRoot(ctx context.Context, override strin if v := o.settingValue(ctx, "qbittorrent.savepath"); v != "" { return v } - return envOrDefault("MMTL_DOWNLOAD_CONTAINER_DIR", "/downloads") + return envOrDefault("MEBOX_DOWNLOAD_CONTAINER_DIR", "/downloads") } // defaultDestRoot resolves the destination root for a directory organize: @@ -83,7 +83,7 @@ func (o *OrganizerService) defaultDestRoot(ctx context.Context, override string) return strings.TrimSpace(v) } } - return envOrDefault("MMTL_MEDIA_CONTAINER_DIR", "/media") + return envOrDefault("MEBOX_MEDIA_CONTAINER_DIR", "/media") } func ensureOrganizeDestinationWritable(dest string) error { @@ -97,7 +97,7 @@ func ensureOrganizeDestinationWritable(dest string) error { if err := os.MkdirAll(dest, 0o755); err != nil { // #nosec G301 -- organized media directories must remain readable by NAS/player users. return fmt.Errorf("destination path is not a writable directory: %s: %w", dest, err) } - probe, err := os.CreateTemp(dest, ".mmtl-write-test-*") // #nosec G304 -- dest is operator-configured organize root. + probe, err := os.CreateTemp(dest, ".mebox-write-test-*") // #nosec G304 -- dest is operator-configured organize root. if err != nil { return fmt.Errorf("destination path is not writable: %s: %w", dest, err) } diff --git a/internal/service/organizer_directory_test.go b/internal/service/organizer_directory_test.go index e74923d..d809b6b 100644 --- a/internal/service/organizer_directory_test.go +++ b/internal/service/organizer_directory_test.go @@ -7,7 +7,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) func writeOrgFile(t *testing.T, path, content string) { diff --git a/internal/service/organizer_directory_versions.go b/internal/service/organizer_directory_versions.go index bbc9926..e85d25d 100644 --- a/internal/service/organizer_directory_versions.go +++ b/internal/service/organizer_directory_versions.go @@ -10,7 +10,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // existingVersionPaths returns existing destination files that represent the @@ -229,9 +229,9 @@ func (o *OrganizerService) replaceVersions(ctx context.Context, src string, exis o.log.Warn("organize replace remove existing failed", zap.String("path", e), zap.Error(err)) } - if o.repo != nil && o.repo.DB != nil { - _ = o.repo.DB.WithContext(ctx).Unscoped().Where("path = ?", e).Delete(&model.Media{}).Error - } + if o.repo != nil && o.repo.DB != nil { + _ = o.repo.DB.WithContext(ctx).Unscoped().Where("path = ?", e).Delete(&model.Media{}).Error + } } // Move staged file + sidecars into the final path. if err := os.Rename(stage, dst); err != nil { diff --git a/internal/service/organizer_library_classification.go b/internal/service/organizer_library_classification.go index 4a22d3c..227dccb 100644 --- a/internal/service/organizer_library_classification.go +++ b/internal/service/organizer_library_classification.go @@ -5,7 +5,7 @@ import ( "path/filepath" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func organizeLibraryModelType(mediaType string) string { diff --git a/internal/service/organizer_localized_title_test.go b/internal/service/organizer_localized_title_test.go index 0c1e27f..fbcb2e4 100644 --- a/internal/service/organizer_localized_title_test.go +++ b/internal/service/organizer_localized_title_test.go @@ -13,9 +13,9 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestOrganizeDirectoryUsesTMDbChineseAlternativeTitle(t *testing.T) { diff --git a/internal/service/organizer_media.go b/internal/service/organizer_media.go index 03ed401..00ad854 100644 --- a/internal/service/organizer_media.go +++ b/internal/service/organizer_media.go @@ -9,7 +9,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) type organizeMediaRequest struct { diff --git a/internal/service/organizer_media_metadata.go b/internal/service/organizer_media_metadata.go index c2b22d5..aa0b9f4 100644 --- a/internal/service/organizer_media_metadata.go +++ b/internal/service/organizer_media_metadata.go @@ -6,7 +6,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func (o *OrganizerService) refreshOrganizeMediaMetadata(ctx context.Context, media *model.Media, lib *model.Library, requestedType string) { diff --git a/internal/service/organizer_metadata_match.go b/internal/service/organizer_metadata_match.go index 63bb1d6..da853e0 100644 --- a/internal/service/organizer_metadata_match.go +++ b/internal/service/organizer_metadata_match.go @@ -6,7 +6,7 @@ import ( "path/filepath" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func organizeMetadataMatchTrusted(query string, sourceYear int, match *Match) bool { diff --git a/internal/service/organizer_reclassify_anime_test.go b/internal/service/organizer_reclassify_anime_test.go index 2aa117e..5701618 100644 --- a/internal/service/organizer_reclassify_anime_test.go +++ b/internal/service/organizer_reclassify_anime_test.go @@ -7,8 +7,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" ) func TestOrganizeDirectoryReclassifiesScannedAnimeUsingDBMetadata(t *testing.T) { diff --git a/internal/service/organizer_reclassify_duplicates_test.go b/internal/service/organizer_reclassify_duplicates_test.go index 4174228..849b765 100644 --- a/internal/service/organizer_reclassify_duplicates_test.go +++ b/internal/service/organizer_reclassify_duplicates_test.go @@ -10,8 +10,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" ) func TestOrganizeDirectoryCleansWrongCategoryDuplicateWhenTargetExists(t *testing.T) { diff --git a/internal/service/organizer_reclassify_media_rows.go b/internal/service/organizer_reclassify_media_rows.go index b4f546c..b4c34b3 100644 --- a/internal/service/organizer_reclassify_media_rows.go +++ b/internal/service/organizer_reclassify_media_rows.go @@ -4,7 +4,7 @@ import ( "context" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func (o *OrganizerService) updateReclassifiedMediaRow(ctx context.Context, oldPath, newPath string, req organizeExistingReclassifyRequest) error { diff --git a/internal/service/organizer_reclassify_metadata.go b/internal/service/organizer_reclassify_metadata.go index d6e62a0..dd155e7 100644 --- a/internal/service/organizer_reclassify_metadata.go +++ b/internal/service/organizer_reclassify_metadata.go @@ -6,7 +6,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func (o *OrganizerService) lookupReclassifyMetadata(ctx context.Context, media model.Media, lib model.Library, mediaType string) *Match { diff --git a/internal/service/organizer_reclassify_metadata_test.go b/internal/service/organizer_reclassify_metadata_test.go index ca6b734..3f72d0a 100644 --- a/internal/service/organizer_reclassify_metadata_test.go +++ b/internal/service/organizer_reclassify_metadata_test.go @@ -10,8 +10,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" ) func TestOrganizeDirectoryCleansReleaseNoiseBeforeMetadataClassify(t *testing.T) { diff --git a/internal/service/organizer_reclassify_scanned.go b/internal/service/organizer_reclassify_scanned.go index 5a98443..dd7ef5c 100644 --- a/internal/service/organizer_reclassify_scanned.go +++ b/internal/service/organizer_reclassify_scanned.go @@ -9,7 +9,7 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // MediaCategoryReclassifyOptions controls the metadata-based category audit. diff --git a/internal/service/organizer_reclassify_test.go b/internal/service/organizer_reclassify_test.go index b06db08..510683c 100644 --- a/internal/service/organizer_reclassify_test.go +++ b/internal/service/organizer_reclassify_test.go @@ -10,8 +10,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" ) func TestOrganizeDirectoryReclassifiesExistingWrongCategoryMedia(t *testing.T) { diff --git a/internal/service/organizer_scan.go b/internal/service/organizer_scan.go index 243f5a2..750bd9c 100644 --- a/internal/service/organizer_scan.go +++ b/internal/service/organizer_scan.go @@ -5,8 +5,8 @@ import ( "path/filepath" "strings" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // OrganizeScanSummary reports a library scan triggered after directory diff --git a/internal/service/organizer_scrape_providers_test.go b/internal/service/organizer_scrape_providers_test.go index 78b0a60..e20d785 100644 --- a/internal/service/organizer_scrape_providers_test.go +++ b/internal/service/organizer_scrape_providers_test.go @@ -11,8 +11,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" ) func TestOrganizeDirectoryUsesAdultMetadataBeforeRename(t *testing.T) { diff --git a/internal/service/organizer_scrape_safety_test.go b/internal/service/organizer_scrape_safety_test.go index 9f478e9..dc91cbf 100644 --- a/internal/service/organizer_scrape_safety_test.go +++ b/internal/service/organizer_scrape_safety_test.go @@ -10,8 +10,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" ) func TestOrganizeDirectoryRejectsWrongYearScraperRename(t *testing.T) { diff --git a/internal/service/organizer_scrape_test.go b/internal/service/organizer_scrape_test.go index 25aaa36..c3838b1 100644 --- a/internal/service/organizer_scrape_test.go +++ b/internal/service/organizer_scrape_test.go @@ -7,8 +7,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" ) func TestOrganizeDirectoryScanAndScrapeAfter(t *testing.T) { diff --git a/internal/service/organizer_settings.go b/internal/service/organizer_settings.go index 0e5991a..e576494 100644 --- a/internal/service/organizer_settings.go +++ b/internal/service/organizer_settings.go @@ -5,7 +5,7 @@ import ( "path/filepath" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // resolveBaseRoot picks the organize destination root (目的地目录): a diff --git a/internal/service/organizer_source_test.go b/internal/service/organizer_source_test.go index 5c0af59..672b905 100644 --- a/internal/service/organizer_source_test.go +++ b/internal/service/organizer_source_test.go @@ -8,8 +8,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" ) // TestOrganizeLibraryScopesToSourceDir verifies organize reads from the diff --git a/internal/service/organizer_test.go b/internal/service/organizer_test.go index 7885ba5..f35d30d 100644 --- a/internal/service/organizer_test.go +++ b/internal/service/organizer_test.go @@ -7,9 +7,9 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestOrganizeMediaReDetectsSeasonFromPath(t *testing.T) { diff --git a/internal/service/organizer_transfer_test.go b/internal/service/organizer_transfer_test.go index 05ed0e0..859b284 100644 --- a/internal/service/organizer_transfer_test.go +++ b/internal/service/organizer_transfer_test.go @@ -8,9 +8,9 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func newOrganizerTestRepo(t *testing.T) *repository.Container { diff --git a/internal/service/permission.go b/internal/service/permission.go index b58e855..b03cc29 100644 --- a/internal/service/permission.go +++ b/internal/service/permission.go @@ -11,8 +11,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // PermissionService manages user permissions. diff --git a/internal/service/play_profile.go b/internal/service/play_profile.go index 848c1e0..d182809 100644 --- a/internal/service/play_profile.go +++ b/internal/service/play_profile.go @@ -18,8 +18,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // PlayProfileService manages PlayProfile rows. diff --git a/internal/service/play_profile_model.go b/internal/service/play_profile_model.go index d9301df..77f8908 100644 --- a/internal/service/play_profile_model.go +++ b/internal/service/play_profile_model.go @@ -7,7 +7,7 @@ import ( "fmt" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // PlayProfileInput is the create/update payload accepted by the API. diff --git a/internal/service/play_profile_test.go b/internal/service/play_profile_test.go index 7147486..20225a8 100644 --- a/internal/service/play_profile_test.go +++ b/internal/service/play_profile_test.go @@ -4,8 +4,8 @@ import ( "errors" "testing" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" "go.uber.org/zap" ) diff --git a/internal/service/playback.go b/internal/service/playback.go index bf5762a..db31a04 100644 --- a/internal/service/playback.go +++ b/internal/service/playback.go @@ -9,18 +9,21 @@ package service import ( "context" "errors" + "sort" "time" "go.uber.org/zap" + "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // PlaybackService bundles history / favourite / playlist business logic. type PlaybackService struct { - log *zap.Logger - repo *repository.Container + log *zap.Logger + repo *repository.Container + remote *EmbyRemoteService } // NewPlaybackService is the constructor. @@ -28,6 +31,14 @@ func NewPlaybackService(log *zap.Logger, repo *repository.Container) *PlaybackSe return &PlaybackService{log: log, repo: repo} } +// SetEmbyRemote wires the remote Emby service for hydrating mounted remote items. +func (p *PlaybackService) SetEmbyRemote(remote *EmbyRemoteService) *PlaybackService { + if p != nil { + p.remote = remote + } + return p +} + // ─── History ──────────────────────────────────────────────────────────────── // RecordProgress upserts the resume position for a (user, media) pair. A @@ -37,18 +48,61 @@ func (p *PlaybackService) RecordProgress(ctx context.Context, userID, mediaID st if userID == "" || mediaID == "" { return errors.New("missing user or media") } - completed := duration > 0 && position >= duration-30_000 + dur := p.resolvePlaybackDuration(ctx, userID, mediaID, duration) + completed := dur > 0 && position >= dur-30_000 h := &model.PlaybackHistory{ UserID: userID, MediaID: mediaID, PositionMs: position, - DurationMs: duration, + DurationMs: dur, WatchedAt: time.Now(), Completed: completed, } return p.repo.History.Upsert(ctx, h) } +// GetProgress returns the saved resume row for one media item, or nil when absent. +func (p *PlaybackService) GetProgress(ctx context.Context, userID, mediaID string) (*model.PlaybackHistory, error) { + if userID == "" || mediaID == "" { + return nil, errors.New("missing user or media") + } + var row model.PlaybackHistory + err := p.repo.DB.WithContext(ctx). + Where("user_id = ? AND media_id = ?", userID, mediaID). + First(&row).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &row, nil +} + +func (p *PlaybackService) resolvePlaybackDuration(ctx context.Context, userID, mediaID string, duration int64) int64 { + if duration > 0 { + return duration + } + var existing model.PlaybackHistory + if err := p.repo.DB.WithContext(ctx). + Where("user_id = ? AND media_id = ?", userID, mediaID). + First(&existing).Error; err == nil && existing.DurationMs > 0 { + return existing.DurationMs + } + if m, _ := p.repo.Media.FindByID(ctx, mediaID); m != nil && m.DurationSec > 0 { + return int64(m.DurationSec) * 1000 + } + if p.remote != nil && IsEmbyRemoteID(mediaID) { + mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID) + if mount, acct, _ := p.remote.ResolveMount(ctx, mountID); mount != nil && acct != nil { + if rm, err := p.remote.RemoteMediaDetail(ctx, mount, acct, remoteID); err == nil && rm != nil && rm.DurationSec > 0 { + return int64(rm.DurationSec) * 1000 + } + } + } + return duration +} + // HistoryItem joins the playback row with its media so the API consumer // gets a fully-populated card without a second round-trip. type HistoryItem struct { @@ -84,9 +138,19 @@ func (p *PlaybackService) RecentHistory(ctx context.Context, userID string, limi if m, ok := mediaByID[rows[i].MediaID]; ok { media := m items = append(items, HistoryItem{PlaybackHistory: rows[i], Media: &media}) - } else { - items = append(items, HistoryItem{PlaybackHistory: rows[i]}) + continue } + if p.remote != nil && IsEmbyRemoteID(rows[i].MediaID) { + mountID, remoteID, _ := DecodeEmbyRemoteID(rows[i].MediaID) + if mount, acct, _ := p.remote.ResolveMount(ctx, mountID); mount != nil && acct != nil { + if rm, err := p.remote.RemoteMediaDetail(ctx, mount, acct, remoteID); err == nil && rm != nil { + media := *rm + items = append(items, HistoryItem{PlaybackHistory: rows[i], Media: &media}) + continue + } + } + } + items = append(items, HistoryItem{PlaybackHistory: rows[i]}) } return items, nil } @@ -107,14 +171,47 @@ func (p *PlaybackService) ListFavourites(ctx context.Context, userID string) ([] if len(favs) == 0 { return nil, nil } - ids := make([]string, len(favs)) - for i, f := range favs { - ids[i] = f.MediaID + sort.Slice(favs, func(i, j int) bool { + return favs[i].CreatedAt.After(favs[j].CreatedAt) + }) + + localIDs := make([]string, 0, len(favs)) + for _, fav := range favs { + if !IsEmbyRemoteID(fav.MediaID) { + localIDs = append(localIDs, fav.MediaID) + } } - var out []model.Media - err = p.repo.DB.Where("id IN ?", ids). - Order("created_at desc").Find(&out).Error - return out, err + mediaByID := map[string]model.Media{} + if len(localIDs) > 0 { + var mediaRows []model.Media + if err := p.repo.DB.WithContext(ctx).Where("id IN ?", localIDs).Find(&mediaRows).Error; err != nil { + return nil, err + } + for _, media := range mediaRows { + mediaByID[media.ID] = media + } + } + + out := make([]model.Media, 0, len(favs)) + for _, fav := range favs { + if media, ok := mediaByID[fav.MediaID]; ok { + out = append(out, media) + continue + } + if p.remote != nil && IsEmbyRemoteID(fav.MediaID) { + mountID, remoteID, _ := DecodeEmbyRemoteID(fav.MediaID) + mount, acct, _ := p.remote.ResolveMount(ctx, mountID) + if mount == nil || acct == nil { + continue + } + remoteMedia, err := p.remote.RemoteMediaDetail(ctx, mount, acct, remoteID) + if err != nil || remoteMedia == nil { + continue + } + out = append(out, *remoteMedia) + } + } + return out, nil } // ─── Playlists ────────────────────────────────────────────────────────────── diff --git a/internal/service/playback_test.go b/internal/service/playback_test.go new file mode 100644 index 0000000..925c56c --- /dev/null +++ b/internal/service/playback_test.go @@ -0,0 +1,93 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "go.uber.org/zap" +) + +func TestRecordProgressPreservesDurationWhenMissing(t *testing.T) { + db := newServiceTestDB(t, &model.PlaybackHistory{}, &model.Media{}) + repos := repository.New(db) + userID := "user-1" + mediaID := "local-movie-1" + if err := db.Create(&model.PlaybackHistory{ + Base: model.Base{ID: "hist-1"}, + UserID: userID, + MediaID: mediaID, + PositionMs: 30_000, + DurationMs: 120_000, + WatchedAt: time.Now(), + }).Error; err != nil { + t.Fatalf("seed history: %v", err) + } + + svc := NewPlaybackService(zap.NewNop(), repos) + if err := svc.RecordProgress(context.Background(), userID, mediaID, 60_000, 0); err != nil { + t.Fatalf("RecordProgress: %v", err) + } + + var hist model.PlaybackHistory + if err := db.Where("user_id = ? AND media_id = ?", userID, mediaID).First(&hist).Error; err != nil { + t.Fatalf("find history: %v", err) + } + if hist.DurationMs != 120_000 { + t.Fatalf("expected duration preserved, got %d", hist.DurationMs) + } + if hist.PositionMs != 60_000 { + t.Fatalf("expected position updated, got %d", hist.PositionMs) + } +} + +func TestGetProgressReturnsNilWhenMissing(t *testing.T) { + db := newServiceTestDB(t, &model.PlaybackHistory{}) + repos := repository.New(db) + svc := NewPlaybackService(zap.NewNop(), repos) + + row, err := svc.GetProgress(context.Background(), "user-1", "missing-media") + if err != nil { + t.Fatalf("GetProgress: %v", err) + } + if row != nil { + t.Fatalf("expected nil progress, got %#v", row) + } +} + +func TestListFavouritesIncludesLocalAndRemoteIDs(t *testing.T) { + db := newServiceTestDB(t, &model.Favorite{}, &model.Media{}) + repos := repository.New(db) + userID := "user-1" + local := model.Media{ + Base: model.Base{ID: "local-movie-1"}, + Title: "Local Movie", + } + if err := db.Create(&local).Error; err != nil { + t.Fatalf("create local media: %v", err) + } + remoteID := EncodeEmbyRemoteID("mount-1", "remote-series-1") + favs := []model.Favorite{ + {Base: model.Base{CreatedAt: time.Now().Add(-time.Minute)}, UserID: userID, MediaID: remoteID}, + {Base: model.Base{CreatedAt: time.Now()}, UserID: userID, MediaID: local.ID}, + } + for i := range favs { + if err := db.Create(&favs[i]).Error; err != nil { + t.Fatalf("create favorite %d: %v", i, err) + } + } + + svc := NewPlaybackService(zap.NewNop(), repos) + items, err := svc.ListFavourites(context.Background(), userID) + if err != nil { + t.Fatalf("ListFavourites: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected one hydrated local favourite without remote service, got %d: %#v", len(items), items) + } + if items[0].ID != local.ID { + t.Fatalf("expected local favourite first by created_at desc, got %#v", items[0]) + } +} diff --git a/internal/service/profile.go b/internal/service/profile.go index 08e950d..99e8312 100644 --- a/internal/service/profile.go +++ b/internal/service/profile.go @@ -8,8 +8,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // ProfileService handles non-credential user mutations. diff --git a/internal/service/public_url.go b/internal/service/public_url.go index d59cfbc..9352c18 100644 --- a/internal/service/public_url.go +++ b/internal/service/public_url.go @@ -5,11 +5,11 @@ import ( "net/url" "strings" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/repository" ) -// PublicServerURL returns the operator-configured public MMTL base +// PublicServerURL returns the operator-configured public MeBox base // URL. It is intentionally read from Setting first so Docker users only need to // fill one domain in the admin settings page after deployment. func PublicServerURL(ctx context.Context, repo *repository.Container, cfg *config.Config) string { diff --git a/internal/service/recognition_words.go b/internal/service/recognition_words.go index 69406c2..194a283 100644 --- a/internal/service/recognition_words.go +++ b/internal/service/recognition_words.go @@ -11,8 +11,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) const ( diff --git a/internal/service/runtime_cache.go b/internal/service/runtime_cache.go index 6297d24..270531c 100644 --- a/internal/service/runtime_cache.go +++ b/internal/service/runtime_cache.go @@ -10,7 +10,7 @@ import ( "github.com/redis/go-redis/v9" "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) type RuntimeCacheService struct { @@ -35,7 +35,7 @@ func NewRuntimeCacheService(cfg *config.Config, log *zap.Logger) *RuntimeCacheSe } c.prefix = strings.Trim(strings.TrimSpace(cfg.Cache.RedisPrefix), ":") if c.prefix == "" { - c.prefix = "mmtl" + c.prefix = "mebox" } rawURL := strings.TrimSpace(cfg.Cache.RedisURL) if rawURL == "" { diff --git a/internal/service/runtime_settings.go b/internal/service/runtime_settings.go index 7581bb3..95e0a1c 100644 --- a/internal/service/runtime_settings.go +++ b/internal/service/runtime_settings.go @@ -7,8 +7,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/repository" ) func ApplyRuntimeSettings(ctx context.Context, cfg *config.Config, repos *repository.Container, log *zap.Logger) { @@ -110,6 +110,13 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) { cfg.App.SSLCertPath = strings.TrimSpace(value) case "https.key_path": cfg.App.SSLKeyPath = strings.TrimSpace(value) + case "cache.images_max_size_mb": + if n, err := strconv.Atoi(value); err == nil { + if n < 0 { + n = 0 + } + cfg.Cache.ImagesMaxSizeMB = n + } } } diff --git a/internal/service/runtime_settings_test.go b/internal/service/runtime_settings_test.go index 171d097..ba8f364 100644 --- a/internal/service/runtime_settings_test.go +++ b/internal/service/runtime_settings_test.go @@ -3,7 +3,7 @@ package service import ( "testing" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) func TestApplyRuntimeSettingTranscodeSwitches(t *testing.T) { diff --git a/internal/service/scanner.go b/internal/service/scanner.go index 5004e45..95c62ba 100644 --- a/internal/service/scanner.go +++ b/internal/service/scanner.go @@ -17,8 +17,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/repository" ) // videoExtensions lists the file extensions treated as media. Matches the diff --git a/internal/service/scanner_existing_media.go b/internal/service/scanner_existing_media.go index 6411c7d..969a8e8 100644 --- a/internal/service/scanner_existing_media.go +++ b/internal/service/scanner_existing_media.go @@ -5,7 +5,7 @@ import ( "path/filepath" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func (s *ScannerService) existingLocalMediaSnapshot(ctx context.Context, libraryID string) (map[string]existingLocalMedia, error) { diff --git a/internal/service/scanner_existing_media_test.go b/internal/service/scanner_existing_media_test.go index 6a75ef2..aea5a5d 100644 --- a/internal/service/scanner_existing_media_test.go +++ b/internal/service/scanner_existing_media_test.go @@ -4,9 +4,9 @@ import ( "path/filepath" "testing" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" "go.uber.org/zap" ) diff --git a/internal/service/scanner_incremental_test.go b/internal/service/scanner_incremental_test.go index f5655dd..1f25c49 100644 --- a/internal/service/scanner_incremental_test.go +++ b/internal/service/scanner_incremental_test.go @@ -10,9 +10,9 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func newScannerTestEnv(t *testing.T) (*ScannerService, *repository.Container) { @@ -425,8 +425,8 @@ func TestScanLibraryMapsPersistedHostLibraryPath(t *testing.T) { if err := os.WriteFile(file, []byte("episode"), 0o644); err != nil { t.Fatal(err) } - t.Setenv("MMTL_MEDIA_DIR", `Q:\media`) - t.Setenv("MMTL_MEDIA_CONTAINER_DIR", containerRoot) + t.Setenv("MEBOX_MEDIA_DIR", `Q:\media`) + t.Setenv("MEBOX_MEDIA_CONTAINER_DIR", containerRoot) lib := model.Library{Name: "国产剧", Path: `Q:\media\电视剧\国产剧`, Type: "tv", Enabled: true} if err := repos.Library.Create(t.Context(), &lib); err != nil { diff --git a/internal/service/scanner_library_roots_test.go b/internal/service/scanner_library_roots_test.go index 9ea569b..311e5d1 100644 --- a/internal/service/scanner_library_roots_test.go +++ b/internal/service/scanner_library_roots_test.go @@ -7,8 +7,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestScanLibraryScansMultipleRootsAndPrunesPerRoot(t *testing.T) { diff --git a/internal/service/scanner_local_ingest.go b/internal/service/scanner_local_ingest.go index 8b523d4..6a05966 100644 --- a/internal/service/scanner_local_ingest.go +++ b/internal/service/scanner_local_ingest.go @@ -8,7 +8,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // ingestFile upserts a single media file. seenInodes dedups hardlinks within a diff --git a/internal/service/scanner_local_metadata_test.go b/internal/service/scanner_local_metadata_test.go index 3569e3f..ee0eef3 100644 --- a/internal/service/scanner_local_metadata_test.go +++ b/internal/service/scanner_local_metadata_test.go @@ -7,9 +7,9 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestScanLibraryUsesLocalMetadata(t *testing.T) { diff --git a/internal/service/scanner_local_probe_queue.go b/internal/service/scanner_local_probe_queue.go index 2bac0a4..c4e2018 100644 --- a/internal/service/scanner_local_probe_queue.go +++ b/internal/service/scanner_local_probe_queue.go @@ -7,7 +7,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func (s *ScannerService) localMediaProbeWorker() { diff --git a/internal/service/scanner_local_write_batch.go b/internal/service/scanner_local_write_batch.go index e85a53c..ffd4f8e 100644 --- a/internal/service/scanner_local_write_batch.go +++ b/internal/service/scanner_local_write_batch.go @@ -6,7 +6,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) type localMediaWriteBatch struct { diff --git a/internal/service/scanner_metadata_refresh.go b/internal/service/scanner_metadata_refresh.go index 693e807..31751ab 100644 --- a/internal/service/scanner_metadata_refresh.go +++ b/internal/service/scanner_metadata_refresh.go @@ -3,7 +3,7 @@ package service import ( "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) type scanDerivedMetadata struct { diff --git a/internal/service/scanner_prune.go b/internal/service/scanner_prune.go index d5328ba..12a48af 100644 --- a/internal/service/scanner_prune.go +++ b/internal/service/scanner_prune.go @@ -8,7 +8,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // RemovePath 物理删除磁盘上已不存在的媒体记录。 diff --git a/internal/service/scanner_roots.go b/internal/service/scanner_roots.go index 3e5f5a9..1e4572c 100644 --- a/internal/service/scanner_roots.go +++ b/internal/service/scanner_roots.go @@ -8,7 +8,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func (s *ScannerService) localLibraryScanRoots(ctx context.Context, lib *model.Library) ([]model.LibraryRoot, error) { diff --git a/internal/service/scanner_scan.go b/internal/service/scanner_scan.go index 2b22b54..7f06697 100644 --- a/internal/service/scanner_scan.go +++ b/internal/service/scanner_scan.go @@ -9,7 +9,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // ScanLibrary walks the library root and persists discovered media files. @@ -100,8 +100,8 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto zap.String("root_id", root.ID), zap.String("path", root.Path), zap.Strings("candidates", mappedPathCandidates(root.Path)), - zap.String("media_dir_env", os.Getenv("MMTL_MEDIA_DIR")), - zap.String("media_container_env", envOrDefault("MMTL_MEDIA_CONTAINER_DIR", "/media")), + zap.String("media_dir_env", os.Getenv("MEBOX_MEDIA_DIR")), + zap.String("media_container_env", envOrDefault("MEBOX_MEDIA_CONTAINER_DIR", "/media")), zap.String("diagnostic", diag), zap.Error(err)) if scanErr == nil { diff --git a/internal/service/scheduler.go b/internal/service/scheduler.go index 55abba2..c140232 100644 --- a/internal/service/scheduler.go +++ b/internal/service/scheduler.go @@ -25,7 +25,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/repository" ) // SchedulerService runs the periodic jobs. @@ -41,6 +41,8 @@ type SchedulerService struct { cacheDir string now func() time.Time + imagesMaxSizeMBProvider func() int + mu sync.Mutex stopCh chan struct{} jobs []*scheduledJob @@ -59,6 +61,17 @@ func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService s.organizePipeline = pipeline } +func (s *SchedulerService) SetImagesMaxSizeMBProvider(fn func() int) { + s.imagesMaxSizeMBProvider = fn +} + +func (s *SchedulerService) imagesMaxSizeMB() int { + if s.imagesMaxSizeMBProvider != nil { + return s.imagesMaxSizeMBProvider() + } + return 0 +} + // scheduledJob is one recurring task. type scheduledJob struct { name string @@ -120,6 +133,11 @@ func (s *SchedulerService) Start(ctx context.Context) { interval: 24 * time.Hour, run: s.jobPurgeRecycleBin, }, + { + name: "image_cache_cleanup", + interval: 1 * time.Hour, + run: s.jobCleanImageCache, + }, } for _, j := range s.jobs { initialDelay := 15 * time.Second diff --git a/internal/service/scheduler_local_jobs.go b/internal/service/scheduler_local_jobs.go index 8b51b1d..cbd4319 100644 --- a/internal/service/scheduler_local_jobs.go +++ b/internal/service/scheduler_local_jobs.go @@ -2,6 +2,7 @@ package service import ( "context" + "path/filepath" "strconv" "strings" "time" @@ -9,7 +10,7 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // jobScanLibraries re-walks every enabled library. @@ -202,3 +203,31 @@ func isMissingTableErr(err error) bool { } return err == gorm.ErrInvalidDB } + +// jobCleanImageCache prunes image proxy cache files when disk usage exceeds the configured limit. +func (s *SchedulerService) jobCleanImageCache(ctx context.Context) error { + if s.cacheDir == "" { + return nil + } + maxMB := s.imagesMaxSizeMB() + if maxMB <= 0 { + return nil + } + imagesDir := filepath.Join(s.cacheDir, "images") + maxSizeBytes := int64(maxMB) * 1024 * 1024 + res, err := PruneImageCache(imagesDir, maxSizeBytes) + if err != nil { + if s.log != nil { + s.log.Warn("scheduled image cache cleanup failed", zap.Error(err)) + } + return err + } + if res.DeletedFiles > 0 && s.log != nil { + s.log.Info("scheduled image cache cleanup completed", + zap.Int("deleted_files", res.DeletedFiles), + zap.Int64("freed_bytes", res.FreedBytes), + zap.Int64("remaining_bytes", res.RemainingBytes), + ) + } + return nil +} diff --git a/internal/service/scheduler_organize_test.go b/internal/service/scheduler_organize_test.go index fc5947d..1d743ac 100644 --- a/internal/service/scheduler_organize_test.go +++ b/internal/service/scheduler_organize_test.go @@ -8,8 +8,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" ) func TestSchedulerOrganizeSourceDisabledByDefault(t *testing.T) { diff --git a/internal/service/scheduler_scan_test.go b/internal/service/scheduler_scan_test.go index 98c7e09..2e0fbf1 100644 --- a/internal/service/scheduler_scan_test.go +++ b/internal/service/scheduler_scan_test.go @@ -7,9 +7,9 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestSchedulerPeriodicLocalScanRunsAtMostOncePerDay(t *testing.T) { diff --git a/internal/service/scraper.go b/internal/service/scraper.go index 17cbe30..dc1922f 100644 --- a/internal/service/scraper.go +++ b/internal/service/scraper.go @@ -11,7 +11,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // EnrichOne runs the provider chain for a single media row. diff --git a/internal/service/scraper_alias_match_test.go b/internal/service/scraper_alias_match_test.go index d65006b..2845eb4 100644 --- a/internal/service/scraper_alias_match_test.go +++ b/internal/service/scraper_alias_match_test.go @@ -8,8 +8,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" ) func TestEnrichOneUsesAlternateLanguageTitleAndKeepsLocalizedMetadata(t *testing.T) { diff --git a/internal/service/scraper_artwork_test.go b/internal/service/scraper_artwork_test.go index 7b0603e..001b987 100644 --- a/internal/service/scraper_artwork_test.go +++ b/internal/service/scraper_artwork_test.go @@ -13,8 +13,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" ) func TestScrapeDelayUsesSettings(t *testing.T) { diff --git a/internal/service/scraper_episode_details_test.go b/internal/service/scraper_episode_details_test.go index c1885a7..da88e43 100644 --- a/internal/service/scraper_episode_details_test.go +++ b/internal/service/scraper_episode_details_test.go @@ -13,9 +13,9 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestEnrichLibraryDefersEpisodeDetailsUntilMainMetadataFinishes(t *testing.T) { diff --git a/internal/service/scraper_library.go b/internal/service/scraper_library.go index c444bec..f9d6277 100644 --- a/internal/service/scraper_library.go +++ b/internal/service/scraper_library.go @@ -7,7 +7,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // lookup runs the provider chain after local NFO has been considered: diff --git a/internal/service/scraper_library_test.go b/internal/service/scraper_library_test.go index a48ac83..10c0990 100644 --- a/internal/service/scraper_library_test.go +++ b/internal/service/scraper_library_test.go @@ -4,7 +4,7 @@ import ( "path/filepath" "testing" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func TestManualEnrichLibraryRetriesNoMatchAndCountsRealMatches(t *testing.T) { diff --git a/internal/service/scraper_local_metadata.go b/internal/service/scraper_local_metadata.go index 800cadb..e9ab0b9 100644 --- a/internal/service/scraper_local_metadata.go +++ b/internal/service/scraper_local_metadata.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func mediaYearHint(m *model.Media) int { diff --git a/internal/service/scraper_local_metadata_test.go b/internal/service/scraper_local_metadata_test.go index 22cccbc..f8bf15e 100644 --- a/internal/service/scraper_local_metadata_test.go +++ b/internal/service/scraper_local_metadata_test.go @@ -12,9 +12,9 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestEnrichOneRejectsWrongYearMatchFromSeriesFolder(t *testing.T) { diff --git a/internal/service/scraper_lookup.go b/internal/service/scraper_lookup.go index 9ee7f51..d74b875 100644 --- a/internal/service/scraper_lookup.go +++ b/internal/service/scraper_lookup.go @@ -6,7 +6,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func (s *ScraperService) matchFromMediaExternalIDs(ctx context.Context, m *model.Media, lib *model.Library) *Match { diff --git a/internal/service/scraper_lookup_test.go b/internal/service/scraper_lookup_test.go index bc31b86..dd71577 100644 --- a/internal/service/scraper_lookup_test.go +++ b/internal/service/scraper_lookup_test.go @@ -5,7 +5,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func TestMediaExternalIDMatchTrustsExactEpisodeID(t *testing.T) { diff --git a/internal/service/scraper_query.go b/internal/service/scraper_query.go index 136df9b..cb07ad0 100644 --- a/internal/service/scraper_query.go +++ b/internal/service/scraper_query.go @@ -6,8 +6,8 @@ import ( "regexp" "strings" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) var ( diff --git a/internal/service/scraper_query_candidates_test.go b/internal/service/scraper_query_candidates_test.go index 3bfd070..26dfd63 100644 --- a/internal/service/scraper_query_candidates_test.go +++ b/internal/service/scraper_query_candidates_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func TestScrapeQueryCandidatesPreferSeriesFolderAndCJKTitle(t *testing.T) { diff --git a/internal/service/scraper_query_movie_test.go b/internal/service/scraper_query_movie_test.go index f0fe5c3..d004667 100644 --- a/internal/service/scraper_query_movie_test.go +++ b/internal/service/scraper_query_movie_test.go @@ -11,9 +11,9 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestScrapeQueryCandidatesUseMovieFolderForGenericFilename(t *testing.T) { diff --git a/internal/service/scraper_query_test.go b/internal/service/scraper_query_test.go index 4ba98d2..1c59a4e 100644 --- a/internal/service/scraper_query_test.go +++ b/internal/service/scraper_query_test.go @@ -12,9 +12,9 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestCleanQuery(t *testing.T) { diff --git a/internal/service/scraper_queue.go b/internal/service/scraper_queue.go index 75a5604..143c826 100644 --- a/internal/service/scraper_queue.go +++ b/internal/service/scraper_queue.go @@ -10,7 +10,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) type ScrapeQueueCounts struct { diff --git a/internal/service/scraper_service.go b/internal/service/scraper_service.go index ccb0ed9..bfb99ae 100644 --- a/internal/service/scraper_service.go +++ b/internal/service/scraper_service.go @@ -5,9 +5,9 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // ScraperService coordinates metadata enrichment across providers. diff --git a/internal/service/scraper_test.go b/internal/service/scraper_test.go index 1dfa893..6c312b0 100644 --- a/internal/service/scraper_test.go +++ b/internal/service/scraper_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" "gorm.io/gorm" ) diff --git a/internal/service/scraper_test_helpers_test.go b/internal/service/scraper_test_helpers_test.go index c5e9932..2810dde 100644 --- a/internal/service/scraper_test_helpers_test.go +++ b/internal/service/scraper_test_helpers_test.go @@ -11,9 +11,9 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func firstIndexFunc(values []string, match func(string) bool) int { diff --git a/internal/service/scraper_tmdb_details.go b/internal/service/scraper_tmdb_details.go index 21c55aa..af27ed2 100644 --- a/internal/service/scraper_tmdb_details.go +++ b/internal/service/scraper_tmdb_details.go @@ -7,7 +7,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func (s *ScraperService) fetchAndSaveTMDbExtendedMetadata(ctx context.Context, mediaID string, tmdbID int, mediaType string) { diff --git a/internal/service/scraper_write_metadata_files.go b/internal/service/scraper_write_metadata_files.go index 8dbf510..2a4b923 100644 --- a/internal/service/scraper_write_metadata_files.go +++ b/internal/service/scraper_write_metadata_files.go @@ -8,7 +8,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // writeMediaArtworkFilesAfterScrape downloads the scraped poster/backdrop into diff --git a/internal/service/scraper_write_metadata_files_test.go b/internal/service/scraper_write_metadata_files_test.go index e9e5416..98fa2b8 100644 --- a/internal/service/scraper_write_metadata_files_test.go +++ b/internal/service/scraper_write_metadata_files_test.go @@ -7,7 +7,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) // TestWriteArtworkDataToPathWritesJellyfinSidecar verifies that in-memory diff --git a/internal/service/security_safety_test.go b/internal/service/security_safety_test.go index c859af3..e5d31bf 100644 --- a/internal/service/security_safety_test.go +++ b/internal/service/security_safety_test.go @@ -4,7 +4,7 @@ import ( "path/filepath" "testing" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) func TestBackupFilePathRejectsTraversal(t *testing.T) { @@ -15,7 +15,7 @@ func TestBackupFilePathRejectsTraversal(t *testing.T) { t.Fatalf("backupFilePath(%q) allowed traversal or non-backup file", name) } } - path, err := svc.backupFilePath("mmtl_20260611_010203.db") + path, err := svc.backupFilePath("mebox_20260611_010203.db") if err != nil { t.Fatalf("backupFilePath(valid) = %v", err) } diff --git a/internal/service/service.go b/internal/service/service.go index 885d447..af8329d 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -1,4 +1,4 @@ -// Package service 包含 MMTL 的业务逻辑。 +// Package service 包含 MeBox 的业务逻辑。 // Handler 反序列化 HTTP 请求,调用 Service 方法,然后序列化响应。 // Services 拥有所有横切策略(认证、扫描、转码等)且不直接处理 HTTP 类型。 package service @@ -9,8 +9,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/repository" ) // Container 持有在启动时初始化的每个服务。Handler 接收指向它的指针并选择相关字段。 @@ -59,13 +59,13 @@ type Container struct { Device *DeviceService Cache *RuntimeCacheService Sessions *SessionTrackerService - RecognitionWords *RecognitionWordsService - Danmaku *DanmakuService - Strm *StrmService - Database *DatabaseAdminService - FFTools *FFmpegToolsService + RecognitionWords *RecognitionWordsService + Danmaku *DanmakuService + Strm *StrmService + Database *DatabaseAdminService + FFTools *FFmpegToolsService - stopCtx context.Context + stopCtx context.Context stopCancel context.CancelFunc // ReloadHTTPServer 由 cmd/server 注入。HTTPS 相关设置保存后,handler @@ -110,12 +110,12 @@ func (c *Container) Boot() { c.Strm.Start(c.stopCtx) } - // 启动刮削队列后台消费者 - if c.Scraper != nil { - c.Scraper.Start(c.stopCtx) - } + // 启动刮削队列后台消费者 + if c.Scraper != nil { + c.Scraper.Start(c.stopCtx) + } - // Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。 + // Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。 // 每天触发一次评估;规则里的窗口可随机,不固定。 if c.Device != nil { go c.runInactivitySweeper(c.stopCtx) diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index c5be37e..a86fcbb 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -2,12 +2,14 @@ package service import ( "context" + "errors" "strings" "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) type serviceContainerBuilder struct { @@ -103,12 +105,12 @@ func (b *serviceContainerBuilder) initContentServices() { b.c.DLNA = NewDLNAService(b.log) b.c.Storage = NewStorageService(b.log, b.repos) b.c.Emby = NewEmbyService(b.cfg, b.log, b.repos) - b.c.EmbyRemote = NewEmbyRemoteService(b.cfg, b.log, b.repos, b.c.Crypto) + b.c.EmbyRemote = NewEmbyRemoteService(b.cfg, b.log, b.repos, b.c.Crypto).SetRuntimeCache(b.c.Cache) b.c.Emby.SetEmbyRemote(b.c.EmbyRemote) b.c.Backup = NewBackupService(b.cfg, b.log, b.repos.DB) b.c.Media = NewMediaService(b.cfg, b.log, b.repos).SetRuntimeCache(b.c.Cache) b.c.Stream = NewStreamService(b.cfg, b.log, b.repos, b.c.Transcoder) - b.c.Playback = NewPlaybackService(b.log, b.repos) + b.c.Playback = NewPlaybackService(b.log, b.repos).SetEmbyRemote(b.c.EmbyRemote) b.c.Subtitle = NewSubtitleService(b.cfg, b.log, b.repos) b.c.Profile = NewProfileService(b.log, b.repos) b.c.Audit = NewAuditService(b.log, b.repos) @@ -117,6 +119,28 @@ func (b *serviceContainerBuilder) initContentServices() { b.c.FFTools = NewFFmpegToolsService(b.cfg, b.log, b.repos) // 弹幕 hash 识别需要把 strm 指向解析成可拉取的直链/本地路径。 b.c.Danmaku.SetStrmResolver(b.c.Strm.ResolvePlay) + // 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。 + if b.c.EmbyRemote != nil { + b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) { + mountID, remoteID, ok := DecodeEmbyRemoteID(encodedID) + if !ok { + return nil, "", errors.New("invalid emby remote id") + } + mount, acct, err := b.c.EmbyRemote.ResolveMount(ctx, mountID) + if err != nil || mount == nil || acct == nil { + if err != nil { + return nil, "", err + } + return nil, "", errors.New("emby mount or account not found") + } + m, err := b.c.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID) + if err != nil || m == nil { + return nil, "", err + } + streamURL, _ := b.c.EmbyRemote.WebStreamURL(ctx, acct, remoteID) + return m, streamURL, nil + }) + } } func (b *serviceContainerBuilder) initAccessAndStorageServices() { @@ -131,6 +155,12 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() { ) b.c.Scheduler.SetTaskTracker(b.c.Tasks) b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline) + b.c.Scheduler.SetImagesMaxSizeMBProvider(func() int { + if b.cfg == nil { + return 0 + } + return b.cfg.Cache.ImagesMaxSizeMB + }) } func (b *serviceContainerBuilder) initIdentityServices() { diff --git a/internal/service/service_builder_library_roots_test.go b/internal/service/service_builder_library_roots_test.go index 3c10a96..2b52daf 100644 --- a/internal/service/service_builder_library_roots_test.go +++ b/internal/service/service_builder_library_roots_test.go @@ -4,8 +4,8 @@ import ( "path/filepath" "testing" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestServiceBuilderLibraryRootsIncludesAllEnabledRoots(t *testing.T) { diff --git a/internal/service/service_library_normalize.go b/internal/service/service_library_normalize.go index fbda877..738ea51 100644 --- a/internal/service/service_library_normalize.go +++ b/internal/service/service_library_normalize.go @@ -5,7 +5,7 @@ import ( "path/filepath" "strings" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func (c *Container) NormalizeLocalLibraryPaths(ctx context.Context) error { diff --git a/internal/service/service_library_normalize_test.go b/internal/service/service_library_normalize_test.go index df9133f..c4818f5 100644 --- a/internal/service/service_library_normalize_test.go +++ b/internal/service/service_library_normalize_test.go @@ -5,8 +5,8 @@ import ( "path/filepath" "testing" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestNormalizeLocalLibraryPathsRewritesRelativeDockerMediaRoot(t *testing.T) { @@ -16,7 +16,7 @@ func TestNormalizeLocalLibraryPathsRewritesRelativeDockerMediaRoot(t *testing.T) if err := os.MkdirAll(containerLibrary, 0o755); err != nil { t.Fatal(err) } - t.Setenv("MMTL_MEDIA_CONTAINER_DIR", containerRoot) + t.Setenv("MEBOX_MEDIA_CONTAINER_DIR", containerRoot) db := newServiceTestDB(t, &model.Library{}, &model.LibraryRoot{}, &model.Media{}) repos := repository.New(db) diff --git a/internal/service/service_search_warmup.go b/internal/service/service_search_warmup.go index df1c3de..7c68663 100644 --- a/internal/service/service_search_warmup.go +++ b/internal/service/service_search_warmup.go @@ -8,7 +8,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/repository" ) func (c *Container) warmMediaSearchIndex(ctx context.Context) { diff --git a/internal/service/session_tracker_activity.go b/internal/service/session_tracker_activity.go index 3088ba2..28a9c0d 100644 --- a/internal/service/session_tracker_activity.go +++ b/internal/service/session_tracker_activity.go @@ -5,7 +5,7 @@ import ( "strings" "time" - "github.com/ShukeBta/MMTL/internal/model" + "github.com/truewhile/MeBox/internal/model" ) func (s *SessionTrackerService) ApplyToUsers(ctx context.Context, users []model.User) { diff --git a/internal/service/session_tracker_test.go b/internal/service/session_tracker_test.go index a3b5409..2f82b6f 100644 --- a/internal/service/session_tracker_test.go +++ b/internal/service/session_tracker_test.go @@ -6,8 +6,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestSessionTrackerAppliesRealtimeActivityToUsers(t *testing.T) { diff --git a/internal/service/storage.go b/internal/service/storage.go index 858c798..189d38b 100644 --- a/internal/service/storage.go +++ b/internal/service/storage.go @@ -10,7 +10,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/repository" ) // StorageService is the read-only aggregator. diff --git a/internal/service/stream_cloud_settings.go b/internal/service/stream_cloud_settings.go index 72cb27b..bde34d2 100644 --- a/internal/service/stream_cloud_settings.go +++ b/internal/service/stream_cloud_settings.go @@ -4,7 +4,7 @@ import ( "context" "strings" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/repository" ) func CloudPlaybackSettings(ctx context.Context, repo *repository.Container) CloudPlaybackOptions { diff --git a/internal/service/stream_file.go b/internal/service/stream_file.go index 1529646..e720054 100644 --- a/internal/service/stream_file.go +++ b/internal/service/stream_file.go @@ -7,8 +7,8 @@ import ( "os" "strings" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // ServeFile streams the file backing the given media ID using @@ -86,9 +86,9 @@ func playableSTRMTarget(ctx context.Context, repo *repository.Container, raw str return STRMPlaybackEnabled(ctx, repo) } -// isStrmMediaRow 判断媒体行是否为 .strm(远程直链)媒体:STRMURL 非空、 +// IsStrmMediaRow 判断媒体行是否为 .strm(远程直链)媒体:STRMURL 非空、 // container=strm 或路径以 .strm 结尾。strm 媒体只能直连播放,禁止转码。 -func isStrmMediaRow(m *model.Media) bool { +func IsStrmMediaRow(m *model.Media) bool { if m == nil { return false } @@ -101,6 +101,10 @@ func isStrmMediaRow(m *model.Media) bool { return strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") } +func isStrmMediaRow(m *model.Media) bool { + return IsStrmMediaRow(m) +} + func isHTTPPlaybackTarget(raw string) bool { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil || u == nil || !u.IsAbs() { diff --git a/internal/service/stream_normalize_test.go b/internal/service/stream_normalize_test.go index 775bb62..c0fe0a9 100644 --- a/internal/service/stream_normalize_test.go +++ b/internal/service/stream_normalize_test.go @@ -22,18 +22,18 @@ func TestNormalizeCloudPlayTarget(t *testing.T) { if parsed.IsAbs() || parsed.Host != "" { t.Fatalf("normalized target should be relative, got %q", got) } - if parsed.Query().Get("ref") != ref { - t.Fatalf("ref round-trip failed: %q", parsed.Query().Get("ref")) - } + if parsed.Query().Get("ref") != ref { + t.Fatalf("ref round-trip failed: %q", parsed.Query().Get("ref")) + } - strmStale := "http://bwg.linkmy.fun:1314/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123" - gotStrm := normalizeCloudPlayTarget(strmStale) - wantStrm := "/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123" - if gotStrm != wantStrm { - t.Fatalf("normalizeCloudPlayTarget(strm) = %q, want %q", gotStrm, wantStrm) - } + strmStale := "http://bwg.linkmy.fun:1314/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123" + gotStrm := normalizeCloudPlayTarget(strmStale) + wantStrm := "/api/strm/play/cloud115/video.mkv?acct=abc&pickcode=123" + if gotStrm != wantStrm { + t.Fatalf("normalizeCloudPlayTarget(strm) = %q, want %q", gotStrm, wantStrm) + } - // 非云盘播放 URL 保持原样(WebDAV/直链等)。 + // 非云盘播放 URL 保持原样(WebDAV/直链等)。 passthrough := "https://dav.example.com/media/file.mkv" if got := normalizeCloudPlayTarget(passthrough); got != passthrough { t.Fatalf("non-cloud target should pass through, got %q", got) diff --git a/internal/service/stream_service.go b/internal/service/stream_service.go index 3908d35..e44d6ea 100644 --- a/internal/service/stream_service.go +++ b/internal/service/stream_service.go @@ -6,8 +6,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/repository" ) const ( diff --git a/internal/service/stream_test.go b/internal/service/stream_test.go index d623a82..ae3c4d3 100644 --- a/internal/service/stream_test.go +++ b/internal/service/stream_test.go @@ -10,9 +10,9 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestWithAuthTokenPropagatesToInternalRedirect(t *testing.T) { diff --git a/internal/service/strm_115_oauth.go b/internal/service/strm_115_oauth.go index d590fc9..435f9a3 100644 --- a/internal/service/strm_115_oauth.go +++ b/internal/service/strm_115_oauth.go @@ -19,8 +19,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service/cloud115" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service/cloud115" ) // strm115RelayKeySetting 是中继授权共享 AES 密钥的设置键。 diff --git a/internal/service/strm_115_oauth_test.go b/internal/service/strm_115_oauth_test.go index 40a1f86..041f770 100644 --- a/internal/service/strm_115_oauth_test.go +++ b/internal/service/strm_115_oauth_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service/cloud115" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service/cloud115" ) // Test115OAuthQRFlow 用 mock 的 115 开放平台接口走完设备码扫码授权: diff --git a/internal/service/strm_account_test.go b/internal/service/strm_account_test.go new file mode 100644 index 0000000..25da9de --- /dev/null +++ b/internal/service/strm_account_test.go @@ -0,0 +1,76 @@ +package service + +import ( + "context" + "encoding/json" + "testing" + + "github.com/truewhile/MeBox/internal/model" +) + +func TestStrmAccountConfigPreviewOf(t *testing.T) { + svc := testStrmService(t) + acct := &model.StrmAccount{ + Provider: model.StrmProviderOpenList, + Config: mustJSON(map[string]string{ + "server": "https://list.example.com", + "username": "alice", + "password": svc.crypto.Encrypt("secret"), + "token": svc.crypto.Encrypt("tok"), + }), + } + + preview := svc.StrmAccountConfigPreviewOf(acct) + if preview.Server != "https://list.example.com" { + t.Fatalf("server = %q", preview.Server) + } + if preview.Username != "alice" { + t.Fatalf("username = %q", preview.Username) + } + if !preview.HasPassword || !preview.HasToken { + t.Fatalf("expected secret flags, got %#v", preview) + } +} + +func TestUpdateStrmAccountMergesConfigWithoutClearingSecrets(t *testing.T) { + svc := testStrmService(t) + ctx := context.Background() + acct, err := svc.CreateStrmAccount(ctx, "openlist", model.StrmProviderOpenList, map[string]string{ + "server": "https://list.example.com", + "username": "alice", + "password": "secret", + "token": "tok", + }) + if err != nil { + t.Fatalf("create account: %v", err) + } + + updated, err := svc.UpdateStrmAccount(ctx, acct.ID, "openlist-renamed", nil, map[string]string{ + "server": "https://list.example.com", + "username": "alice", + }) + if err != nil { + t.Fatalf("update account: %v", err) + } + if updated.Name != "openlist-renamed" { + t.Fatalf("name = %q", updated.Name) + } + cfg, err := svc.strmAccountConfig(updated) + if err != nil { + t.Fatalf("decode config: %v", err) + } + if cfg["password"] != "secret" { + t.Fatalf("password was cleared: %#v", cfg) + } + if cfg["token"] != "tok" { + t.Fatalf("token was cleared: %#v", cfg) + } +} + +func mustJSON(v any) string { + data, err := json.Marshal(v) + if err != nil { + panic(err) + } + return string(data) +} diff --git a/internal/service/strm_play.go b/internal/service/strm_play.go index 445ffa7..61f3803 100644 --- a/internal/service/strm_play.go +++ b/internal/service/strm_play.go @@ -14,8 +14,8 @@ import ( "path/filepath" "strings" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service/cloud" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service/cloud" ) // ErrStrmPlayNotFound 表示 strm 播放目标不存在(handler 返回 404)。 diff --git a/internal/service/strm_queue.go b/internal/service/strm_queue.go index 10148b5..b656e62 100644 --- a/internal/service/strm_queue.go +++ b/internal/service/strm_queue.go @@ -18,9 +18,9 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service/cloud" - "github.com/ShukeBta/MMTL/internal/service/cloud115" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service/cloud" + "github.com/truewhile/MeBox/internal/service/cloud115" ) const ( @@ -29,9 +29,8 @@ const ( // downloadWorker 下载队列 worker:认领 → 解析直链 → 下载 → 落盘。 // -// 采用「批量认领 + 全局并发限流」:一次认领数个任务,用 StrmService 上的全局信号量 -// 限制整个进程「同时换直链+下载」的并发数(与 115 换链风控匹配,见 strmDownloadSemCap), -// 同时让下载充分并行。换链走全局令牌桶(QPS=3)兜底,下载走 CDN 不限速。 +// 采用「批量认领 + 按网盘类型限流」:一次认领数个任务,115 与 WebDAV/OpenList +// 使用独立信号量(115 固定 3 防风控;OpenList/CloudDrive2 并发由 download_threads 控制)。 func (s *StrmService) downloadWorker(ctx context.Context) { const claimBatch = 12 // 每次批量认领的任务数 for { @@ -42,12 +41,6 @@ func (s *StrmService) downloadWorker(ctx context.Context) { return default: } - // 115 风控/限流熔断:冷却期间整体暂停,不给 WAF 续封机会 - if left := s.wafCooldownLeft(); left > 0 { - s.log.Debug("下载队列冷却中", zap.Duration("remaining", left)) - sleepContext(ctx, left) - continue - } tasks, err := s.repo.StrmDownload.ClaimPendingDownload(ctx, claimBatch) if err != nil { s.log.Warn("claim strm download task failed", zap.Error(err)) @@ -58,24 +51,37 @@ func (s *StrmService) downloadWorker(ctx context.Context) { sleepContext(ctx, 2*time.Second) continue } - // 并发处理本批任务:每个任务先获取全局下载槽位,槽位内部执行换链+下载。 - // 信号量与令牌桶双重限速,确保任意时刻并发换链请求不超过安全阈值。 var wg sync.WaitGroup for i := range tasks { wg.Add(1) go func(i int) { defer wg.Done() - if !s.acquireDownloadSlot(ctx) { + task := &tasks[i] + if task.Provider == model.StrmProvider115 && s.wafCooldownLeft() > 0 { + s.requeueDownloadTask(task) return } - defer s.releaseDownloadSlot() - s.processDownloadTask(ctx, &tasks[i]) + if !s.acquireDownloadSlot(ctx, task.Provider) { + s.requeueDownloadTask(task) + return + } + defer s.releaseDownloadSlot(task.Provider) + s.processDownloadTask(ctx, task) }(i) } wg.Wait() } } +// requeueDownloadTask 把已认领但未实际执行的任务退回 pending,避免长期停留在 running。 +func (s *StrmService) requeueDownloadTask(task *model.StrmDownloadTask) { + task.Status = model.StrmTaskPending + task.StartedAt = nil + if err := s.repo.StrmDownload.Update(context.Background(), task); err != nil { + s.log.Warn("requeue strm download task failed", zap.Error(err), zap.String("id", task.ID)) + } +} + func (s *StrmService) processDownloadTask(ctx context.Context, task *model.StrmDownloadTask) { cleanPath := sanitizeLocalPath(task.LocalPath) if cleanPath != "" && cleanPath != task.LocalPath { @@ -609,11 +615,26 @@ func (s *StrmService) ClearCanceledUploadTasks(ctx context.Context) (int64, erro return s.repo.StrmUpload.ClearCanceled(ctx) } +// ClearDoneUploadTasks 清空全部已完成上传记录,返回删除数量。 +func (s *StrmService) ClearDoneUploadTasks(ctx context.Context) (int64, error) { + return s.repo.StrmUpload.ClearDone(ctx) +} + +// ClearFinishedUploadTasks 清空全部已完成与失败的上传记录,返回删除数量。 +func (s *StrmService) ClearFinishedUploadTasks(ctx context.Context) (int64, error) { + return s.repo.StrmUpload.ClearFinished(ctx) +} + // RetryAllFailedDownloadTasks 批量重试所有失败下载任务,返回重新入队数量。 func (s *StrmService) RetryAllFailedDownloadTasks(ctx context.Context) (int64, error) { return s.repo.StrmDownload.RetryAllFailed(ctx) } +// RetryAllFailedUploadTasks 批量重试所有失败上传任务,返回重新入队数量。 +func (s *StrmService) RetryAllFailedUploadTasks(ctx context.Context) (int64, error) { + return s.repo.StrmUpload.RetryAllFailed(ctx) +} + // CancelPendingDownloadTasks 批量取消所有排队下载任务,返回取消数量。 func (s *StrmService) CancelPendingDownloadTasks(ctx context.Context) (int64, error) { return s.repo.StrmDownload.CancelPending(ctx) @@ -633,7 +654,7 @@ func sleepContext(ctx context.Context, d time.Duration) { // ─── 115 风控/限流熔断 ──────────────────────────────────────────────────────── -// triggerWAFCooldown 检测到 115 风控/限流后触发全局冷却,冷却期间下载 worker 暂停。 +// triggerWAFCooldown 检测到 115 风控/限流后触发冷却,仅影响 115 下载任务。 // 冷却时间取最大值,避免连续触发时缩短等待。 func (s *StrmService) triggerWAFCooldown() { s.mu.Lock() diff --git a/internal/service/strm_queue_test.go b/internal/service/strm_queue_test.go index 498d57a..9e17620 100644 --- a/internal/service/strm_queue_test.go +++ b/internal/service/strm_queue_test.go @@ -1,8 +1,14 @@ package service import ( + "context" "errors" "testing" + "time" + + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "go.uber.org/zap" ) func TestIs115Blocked(t *testing.T) { @@ -44,3 +50,117 @@ func TestIsHTTPDownloadFailure(t *testing.T) { } } } + +func TestStrmUploadTasksClearAndRetry(t *testing.T) { + db := newServiceTestDB(t, &model.StrmUploadTask{}) + repos := repository.New(db) + svc := NewStrmService(nil, zap.NewNop(), repos, nil) + ctx := context.Background() + + tasks := []*model.StrmUploadTask{ + {Base: model.Base{ID: "task-pending"}, Status: model.StrmTaskPending, FileName: "1.nfo"}, + {Base: model.Base{ID: "task-running"}, Status: model.StrmTaskRunning, FileName: "2.nfo"}, + {Base: model.Base{ID: "task-done"}, Status: model.StrmTaskDone, FileName: "3.nfo"}, + {Base: model.Base{ID: "task-failed"}, Status: model.StrmTaskFailed, FileName: "4.nfo", Error: "some error", RetryCount: 3}, + {Base: model.Base{ID: "task-canceled"}, Status: model.StrmTaskCanceled, FileName: "5.nfo"}, + } + for _, task := range tasks { + if err := db.Create(task).Error; err != nil { + t.Fatalf("failed to insert task: %v", err) + } + } + + // 1. RetryAllFailedUploadTasks + retried, err := svc.RetryAllFailedUploadTasks(ctx) + if err != nil { + t.Fatalf("RetryAllFailedUploadTasks failed: %v", err) + } + if retried != 1 { + t.Fatalf("expected 1 retried task, got %d", retried) + } + var failedTask model.StrmUploadTask + if err := db.First(&failedTask, "id = ?", "task-failed").Error; err != nil { + t.Fatalf("failed to get task-failed: %v", err) + } + if failedTask.Status != model.StrmTaskPending || failedTask.Error != "" || failedTask.RetryCount != 0 { + t.Fatalf("task-failed was not reset properly: %+v", failedTask) + } + + // 再次改为 failed 以便测试 ClearFinished + db.Model(&model.StrmUploadTask{}).Where("id = ?", "task-failed").Updates(map[string]any{"status": model.StrmTaskFailed}) + + // 2. ClearFinishedUploadTasks 应删除 done, failed, canceled 三条历史记录 + deleted, err := svc.ClearFinishedUploadTasks(ctx) + if err != nil { + t.Fatalf("ClearFinishedUploadTasks failed: %v", err) + } + if deleted != 3 { + t.Fatalf("expected 3 deleted tasks (done, failed, canceled), got %d", deleted) + } + + // 验证剩余的任务只有 pending 和 running + var count int64 + db.Model(&model.StrmUploadTask{}).Count(&count) + if count != 2 { + t.Fatalf("expected 2 remaining tasks, got %d", count) + } +} + +func TestDownloadSemSeparateByProvider(t *testing.T) { + svc := NewStrmService(nil, zap.NewNop(), nil, nil) + svc.initDownloadSems(6) + + if cap(svc.downloadSem115) != strm115DownloadSemCap { + t.Fatalf("115 sem cap = %d, want %d", cap(svc.downloadSem115), strm115DownloadSemCap) + } + if cap(svc.downloadSemDAV) != 6 { + t.Fatalf("dav sem cap = %d, want 6", cap(svc.downloadSemDAV)) + } + + ctx := context.Background() + for i := 0; i < strm115DownloadSemCap; i++ { + if !svc.acquireDownloadSlot(ctx, model.StrmProvider115) { + t.Fatalf("failed to acquire 115 slot %d", i) + } + } + // 115 槽位占满后,OpenList 仍应能独立获取槽位。 + if !svc.acquireDownloadSlot(ctx, model.StrmProviderOpenList) { + t.Fatal("openlist slot should remain available while 115 slots are full") + } + for i := 0; i < 5; i++ { + if !svc.acquireDownloadSlot(ctx, model.StrmProviderOpenList) { + t.Fatalf("failed to acquire extra openlist slot %d", i) + } + } + timeoutCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancel() + if svc.acquireDownloadSlot(timeoutCtx, model.StrmProviderOpenList) { + t.Fatal("expected openlist slots to be exhausted after 6 acquisitions") + } +} + +func TestRequeueDownloadTask(t *testing.T) { + db := newServiceTestDB(t, &model.StrmDownloadTask{}) + repos := repository.New(db) + svc := NewStrmService(nil, zap.NewNop(), repos, nil) + now := time.Now() + task := &model.StrmDownloadTask{ + Base: model.Base{ID: "dl-running"}, + Status: model.StrmTaskRunning, + Provider: model.StrmProviderOpenList, + StartedAt: &now, + } + if err := db.Create(task).Error; err != nil { + t.Fatalf("insert task: %v", err) + } + + svc.requeueDownloadTask(task) + + var got model.StrmDownloadTask + if err := db.First(&got, "id = ?", task.ID).Error; err != nil { + t.Fatalf("load task: %v", err) + } + if got.Status != model.StrmTaskPending || got.StartedAt != nil { + t.Fatalf("task not requeued: %+v", got) + } +} diff --git a/internal/service/strm_service.go b/internal/service/strm_service.go index ca46671..d04e43c 100644 --- a/internal/service/strm_service.go +++ b/internal/service/strm_service.go @@ -23,10 +23,10 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" - "github.com/ShukeBta/MMTL/internal/service/cloud" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service/cloud" ) // strm 全局设置键(存于 Setting 表,strm.* 前缀)。 @@ -68,7 +68,7 @@ var StrmSettingDefs = map[string]struct { StrmSettingUploadMeta: {Default: "false", Label: "上传元数据", Kind: "bool", Help: "同步时把本地元数据上传到远端(需网盘支持写入)"}, StrmSettingDeleteDir: {Default: "false", Label: "清理空目录", Kind: "bool", Help: "清理远端已删除的多余 .strm/元数据后,删除空目录"}, Strm115RelayKeySetting: {Default: "", Label: "115 中继授权共享密钥", Kind: "text", Help: "QMediaSync/MQFamily 中继授权的共享 AES 密钥(OAUTH_RELAY_ENCRYPTION_KEY);不配置则中继授权不可用"}, - StrmSettingDownloadThreads: {Default: "3", Label: "下载队列线程数", Kind: "number", Help: "元数据下载并发数"}, + StrmSettingDownloadThreads: {Default: "6", Label: "下载队列线程数", Kind: "number", Help: "OpenList/CloudDrive2 元数据下载并发数(115 独立限速为 3)"}, StrmSettingUploadThreads: {Default: "2", Label: "上传队列线程数", Kind: "number", Help: "元数据上传并发数"}, } @@ -91,33 +91,51 @@ type StrmService struct { oauthSessions map[string]*strm115AuthSession wafUntil time.Time // 115 风控/限流熔断截止时间(由 mu 保护) - downloadSem chan struct{} // 全局下载并发信号量:限制整个进程同时进行「换直链+下载」的并发数 + downloadSem115 chan struct{} // 115 换直链+下载并发上限(风控兜底) + downloadSemDAV chan struct{} // WebDAV/OpenList/CloudDrive2 元数据下载并发上限 downloadSemOnce sync.Once } -// strmWAFCooldown 检测到 115 风控/限流后下载队列的全局冷却时长。 +// strmWAFCooldown 检测到 115 风控/限流后 115 下载任务的全局冷却时长。 const strmWAFCooldown = 3 * time.Minute -// strmDownloadSemCap 全局同时进行「换直链+下载」的并发上限。 +// strm115DownloadSemCap 115 同时进行「换直链+下载」的并发上限。 // // 115 对换直链接口(/open/ufile/downurl)风控极严:过去把全局 QPS 提到 8 或让多 // worker 高并发换链,会瞬时撞上 WAF 返回 405 阻断页并触发 180 秒冷却,反而更慢。 -// 因此用信号量把整个进程同时换直链的并发数压到 3,与令牌桶限速共同兜底: -// 宁可下载稍慢,也绝不触发风控。下载本身走 CDN 不限速。 -const strmDownloadSemCap = 3 +// 因此把 115 换直链并发压到 3,与令牌桶限速共同兜底;OpenList/CloudDrive2 元数据 +// 走 WebDAV,不受此限制,并发由 strm.download_threads 控制。 +const strm115DownloadSemCap = 3 -// ensureDownloadSem 惰性初始化全局共享的下载并发信号量。 -func (s *StrmService) ensureDownloadSem() { +// initDownloadSems 按设置初始化 115 与 WebDAV 两套下载并发信号量。 +func (s *StrmService) initDownloadSems(davCap int) { s.downloadSemOnce.Do(func() { - s.downloadSem = make(chan struct{}, strmDownloadSemCap) + if davCap < 1 { + davCap = 1 + } + if davCap > 16 { + davCap = 16 + } + s.downloadSem115 = make(chan struct{}, strm115DownloadSemCap) + s.downloadSemDAV = make(chan struct{}, davCap) }) } +func (s *StrmService) downloadSemForProvider(provider string) chan struct{} { + if provider == model.StrmProvider115 { + return s.downloadSem115 + } + return s.downloadSemDAV +} + // acquireDownloadSlot 获取一个下载并发槽位(等待/取消安全)。 -func (s *StrmService) acquireDownloadSlot(ctx context.Context) bool { - s.ensureDownloadSem() +func (s *StrmService) acquireDownloadSlot(ctx context.Context, provider string) bool { + sem := s.downloadSemForProvider(provider) + if sem == nil { + return false + } select { - case s.downloadSem <- struct{}{}: + case sem <- struct{}{}: return true case <-ctx.Done(): return false @@ -125,11 +143,12 @@ func (s *StrmService) acquireDownloadSlot(ctx context.Context) bool { } // releaseDownloadSlot 释放一个下载并发槽位。 -func (s *StrmService) releaseDownloadSlot() { - if s.downloadSem == nil { +func (s *StrmService) releaseDownloadSlot(provider string) { + sem := s.downloadSemForProvider(provider) + if sem == nil { return } - <-s.downloadSem + <-sem } // NewStrmService constructs the STRM service. @@ -151,13 +170,14 @@ func NewStrmService(cfg *config.Config, log *zap.Logger, repos *repository.Conta func (s *StrmService) Start(ctx context.Context) { s.sync115RelayKey(ctx) s.recoverInterruptedSyncs(ctx) - downloadThreads := s.strmIntSetting(ctx, StrmSettingDownloadThreads, 3) + downloadThreads := s.strmIntSetting(ctx, StrmSettingDownloadThreads, 6) if downloadThreads < 1 { downloadThreads = 1 } - if downloadThreads > 8 { - downloadThreads = 8 + if downloadThreads > 16 { + downloadThreads = 16 } + s.initDownloadSems(downloadThreads) uploadThreads := s.strmIntSetting(ctx, StrmSettingUploadThreads, 2) if uploadThreads < 1 { uploadThreads = 1 @@ -237,9 +257,9 @@ func (s *StrmService) strmAccountConfig(acct *model.StrmAccount) (map[string]str return cfg, nil } -// mergeEmbyRemoteConfig 对远程 Emby 账号配置做合并式更新:config 中出现的键 -// 覆盖写入(敏感键按明文加密),未出现的键保留原密文;显式空字符串=清除。 -func (s *StrmService) mergeEmbyRemoteConfig(existing string, config map[string]string) (string, error) { +// mergeStrmAccountConfig 对账号配置做合并式更新:config 中出现的键覆盖写入(敏感键按明文 +// 加密),未出现的键保留原密文;显式空字符串=清除。 +func (s *StrmService) mergeStrmAccountConfig(existing string, config map[string]string) (string, error) { out := map[string]string{} if strings.TrimSpace(existing) != "" { if err := json.Unmarshal([]byte(existing), &out); err != nil { @@ -264,6 +284,44 @@ func (s *StrmService) mergeEmbyRemoteConfig(existing string, config map[string]s return string(data), nil } +// StrmAccountConfigPreview 返回可安全回显给前端的非敏感配置字段。 +type StrmAccountConfigPreview struct { + URL string `json:"url,omitempty"` + Server string `json:"server,omitempty"` + Username string `json:"username,omitempty"` + HasPassword bool `json:"has_password,omitempty"` + HasToken bool `json:"has_token,omitempty"` + HasAPIKey bool `json:"has_api_key,omitempty"` +} + +// StrmAccountConfigPreviewOf 从账号配置提取可回显字段(不含密码/令牌明文)。 +func (s *StrmService) StrmAccountConfigPreviewOf(acct *model.StrmAccount) StrmAccountConfigPreview { + var out StrmAccountConfigPreview + if acct == nil || strings.TrimSpace(acct.Config) == "" { + return out + } + raw := map[string]string{} + if err := json.Unmarshal([]byte(acct.Config), &raw); err != nil { + return out + } + out.URL = strings.TrimSpace(raw["url"]) + out.Server = strings.TrimSpace(raw["server"]) + out.Username = strings.TrimSpace(raw["username"]) + for _, k := range []string{"password", "token", "api_key"} { + if v, ok := raw[k]; ok && strings.TrimSpace(v) != "" { + switch k { + case "password": + out.HasPassword = true + case "token": + out.HasToken = true + case "api_key": + out.HasAPIKey = true + } + } + } + return out +} + // HasStrmAccountCredential 报告账号是否已配置核心凭据(用于前端展示)。 func HasStrmAccountCredential(acct *model.StrmAccount) bool { switch acct.Provider { @@ -273,8 +331,9 @@ func HasStrmAccountCredential(acct *model.StrmAccount) bool { case model.StrmProviderOpenList: return strings.Contains(acct.Config, `"token"`) || strings.Contains(acct.Config, `"password"`) case model.StrmProviderEmbyRemote: - // 远程 Emby:接入地址 + (自动认证凭据 或 手动 api_key) 即视为已配置。 - return strings.Contains(acct.Config, `"url"`) && + // 远程 Emby:接入线路 + (自动认证凭据 或 手动 api_key) 即视为已配置。 + hasURL := strings.Contains(acct.Config, `"url"`) || strings.Contains(acct.Config, `"urls"`) + return hasURL && (strings.Contains(acct.Config, `"token"`) || strings.Contains(acct.Config, `"api_key"`) || (strings.Contains(acct.Config, `"username"`) && strings.Contains(acct.Config, `"password"`))) default: @@ -319,22 +378,13 @@ func (s *StrmService) UpdateStrmAccount(ctx context.Context, id, name string, en if enabled != nil { acct.Enabled = *enabled } - if len(config) > 0 { - var enc string - var err error - if acct.Provider == model.StrmProviderEmbyRemote { - // 远程 Emby 账号:合并式更新。config 中出现的键覆盖(敏感键按明文 - // 加密写入),未出现的键保留原密文——避免编辑「代理开关」时把已 - // 保存的地址与凭据清空。 - enc, err = s.mergeEmbyRemoteConfig(acct.Config, config) - } else { - enc, err = s.strmAccountConfigJSON(config, true) + if len(config) > 0 { + enc, err := s.mergeStrmAccountConfig(acct.Config, config) + if err != nil { + return nil, err + } + acct.Config = enc } - if err != nil { - return nil, err - } - acct.Config = enc - } if err := s.repo.StrmAccount.Update(ctx, acct); err != nil { return nil, err } @@ -706,7 +756,7 @@ var StrmProviderLabels = map[string]string{ model.StrmProviderEmbyRemote: "Emby 远程挂载", } -const defaultStrmUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36 MMTL-Strm/1.0" +const defaultStrmUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36 MeBox-Strm/1.0" // ensureLocalDir 创建本地输出目录。 func ensureLocalDir(dir string) error { diff --git a/internal/service/strm_sync.go b/internal/service/strm_sync.go index e3af7ef..85fd59b 100644 --- a/internal/service/strm_sync.go +++ b/internal/service/strm_sync.go @@ -18,9 +18,9 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/service/cloud" - "github.com/ShukeBta/MMTL/internal/service/cloud115" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/service/cloud" + "github.com/truewhile/MeBox/internal/service/cloud115" ) // strmSyncState 是一次同步执行的上下文。 diff --git a/internal/service/strm_sync_test.go b/internal/service/strm_sync_test.go index 01cd509..0d62a1d 100644 --- a/internal/service/strm_sync_test.go +++ b/internal/service/strm_sync_test.go @@ -18,11 +18,11 @@ import ( "gorm.io/gorm" "gorm.io/gorm/logger" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" - "github.com/ShukeBta/MMTL/internal/service/cloud" - "github.com/ShukeBta/MMTL/internal/service/cloud115" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service/cloud" + "github.com/truewhile/MeBox/internal/service/cloud115" ) // testStrmService 构建带内存库的 StrmService。 diff --git a/internal/service/subtitle.go b/internal/service/subtitle.go index fa006b3..32b10ae 100644 --- a/internal/service/subtitle.go +++ b/internal/service/subtitle.go @@ -26,8 +26,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/repository" ) // SubtitleService is the discovery + conversion entry point. @@ -205,4 +205,4 @@ func (s *SubtitleService) ServeRaw(ctx context.Context, mediaID, sub string, w i defer f.Close() _, err = io.Copy(w, f) return err -} \ No newline at end of file +} diff --git a/internal/service/subtitle_test.go b/internal/service/subtitle_test.go index 1643cae..bfb5236 100644 --- a/internal/service/subtitle_test.go +++ b/internal/service/subtitle_test.go @@ -10,9 +10,9 @@ import ( "go.uber.org/zap" "gorm.io/gorm" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestSubtitleDiscoverNoTracksReturnsEmptySlice(t *testing.T) { diff --git a/internal/service/system_update.go b/internal/service/system_update.go index b793bc8..36ceedd 100644 --- a/internal/service/system_update.go +++ b/internal/service/system_update.go @@ -10,8 +10,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/repository" ) const ( @@ -20,7 +20,7 @@ const ( SystemUpdateCommandSettingKey = "system.update.command" SystemUpdateComposeDirSettingKey = "system.update.compose_dir" - DefaultSystemUpdateImage = "ghcr.io/shukebta/mmtl:latest" + DefaultSystemUpdateImage = "ghcr.io/truewhile/mebox:latest" DefaultSystemUpdateWatchtowerImage = "containrrr/watchtower:latest" ) @@ -198,11 +198,11 @@ func (s *SystemUpdateService) startUpdateTask(status SystemUpdateStatus) *TaskHa } func (s *SystemUpdateService) baseStatus(ctx context.Context) SystemUpdateStatus { - image := s.setting(ctx, SystemUpdateImageSettingKey, os.Getenv("MMTL_UPDATE_IMAGE")) + image := s.setting(ctx, SystemUpdateImageSettingKey, os.Getenv("MEBOX_UPDATE_IMAGE")) if image == "" { image = DefaultSystemUpdateImage } - watchtowerImage := s.setting(ctx, SystemUpdateWatchtowerImageSettingKey, os.Getenv("MMTL_UPDATE_WATCHTOWER_IMAGE")) + watchtowerImage := s.setting(ctx, SystemUpdateWatchtowerImageSettingKey, os.Getenv("MEBOX_UPDATE_WATCHTOWER_IMAGE")) if watchtowerImage == "" { watchtowerImage = DefaultSystemUpdateWatchtowerImage } diff --git a/internal/service/system_update_command.go b/internal/service/system_update_command.go index a27bafd..fe626c3 100644 --- a/internal/service/system_update_command.go +++ b/internal/service/system_update_command.go @@ -19,7 +19,7 @@ func (s *SystemUpdateService) updateCommand(ctx context.Context, status SystemUp } func (s *SystemUpdateService) rawUpdateCommand(ctx context.Context) string { - return s.setting(ctx, SystemUpdateCommandSettingKey, os.Getenv("MMTL_UPDATE_COMMAND")) + return s.setting(ctx, SystemUpdateCommandSettingKey, os.Getenv("MEBOX_UPDATE_COMMAND")) } func defaultSystemUpdateCommand() string { @@ -33,7 +33,7 @@ func renderSystemUpdateCommand(template string, status SystemUpdateStatus) strin "{{compose_dir}}": shellQuote(status.ComposeDir), "{{compose_file}}": shellQuote(status.ComposeFile), "{{compose_command}}": firstNonEmpty(status.ComposeCommand, "docker compose"), - "{{container}}": shellQuote(firstNonEmpty(status.ContainerName, status.ContainerID, "mmtl")), + "{{container}}": shellQuote(firstNonEmpty(status.ContainerName, status.ContainerID, "mebox")), "{{container_id}}": shellQuote(status.ContainerID), "{{container_name}}": shellQuote(status.ContainerName), } diff --git a/internal/service/system_update_compose.go b/internal/service/system_update_compose.go index 2172ba9..927e66f 100644 --- a/internal/service/system_update_compose.go +++ b/internal/service/system_update_compose.go @@ -32,7 +32,7 @@ func (s *SystemUpdateService) resolveComposeTarget(ctx context.Context) (systemU } func (s *SystemUpdateService) composeTargetFromConfiguredDir(ctx context.Context) systemUpdateComposeTarget { - configured := s.setting(ctx, SystemUpdateComposeDirSettingKey, firstNonEmpty(os.Getenv("MMTL_UPDATE_COMPOSE_DIR"), os.Getenv("MMTL_UPDATE_WORKDIR"))) + configured := s.setting(ctx, SystemUpdateComposeDirSettingKey, firstNonEmpty(os.Getenv("MEBOX_UPDATE_COMPOSE_DIR"), os.Getenv("MEBOX_UPDATE_WORKDIR"))) return composeTargetInDir(configured) } @@ -143,7 +143,7 @@ func composeFileMatches(file string) bool { return false } content := strings.ToLower(string(raw)) - return strings.Contains(content, "mmtl") || strings.Contains(content, strings.ToLower(DefaultSystemUpdateImage)) + return strings.Contains(content, "mebox") || strings.Contains(content, strings.ToLower(DefaultSystemUpdateImage)) } func preferredComposeCommand() string { diff --git a/internal/service/system_update_docker.go b/internal/service/system_update_docker.go index 1fe9180..04ea86c 100644 --- a/internal/service/system_update_docker.go +++ b/internal/service/system_update_docker.go @@ -70,7 +70,7 @@ func (s *SystemUpdateService) check(ctx context.Context) SystemUpdateStatus { status.ContainerID = containerID if containerID == "" { if status.CanApply { - status.Message = "已识别 Docker Compose 安装目录;无法识别当前容器 ID,将使用默认容器名 mmtl 重启" + status.Message = "已识别 Docker Compose 安装目录;无法识别当前容器 ID,将使用默认容器名 mebox 重启" } return status } diff --git a/internal/service/system_update_test.go b/internal/service/system_update_test.go index 9bb8a2e..0f0aef1 100644 --- a/internal/service/system_update_test.go +++ b/internal/service/system_update_test.go @@ -10,10 +10,10 @@ import ( func TestRenderSystemUpdateCommand(t *testing.T) { status := SystemUpdateStatus{ - Image: "ghcr.io/shukebta/mmtl:latest", + Image: "ghcr.io/truewhile/mebox:latest", WatchtowerImage: "containrrr/watchtower:latest", ContainerID: "abc123def456", - ContainerName: "mmtl", + ContainerName: "mebox", } got := renderSystemUpdateCommand( @@ -27,8 +27,8 @@ func TestRenderSystemUpdateCommand(t *testing.T) { } for _, want := range []string{ "containrrr/watchtower:latest", - "mmtl", - "ghcr.io/shukebta/mmtl:latest", + "mebox", + "ghcr.io/truewhile/mebox:latest", "abc123def456", } { if !strings.Contains(got, want) { @@ -39,17 +39,17 @@ func TestRenderSystemUpdateCommand(t *testing.T) { func TestDefaultSystemUpdateCommandUsesCompose(t *testing.T) { status := SystemUpdateStatus{ - ComposeDir: "/opt/mmtl", + ComposeDir: "/opt/mebox", ComposeCommand: "docker compose", - ContainerName: "mmtl", + ContainerName: "mebox", } got := renderSystemUpdateCommand(defaultSystemUpdateCommand(), status) for _, want := range []string{ - "cd " + shellQuote("/opt/mmtl"), + "cd " + shellQuote("/opt/mebox"), "docker compose pull", "docker compose up -d", "docker image prune -f", - "docker restart " + shellQuote("mmtl"), + "docker restart " + shellQuote("mebox"), } { if !strings.Contains(got, want) { t.Fatalf("default command %q does not contain %q", got, want) @@ -60,12 +60,12 @@ func TestDefaultSystemUpdateCommandUsesCompose(t *testing.T) { } } -func TestComposeTargetInDirMatchesMMTLCompose(t *testing.T) { +func TestComposeTargetInDirMatchesMeBoxCompose(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(` services: - mmtl: - image: ghcr.io/shukebta/mmtl:latest + mebox: + image: ghcr.io/truewhile/mebox:latest `), 0o600); err != nil { t.Fatal(err) } @@ -90,8 +90,8 @@ services: } func TestParseContainerInspectLine(t *testing.T) { - name, imageID := parseContainerInspectLine("/mmtl|sha256:abc") - if name != "mmtl" || imageID != "sha256:abc" { + name, imageID := parseContainerInspectLine("/mebox|sha256:abc") + if name != "mebox" || imageID != "sha256:abc" { t.Fatalf("parse inspect line = %q %q", name, imageID) } @@ -155,11 +155,11 @@ func TestSystemUpdateOutputDetailsKeepsTail(t *testing.T) { } func TestSystemUpdateStatusIncludesCurrentVersion(t *testing.T) { - svc := NewSystemUpdateService(nil, nil, nil, nil, "MMTL-v0.0.99") + svc := NewSystemUpdateService(nil, nil, nil, nil, "MeBox-v0.0.99") status := svc.Status(context.Background()) - if status.CurrentVersion != "MMTL-v0.0.99" { - t.Fatalf("current_version = %q, want MMTL-v0.0.99", status.CurrentVersion) + if status.CurrentVersion != "MeBox-v0.0.99" { + t.Fatalf("current_version = %q, want MeBox-v0.0.99", status.CurrentVersion) } } diff --git a/internal/service/thetvdb.go b/internal/service/thetvdb.go index c3a74cd..9eaa9e4 100644 --- a/internal/service/thetvdb.go +++ b/internal/service/thetvdb.go @@ -24,7 +24,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) // TheTVDBProvider talks to https://api4.thetvdb.com/v4. diff --git a/internal/service/tmdb.go b/internal/service/tmdb.go index e75368c..1ad6d4b 100644 --- a/internal/service/tmdb.go +++ b/internal/service/tmdb.go @@ -24,7 +24,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) // TMDbProvider talks to https://api.themoviedb.org/3. diff --git a/internal/service/tmdb_chinese_title_test.go b/internal/service/tmdb_chinese_title_test.go index 8ac91a0..1f1c79e 100644 --- a/internal/service/tmdb_chinese_title_test.go +++ b/internal/service/tmdb_chinese_title_test.go @@ -9,7 +9,7 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) func TestApplyTMDbChineseTitlePrefersMainlandAlternative(t *testing.T) { diff --git a/internal/service/token_svc.go b/internal/service/token_svc.go index 9b3ceb1..72c8969 100644 --- a/internal/service/token_svc.go +++ b/internal/service/token_svc.go @@ -12,9 +12,9 @@ import ( "github.com/golang-jwt/jwt/v5" "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) const ( @@ -150,7 +150,7 @@ func (s *TokenService) issueAccessToken(userID, role, tier string) (string, erro RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(time.Now()), ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)), - Issuer: "mmtl", + Issuer: "mebox", Subject: userID, }, } diff --git a/internal/service/token_svc_pending.go b/internal/service/token_svc_pending.go index 2ca62aa..c2eaf09 100644 --- a/internal/service/token_svc_pending.go +++ b/internal/service/token_svc_pending.go @@ -7,8 +7,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) const loginRefreshTokenStoreTimeout = 750 * time.Millisecond diff --git a/internal/service/token_svc_pending_test.go b/internal/service/token_svc_pending_test.go index 6668f4e..6060c4a 100644 --- a/internal/service/token_svc_pending_test.go +++ b/internal/service/token_svc_pending_test.go @@ -6,9 +6,9 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func newTokenTestRepo(t *testing.T) *repository.Container { diff --git a/internal/service/transcoder.go b/internal/service/transcoder.go index b9bfc2d..cfac991 100644 --- a/internal/service/transcoder.go +++ b/internal/service/transcoder.go @@ -32,8 +32,8 @@ import ( "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/config" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/repository" ) // TranscoderService orchestrates background ffmpeg transcodes. diff --git a/internal/service/transcoder_ffmpeg_args.go b/internal/service/transcoder_ffmpeg_args.go index 1ffb629..318b3d7 100644 --- a/internal/service/transcoder_ffmpeg_args.go +++ b/internal/service/transcoder_ffmpeg_args.go @@ -4,7 +4,7 @@ import ( "fmt" "strconv" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) type ffmpegArgSettings struct { diff --git a/internal/service/transcoder_test.go b/internal/service/transcoder_test.go index f201df4..fea28d9 100644 --- a/internal/service/transcoder_test.go +++ b/internal/service/transcoder_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/ShukeBta/MMTL/internal/config" + "github.com/truewhile/MeBox/internal/config" ) func TestBuildFFmpegArgs(t *testing.T) { diff --git a/internal/service/user_limits.go b/internal/service/user_limits.go new file mode 100644 index 0000000..84f4935 --- /dev/null +++ b/internal/service/user_limits.go @@ -0,0 +1,71 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/truewhile/MeBox/internal/repository" +) + +const ( + // SettingMaxUsers stores the per-instance user cap in the settings table. + SettingMaxUsers = "user.max_users" + + // DefaultMaxUsers is used when the setting is missing or invalid. + DefaultMaxUsers = 20 + + // MaxUsersHardCap prevents absurd values from admin input. + MaxUsersHardCap = 10000 +) + +var ( + ErrInvalidMaxUsers = errors.New("invalid max users") +) + +// LoadMaxUsers reads the configured user cap, falling back to DefaultMaxUsers. +func LoadMaxUsers(ctx context.Context, repo *repository.Container) (int, error) { + if repo == nil || repo.Setting == nil { + return DefaultMaxUsers, nil + } + raw, err := repo.Setting.Get(ctx, SettingMaxUsers) + if err != nil { + return DefaultMaxUsers, err + } + return parseMaxUsersSetting(raw), nil +} + +func parseMaxUsersSetting(raw string) int { + n, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || n < 1 { + return DefaultMaxUsers + } + if n > MaxUsersHardCap { + return MaxUsersHardCap + } + return n +} + +// ValidateMaxUsers checks an admin-provided cap before persisting it. +func ValidateMaxUsers(n int) error { + if n < 1 { + return fmt.Errorf("%w: must be at least 1", ErrInvalidMaxUsers) + } + if n > MaxUsersHardCap { + return fmt.Errorf("%w: must be at most %d", ErrInvalidMaxUsers, MaxUsersHardCap) + } + return nil +} + +// SaveMaxUsers persists the user cap in settings. +func SaveMaxUsers(ctx context.Context, repo *repository.Container, n int) error { + if err := ValidateMaxUsers(n); err != nil { + return err + } + if repo == nil || repo.Setting == nil { + return errors.New("settings repository unavailable") + } + return repo.Setting.Set(ctx, SettingMaxUsers, strconv.Itoa(n)) +} diff --git a/internal/service/user_limits_test.go b/internal/service/user_limits_test.go new file mode 100644 index 0000000..59bdde6 --- /dev/null +++ b/internal/service/user_limits_test.go @@ -0,0 +1,67 @@ +package service + +import ( + "context" + "fmt" + "testing" + + "github.com/truewhile/MeBox/internal/model" +) + +func TestLoadMaxUsersDefaultsToTwenty(t *testing.T) { + ctx := context.Background() + repos, _, _, _ := newAuthTestServices(t) + got, err := LoadMaxUsers(ctx, repos) + if err != nil { + t.Fatal(err) + } + if got != DefaultMaxUsers { + t.Fatalf("max users = %d, want %d", got, DefaultMaxUsers) + } +} + +func TestSaveAndLoadMaxUsers(t *testing.T) { + ctx := context.Background() + repos, _, _, _ := newAuthTestServices(t) + if err := SaveMaxUsers(ctx, repos, 50); err != nil { + t.Fatal(err) + } + got, err := LoadMaxUsers(ctx, repos) + if err != nil { + t.Fatal(err) + } + if got != 50 { + t.Fatalf("max users = %d, want 50", got) + } +} + +func TestValidateMaxUsersRejectsInvalid(t *testing.T) { + if err := ValidateMaxUsers(0); err == nil { + t.Fatal("expected error for 0") + } + if err := ValidateMaxUsers(MaxUsersHardCap + 1); err == nil { + t.Fatal("expected error above hard cap") + } +} + +func TestRegisterRespectsConfiguredUserLimit(t *testing.T) { + ctx := context.Background() + repos, auth, _, _ := newAuthTestServices(t) + if err := SaveMaxUsers(ctx, repos, 3); err != nil { + t.Fatal(err) + } + for i := 0; i < 3; i++ { + if err := repos.User.Create(ctx, &model.User{ + Username: fmt.Sprintf("user-%02d", i), + PasswordHash: "hash", + Role: "user", + Tier: "free", + }); err != nil { + t.Fatal(err) + } + } + _, _, err := auth.Register(ctx, "overflow", "password") + if err == nil { + t.Fatal("expected user limit error") + } +} diff --git a/internal/service/visibility.go b/internal/service/visibility.go index 508fe74..29689f3 100644 --- a/internal/service/visibility.go +++ b/internal/service/visibility.go @@ -5,8 +5,8 @@ import ( "encoding/json" "strings" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) const AdultLibraryIDsSettingKey = "adult.library_ids" diff --git a/internal/service/watcher.go b/internal/service/watcher.go index b49bc38..e72b14b 100644 --- a/internal/service/watcher.go +++ b/internal/service/watcher.go @@ -22,8 +22,8 @@ import ( "github.com/fsnotify/fsnotify" "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) // pendingEvent records the most recent change to a path and the library it diff --git a/internal/service/watcher_test.go b/internal/service/watcher_test.go index abc7cbb..1ed55b3 100644 --- a/internal/service/watcher_test.go +++ b/internal/service/watcher_test.go @@ -8,8 +8,8 @@ import ( "github.com/fsnotify/fsnotify" "go.uber.org/zap" - "github.com/ShukeBta/MMTL/internal/model" - "github.com/ShukeBta/MMTL/internal/repository" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestWatcherRefreshMapsHostLibraryPathToContainerPath(t *testing.T) { @@ -20,8 +20,8 @@ func TestWatcherRefreshMapsHostLibraryPathToContainerPath(t *testing.T) { if err := os.MkdirAll(containerLibrary, 0o755); err != nil { t.Fatalf("mkdir container library: %v", err) } - t.Setenv("MMTL_MEDIA_DIR", hostMedia) - t.Setenv("MMTL_MEDIA_CONTAINER_DIR", containerMedia) + t.Setenv("MEBOX_MEDIA_DIR", hostMedia) + t.Setenv("MEBOX_MEDIA_CONTAINER_DIR", containerMedia) db := newServiceTestDB(t, &model.Library{}) repos := repository.New(db) diff --git a/server.exe.old b/server.exe.old deleted file mode 100644 index ebf5f23..0000000 Binary files a/server.exe.old and /dev/null differ diff --git a/server.exe.old2 b/server.exe.old2 deleted file mode 100644 index 829885c..0000000 Binary files a/server.exe.old2 and /dev/null differ diff --git a/web/index.html b/web/index.html index 948f2b0..55c74d7 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ - + @@ -14,7 +14,7 @@ href="https://fonts.googleapis.com/css2?family=Cabinet+Grotesk:wght@400;500;600;700;800&family=Geist:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" /> - MMTL + MeBox
diff --git a/web/package-lock.json b/web/package-lock.json index c0d8dc8..578c230 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,11 +1,11 @@ { - "name": "mmtl-web", + "name": "mebox-web", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "mmtl-web", + "name": "mebox-web", "version": "0.1.0", "dependencies": { "axios": "^1.7.2", diff --git a/web/package.json b/web/package.json index 1a128de..8804d70 100644 --- a/web/package.json +++ b/web/package.json @@ -1,5 +1,5 @@ { - "name": "mmtl-web", + "name": "mebox-web", "private": true, "version": "0.1.0", "type": "module", diff --git a/web/public/artwork-cache-sw.js b/web/public/artwork-cache-sw.js index fef4a5c..85333a8 100644 --- a/web/public/artwork-cache-sw.js +++ b/web/public/artwork-cache-sw.js @@ -1,4 +1,4 @@ -const ARTWORK_CACHE_PREFIX = 'mmtl-artwork-' +const ARTWORK_CACHE_PREFIX = 'mebox-artwork-' const ARTWORK_CACHE = `${ARTWORK_CACHE_PREFIX}v2` const MIN_CACHEABLE_ARTWORK_BYTES = 128 const STRIP_QUERY_KEYS = ['token', 'profile_id', 'profile_pin_token'] diff --git a/web/public/brand/logo-192.png b/web/public/brand/logo-192.png index 674b950..99411a9 100644 Binary files a/web/public/brand/logo-192.png and b/web/public/brand/logo-192.png differ diff --git a/web/public/brand/logo-512.png b/web/public/brand/logo-512.png index 7daa368..ac89c6f 100644 Binary files a/web/public/brand/logo-512.png and b/web/public/brand/logo-512.png differ diff --git a/web/public/brand/logo-64.png b/web/public/brand/logo-64.png index bc847c4..e174fb6 100644 Binary files a/web/public/brand/logo-64.png and b/web/public/brand/logo-64.png differ diff --git a/web/public/brand/logo-full.png b/web/public/brand/logo-full.png new file mode 100644 index 0000000..c182fb7 Binary files /dev/null and b/web/public/brand/logo-full.png differ diff --git a/web/public/brand/logo.png b/web/public/brand/logo.png index a19a8b2..ac89c6f 100644 Binary files a/web/public/brand/logo.png and b/web/public/brand/logo.png differ diff --git a/web/public/brand/logo.svg b/web/public/brand/logo.svg index cee18e6..90dce63 100644 --- a/web/public/brand/logo.svg +++ b/web/public/brand/logo.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/web/public/brand/mediastationgo-logo.svg b/web/public/brand/mediastationgo-logo.svg index cee18e6..ee184a6 100644 --- a/web/public/brand/mediastationgo-logo.svg +++ b/web/public/brand/mediastationgo-logo.svg @@ -1,3 +1,3 @@ - + diff --git a/web/public/brand/mgo-emby-icon.svg b/web/public/brand/mgo-emby-icon.svg index cee18e6..ee184a6 100644 --- a/web/public/brand/mgo-emby-icon.svg +++ b/web/public/brand/mgo-emby-icon.svg @@ -1,3 +1,3 @@ - + diff --git a/web/public/favicon.ico b/web/public/favicon.ico index 28d4d5a..71e3b74 100644 Binary files a/web/public/favicon.ico and b/web/public/favicon.ico differ diff --git a/web/public/favicon.svg b/web/public/favicon.svg index cee18e6..90dce63 100644 --- a/web/public/favicon.svg +++ b/web/public/favicon.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/web/src/App.tsx b/web/src/App.tsx index 536d549..a6e23e2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -16,7 +16,7 @@ class AppErrorBoundary extends Component<{ children: ReactNode }, { hasError: bo } componentDidCatch(error: Error, info: ErrorInfo) { - console.error('MMTL UI crashed', error, info) + console.error('MeBox UI crashed', error, info) } render() { diff --git a/web/src/api/admin.ts b/web/src/api/admin.ts index 925d627..98f3d86 100644 --- a/web/src/api/admin.ts +++ b/web/src/api/admin.ts @@ -64,8 +64,24 @@ export interface SystemUpdateStatus { started_at?: string } +export interface UserListResponse { + users: User[] + max_users: number + current_users: number +} + +export interface UserLimitResponse { + max_users: number + current_users: number +} + export const adminAPI = { - listUsers: () => api.get('/admin/users').then((r) => r.data), + listUsers: () => api.get('/admin/users').then((r) => r.data), + + getUserLimit: () => api.get('/admin/users/limit').then((r) => r.data), + + updateUserLimit: (maxUsers: number) => + api.put('/admin/users/limit', { max_users: maxUsers }).then((r) => r.data), createUser: (payload: { username: string; password: string }) => api.post('/admin/users', payload).then((r) => r.data), diff --git a/web/src/api/emby.ts b/web/src/api/emby.ts index a764944..78cfff1 100644 --- a/web/src/api/emby.ts +++ b/web/src/api/emby.ts @@ -8,6 +8,8 @@ export const embyAPI = { updateMount: (id: string, payload: { name?: string; proxy_play?: boolean; enabled?: boolean }) => api.put(`/admin/emby/mounts/${id}`, payload).then((r) => r.data), deleteMount: (id: string) => api.delete(`/admin/emby/mounts/${id}`).then((r) => r.data), + reorderMounts: (ids: string[]) => + api.put<{ ok: boolean }>('/admin/emby/mounts/reorder', { ids }).then((r) => r.data), listAccountViews: (accountId: string) => api.get(`/admin/emby/accounts/${accountId}/views`).then((r) => r.data), fullMountAccount: (accountId: string, proxy: boolean) => diff --git a/web/src/api/playback.ts b/web/src/api/playback.ts index f925314..09007c2 100644 --- a/web/src/api/playback.ts +++ b/web/src/api/playback.ts @@ -29,10 +29,15 @@ export interface ExternalPlayer { function publicOriginHeader() { if (typeof window === 'undefined' || !window.location?.origin) return undefined - return { 'X-MMTL-Public-Origin': window.location.origin } + return { 'X-MeBox-Public-Origin': window.location.origin } } export const playbackAPI = { + getResume: (mediaId: string) => + api + .get<{ position_ms: number; duration_ms: number; completed: boolean }>(`/playback/${mediaId}/resume`) + .then((r) => r.data), + recordProgress: (mediaId: string, positionMs: number, durationMs: number) => api .post('/history', { diff --git a/web/src/api/strm.ts b/web/src/api/strm.ts index e29bda9..46680bb 100644 --- a/web/src/api/strm.ts +++ b/web/src/api/strm.ts @@ -187,6 +187,15 @@ export const strmAPI = { cancelPendingUploads: () => api.post<{ canceled: number }>('/admin/strm/uploads/cancel-pending').then((r) => r.data), + clearDoneUploads: () => + api.post<{ deleted: number }>('/admin/strm/uploads/clear-done').then((r) => r.data), + + clearFinishedUploads: () => + api.post<{ deleted: number }>('/admin/strm/uploads/clear-finished').then((r) => r.data), + clearCanceledUploads: () => api.post<{ deleted: number }>('/admin/strm/uploads/clear-canceled').then((r) => r.data), + + retryFailedUploads: () => + api.post<{ retried: number }>('/admin/strm/uploads/retry-failed').then((r) => r.data), } \ No newline at end of file diff --git a/web/src/components/APIConfigsPanel.tsx b/web/src/components/APIConfigsPanel.tsx index fd9cc9c..3a202ee 100644 --- a/web/src/components/APIConfigsPanel.tsx +++ b/web/src/components/APIConfigsPanel.tsx @@ -39,8 +39,8 @@ export function APIConfigsPanel() { )} {!loading && ( -
- +
+
diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 9068ca9..9016732 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -7,15 +7,16 @@ import { LayoutSidebars, LayoutWorkspace, } from './LayoutSections' +import { MobileBottomNav } from './MobileBottomNav' +import { isAdminEntrySearch, isPlayerRoute, shouldShowMobileBottomNav } from './layoutNavigation' import { useLayoutPermissions } from './useLayoutPermissions' import { useLayoutProfiles } from './useLayoutProfiles' import { useLayoutSidebar } from './useLayoutSidebar' import { useThemeMode } from './useThemeMode' function isMediaView(pathname: string, search: string): boolean { - const params = new URLSearchParams(search) - // 从设置/管理后台菜单进入(携带 from=admin 或 from=settings 或 manage=1)时展示左侧栏 - if (params.get('from') === 'admin' || params.get('from') === 'settings' || params.get('manage') === '1') { + // 从设置/管理后台菜单进入时展示左侧栏 + if (isAdminEntrySearch(search)) { return false } @@ -24,7 +25,7 @@ function isMediaView(pathname: string, search: string): boolean { pathname === '/libraries' || pathname.startsWith('/library') || pathname.startsWith('/media') || - pathname.startsWith('/play') || + isPlayerRoute(pathname) || pathname === '/favourites' || pathname === '/playlists' || pathname.startsWith('/playlist') || @@ -50,15 +51,17 @@ export function Layout() { const showSidebar = !isMediaView(location.pathname, location.search) const hideSearch = location.pathname.startsWith('/settings') - const isPlayPage = location.pathname.startsWith('/play') + const isPlayPage = isPlayerRoute(location.pathname) + const showMobileBottomNav = shouldShowMobileBottomNav(location.pathname) return ( -
+
{!isPlayPage && ( @@ -72,9 +75,13 @@ export function Layout() { onLogout={closeProfileAndLogout} showSidebar={showSidebar} hideSearch={hideSearch} + pathname={location.pathname} /> )} - + + {showMobileBottomNav && ( + sidebar.setIsMobileDrawerOpen(true)} /> + )}
) diff --git a/web/src/components/LayoutHeaderSections.tsx b/web/src/components/LayoutHeaderSections.tsx index fd8f9bb..e900e24 100644 --- a/web/src/components/LayoutHeaderSections.tsx +++ b/web/src/components/LayoutHeaderSections.tsx @@ -1,11 +1,12 @@ import { useEffect, useRef, useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' -import { Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react' +import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react' import { imageURL } from '../api/client' import { mediaAPI } from '../api/library' import type { Media, PlayProfile, User } from '../types' +import { resolveHeaderBack } from './layoutNavigation' import { LayoutThemeToggle } from './LayoutThemeToggle' import { LayoutUserMenu } from './LayoutUserMenu' import type { useLayoutProfiles } from './useLayoutProfiles' @@ -29,6 +30,7 @@ type LayoutHeaderProps = { onLogout: () => void showSidebar?: boolean hideSearch?: boolean + pathname?: string } export function LayoutHeader({ @@ -41,11 +43,26 @@ export function LayoutHeader({ onLogout, showSidebar, hideSearch, + pathname = '', }: LayoutHeaderProps) { + const navigate = useNavigate() + const headerBack = resolveHeaderBack(pathname) + return ( -
- {/* Left: Mobile Menu button or Brand Logo */} -
+
+ {/* Left: back / menu / brand */} +
+ {headerBack ? ( + + ) : null} - + MMTL - MMTL + MeBox
{/* Middle: Search Box */} -
+
{!hideSearch && }
@@ -155,8 +167,8 @@ function LayoutHeaderSearch() {
0) setIsOpen(true) }} onKeyDown={handleKeyDown} - placeholder="搜索电影、剧集、演员…" - className="w-full h-10 pl-10 pr-9 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] shadow-sm outline-none transition-all duration-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:bg-[var(--app-panel-elevated)]" + placeholder="搜索媒体…" + className="w-full h-9 sm:h-10 pl-8 sm:pl-10 pr-8 sm:pr-9 rounded-xl sm:rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] text-xs sm:text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] shadow-sm outline-none transition-all duration-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:bg-[var(--app-panel-elevated)]" /> {loading ? ( @@ -288,8 +300,10 @@ function LayoutHeaderActions({ }: LayoutHeaderActionsProps) { return (
- - +
+ + +
) diff --git a/web/src/components/LayoutSections.tsx b/web/src/components/LayoutSections.tsx index 5f30736..f64d385 100644 --- a/web/src/components/LayoutSections.tsx +++ b/web/src/components/LayoutSections.tsx @@ -3,6 +3,7 @@ import { AnimatePresence, motion } from 'framer-motion' import clsx from 'clsx' import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent' +import { isPlayerRoute } from './layoutNavigation' import { RouteErrorBoundary } from './RouteErrorBoundary' import type { useLayoutSidebar } from './useLayoutSidebar' @@ -25,10 +26,12 @@ type LayoutSidebarsProps = Omit< > & { sidebar: LayoutSidebarState showSidebar: boolean + sidebarVariant?: 'media' | 'admin' } type LayoutWorkspaceProps = { routeKey: string + showMobileBottomNav?: boolean } export { LayoutHeader } from './LayoutHeaderSections' @@ -37,7 +40,7 @@ export function LayoutDesktopSidebar({ children, isSidebarOpen }: LayoutSidebarP return (
服务
+
+
diff --git a/web/src/pages/AutoOrganizeSettingsPanel.tsx b/web/src/pages/AutoOrganizeSettingsPanel.tsx index 21d426b..83f232b 100644 --- a/web/src/pages/AutoOrganizeSettingsPanel.tsx +++ b/web/src/pages/AutoOrganizeSettingsPanel.tsx @@ -18,7 +18,6 @@ type AutoOrganizeSettingsPanelProps = { loading: boolean saving: boolean running: boolean - moveKeepsSeeding: boolean onRefresh: () => void onSave: () => void onRunNow: () => void @@ -40,7 +39,6 @@ export function AutoOrganizeSettingsPanel({ loading, saving, running, - moveKeepsSeeding, onRefresh, onSave, onRunNow, @@ -108,7 +106,6 @@ export function AutoOrganizeSettingsPanel({ )} diff --git a/web/src/pages/AutoOrganizeSettingsTabs.tsx b/web/src/pages/AutoOrganizeSettingsTabs.tsx index f5a4158..be017f1 100644 --- a/web/src/pages/AutoOrganizeSettingsTabs.tsx +++ b/web/src/pages/AutoOrganizeSettingsTabs.tsx @@ -13,11 +13,9 @@ type AutoOrganizeTabProps = { export function AutoOrganizeBasicTab({ config, currentDir, - moveKeepsSeeding, onConfigChange, }: AutoOrganizeTabProps & { currentDir: string - moveKeepsSeeding: boolean }) { return ( <> @@ -68,7 +66,7 @@ export function AutoOrganizeBasicTab({ onChange={(event) => onConfigChange('transferMode', event.target.value)} > - + @@ -85,20 +83,10 @@ export function AutoOrganizeBasicTab({ - {moveKeepsSeeding && ( -
- 当前同时选择了“移动”和“保种”。为避免 qB 做种源文件被删除,后端会实际使用硬链接;Docker / NAS - 多挂载或不同子卷下可能报 invalid cross-device link。需要真正移动时请关闭“保种”,需要保种但硬链接失败时请选择“复制”。 -
- )} -
- - -
) diff --git a/web/src/pages/DatabaseSettingsPanel.tsx b/web/src/pages/DatabaseSettingsPanel.tsx index 7711a83..0b6c69b 100644 --- a/web/src/pages/DatabaseSettingsPanel.tsx +++ b/web/src/pages/DatabaseSettingsPanel.tsx @@ -35,7 +35,7 @@ export function DatabaseSettingsPanel() { port: 5432, user: 'postgres', password: '', - dbname: 'mmtl', + dbname: 'mebox', sslmode: 'disable', dsn: '', }) @@ -70,7 +70,7 @@ export function DatabaseSettingsPanel() { port: Number(formData.port) || 5432, user: formData.user?.trim() || '', password: formData.password || '', - dbname: formData.dbname?.trim() || 'mmtl', + dbname: formData.dbname?.trim() || 'mebox', sslmode: formData.sslmode || 'disable', } } @@ -144,7 +144,7 @@ export function DatabaseSettingsPanel() { const payload = getPayload() const ok = await confirmAction({ title: '切换数据库', - message: '保存后系统配置将更新为使用 PostgreSQL。需要重启 MMTL 服务使新数据库生效。确定保存吗?', + message: '保存后系统配置将更新为使用 PostgreSQL。需要重启 MeBox 服务使新数据库生效。确定保存吗?', }) if (!ok) return @@ -211,7 +211,7 @@ export function DatabaseSettingsPanel() {

{status?.type === 'postgres' ? status.dsn || '配置的 PostgreSQL 实例' - : status?.db_path || './data/mmtl.db'} + : status?.db_path || './data/mebox.db'}

@@ -292,7 +292,7 @@ export function DatabaseSettingsPanel() { type="text" value={formData.dbname || ''} onChange={(e) => setFormData({ ...formData, dbname: e.target.value })} - placeholder="mmtl" + placeholder="mebox" className="w-full rounded-lg border border-gray-200 bg-sand-200/40 px-3 py-2 text-ink-600 placeholder:text-gray-400 focus:border-brand-500 focus:outline-none" />
@@ -339,7 +339,7 @@ export function DatabaseSettingsPanel() { type="text" value={formData.dsn || ''} onChange={(e) => setFormData({ ...formData, dsn: e.target.value })} - placeholder="postgres://user:password@127.0.0.1:5432/mmtl?sslmode=disable" + placeholder="postgres://user:password@127.0.0.1:5432/mebox?sslmode=disable" className="w-full font-mono text-xs rounded-lg border border-gray-200 bg-sand-200/40 px-3 py-2 text-ink-600 placeholder:text-gray-400 focus:border-brand-500 focus:outline-none" /> diff --git a/web/src/pages/EmbyMountPage.tsx b/web/src/pages/EmbyMountPage.tsx index e3b6ccb..dc5daaf 100644 --- a/web/src/pages/EmbyMountPage.tsx +++ b/web/src/pages/EmbyMountPage.tsx @@ -2,8 +2,11 @@ import { useCallback, useEffect, useState } from 'react' import toast from 'react-hot-toast' import { + ArrowDown, + ArrowUp, Check, Globe, + GripVertical, Loader2, Pencil, Plus, @@ -22,6 +25,12 @@ import type { StrmAccount } from '../types/strm' import type { EmbyMount, RemoteEmbyView } from '../types/emby' import { apiErrorMessage } from './StrmManagePage' import { confirmAction } from '../components/confirmAction' +import { + defaultEmbyRemoteLines, + encodeEmbyRemoteConfigLines, + normalizeEmbyRemoteLines, + type EmbyRemoteLine, +} from '../utils/embyRemoteLines' const inputCls = 'input-base w-full' @@ -37,21 +46,50 @@ function AccountDialog({ onSaved: () => void }) { const [name, setName] = useState(existing?.name ?? '') - const [url, setUrl] = useState('') + const [lines, setLines] = useState(() => defaultEmbyRemoteLines(existing?.emby_lines)) const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [token, setToken] = useState('') const [enabled, setEnabled] = useState(existing?.enabled ?? true) const [saving, setSaving] = useState(false) - const canSave = () => url !== '' && (token !== '' || password !== '') + const normalizedLines = normalizeEmbyRemoteLines(lines) + const canSave = () => normalizedLines.length > 0 && (token !== '' || password !== '' || !!existing?.has_credential) + + const updateLine = (index: number, patch: Partial) => { + setLines((prev) => prev.map((line, i) => (i === index ? { ...line, ...patch } : line))) + } + + const addLine = () => { + setLines((prev) => [...prev, { name: `线路 ${prev.length + 1}`, url: '' }]) + } + + const removeLine = (index: number) => { + setLines((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index))) + } + + const moveLine = (index: number, direction: 'up' | 'down') => { + setLines((prev) => { + const target = direction === 'up' ? index - 1 : index + 1 + if (target < 0 || target >= prev.length) return prev + const next = [...prev] + const [moved] = next.splice(index, 1) + next.splice(target, 0, moved) + return next + }) + } const submit = async (event: React.FormEvent) => { event.preventDefault() + const readyLines = normalizeEmbyRemoteLines(lines) + if (readyLines.length === 0) { + toast.error('请至少填写一条有效的 Emby 线路地址') + return + } setSaving(true) try { const config: Record = { - ...(url ? { url } : {}), + ...encodeEmbyRemoteConfigLines(readyLines), ...(username ? { username } : {}), ...(password ? { password } : {}), ...(token ? { token } : {}), @@ -66,7 +104,7 @@ function AccountDialog({ toast.success('Emby 账号已更新') } else { if (!canSave()) { - toast.error('请填写 Emby 地址与密码(或 API Key)') + toast.error('请填写线路地址与密码(或 API Key)') return } await strmAPI.createAccount({ name: name || 'Emby 服务器', provider: 'emby_remote', config }) @@ -82,11 +120,11 @@ function AccountDialog({ return (
-
e.stopPropagation()}> +
e.stopPropagation()}>

{existing ? '编辑 Emby 账号' : '添加 Emby 账号'}

-

连接远程 Emby 服务器(Jellyfin 暂不支持)

+

可配置内网、外网等多条线路,连接失败时按顺序自动切换

+
+ {lines.map((line, index) => ( +
+
+ + 线路 {index + 1} + {existing?.emby_active_line === index ? ' · 当前优先' : index === 0 ? ' · 默认优先' : ''} + +
+ + + +
+
+ updateLine(index, { name: e.target.value })} + /> + updateLine(index, { url: e.target.value })} + /> +
+ ))} +
+
@@ -454,8 +610,33 @@ export function EmbyMountPage() {

) : (
- {acctMounts.map((m) => ( -
+ {acctMounts.map((m, index) => ( +
handleDragOver(e, m.id)} + onDrop={(e) => handleDrop(e, m.id, acct.id)} + className={`flex items-center gap-3 rounded-xl border px-3 py-3 transition-colors ${ + draggingId === m.id + ? 'border-dashed border-brand-400 bg-brand-50/20 opacity-50' + : dragOverId === m.id + ? 'border-brand-400 bg-brand-50/30' + : m.enabled + ? 'border-sand-200 bg-gray-50/60' + : 'border-gray-100 bg-gray-50 opacity-60' + }`} + > +
handleDragStart(e, m.id)} + onDragEnd={() => { + setDraggingId(null) + setDragOverId(null) + }} + className="shrink-0 cursor-grab active:cursor-grabbing text-sand-400 hover:text-ink-600 p-0.5" + title="按住拖拽调整顺序" + > + +

@@ -466,6 +647,24 @@ export function EmbyMountPage() { {m.proxy_play ? ' · 本机代理播放' : ' · 直连播放'}

+
+ + +
用户名
+
+
diff --git a/web/src/pages/FileManagerPage.tsx b/web/src/pages/FileManagerPage.tsx index fa14add..46c758e 100644 --- a/web/src/pages/FileManagerPage.tsx +++ b/web/src/pages/FileManagerPage.tsx @@ -4,7 +4,6 @@ import { ChevronUp, Home } from 'lucide-react' import { filesAPI, type FileEntry, type FileListing } from '../api/files' import { libraryAPI } from '../api/library' import type { Library } from '../types' -import { settingOn } from './autoOrganizeModel' import { AutoOrganizeSettingsPanel } from './AutoOrganizeSettingsPanel' import { FileBrowserRoots } from './FileBrowserRoots' import { FileEntriesTable } from './FileEntriesTable' @@ -36,7 +35,6 @@ export function FileManagerPage() { () => libraries.filter((library) => !isCloudLibraryPath(library.path)), [libraries], ) - const autoMoveKeepsSeeding = autoOrganize.moveKeepsSeeding const refresh = useCallback(() => { setLoading(true) @@ -68,7 +66,6 @@ export function FileManagerPage() { selectedPath: fileOperations.selected?.path, selectedPaths: fileOperations.selectedPaths, scrapeAfter, - keepSeeding: settingOn(autoOrganize.config.keepSeeding), onScrapeAfterChange: setScrapeAfter, onClearSelected: () => fileOperations.setSelectedPaths([]), refresh, @@ -102,7 +99,6 @@ export function FileManagerPage() { loading={autoOrganize.loading} saving={autoOrganize.saving} running={autoOrganize.running} - moveKeepsSeeding={autoMoveKeepsSeeding} onRefresh={autoOrganize.refresh} onSave={() => void autoOrganize.save()} onRunNow={autoOrganize.runNow} @@ -119,7 +115,6 @@ export function FileManagerPage() { organizeDestPath={manualOrganize.organizeDestPath} organizeMediaType={manualOrganize.organizeMediaType} organizeTransferMode={manualOrganize.organizeTransferMode} - manualMoveKeepsSeeding={manualOrganize.manualMoveKeepsSeeding} scanAfter={manualOrganize.scanAfter} scrapeAfter={scrapeAfter} organizeReady={manualOrganize.organizeReady} diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index b517c0d..e40c1cb 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from 'react' import { libraryAPI, type LibraryWithPreview } from '../api/library' -import { playbackAPI, type HistoryItem } from '../api/playback' +import { historyAPI } from '../api/history' +import type { HistoryItem } from '../api/playback' import type { Library, Media } from '../types' import type { SeriesCard } from '../utils/groupSeries' import { @@ -33,15 +34,24 @@ export function HomePage() { .list({ withPreview: true, previewLimit: 20 }) .then((rows) => asArray(rows)) .catch(() => [] as LibraryWithPreview[]), - playbackAPI - .recentHistory() - .then((rows) => asArray(rows)) + historyAPI + .continueWatching(12) + .then((rows) => + rows.map( + (row): HistoryItem => ({ + ...row.history, + created_at: '', + updated_at: '', + media: row.media, + }), + ), + ) .catch(() => [] as HistoryItem[]), ]) if (cancelled) return setLibraries(libs) - setHistory(hist.filter((h) => h && !h.completed && !!h.media)) + setHistory(hist.filter((h) => h && !h.completed)) const mapData: Record = {} for (const lib of libs) { diff --git a/web/src/pages/HomePageSections.tsx b/web/src/pages/HomePageSections.tsx index c4aa643..b79aabe 100644 --- a/web/src/pages/HomePageSections.tsx +++ b/web/src/pages/HomePageSections.tsx @@ -23,6 +23,7 @@ import type { HistoryItem } from '../api/playback' import type { Library, Media } from '../types' import type { SeriesCard } from '../utils/groupSeries' import { seriesCardLink } from '../utils/groupSeries' +import { isRemoteEmbyID } from '../utils/remoteEmby' import { getLibraryArtworks } from './librariesPageModel' const TYPE_ICONS: Record = { @@ -575,7 +576,12 @@ export function ContinueWatchingSection({ history }: { history: HistoryItem[] }) style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }} > {history.slice(0, 12).map((h) => { - const media = h.media! + const media: Media = h.media ?? ({ + id: h.media_id, + title: isRemoteEmbyID(h.media_id) ? '远程媒体' : '媒体', + poster_url: '', + updated_at: h.watched_at, + } as Media) const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0 return (
@@ -591,7 +597,7 @@ export function ContinueWatchingSection({ history }: { history: HistoryItem[] }) function ContinueCard({ media, progress }: { media: Media; progress: number }) { return (
diff --git a/web/src/pages/LibrariesPage.tsx b/web/src/pages/LibrariesPage.tsx index 6f59f0d..f7541d5 100644 --- a/web/src/pages/LibrariesPage.tsx +++ b/web/src/pages/LibrariesPage.tsx @@ -9,9 +9,11 @@ import { LibrariesHeader, } from './LibrariesPageSections' import type { LibraryPreview } from './librariesPageModel' +import { readPinnedLibraryIds, sortLibraryPreviews, togglePinnedLibraryId } from '../utils/pinnedLibraries' export function LibrariesPage() { const [previews, setPreviews] = useState([]) + const [pinnedIds, setPinnedIds] = useState(() => readPinnedLibraryIds()) const [loading, setLoading] = useState(true) const [repairing, setRepairing] = useState(false) const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false) @@ -57,6 +59,12 @@ export function LibrariesPage() { loadLibraries().catch(() => undefined) }, [loadLibraries]) + const sortedPreviews = useMemo(() => sortLibraryPreviews(previews, pinnedIds), [previews, pinnedIds]) + + const handleTogglePin = useCallback((libraryId: string) => { + setPinnedIds(togglePinnedLibraryId(libraryId)) + }, []) + const total = useMemo(() => previews.reduce((sum, preview) => sum + preview.total, 0), [previews]) if (loading) { @@ -79,7 +87,7 @@ export function LibrariesPage() { {previews.length === 0 ? ( ) : ( - + )}
) diff --git a/web/src/pages/LibrariesPageSections.tsx b/web/src/pages/LibrariesPageSections.tsx index 649f06d..6154720 100644 --- a/web/src/pages/LibrariesPageSections.tsx +++ b/web/src/pages/LibrariesPageSections.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react' import { Link } from 'react-router-dom' import { motion } from 'framer-motion' -import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react' +import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, Pin, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react' import { imageURL } from '../api/client' import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle' @@ -9,6 +9,7 @@ import { MediaCard } from '../components/MediaCard' import { seriesCardLink } from '../utils/groupSeries' import { libraryDisplayPath } from './libraryDisplayModel' import { libraryArtworkItems, type LibraryPreview } from './librariesPageModel' +import { isLibraryPinned } from '../utils/pinnedLibraries' const TYPE_ICONS: Record = { movie: , @@ -48,36 +49,44 @@ export function LibrariesHeader({ onManageLibraries: () => void }) { return ( -
+
-

媒体库

-

+

媒体库

+

共 {previewCount} 个目录 · {total.toLocaleString()} 个条目。每个目录直接展示最新入库内容。

-
- {repairMsg && {repairMsg}} +
+ {repairMsg && {repairMsg}} - + 刮削队列 -
@@ -94,15 +103,28 @@ export function LibrariesEmptyState() { ) } -export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) { +export function LibrariesContent({ + previews, + pinnedIds, + onTogglePin, +}: { + previews: LibraryPreview[] + pinnedIds: string[] + onTogglePin: (libraryId: string) => void +}) { + const pinnedCount = previews.filter((preview) => isLibraryPinned(preview.library.id, pinnedIds)).length + return ( <>

媒体库入口

-

按目录进入完整媒体库;下方每个目录也会直接展示最新内容。

+

+ 按目录进入完整媒体库;下方每个目录也会直接展示最新内容。 + {pinnedCount > 0 ? ` 已置顶 ${pinnedCount} 个媒体库。` : ' 点击卡片右上角图钉可置顶常用媒体库。'} +

-
+
{previews.map((preview, index) => ( - + onTogglePin(preview.library.id)} + /> ))}
@@ -124,7 +150,10 @@ export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) { animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.03 }} > - + ))}
@@ -132,7 +161,15 @@ export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) { ) } -function LibraryEntryCard({ preview }: { preview: LibraryPreview }) { +function LibraryEntryCard({ + preview, + pinned, + onTogglePin, +}: { + preview: LibraryPreview + pinned: boolean + onTogglePin: () => void +}) { const library = preview.library const artwork = library.cover_url ? [{ src: library.cover_url, version: library.updated_at }] @@ -140,11 +177,36 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) { const displayPath = libraryDisplayPath(library.path) return ( - -
1 ? 'grid-cols-2' : 'grid-cols-1'}`}> + + +
1 ? 'grid-cols-2' : 'grid-cols-1'}`}> {artwork.length > 0 ? ( artwork.map(({ src, version }, index) => ( - {TYPE_ICONS[library.type] ?? } + {TYPE_ICONS[library.type] ?? }
)}
-
+
-
- {TYPE_LABELS[library.type] ?? library.type} +
+ + {TYPE_LABELS[library.type] ?? library.type} + + + {preview.total.toLocaleString()} 个条目 +
-

+

{library.name} + {pinned ? 置顶 : null}

-

+

{displayPath}

-
- {preview.total.toLocaleString()} 个条目 - 浏览全部 +
+ {preview.total.toLocaleString()} 个条目 + + 浏览全部 +
- + +
) } -function LibraryShelf({ preview }: { preview: LibraryPreview }) { +function LibraryShelf({ preview, pinned }: { preview: LibraryPreview; pinned?: boolean }) { const library = preview.library const cards = preview.cards.slice(0, 10) const displayPath = libraryDisplayPath(library.path) return ( -
-
+
+
-
+
{TYPE_ICONS[library.type] ?? } {TYPE_LABELS[library.type] ?? library.type}
-

{library.name}

+

+ {library.name} + {pinned ? 置顶 : null} +

{displayPath} · {preview.total.toLocaleString()} 个条目 · 最新 {cards.length} 部

- + 浏览全部 diff --git a/web/src/pages/LibraryMediaSections.tsx b/web/src/pages/LibraryMediaSections.tsx index 0b7b04f..fd4000b 100644 --- a/web/src/pages/LibraryMediaSections.tsx +++ b/web/src/pages/LibraryMediaSections.tsx @@ -11,7 +11,7 @@ type LibraryMediaSectionsProps = { seriesCards: SeriesCard[] selectedSeries: SeriesCard | null loading: boolean - movieActions: (media: Media) => ReactNode + cardActions: (media: Media) => ReactNode onSeriesClick: (series: SeriesCard) => void } @@ -21,7 +21,7 @@ export function LibraryMediaSections({ seriesCards, selectedSeries, loading, - movieActions, + cardActions, onSeriesClick, }: LibraryMediaSectionsProps) { return ( @@ -29,7 +29,7 @@ export function LibraryMediaSections({ {!isSeries && items.length > 0 && (
{items.map((media) => ( - + ))}
)} @@ -45,6 +45,7 @@ export function LibraryMediaSections({ key={series.key} media={series.rep} count={series.count} + actions={cardActions(series.rep)} onClick={() => onSeriesClick(series)} /> ))} diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index bda920f..578c79b 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState, Fragment, type ReactNode } from 'react' import { useLocation, useParams, useSearchParams } from 'react-router-dom' import { motion } from 'framer-motion' @@ -13,6 +13,8 @@ import { type SortOrder, } from '../utils/mediaSort' import { LibraryPageDialogs } from './LibraryPageDialogs' +import { PageBackButton } from '../components/PageBackButton' +import { MediaFavouriteButton } from '../components/MediaFavouriteButton' import { LibraryPageHeader } from './LibraryPageHeader' import { LibraryMediaSections } from './LibraryMediaSections' import { LibrarySeriesDetailSection } from './LibrarySeriesDetailSection' @@ -20,12 +22,17 @@ import { useLibraryData } from './useLibraryData' import { useLibraryScanStatus } from './useLibraryScanStatus' import { useLibrarySeriesSelection } from './useLibrarySeriesSelection' import { useLibraryAdminActions } from './useLibraryAdminActions' +import { usePermission } from '../hooks/usePermission' +import { useFavourites } from '../hooks/useFavourites' export function LibraryPage() { const { id = '' } = useParams() const [searchParams, setSearchParams] = useSearchParams() const location = useLocation() const role = useAuthStore((s) => s.user?.role) + const canFavorite = usePermission('can_favorite') + const { isFavourite, toggleFavourite } = useFavourites() + const [favouriteBusyID, setFavouriteBusyID] = useState('') const [scrapeDialogOpen, setScrapeDialogOpen] = useState(false) const [manualSeriesScrapeOpen, setManualSeriesScrapeOpen] = useState(false) @@ -34,20 +41,23 @@ export function LibraryPage() { // 排序状态(支持按库记忆) const [sortField, setSortField] = useState(() => { - const saved = localStorage.getItem(`mmtl_lib_sort_field_${id}`) || localStorage.getItem('mmtl_lib_sort_field') + const saved = localStorage.getItem(`mebox_lib_sort_field_${id}`) || localStorage.getItem('mebox_lib_sort_field') return (saved as SortField) || 'title' }) const [sortOrder, setSortOrder] = useState(() => { - const saved = localStorage.getItem(`mmtl_lib_sort_order_${id}`) || localStorage.getItem('mmtl_lib_sort_order') + const saved = localStorage.getItem(`mebox_lib_sort_order_${id}`) || localStorage.getItem('mebox_lib_sort_order') return (saved as SortOrder) || 'asc' }) const [randomSeed, setRandomSeed] = useState(() => Date.now()) const [historyMap, setHistoryMap] = useState>(new Map()) useEffect(() => { + if (sortField !== 'last_played') return + let cancelled = false historyAPI .list(1000) .then((historyItems) => { + if (cancelled) return const map = new Map() for (const item of historyItems ?? []) { if (item.media_id && item.watched_at) { @@ -59,17 +69,20 @@ export function LibraryPage() { setHistoryMap(map) }) .catch(() => {}) - }, []) + return () => { + cancelled = true + } + }, [sortField]) const handleSortChange = (field: SortField, order: SortOrder) => { setSortField(field) setSortOrder(order) if (id) { - localStorage.setItem(`mmtl_lib_sort_field_${id}`, field) - localStorage.setItem(`mmtl_lib_sort_order_${id}`, order) + localStorage.setItem(`mebox_lib_sort_field_${id}`, field) + localStorage.setItem(`mebox_lib_sort_order_${id}`, order) } - localStorage.setItem('mmtl_lib_sort_field', field) - localStorage.setItem('mmtl_lib_sort_order', order) + localStorage.setItem('mebox_lib_sort_field', field) + localStorage.setItem('mebox_lib_sort_order', order) if (field === 'random') { setRandomSeed(Date.now()) } @@ -162,6 +175,39 @@ export function LibraryPage() { setManualMovie, }) + const handleToggleFavourite = async (mediaID: string) => { + if (!canFavorite || favouriteBusyID) return + setFavouriteBusyID(mediaID) + try { + await toggleFavourite(mediaID) + } finally { + setFavouriteBusyID('') + } + } + + const cardActions = (media: Media): ReactNode => { + const actions: ReactNode[] = [] + if (canFavorite) { + actions.push( + { + void handleToggleFavourite(media.id) + }} + />, + ) + } + const adminActions = movieActions(media) + if (adminActions) { + actions.push({adminActions}) + } + if (actions.length === 0) return undefined + return <>{actions} + } + if (loading) { return (
@@ -175,6 +221,12 @@ export function LibraryPage() { return (
+ {!selectedSeries && ( +
+ +
+ )} + {!selectedSeries && ( @@ -216,6 +268,13 @@ export function LibraryPage() { loadingEpisodes={loadingSeriesEpisodes} playbackFrom={`${location.pathname}${location.search}`} isAdmin={role === 'admin'} + canFavorite={canFavorite} + favourite={selectedSeries ? isFavourite(selectedSeries.rep.id) : false} + favouriteBusy={!!selectedSeries && favouriteBusyID === selectedSeries.rep.id} + onToggleFavourite={() => { + if (!selectedSeries) return + void handleToggleFavourite(selectedSeries.rep.id) + }} seriesToolBusy={seriesToolBusy} onBack={clearSelectedSeries} onSmartScrape={handleSeriesSmartScrape} diff --git a/web/src/pages/LibraryPageHeader.tsx b/web/src/pages/LibraryPageHeader.tsx index acfdff2..bc2cf90 100644 --- a/web/src/pages/LibraryPageHeader.tsx +++ b/web/src/pages/LibraryPageHeader.tsx @@ -46,13 +46,13 @@ export function LibraryPageHeader({ const displayPath = library ? libraryDisplayPath(library.path) : '' return ( -
+
-

+

{library?.name ?? '媒体库'} ({itemCount})

- {library &&

{library.type} · {displayPath}

} + {library &&

{library.type} · {displayPath}

} {loadingAllText &&

{loadingAllText}

} {scanProgress &&

{scanProgress}

}
@@ -69,18 +69,27 @@ export function LibraryPageHeader({ checked={scrapeEpisodeArtwork} onChange={onScrapeEpisodeArtworkChange} title="关闭后仍会获取主海报和每集文字元数据,只跳过每集图片" - className="h-9" + className="h-9 text-xs sm:text-sm" /> - -