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
-
+
-适合 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
-
+
-
-
-
+
+
+
+---
+
## 许可证
-本项目使用 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
-
+
-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.
-
+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
-
+
-
-
-
+
+
+
---
-## 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 */}
-