mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd5bfdcc10 | |||
| 73dc37fef8 | |||
| f1b7d99a33 | |||
| 638dae0c91 | |||
| 791722aec6 | |||
| 1c1da494b7 | |||
| 97b3a7bd7f | |||
| 543615a2a8 | |||
| 2b7828e1fe | |||
| 0b1fe52e24 | |||
| 2fc2c17b05 | |||
| be96acb035 | |||
| 6a72e7ab82 | |||
| 4ebd477ca2 | |||
| 0320d956ed | |||
| 95395c479a | |||
| eba7904c99 | |||
| 1171fe9464 | |||
| 06b96102c8 | |||
| 077458df31 | |||
| 9124c9be9c | |||
| 3ae3c6540d | |||
| 313ebdd402 | |||
| db52792ada | |||
| a8065680bb | |||
| 3304f09b1d | |||
| ba621cfc4a | |||
| deca7735a7 | |||
| b503fdee7a | |||
| 3fe37e050b | |||
| d3233a62c0 |
@@ -4,9 +4,7 @@ name: AuTo Docker Image
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
|
||||
# 保留手动触发作为备选
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
|
||||
@@ -4,20 +4,20 @@
|
||||
<img src="web/public/brand/logo-192.png" width="96" height="96" alt="MMTL Logo" />
|
||||
</p>
|
||||
|
||||
<h3 align="center">适合 NAS、家庭共享和多端播放的私人媒体中心</h3>
|
||||
<h3 align="center">面向 NAS 与家庭影音场景的私人媒体中心</h3>
|
||||
|
||||
<p align="center">
|
||||
<strong>Docker 一键部署 · PostgreSQL 主库 · Redis 热缓存 · OpenSearch 搜索增强 · Emby 协议兼容 · Bot 通知</strong>
|
||||
<strong>媒体库 · 刮削整理 · 网盘 STRM · Emby 协议 · 远程 Emby 挂载 · 多用户权限 · Docker 一键部署</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#项目简介">项目简介</a> ·
|
||||
<a href="#快速开始">快速开始</a> ·
|
||||
<a href="#三挡部署">三挡部署</a> ·
|
||||
<a href="#路径映射">路径映射</a> ·
|
||||
<a href="#旧-sqlite-迁移">旧 SQLite 迁移</a> ·
|
||||
<a href="#部署档位">部署档位</a> ·
|
||||
<a href="#鸣谢">鸣谢</a> ·
|
||||
<a href="#开发构建">开发构建</a> ·
|
||||
<a href="CONTRIBUTING.md">贡献规范</a> ·
|
||||
<a href="https://mgo.3jzs.com">在线演示</a>
|
||||
<a href="README_EN.md">English</a> ·
|
||||
<a href="CONTRIBUTING.md">贡献规范</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,400 +31,223 @@
|
||||
|
||||
## 项目简介
|
||||
|
||||
MMTL 是一个自托管媒体管理系统,面向 NAS、小主机、家庭影音和多用户共享场景。它把媒体库、刮削、下载整理、订阅、网盘播放、Emby 协议兼容、用户权限和 Bot 通知放在一个后台里,目标是让用户只维护一套服务,就能给网页端、手机端、电视端和第三方播放器使用。
|
||||
**MMTL** 是一个自托管私人媒体管理系统,适合 NAS、小主机、家庭共享和多端播放场景。本项目由 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) fork 并持续二开维护,在保留「一套服务覆盖网页、手机、电视与第三方播放器」思路的同时,围绕网盘播放、任务队列、远程挂载和权限体系做了大量增强。
|
||||
|
||||
核心能力:
|
||||
你可以把 MMTL 理解为:
|
||||
|
||||
- 一个带现代 Web UI 的**媒体库后台**
|
||||
- 一个兼容 Emby/Jellyfin 客户端的**协议网关**
|
||||
- 一个连接本地硬盘、下载目录与网盘存储的**整理与播放入口**
|
||||
|
||||
### 核心能力
|
||||
|
||||
| 模块 | 说明 |
|
||||
| --- | --- |
|
||||
| **媒体库** | 电影、电视剧、动漫、综艺、音乐与自定义库;多根目录、扫库、海报墙、继续观看 |
|
||||
| **元数据刮削** | TMDb、Bangumi、Douban、TheTVDB、Fanart 等;支持 NFO、手动匹配、刮削队列 |
|
||||
| **播放** | 网页播放器、HLS 转码、弹幕、字幕、播放配置档、观看历史与收藏 |
|
||||
| **Emby 协议** | Infuse、SenPlayer、Fileball 等客户端可直接添加本服务,使用 MMTL 账号登录 |
|
||||
| **远程 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,只启动一个容器
|
||||
mkdir -p MMTL && cd MMTL
|
||||
|
||||
# 最省心:单镜像 + 内置 SQLite
|
||||
curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.simple.yml -o docker-compose.yml
|
||||
# 或第一档:PostgreSQL(多用户/高并发更稳)
|
||||
|
||||
# 或多用户场景:PostgreSQL 第一档
|
||||
# curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/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/mmtl: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` | MMTL + SQLite | 新手、单人、低配 NAS,只想一个容器跑起来 |
|
||||
| 第一档 | `docker-compose.yml` | MMTL + 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` 里的这几处:
|
||||
- 只启动 **一个** MMTL 容器,数据在 `./data/mmtl.db`
|
||||
- 通常只需改端口与媒体目录挂载
|
||||
- **不要**设置 `MMTL_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/mmtl.db`,首次启动会自动迁移到 PostgreSQL
|
||||
- 迁移完成后可将 `MMTL_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
|
||||
MMTL_DATABASE_DB_PATH: /data/no-sqlite-migration.db
|
||||
```
|
||||
|
||||
## 路径映射
|
||||
### 必须备份与可重建
|
||||
|
||||
路径映射是 Docker 部署里最容易填错的地方。原则是:`volumes` 左边是宿主机真实路径,右边是容器内路径;环境变量里也要保持对应关系。
|
||||
| 路径 | 说明 |
|
||||
| --- | --- |
|
||||
| `./data` | JWT 密钥、运行配置、SQLite 主库或迁移源 |
|
||||
| `./postgres` | PostgreSQL 主库(PG 档位) |
|
||||
| `./cache` | 海报/转码缓存,可重建 |
|
||||
| `./redis` | 热缓存,可重建 |
|
||||
| `./opensearch` | 搜索索引,可重建 |
|
||||
|
||||
NAS 示例:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /vol1/1000/Docker/moviepilot-v2/media:/vol1/1000/Docker/moviepilot-v2/media
|
||||
- /vol1/1000/qBittorrent/downloads:/vol1/1000/qBittorrent/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
|
||||
```
|
||||
|
||||
Windows Docker Desktop 示例:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- D:/Media:/media
|
||||
environment:
|
||||
MMTL_MEDIA_DIR: D:/Media
|
||||
MMTL_MEDIA_CONTAINER_DIR: /media
|
||||
```
|
||||
|
||||
如果后台添加媒体库时填的是 `/vol1/...`,Compose 里也建议把同一个 `/vol1/...` 挂进容器,避免自动整理和下载入库时路径不可访问。
|
||||
|
||||
## 旧 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`,再手动修改镜像版本并单独拉取。
|
||||
日常更新只拉 `mmtl` 服务即可,不要随意 `docker compose pull` 升级 PostgreSQL/Redis/OpenSearch 基础镜像。
|
||||
|
||||
如果第二档或第三档保留了原始文件名,更新时指定对应完整文件:
|
||||
---
|
||||
|
||||
```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
|
||||
Docker 部署最常见的问题是路径填错。记住:
|
||||
|
||||
- `volumes` **左侧**是宿主机真实路径,**右侧**是容器内路径
|
||||
- 网页后台添加媒体库时,应填写**容器内**路径(如 `/media/电影`)
|
||||
- 若使用自动整理/下载入库,`MMTL_MEDIA_DIR` 与 `MMTL_DOWNLOAD_DIR` 需与挂载一致
|
||||
|
||||
NAS 示例:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /vol1/1000/Media:/media
|
||||
- /vol1/1000/Downloads:/downloads
|
||||
environment:
|
||||
MMTL_MEDIA_DIR: /vol1/1000/Media
|
||||
MMTL_MEDIA_CONTAINER_DIR: /media
|
||||
MMTL_DOWNLOAD_DIR: /vol1/1000/Downloads
|
||||
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
|
||||
```
|
||||
|
||||
必须备份:
|
||||
---
|
||||
|
||||
```text
|
||||
./postgres PostgreSQL 主数据库
|
||||
./data JWT 密钥、运行配置、旧 SQLite 迁移源
|
||||
```
|
||||
## 首次使用建议
|
||||
|
||||
可重建:
|
||||
1. **创建媒体库** → 填写 `/media/...` → 执行扫库
|
||||
2. **配置元数据源** → 系统设置中添加 TMDb、Bangumi 等 API
|
||||
3. **(可选)连接 qBittorrent** → 下载客户端设置,宿主机可用 `http://host.docker.internal:8085`
|
||||
4. **(可选)配置网盘账号** → STRM 管理中添加 OpenList / 115 / WebDAV 等
|
||||
5. **第三方播放器** → 以 Emby 服务器添加 `http://服务器IP:18080`,使用 MMTL 账号登录
|
||||
|
||||
```text
|
||||
./cache 图片缓存、临时文件、转码缓存
|
||||
./redis Redis 热缓存
|
||||
./opensearch 搜索索引
|
||||
```
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
**启动后还是反复迁移 SQLite?**
|
||||
**扫库或入库很慢?**
|
||||
先确认路径映射与数据库档位。网盘扫描还受接口限速与目录规模影响;大库可考虑第二档 Redis 或第三档 OpenSearch。
|
||||
|
||||
确认旧数据已经迁移成功后,把 `MMTL_DATABASE_DB_PATH` 改成不存在的路径,例如 `/data/no-sqlite-migration.db`,然后重启容器。
|
||||
**qBittorrent 下载后无法整理?**
|
||||
确认下载目录已通过 `volumes` 挂进容器,且 `MMTL_DOWNLOAD_*` 环境变量对应正确。
|
||||
|
||||
**扫库或入库速度很慢?**
|
||||
**硬链接失败(cross-device link)?**
|
||||
硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 btrfs 子卷或网盘挂载时请改用复制或软链接。
|
||||
|
||||
先确认数据库档位和路径映射正确。第一档已经足够大多数场景;第二档 Redis 能缓解频繁刷新造成的数据库压力;第三档主要增强搜索,不会替代媒体扫描本身。网盘扫描还会受网盘接口响应、目录数量和网络质量影响。
|
||||
**第三方播放器连不上?**
|
||||
确认地址为 `http://IP:18080`,使用 MMTL 用户账号;反代部署需正确配置外部 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
|
||||
```
|
||||
MMTL 在 [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
|
||||
|
||||
<a href="https://www.star-history.com/?repos=ShukeBta%2FMMTL&type=date&legend=top-left">
|
||||
<a href="https://www.star-history.com/?repos=truewhile%2FMMTL&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=ShukeBta/MMTL&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=ShukeBta/MMTL&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=ShukeBta/MMTL&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=truewhile/MMTL&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=truewhile/MMTL&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=truewhile/MMTL&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目使用 GPL-3.0 License。详见 [LICENSE](LICENSE)。
|
||||
本项目采用 [GPL-3.0](LICENSE) 许可证。
|
||||
|
||||
+116
-472
@@ -4,18 +4,19 @@
|
||||
<img src="web/public/brand/logo-192.png" width="96" height="96" alt="MMTL Logo" />
|
||||
</p>
|
||||
|
||||
<h3 align="center">A lightweight, polished, NAS-friendly private media center</h3>
|
||||
<h3 align="center">A self-hosted media center for NAS and home theater</h3>
|
||||
|
||||
<p align="center">
|
||||
<strong>Docker-first setup · Multi-user management · Media library · Metadata · Downloads · Emby-protocol clients · Cloud playback</strong>
|
||||
<strong>Libraries · Metadata · Cloud STRM · Emby protocol · Remote Emby mounts · Multi-user · Docker-first</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">中文</a> ·
|
||||
<a href="#overview">Overview</a> ·
|
||||
<a href="#quick-start">Quick Start</a> ·
|
||||
<a href="#docker-compose-recommended">Docker Compose</a> ·
|
||||
<a href="#faq">FAQ</a> ·
|
||||
<a href="https://mgo.3jzs.com">Live Demo</a>
|
||||
<a href="#deployment-tiers">Deployment</a> ·
|
||||
<a href="#acknowledgements">Acknowledgements</a> ·
|
||||
<a href="#development">Development</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -27,78 +28,51 @@
|
||||
|
||||
---
|
||||
|
||||
## What is it?
|
||||
## Overview
|
||||
|
||||
MMTL is a self-hosted media center for personal libraries, home NAS, and home-theater users.
|
||||
**MMTL** 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, MMTL 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 MMTL in Infuse, SenPlayer, Fileball, etc. and sign in with MMTL accounts |
|
||||
| **Remote Emby mounts** | Browse remote Emby libraries inside MMTL 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 MMTL && cd MMTL
|
||||
|
||||
Edit `docker-compose.yml`:
|
||||
# Simplest: one container with built-in SQLite
|
||||
curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/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/MMTL/main/docker-compose.yml -o docker-compose.yml
|
||||
|
||||
Start:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
@@ -108,295 +82,46 @@ Open:
|
||||
http://SERVER_IP:18080
|
||||
```
|
||||
|
||||
Default login:
|
||||
Default login: `admin` / `admin123` — change the password immediately.
|
||||
|
||||
Image:
|
||||
|
||||
```text
|
||||
Username: admin
|
||||
Password: admin123
|
||||
ghcr.io/truewhile/mmtl: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` | MMTL + SQLite | Beginners, single-user, low-resource NAS |
|
||||
| Tier 1 | `docker-compose.yml` | MMTL + 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 MMTL container; database lives in `./data/mmtl.db`
|
||||
- Do **not** set `MMTL_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/mmtl.db` migrates automatically on first start
|
||||
- After migration, point `MMTL_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:<version-tag>
|
||||
# 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
|
||||
|
||||
@@ -405,181 +130,100 @@ docker compose pull mmtl
|
||||
docker compose up -d --no-deps mmtl
|
||||
```
|
||||
|
||||
### 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 `MMTL_MEDIA_DIR` / `MMTL_DOWNLOAD_DIR` aligned with mounts when organizing or ingesting downloads
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /vol1/1000/Media:/media
|
||||
- /vol1/1000/Downloads:/downloads
|
||||
environment:
|
||||
MMTL_MEDIA_DIR: /vol1/1000/Media
|
||||
MMTL_MEDIA_CONTAINER_DIR: /media
|
||||
MMTL_DOWNLOAD_DIR: /vol1/1000/Downloads
|
||||
MMTL_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 MMTL 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 MMTL 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: <https://t.me/MMTL>
|
||||
- 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.
|
||||
MMTL 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.
|
||||
|
||||
<img width="200" height="200" alt="WeChat Donation QR" src="https://github.com/user-attachments/assets/d6077de5-8305-400d-8b82-470ef05d926e" />
|
||||
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
|
||||
|
||||
<a href="https://www.star-history.com/?repos=ShukeBta%2FMMTL&type=date&legend=top-left">
|
||||
<a href="https://www.star-history.com/?repos=truewhile%2FMMTL&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=ShukeBta/MMTL&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=ShukeBta/MMTL&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=ShukeBta/MMTL&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=truewhile/MMTL&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=truewhile/MMTL&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=truewhile/MMTL&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">Made with ❤️ by ShukeBta</p>
|
||||
This project is licensed under [GPL-3.0](LICENSE).
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ MMTL 是自托管媒体系统,常部署在 NAS、家庭网络、Docker、反
|
||||
我们优先支持以下版本和部署方式的安全修复:
|
||||
|
||||
- 当前 `main` 分支。
|
||||
- 最新发布镜像:`ghcr.io/shukebta/mmtl:latest`。
|
||||
- 最新发布镜像:`ghcr.io/truewhile/mmtl:latest`。
|
||||
- README 中推荐的 Docker Compose 第一档、第二档、第三档部署方式。
|
||||
|
||||
历史版本、私有魔改镜像、未公开补丁分支和非标准部署仍可报告,但维护者可能要求先在最新 `main` 或最新镜像中复现。
|
||||
|
||||
@@ -20,11 +20,7 @@
|
||||
|
||||
services:
|
||||
mmtl:
|
||||
# 镜像二选一:
|
||||
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
|
||||
image: ghcr.io/shukebta/mmtl:latest
|
||||
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
|
||||
# image: shukbet/mmtl:latest
|
||||
image: ghcr.io/truewhile/mmtl:latest
|
||||
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
@@ -101,7 +97,7 @@ services:
|
||||
MMTL_SEARCH_OPENSEARCH_URL: http://opensearch:9200
|
||||
MMTL_SEARCH_INDEX: mmtl_media
|
||||
|
||||
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
|
||||
MMTL_UPDATE_IMAGE: ghcr.io/truewhile/mmtl:latest
|
||||
|
||||
# 默认推荐在网页里使用容器路径 /media。
|
||||
# 如果旧媒体库已经保存了宿主机路径 /vol1/1000/Media,
|
||||
|
||||
@@ -13,9 +13,7 @@
|
||||
|
||||
services:
|
||||
mmtl:
|
||||
image: ghcr.io/shukebta/mmtl:latest
|
||||
# Docker Hub 备用:
|
||||
# image: shukbet/mmtl:latest
|
||||
image: ghcr.io/truewhile/mmtl:latest
|
||||
|
||||
container_name: mmtl
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -17,11 +17,7 @@
|
||||
|
||||
services:
|
||||
mmtl:
|
||||
# 镜像二选一:
|
||||
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
|
||||
image: ghcr.io/shukebta/mmtl:latest
|
||||
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
|
||||
# image: shukbet/mmtl:latest
|
||||
image: ghcr.io/truewhile/mmtl:latest
|
||||
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
@@ -92,7 +88,7 @@ services:
|
||||
MMTL_CACHE_REDIS_URL: redis://redis:6379/0
|
||||
MMTL_CACHE_CACHE_DIR: /cache
|
||||
|
||||
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
|
||||
MMTL_UPDATE_IMAGE: ghcr.io/truewhile/mmtl:latest
|
||||
|
||||
# 路径换算配置。左边宿主机真实路径要和 volumes 左边保持一致。
|
||||
MMTL_MEDIA_DIR: /media
|
||||
|
||||
+2
-6
@@ -16,11 +16,7 @@
|
||||
|
||||
services:
|
||||
mmtl:
|
||||
# 镜像二选一:
|
||||
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
|
||||
image: ghcr.io/shukebta/mmtl:latest
|
||||
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
|
||||
# image: shukbet/mmtl:latest
|
||||
image: ghcr.io/truewhile/mmtl:latest
|
||||
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
@@ -112,7 +108,7 @@ services:
|
||||
MMTL_CACHE_CACHE_DIR: /cache
|
||||
|
||||
# 管理面板热更新默认拉取此镜像,并用 Watchtower 一次性重建当前容器。
|
||||
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
|
||||
MMTL_UPDATE_IMAGE: ghcr.io/truewhile/mmtl:latest
|
||||
|
||||
# 路径换算配置。
|
||||
# 默认推荐在网页里使用容器路径 /media。
|
||||
|
||||
@@ -29,7 +29,7 @@ func setDefaults(v *viper.Viper) {
|
||||
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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -24,11 +24,19 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+61
-61
@@ -192,77 +192,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/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"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -219,4 +219,4 @@ func reorderEmbyMountsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,13 @@ import (
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -401,17 +401,17 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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)
|
||||
@@ -478,4 +478,3 @@ func TestListSubtitlesForRemoteEmbyMediaReturnsEmptyTracks(t *testing.T) {
|
||||
t.Fatalf("expected empty tracks array, got %v", payload.Tracks)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,11 +49,11 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
// Emby 挂载管理:远程 Emby 媒体库挂载(账号复用 strm/accounts)
|
||||
admin.GET("/emby/accounts/:id/views", embyAccountViewsHandler(svc))
|
||||
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))
|
||||
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))
|
||||
|
||||
admin.GET("/strm/accounts", listStrmAccountsHandler(svc))
|
||||
admin.POST("/strm/accounts", createStrmAccountHandler(svc))
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -30,6 +31,9 @@ type strmAccountView struct {
|
||||
ProviderLabel string `json:"provider_label"`
|
||||
// ProxyPlay 仅远程 Emby 挂载账号返回:播放流量是否经过 MMTL 代理(编辑回显用)。
|
||||
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 {
|
||||
@@ -45,6 +49,10 @@ func strmAccountViews(svc *service.Container, accounts []model.StrmAccount) []st
|
||||
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 +125,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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,19 +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/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",
|
||||
"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)
|
||||
|
||||
@@ -125,6 +125,18 @@ 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 {
|
||||
out = append(out, gin.H{
|
||||
"history": r,
|
||||
"media": *rm,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
@@ -163,7 +175,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
|
||||
|
||||
@@ -9,12 +9,12 @@ 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"` // 覆盖显示名(可选,默认「账号 · 库名」)
|
||||
SortOrder int `gorm:"default:0;index" json:"sort_order"` // 手动排序用,越小越靠前
|
||||
ProxyPlay bool `gorm:"default:false" json:"proxy_play"` // 该挂载播放流量是否经 MMTL 反向代理
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // 是否在媒体库中展示
|
||||
}
|
||||
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"` // 该挂载播放流量是否经 MMTL 反向代理
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // 是否在媒体库中展示
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -54,10 +54,10 @@ func AllModels() []interface{} {
|
||||
&StrmAccount{},
|
||||
&StrmSyncPath{},
|
||||
&StrmSyncRecord{},
|
||||
&StrmDownloadTask{},
|
||||
&StrmUploadTask{},
|
||||
&StrmDirCache{},
|
||||
&ScrapeTask{},
|
||||
&EmbyMount{},
|
||||
}
|
||||
&StrmDownloadTask{},
|
||||
&StrmUploadTask{},
|
||||
&StrmDirCache{},
|
||||
&ScrapeTask{},
|
||||
&EmbyMount{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
+11
-12
@@ -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"` // 相对根目录的路径
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -124,4 +124,4 @@ func (r *EmbyMountRepository) DeleteByAccountID(ctx context.Context, accountID s
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -3,6 +3,8 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -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:mmtl_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 mmtl_rn
|
||||
FROM media
|
||||
WHERE %s
|
||||
) ranked
|
||||
WHERE mmtl_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"`
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/database"
|
||||
"github.com/ShukeBta/MMTL/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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -821,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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -138,4 +138,3 @@ func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResul
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[:])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,4 +81,4 @@ func TestDanmakuCredentialsSelection(t *testing.T) {
|
||||
require.False(t, ok)
|
||||
require.Empty(t, id)
|
||||
require.Empty(t, key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,72 +278,72 @@ 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, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">自动识别弹幕</d></i>`)
|
||||
})
|
||||
mux.HandleFunc("/api/v2/comment/99999", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user2">手动搜索弹幕</d></i>`)
|
||||
})
|
||||
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, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">自动识别弹幕</d></i>`)
|
||||
})
|
||||
mux.HandleFunc("/api/v2/comment/99999", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user2">手动搜索弹幕</d></i>`)
|
||||
})
|
||||
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, "手动搜索弹幕")
|
||||
}
|
||||
// 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) {
|
||||
@@ -445,4 +445,4 @@ func TestDanmakuFetchEmbyRemoteStreamFailedFallsBackToSearch(t *testing.T) {
|
||||
require.Equal(t, int64(7799), res.EpisodeID)
|
||||
require.Equal(t, "降级搜索番剧", res.AnimeTitle)
|
||||
require.Contains(t, res.Raw, "降级搜索弹幕")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,94 +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 != "" || 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
|
||||
}
|
||||
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 {
|
||||
@@ -787,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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+159
-14
@@ -56,7 +56,9 @@ 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(手动填写或自动认证获得)
|
||||
@@ -322,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"])),
|
||||
@@ -333,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
|
||||
}
|
||||
|
||||
@@ -366,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)))
|
||||
@@ -399,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 加密写回账号配置(下次请求免登录)。
|
||||
@@ -425,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()
|
||||
@@ -452,8 +504,8 @@ 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)
|
||||
@@ -783,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 != "" {
|
||||
@@ -837,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)
|
||||
@@ -902,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 {
|
||||
|
||||
@@ -106,4 +106,4 @@ func rewriteEmbyRemoteIDsMap(m map[string]any, accountID string) {
|
||||
if items, ok := m["Items"]; ok {
|
||||
RewriteEmbyRemoteIDs(items, accountID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ShukeBta/MMTL/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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -49,11 +49,11 @@ func TestRewriteSubtitleDeliveryURLsFallsBackIndexOne(t *testing.T) {
|
||||
}
|
||||
rewriteSubtitleDeliveryURLs(src, "/Videos/embyremote~acct-1~item-1", &EmbyRemoteConfig{})
|
||||
streams := src["MediaStreams"].([]any)
|
||||
want := "/Videos/embyremote~acct-1~item-1/Subtitles/1/Stream"
|
||||
if got := streams[0].(map[string]any)["DeliveryUrl"]; got != want {
|
||||
t.Fatalf("subtitle DeliveryUrl = %v, want %v", got, want)
|
||||
}
|
||||
want := "/Videos/embyremote~acct-1~item-1/Subtitles/1/Stream"
|
||||
if got := streams[0].(map[string]any)["DeliveryUrl"]; got != want {
|
||||
t.Fatalf("subtitle DeliveryUrl = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapRemoteItemToMediaExtractsCodecsAndContainer(t *testing.T) {
|
||||
r := &EmbyRemoteService{}
|
||||
@@ -96,4 +96,4 @@ func TestMapRemoteItemToMediaExtractsCodecsAndContainer(t *testing.T) {
|
||||
if media.SizeBytes != 104857600 {
|
||||
t.Fatalf("size = %d, want 104857600", media.SizeBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 MMTL user's locally recorded playback
|
||||
// state to remote Emby payloads. Remote metadata remains authoritative unless the
|
||||
// user has played the item through MMTL.
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ShukeBta/MMTL/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")
|
||||
}
|
||||
}
|
||||
@@ -111,4 +111,3 @@ func safeZipTarget(destRoot, name string) (string, error) {
|
||||
}
|
||||
return targetAbs, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -78,4 +78,3 @@ func TestStartInstallRejectsConcurrent(t *testing.T) {
|
||||
svc.running = false
|
||||
svc.mu.Unlock()
|
||||
}
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -93,4 +93,3 @@ func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
|
||||
maxBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024
|
||||
return PruneImageCache(p.cacheDir, maxBytes)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
@@ -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)
|
||||
|
||||
@@ -12,6 +12,11 @@ import (
|
||||
"github.com/ShukeBta/MMTL/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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -19,8 +19,9 @@ import (
|
||||
|
||||
// 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 +29,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
|
||||
@@ -84,9 +93,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
|
||||
}
|
||||
|
||||
@@ -106,18 +106,18 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
|
||||
cfg.App.SSLCert = value
|
||||
case "https.key":
|
||||
cfg.App.SSLKey = value
|
||||
case "https.cert_path":
|
||||
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
|
||||
case "https.cert_path":
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ParseBoolSetting is the exported variant of parseBoolSetting for handlers
|
||||
|
||||
@@ -231,4 +231,3 @@ func (s *SchedulerService) jobCleanImageCache(ctx context.Context) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+11
-11
@@ -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)
|
||||
|
||||
@@ -110,7 +110,7 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -91,7 +91,7 @@ type StrmService struct {
|
||||
oauthSessions map[string]*strm115AuthSession
|
||||
wafUntil time.Time // 115 风控/限流熔断截止时间(由 mu 保护)
|
||||
|
||||
downloadSem chan struct{} // 全局下载并发信号量:限制整个进程同时进行「换直链+下载」的并发数
|
||||
downloadSem chan struct{} // 全局下载并发信号量:限制整个进程同时进行「换直链+下载」的并发数
|
||||
downloadSemOnce sync.Once
|
||||
}
|
||||
|
||||
@@ -273,8 +273,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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const (
|
||||
SystemUpdateCommandSettingKey = "system.update.command"
|
||||
SystemUpdateComposeDirSettingKey = "system.update.compose_dir"
|
||||
|
||||
DefaultSystemUpdateImage = "ghcr.io/shukebta/mmtl:latest"
|
||||
DefaultSystemUpdateImage = "ghcr.io/truewhile/mmtl:latest"
|
||||
DefaultSystemUpdateWatchtowerImage = "containrrr/watchtower:latest"
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
func TestRenderSystemUpdateCommand(t *testing.T) {
|
||||
status := SystemUpdateStatus{
|
||||
Image: "ghcr.io/shukebta/mmtl:latest",
|
||||
Image: "ghcr.io/truewhile/mmtl:latest",
|
||||
WatchtowerImage: "containrrr/watchtower:latest",
|
||||
ContainerID: "abc123def456",
|
||||
ContainerName: "mmtl",
|
||||
@@ -28,7 +28,7 @@ func TestRenderSystemUpdateCommand(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"containrrr/watchtower:latest",
|
||||
"mmtl",
|
||||
"ghcr.io/shukebta/mmtl:latest",
|
||||
"ghcr.io/truewhile/mmtl:latest",
|
||||
"abc123def456",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
@@ -65,7 +65,7 @@ func TestComposeTargetInDirMatchesMMTLCompose(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(`
|
||||
services:
|
||||
mmtl:
|
||||
image: ghcr.io/shukebta/mmtl:latest
|
||||
image: ghcr.io/truewhile/mmtl:latest
|
||||
`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -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,7 +51,8 @@ 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 (
|
||||
<div className="flex h-[100dvh] min-h-0 w-full overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
|
||||
@@ -59,6 +61,7 @@ export function Layout() {
|
||||
isAdmin={permissions.isAdmin}
|
||||
can={permissions.can}
|
||||
showSidebar={showSidebar}
|
||||
sidebarVariant={showSidebar ? 'admin' : 'media'}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col min-w-0 overflow-hidden">
|
||||
{!isPlayPage && (
|
||||
@@ -72,9 +75,13 @@ export function Layout() {
|
||||
onLogout={closeProfileAndLogout}
|
||||
showSidebar={showSidebar}
|
||||
hideSearch={hideSearch}
|
||||
pathname={location.pathname}
|
||||
/>
|
||||
)}
|
||||
<LayoutWorkspace routeKey={location.pathname} />
|
||||
<LayoutWorkspace routeKey={location.pathname} showMobileBottomNav={showMobileBottomNav} />
|
||||
{showMobileBottomNav && (
|
||||
<MobileBottomNav onOpenMenu={() => sidebar.setIsMobileDrawerOpen(true)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<header className="relative z-30 flex h-16 shrink-0 items-center justify-between gap-2 border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-3 backdrop-blur-md sm:h-20 sm:gap-4 sm:px-4 md:px-8">
|
||||
{/* Left: Mobile Menu button or Brand Logo */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{/* Left: back / menu / brand */}
|
||||
<div className="flex items-center gap-2 shrink-0 sm:gap-3">
|
||||
{headerBack ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(headerBack.to)}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-[var(--app-border)] px-2.5 py-2 text-xs font-semibold text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] lg:hidden"
|
||||
title={headerBack.label}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span className="max-w-[4.5rem] truncate sm:max-w-none">{headerBack.label}</span>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
onClick={onOpenMobileDrawer}
|
||||
className="rounded-xl border border-[var(--app-border)] p-2.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] transition-colors lg:hidden"
|
||||
@@ -53,12 +70,7 @@ export function LayoutHeader({
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
<Link
|
||||
to="/"
|
||||
className={`flex items-center gap-2.5 transition-transform hover:scale-105 ${
|
||||
showSidebar ? 'lg:hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<Link to="/" className={`flex items-center gap-2.5 transition-transform hover:scale-105 ${showSidebar ? 'lg:hidden' : 'hidden sm:flex'}`}>
|
||||
<img
|
||||
src="/brand/logo-192.png"
|
||||
alt="MMTL"
|
||||
@@ -303,6 +315,8 @@ function LayoutHeaderActions({
|
||||
onUseDefaultProfile={onUseDefaultProfile}
|
||||
onSwitchProfile={onSwitchProfile}
|
||||
onLogout={onLogout}
|
||||
themeMode={themeMode}
|
||||
onThemeChange={onThemeChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AnimatePresence, motion } from 'framer-motion'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { LayoutSidebarContent, type LayoutSidebarContentProps } from './LayoutSidebarContent'
|
||||
import { isPlayerRoute } from './layoutNavigation'
|
||||
import { RouteErrorBoundary } from './RouteErrorBoundary'
|
||||
import type { useLayoutSidebar } from './useLayoutSidebar'
|
||||
|
||||
@@ -25,10 +26,12 @@ type LayoutSidebarsProps = Omit<
|
||||
> & {
|
||||
sidebar: LayoutSidebarState
|
||||
showSidebar: boolean
|
||||
sidebarVariant?: 'media' | 'admin'
|
||||
}
|
||||
|
||||
type LayoutWorkspaceProps = {
|
||||
routeKey: string
|
||||
showMobileBottomNav?: boolean
|
||||
}
|
||||
|
||||
export { LayoutHeader } from './LayoutHeaderSections'
|
||||
@@ -37,7 +40,7 @@ export function LayoutDesktopSidebar({ children, isSidebarOpen }: LayoutSidebarP
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
'hidden lg:flex flex-col h-full shrink-0 transition-all duration-300 ease-out',
|
||||
'hidden lg:flex min-h-0 h-full shrink-0 flex-col transition-all duration-300 ease-out',
|
||||
isSidebarOpen ? 'w-64' : 'w-20',
|
||||
)}
|
||||
>
|
||||
@@ -63,7 +66,7 @@ export function LayoutMobileSidebar({ children, isOpen, onClose }: LayoutMobileS
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '-100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 220 }}
|
||||
className="relative flex w-64 max-w-xs flex-col h-full z-10 shadow-xl"
|
||||
className="relative z-10 flex h-full min-h-0 w-64 max-w-xs flex-col overflow-hidden shadow-xl"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
@@ -78,6 +81,7 @@ export function LayoutSidebars({
|
||||
isAdmin,
|
||||
can,
|
||||
showSidebar,
|
||||
sidebarVariant = 'admin',
|
||||
}: LayoutSidebarsProps) {
|
||||
const content = (
|
||||
<LayoutSidebarContent
|
||||
@@ -87,6 +91,7 @@ export function LayoutSidebars({
|
||||
can={can}
|
||||
onToggleSidebar={sidebar.toggleSidebar}
|
||||
onCloseMobileDrawer={() => sidebar.setIsMobileDrawerOpen(false)}
|
||||
variant={sidebarVariant}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -116,8 +121,12 @@ export function LayoutSidebars({
|
||||
)
|
||||
}
|
||||
|
||||
export function LayoutWorkspace({ routeKey }: LayoutWorkspaceProps) {
|
||||
if (routeKey.startsWith('/play')) {
|
||||
export function LayoutWorkspace({ routeKey, showMobileBottomNav = false }: LayoutWorkspaceProps) {
|
||||
const bottomPad = showMobileBottomNav
|
||||
? 'pb-[calc(3.75rem+env(safe-area-inset-bottom,0px))] lg:pb-10'
|
||||
: ''
|
||||
|
||||
if (isPlayerRoute(routeKey)) {
|
||||
return (
|
||||
<main className="flex flex-1 h-full w-full overflow-hidden">
|
||||
<RouteErrorBoundary>
|
||||
@@ -128,7 +137,7 @@ export function LayoutWorkspace({ routeKey }: LayoutWorkspaceProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10">
|
||||
<main className={clsx('flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10', bottomPad)}>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
|
||||
@@ -2,7 +2,11 @@ import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowLeft, Menu, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { LAYOUT_NAV_ITEMS, type LayoutNavItem } from './layoutNavigation'
|
||||
|
||||
import {
|
||||
LAYOUT_NAV_ITEMS,
|
||||
type LayoutNavItem,
|
||||
} from './layoutNavigation'
|
||||
import { SidebarLink } from './LayoutSidebarNav'
|
||||
|
||||
export type LayoutSidebarContentProps = {
|
||||
@@ -12,6 +16,7 @@ export type LayoutSidebarContentProps = {
|
||||
can: (key: string) => boolean
|
||||
onToggleSidebar: () => void
|
||||
onCloseMobileDrawer: () => void
|
||||
variant?: 'media' | 'admin'
|
||||
}
|
||||
|
||||
export function LayoutSidebarContent({
|
||||
@@ -21,18 +26,24 @@ export function LayoutSidebarContent({
|
||||
can,
|
||||
onToggleSidebar,
|
||||
onCloseMobileDrawer,
|
||||
variant = 'admin',
|
||||
}: LayoutSidebarContentProps) {
|
||||
const sidebarExpanded = isSidebarOpen || isMobileDrawerOpen
|
||||
const visibleItems = visibleSidebarItems({ isAdmin, can })
|
||||
const adminItems = visibleSidebarItems({ isAdmin, can, items: LAYOUT_NAV_ITEMS })
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col border-r border-[var(--app-border)] bg-[var(--app-panel)]">
|
||||
<div className="flex h-full min-h-0 flex-col border-r border-[var(--app-border)] bg-[var(--app-panel)]">
|
||||
<LayoutSidebarHeader
|
||||
sidebarExpanded={sidebarExpanded}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
onCloseMobileDrawer={onCloseMobileDrawer}
|
||||
/>
|
||||
<LayoutSidebarNav items={visibleItems} sidebarExpanded={sidebarExpanded} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-hide">
|
||||
{/* 窄屏媒体浏览抽屉不展示 MEDIA_NAV_ITEMS:底部导航与用户菜单已覆盖 */}
|
||||
{variant !== 'media' || adminItems.length > 0 ? (
|
||||
<LayoutSidebarNav items={adminItems} sidebarExpanded={sidebarExpanded} />
|
||||
) : null}
|
||||
</div>
|
||||
<LayoutSidebarHomeBack sidebarExpanded={sidebarExpanded} />
|
||||
</div>
|
||||
)
|
||||
@@ -41,8 +52,9 @@ export function LayoutSidebarContent({
|
||||
function visibleSidebarItems({
|
||||
isAdmin,
|
||||
can,
|
||||
}: Pick<LayoutSidebarContentProps, 'isAdmin' | 'can'>): LayoutNavItem[] {
|
||||
return LAYOUT_NAV_ITEMS.filter(
|
||||
items,
|
||||
}: Pick<LayoutSidebarContentProps, 'isAdmin' | 'can'> & { items: LayoutNavItem[] }): LayoutNavItem[] {
|
||||
return items.filter(
|
||||
(item) => (!item.adminOnly || isAdmin) && (!item.permission || can(item.permission)),
|
||||
)
|
||||
}
|
||||
@@ -57,7 +69,7 @@ function LayoutSidebarHeader({
|
||||
onCloseMobileDrawer: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-20 items-center justify-between border-b border-[var(--app-border)] px-6">
|
||||
<div className="flex h-20 shrink-0 items-center justify-between border-b border-[var(--app-border)] px-6">
|
||||
<Link to="/" className="flex items-center gap-3">
|
||||
<img
|
||||
src="/brand/logo-192.png"
|
||||
@@ -92,7 +104,7 @@ function LayoutSidebarNav({
|
||||
sidebarExpanded: boolean
|
||||
}) {
|
||||
return (
|
||||
<nav className="flex-1 overflow-y-auto px-4 py-5 space-y-1 scrollbar-hide">
|
||||
<nav className="px-4 py-5 space-y-1">
|
||||
{items.map((item) => {
|
||||
const ItemIcon = item.icon
|
||||
return (
|
||||
@@ -112,7 +124,7 @@ function LayoutSidebarNav({
|
||||
|
||||
function LayoutSidebarHomeBack({ sidebarExpanded }: { sidebarExpanded: boolean }) {
|
||||
return (
|
||||
<div className="border-t border-[var(--app-border)] bg-[var(--app-panel-soft)] p-4">
|
||||
<div className="shrink-0 border-t border-[var(--app-border)] bg-[var(--app-panel-soft)] p-4">
|
||||
<Link
|
||||
to="/"
|
||||
className={clsx(
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { isSidebarLinkActive } from './layoutNavigation'
|
||||
|
||||
type SidebarGroupProps = {
|
||||
id: string
|
||||
icon: ReactNode
|
||||
@@ -79,44 +81,41 @@ type SidebarLinkProps = {
|
||||
}
|
||||
|
||||
export function SidebarLink({ to, icon, label, end, collapsed, child }: SidebarLinkProps) {
|
||||
const location = useLocation()
|
||||
const isActive = isSidebarLinkActive(to, location.pathname, location.search, end)
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
<Link
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'relative flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 group',
|
||||
child && 'py-2.5 text-[13px]',
|
||||
isActive
|
||||
? 'bg-[var(--app-active-bg)] text-[var(--app-active-text)] shadow-sm'
|
||||
: 'text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span className={clsx(
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center transition-transform duration-300 group-hover:scale-110',
|
||||
isActive ? 'text-[var(--app-active-icon)]' : 'text-[var(--app-muted)] group-hover:text-[var(--app-subtle)]',
|
||||
)}>
|
||||
{icon}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -5 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="truncate whitespace-nowrap"
|
||||
>
|
||||
{label}
|
||||
</motion.span>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="pointer-events-none absolute left-full z-50 ml-3 whitespace-nowrap rounded-xl bg-[var(--app-tooltip-bg)] px-2.5 py-1.5 text-xs font-semibold text-[var(--app-tooltip-text)] opacity-0 shadow-lg transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={clsx(
|
||||
'relative flex items-center gap-3.5 rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-300 group',
|
||||
child && 'py-2.5 text-[13px]',
|
||||
isActive
|
||||
? 'bg-[var(--app-active-bg)] text-[var(--app-active-text)] shadow-sm'
|
||||
: 'text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]',
|
||||
)}
|
||||
</NavLink>
|
||||
>
|
||||
<span className={clsx(
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center transition-transform duration-300 group-hover:scale-110',
|
||||
isActive ? 'text-[var(--app-active-icon)]' : 'text-[var(--app-muted)] group-hover:text-[var(--app-subtle)]',
|
||||
)}>
|
||||
{icon}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -5 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="truncate whitespace-nowrap"
|
||||
>
|
||||
{label}
|
||||
</motion.span>
|
||||
)}
|
||||
{collapsed && (
|
||||
<div className="pointer-events-none absolute left-full z-50 ml-3 whitespace-nowrap rounded-xl bg-[var(--app-tooltip-bg)] px-2.5 py-1.5 text-xs font-semibold text-[var(--app-tooltip-text)] opacity-0 shadow-lg transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Cast, ChevronDown, Clock, Heart, ListMusic, LogOut, Settings, UserCog } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import type { PlayProfile } from '../types'
|
||||
import { LayoutThemeToggle } from './LayoutThemeToggle'
|
||||
import type { ThemeMode } from './useThemeMode'
|
||||
|
||||
type MenuPosition = {
|
||||
top: number
|
||||
right: number
|
||||
}
|
||||
|
||||
type LayoutUser = {
|
||||
username?: string
|
||||
@@ -22,6 +30,8 @@ type LayoutUserMenuProps = {
|
||||
onUseDefaultProfile: () => void
|
||||
onSwitchProfile: (profile: PlayProfile) => void
|
||||
onLogout: () => void
|
||||
themeMode?: ThemeMode
|
||||
onThemeChange?: (mode: ThemeMode) => void
|
||||
}
|
||||
|
||||
export function LayoutUserMenu({
|
||||
@@ -35,74 +45,95 @@ export function LayoutUserMenu({
|
||||
onUseDefaultProfile,
|
||||
onSwitchProfile,
|
||||
onLogout,
|
||||
themeMode,
|
||||
onThemeChange,
|
||||
}: LayoutUserMenuProps) {
|
||||
const location = useLocation()
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const lastLocationRef = useRef(`${location.pathname}${location.search}`)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const onCloseRef = useRef(onClose)
|
||||
const [menuPosition, setMenuPosition] = useState<MenuPosition | null>(null)
|
||||
|
||||
onCloseRef.current = onClose
|
||||
|
||||
const updateMenuPosition = useCallback(() => {
|
||||
const trigger = triggerRef.current
|
||||
if (!trigger) return
|
||||
const rect = trigger.getBoundingClientRect()
|
||||
setMenuPosition({
|
||||
top: rect.bottom + 12,
|
||||
right: Math.max(8, window.innerWidth - rect.right),
|
||||
})
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) {
|
||||
setMenuPosition(null)
|
||||
return undefined
|
||||
}
|
||||
|
||||
updateMenuPosition()
|
||||
window.addEventListener('resize', updateMenuPosition)
|
||||
window.addEventListener('scroll', updateMenuPosition, true)
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateMenuPosition)
|
||||
window.removeEventListener('scroll', updateMenuPosition, true)
|
||||
}
|
||||
}, [isOpen, updateMenuPosition])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const root = rootRef.current
|
||||
const target = event.target
|
||||
if (!root || !(target instanceof Node) || root.contains(target)) return
|
||||
onClose()
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
if (event.key === 'Escape') onCloseRef.current()
|
||||
}
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown, true)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const nextLocation = `${location.pathname}${location.search}`
|
||||
if (lastLocationRef.current === nextLocation) return
|
||||
lastLocationRef.current = nextLocation
|
||||
if (isOpen) onClose()
|
||||
}, [isOpen, location.pathname, location.search, onClose])
|
||||
onCloseRef.current()
|
||||
}, [location.pathname, location.search])
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative" data-testid="layout-user-menu">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="menu"
|
||||
className="flex items-center gap-2.5 rounded-full border border-[var(--app-border)] p-1 pr-3 transition-all hover:bg-[var(--app-hover)]"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-[#111827] to-[#1f2937] font-display text-xs font-bold text-white shadow-sm">
|
||||
{user?.username?.slice(0, 2).toUpperCase() || 'US'}
|
||||
</div>
|
||||
<div className="hidden text-left md:block">
|
||||
<p className="text-xs font-bold leading-none text-[var(--app-text)]">{user?.username}</p>
|
||||
<p className="mt-0.5 text-[9px] font-bold uppercase leading-none tracking-wider text-[var(--app-muted)]">
|
||||
{activeProfile ? `Profile: ${activeProfile.name}` : user?.role}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown size={14} className="text-[var(--app-muted)]" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
const menuPortal = isOpen && menuPosition && typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key="layout-user-menu-backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="fixed inset-0 z-[120]"
|
||||
aria-hidden="true"
|
||||
onPointerDown={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
key="layout-user-menu-panel"
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
role="menu"
|
||||
className="absolute right-0 z-50 mt-3 w-56 origin-top-right rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-xl"
|
||||
style={{ top: menuPosition.top, right: menuPosition.right }}
|
||||
className="fixed z-[121] w-56 origin-top-right rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-xl"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<UserMenuLink to="/profile" icon={<Settings size={16} />} label="设置" onClick={onClose} />
|
||||
<UserMenuLink to="/favourites" icon={<Heart size={16} />} label="我的收藏" onClick={onClose} />
|
||||
<UserMenuLink to="/playlists" icon={<ListMusic size={16} />} label="播放列表" onClick={onClose} />
|
||||
<UserMenuLink to="/history" icon={<Clock size={16} />} label="观看历史" onClick={onClose} />
|
||||
<UserMenuLink to="/dlna" icon={<Cast size={16} />} label="DLNA投屏" onClick={onClose} />
|
||||
<UserMenuLink to="/profile" icon={<Settings size={16} />} label="设置" onNavigate={onClose} />
|
||||
<UserMenuLink to="/favourites" icon={<Heart size={16} />} label="我的收藏" onNavigate={onClose} />
|
||||
<UserMenuLink to="/playlists" icon={<ListMusic size={16} />} label="播放列表" onNavigate={onClose} />
|
||||
<UserMenuLink to="/history" icon={<Clock size={16} />} label="观看历史" onNavigate={onClose} />
|
||||
<UserMenuLink to="/dlna" icon={<Cast size={16} />} label="DLNA投屏" onNavigate={onClose} />
|
||||
{themeMode && onThemeChange ? (
|
||||
<div className="px-3 py-2 sm:hidden">
|
||||
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
||||
主题
|
||||
</p>
|
||||
<LayoutThemeToggle mode={themeMode} onChange={onThemeChange} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="my-1.5 border-t border-[var(--app-border)]" />
|
||||
<div className="px-3 py-2">
|
||||
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
||||
@@ -128,18 +159,50 @@ export function LayoutUserMenu({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<UserMenuLink to="/play-profiles" icon={<UserCog size={16} />} label="管理观影 Profile" onClick={onClose} />
|
||||
<UserMenuLink
|
||||
to="/play-profiles"
|
||||
icon={<UserCog size={16} />}
|
||||
label="管理观影 Profile"
|
||||
onNavigate={onClose}
|
||||
/>
|
||||
<div className="my-1.5 border-t border-[var(--app-border)]" />
|
||||
<button
|
||||
onClick={onLogout}
|
||||
onClick={() => {
|
||||
onClose()
|
||||
onLogout()
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-sm text-red-500 transition-colors hover:bg-[var(--app-danger-soft)]"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>安全登出系统</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</AnimatePresence>,
|
||||
document.body,
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="relative z-[122]" data-testid="layout-user-menu">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={onToggle}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="menu"
|
||||
className="relative z-[122] flex items-center gap-2.5 rounded-full border border-[var(--app-border)] p-1 pr-3 transition-all hover:bg-[var(--app-hover)]"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-[#111827] to-[#1f2937] font-display text-xs font-bold text-white shadow-sm">
|
||||
{user?.username?.slice(0, 2).toUpperCase() || 'US'}
|
||||
</div>
|
||||
<div className="hidden text-left md:block">
|
||||
<p className="text-xs font-bold leading-none text-[var(--app-text)]">{user?.username}</p>
|
||||
<p className="mt-0.5 text-[9px] font-bold uppercase leading-none tracking-wider text-[var(--app-muted)]">
|
||||
{activeProfile ? `Profile: ${activeProfile.name}` : user?.role}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown size={14} className="text-[var(--app-muted)]" />
|
||||
</button>
|
||||
{menuPortal}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -148,17 +211,17 @@ function UserMenuLink({
|
||||
to,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
onNavigate,
|
||||
}: {
|
||||
to: string
|
||||
icon: React.ReactNode
|
||||
icon: ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
onNavigate: () => void
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
onClick={onNavigate}
|
||||
className="flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import clsx from 'clsx'
|
||||
import { Menu } from 'lucide-react'
|
||||
|
||||
import { MOBILE_BOTTOM_NAV_ITEMS } from './layoutNavigation'
|
||||
|
||||
type MobileBottomNavProps = {
|
||||
onOpenMenu: () => void
|
||||
}
|
||||
|
||||
export function MobileBottomNav({ onOpenMenu }: MobileBottomNavProps) {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="fixed inset-x-0 bottom-0 z-40 border-t border-[var(--app-border)] bg-[var(--app-panel)]/95 backdrop-blur-md lg:hidden"
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
aria-label="主导航"
|
||||
>
|
||||
<div className="mx-auto flex h-14 max-w-lg items-stretch justify-around px-1">
|
||||
{MOBILE_BOTTOM_NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = item.match
|
||||
? item.match(location.pathname)
|
||||
: item.end
|
||||
? location.pathname === item.to
|
||||
: location.pathname === item.to || location.pathname.startsWith(`${item.to}/`)
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={clsx(
|
||||
'flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 rounded-xl px-1 py-1 text-[10px] font-semibold transition-colors',
|
||||
active ? 'text-brand-600' : 'text-[var(--app-muted)] hover:text-[var(--app-text)]',
|
||||
)}
|
||||
>
|
||||
<Icon size={18} strokeWidth={active ? 2.4 : 2} />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenMenu}
|
||||
className="flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 rounded-xl px-1 py-1 text-[10px] font-semibold text-[var(--app-muted)] transition-colors hover:text-[var(--app-text)]"
|
||||
aria-label="打开更多菜单"
|
||||
>
|
||||
<Menu size={18} />
|
||||
<span>更多</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type PageBackButtonProps = {
|
||||
to?: string
|
||||
label?: string
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function PageBackButton({
|
||||
to,
|
||||
label = '返回',
|
||||
onClick,
|
||||
className,
|
||||
compact = false,
|
||||
}: PageBackButtonProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick()
|
||||
return
|
||||
}
|
||||
if (to) {
|
||||
navigate(to)
|
||||
return
|
||||
}
|
||||
navigate(-1)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-3 py-2 text-sm font-semibold text-[var(--app-subtle)] shadow-sm transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]',
|
||||
compact && 'px-2.5 py-1.5 text-xs',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ArrowLeft size={compact ? 15 : 16} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { PageBackButton } from './PageBackButton'
|
||||
|
||||
type PageHeaderProps = {
|
||||
title: string
|
||||
description?: string
|
||||
backTo?: string
|
||||
backLabel?: string
|
||||
onBack?: () => void
|
||||
actions?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
backTo,
|
||||
backLabel,
|
||||
onBack,
|
||||
actions,
|
||||
className,
|
||||
}: PageHeaderProps) {
|
||||
const showBack = Boolean(backTo || onBack)
|
||||
|
||||
return (
|
||||
<header className={className ?? 'space-y-3'}>
|
||||
{showBack && <PageBackButton to={backTo} label={backLabel} onClick={onBack} />}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display text-2xl font-bold text-ink-600 sm:text-3xl">{title}</h1>
|
||||
{description ? <p className="mt-0.5 text-xs text-ink-50 sm:text-sm">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Clock,
|
||||
FileOutput,
|
||||
FolderOpen,
|
||||
Heart,
|
||||
Home,
|
||||
Library,
|
||||
ListChecks,
|
||||
ListMusic,
|
||||
Settings,
|
||||
Tv,
|
||||
User,
|
||||
@@ -19,6 +23,20 @@ export type LayoutNavItem = {
|
||||
adminOnly?: boolean
|
||||
}
|
||||
|
||||
export type HeaderBackTarget = {
|
||||
to: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type MobileBottomNavItem = {
|
||||
to: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
end?: boolean
|
||||
match?: (pathname: string) => boolean
|
||||
}
|
||||
|
||||
/** Desktop admin sidebar and admin mobile drawer. */
|
||||
export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
|
||||
{ to: '/profile', label: '个人资料', icon: User },
|
||||
{ to: '/libraries?from=admin', label: '媒体库', icon: Library },
|
||||
@@ -26,8 +44,136 @@ export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
|
||||
{ to: '/admin', label: '用户管理', icon: Users, adminOnly: true },
|
||||
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
|
||||
{ to: '/settings', label: '系统设置', icon: Settings, adminOnly: true },
|
||||
// Emby 挂载(设置区):远程 Emby 媒体库挂载
|
||||
{ to: '/emby-mount', label: 'Emby 挂载', icon: Tv, adminOnly: true },
|
||||
// STRM 管理(设置区):网盘目录生成 strm 与元数据同步
|
||||
{ to: '/strm', label: 'STRM 管理', icon: FileOutput, adminOnly: true },
|
||||
]
|
||||
]
|
||||
|
||||
/** Media browsing links for the mobile drawer when browsing libraries. */
|
||||
export const MEDIA_NAV_ITEMS: LayoutNavItem[] = [
|
||||
{ to: '/', label: '首页', icon: Home, end: true },
|
||||
{ to: '/libraries', label: '媒体库', icon: Library },
|
||||
{ to: '/favourites', label: '我的收藏', icon: Heart },
|
||||
{ to: '/playlists', label: '播放列表', icon: ListMusic },
|
||||
{ to: '/history', label: '观看历史', icon: Clock },
|
||||
]
|
||||
|
||||
export const MOBILE_BOTTOM_NAV_ITEMS: MobileBottomNavItem[] = [
|
||||
{ to: '/', label: '首页', icon: Home, end: true },
|
||||
{
|
||||
to: '/libraries',
|
||||
label: '媒体库',
|
||||
icon: Library,
|
||||
match: (p) => p === '/libraries' || p.startsWith('/library'),
|
||||
},
|
||||
{ to: '/favourites', label: '收藏', icon: Heart },
|
||||
{
|
||||
to: '/playlists',
|
||||
label: '列表',
|
||||
icon: ListMusic,
|
||||
match: (p) => p === '/playlists' || p.startsWith('/playlist'),
|
||||
},
|
||||
]
|
||||
|
||||
const ROOT_MEDIA_PATHS = new Set(['/', '/libraries', '/favourites', '/playlists', '/history'])
|
||||
|
||||
/** True only for the fullscreen player route (`/play` or `/play/:id`), not `/playlists`. */
|
||||
export function isPlayerRoute(pathname: string): boolean {
|
||||
return pathname === '/play' || pathname.startsWith('/play/')
|
||||
}
|
||||
|
||||
export function isRootMediaPath(pathname: string): boolean {
|
||||
return ROOT_MEDIA_PATHS.has(pathname)
|
||||
}
|
||||
|
||||
/** Admin-entry query used to open the management sidebar on library pages. */
|
||||
export function isAdminEntrySearch(search: string): boolean {
|
||||
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search)
|
||||
return (
|
||||
params.get('from') === 'admin' ||
|
||||
params.get('from') === 'settings' ||
|
||||
params.get('manage') === '1'
|
||||
)
|
||||
}
|
||||
|
||||
export function isLibraryPath(pathname: string): boolean {
|
||||
return pathname === '/libraries' || pathname.startsWith('/library')
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar active state must honor query strings. NavLink only matches pathname,
|
||||
* so `/libraries` and `/libraries?from=admin` would otherwise both highlight.
|
||||
*/
|
||||
export function isSidebarLinkActive(
|
||||
to: string,
|
||||
pathname: string,
|
||||
search: string,
|
||||
end?: boolean,
|
||||
): boolean {
|
||||
const [toPath, toQuery = ''] = to.split('?')
|
||||
const toIsAdminLibrary = toPath === '/libraries' && isAdminEntrySearch(toQuery)
|
||||
const toIsMediaLibrary = toPath === '/libraries' && !toIsAdminLibrary
|
||||
|
||||
if (toIsAdminLibrary) {
|
||||
return isLibraryPath(pathname) && isAdminEntrySearch(search)
|
||||
}
|
||||
if (toIsMediaLibrary) {
|
||||
return isLibraryPath(pathname) && !isAdminEntrySearch(search)
|
||||
}
|
||||
|
||||
return end ? pathname === toPath : pathname === toPath || pathname.startsWith(`${toPath}/`)
|
||||
}
|
||||
|
||||
export function resolveHeaderBack(pathname: string): HeaderBackTarget | null {
|
||||
if (pathname.startsWith('/library/')) {
|
||||
return { to: '/libraries', label: '媒体库' }
|
||||
}
|
||||
if (pathname === '/playlists' || pathname === '/favourites' || pathname === '/history') {
|
||||
return { to: '/', label: '首页' }
|
||||
}
|
||||
if (pathname.startsWith('/playlist/')) {
|
||||
return { to: '/playlists', label: '播放列表' }
|
||||
}
|
||||
if (pathname.startsWith('/media/')) {
|
||||
return null
|
||||
}
|
||||
if (pathname === '/scraper/queue') {
|
||||
return { to: '/queue', label: '任务队列' }
|
||||
}
|
||||
if (pathname === '/strm/downloads' || pathname === '/strm/uploads') {
|
||||
return { to: '/strm', label: 'STRM 管理' }
|
||||
}
|
||||
if (
|
||||
pathname === '/profile' ||
|
||||
pathname === '/dlna' ||
|
||||
pathname === '/play-profiles' ||
|
||||
pathname === '/poster-wall'
|
||||
) {
|
||||
return { to: '/', label: '首页' }
|
||||
}
|
||||
if (
|
||||
pathname === '/settings' ||
|
||||
pathname === '/admin' ||
|
||||
pathname === '/files' ||
|
||||
pathname === '/queue' ||
|
||||
pathname === '/emby-mount' ||
|
||||
pathname === '/strm'
|
||||
) {
|
||||
return { to: '/', label: '首页' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function shouldShowMobileBottomNav(pathname: string): boolean {
|
||||
if (isPlayerRoute(pathname)) return false
|
||||
return (
|
||||
pathname === '/' ||
|
||||
pathname === '/libraries' ||
|
||||
pathname.startsWith('/library') ||
|
||||
pathname.startsWith('/media') ||
|
||||
pathname === '/favourites' ||
|
||||
pathname === '/playlists' ||
|
||||
pathname.startsWith('/playlist') ||
|
||||
pathname === '/history' ||
|
||||
pathname === '/poster-wall'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ type AutoOrganizeSettingsPanelProps = {
|
||||
loading: boolean
|
||||
saving: boolean
|
||||
running: boolean
|
||||
moveKeepsSeeding: boolean
|
||||
onRefresh: () => void
|
||||
onSave: () => void
|
||||
onRunNow: () => void
|
||||
@@ -40,7 +39,6 @@ export function AutoOrganizeSettingsPanel({
|
||||
loading,
|
||||
saving,
|
||||
running,
|
||||
moveKeepsSeeding,
|
||||
onRefresh,
|
||||
onSave,
|
||||
onRunNow,
|
||||
@@ -108,7 +106,6 @@ export function AutoOrganizeSettingsPanel({
|
||||
<AutoOrganizeBasicTab
|
||||
config={config}
|
||||
currentDir={currentDir}
|
||||
moveKeepsSeeding={moveKeepsSeeding}
|
||||
onConfigChange={onConfigChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -13,11 +13,9 @@ type AutoOrganizeTabProps = {
|
||||
export function AutoOrganizeBasicTab({
|
||||
config,
|
||||
currentDir,
|
||||
moveKeepsSeeding,
|
||||
onConfigChange,
|
||||
}: AutoOrganizeTabProps & {
|
||||
currentDir: string
|
||||
moveKeepsSeeding: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -68,7 +66,7 @@ export function AutoOrganizeBasicTab({
|
||||
onChange={(event) => onConfigChange('transferMode', event.target.value)}
|
||||
>
|
||||
<option value="hardlink">硬链接</option>
|
||||
<option value="move">移动(关闭保种才会移动)</option>
|
||||
<option value="move">移动</option>
|
||||
<option value="copy">复制</option>
|
||||
<option value="symlink">软链接</option>
|
||||
</select>
|
||||
@@ -85,20 +83,10 @@ export function AutoOrganizeBasicTab({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{moveKeepsSeeding && (
|
||||
<div className="rounded-xl border border-orange-300 bg-orange-50 px-3 py-2 text-xs text-orange-700">
|
||||
当前同时选择了“移动”和“保种”。为避免 qB 做种源文件被删除,后端会实际使用硬链接;Docker / NAS
|
||||
多挂载或不同子卷下可能报 invalid cross-device link。需要真正移动时请关闭“保种”,需要保种但硬链接失败时请选择“复制”。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<BooleanSetting config={config} settingKey="enabled" label="整理源目录定时自动整理" onConfigChange={onConfigChange} />
|
||||
<BooleanSetting config={config} settingKey="afterDownload" label="qB 下载完成后自动整理" onConfigChange={onConfigChange} />
|
||||
<BooleanSetting config={config} settingKey="downloadSmartClassify" label="下载器智能分类" onConfigChange={onConfigChange} />
|
||||
<BooleanSetting config={config} settingKey="smartClassify" label="智能分类到子库" onConfigChange={onConfigChange} />
|
||||
<BooleanSetting config={config} settingKey="autoAddLibrary" label="自动注册目的地媒体库" onConfigChange={onConfigChange} />
|
||||
<BooleanSetting config={config} settingKey="keepSeeding" label="保种" onConfigChange={onConfigChange} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -25,6 +25,12 @@ import type { StrmAccount } from '../types/strm'
|
||||
import type { EmbyMount, RemoteEmbyView } from '../types/emby'
|
||||
import { apiErrorMessage } from './StrmManagePage'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import {
|
||||
defaultEmbyRemoteLines,
|
||||
encodeEmbyRemoteConfigLines,
|
||||
normalizeEmbyRemoteLines,
|
||||
type EmbyRemoteLine,
|
||||
} from '../utils/embyRemoteLines'
|
||||
|
||||
const inputCls = 'input-base w-full'
|
||||
|
||||
@@ -40,21 +46,50 @@ function AccountDialog({
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [name, setName] = useState(existing?.name ?? '')
|
||||
const [url, setUrl] = useState('')
|
||||
const [lines, setLines] = useState<EmbyRemoteLine[]>(() => defaultEmbyRemoteLines(existing?.emby_lines))
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
const [enabled, setEnabled] = useState(existing?.enabled ?? true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const canSave = () => url !== '' && (token !== '' || password !== '')
|
||||
const normalizedLines = normalizeEmbyRemoteLines(lines)
|
||||
const canSave = () => normalizedLines.length > 0 && (token !== '' || password !== '' || !!existing?.has_credential)
|
||||
|
||||
const updateLine = (index: number, patch: Partial<EmbyRemoteLine>) => {
|
||||
setLines((prev) => prev.map((line, i) => (i === index ? { ...line, ...patch } : line)))
|
||||
}
|
||||
|
||||
const addLine = () => {
|
||||
setLines((prev) => [...prev, { name: `线路 ${prev.length + 1}`, url: '' }])
|
||||
}
|
||||
|
||||
const removeLine = (index: number) => {
|
||||
setLines((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index)))
|
||||
}
|
||||
|
||||
const moveLine = (index: number, direction: 'up' | 'down') => {
|
||||
setLines((prev) => {
|
||||
const target = direction === 'up' ? index - 1 : index + 1
|
||||
if (target < 0 || target >= prev.length) return prev
|
||||
const next = [...prev]
|
||||
const [moved] = next.splice(index, 1)
|
||||
next.splice(target, 0, moved)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
const readyLines = normalizeEmbyRemoteLines(lines)
|
||||
if (readyLines.length === 0) {
|
||||
toast.error('请至少填写一条有效的 Emby 线路地址')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const config: Record<string, string> = {
|
||||
...(url ? { url } : {}),
|
||||
...encodeEmbyRemoteConfigLines(readyLines),
|
||||
...(username ? { username } : {}),
|
||||
...(password ? { password } : {}),
|
||||
...(token ? { token } : {}),
|
||||
@@ -69,7 +104,7 @@ function AccountDialog({
|
||||
toast.success('Emby 账号已更新')
|
||||
} else {
|
||||
if (!canSave()) {
|
||||
toast.error('请填写 Emby 地址与密码(或 API Key)')
|
||||
toast.error('请填写线路地址与密码(或 API Key)')
|
||||
return
|
||||
}
|
||||
await strmAPI.createAccount({ name: name || 'Emby 服务器', provider: 'emby_remote', config })
|
||||
@@ -85,11 +120,11 @@ function AccountDialog({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
|
||||
<div className="w-full max-w-md rounded-3xl border border-sand-200 bg-white p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-3xl border border-sand-200 bg-white p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-display text-xl font-bold text-ink-600">{existing ? '编辑 Emby 账号' : '添加 Emby 账号'}</h3>
|
||||
<p className="text-xs text-sand-500">连接远程 Emby 服务器(Jellyfin 暂不支持)</p>
|
||||
<p className="text-xs text-sand-500">可配置内网、外网等多条线路,连接失败时按顺序自动切换</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-1.5 text-sand-500 hover:bg-gray-100">
|
||||
<X size={18} />
|
||||
@@ -100,16 +135,48 @@ function AccountDialog({
|
||||
<span className="mb-1 block font-semibold text-ink-600">账号名称</span>
|
||||
<input className={inputCls} value={name} placeholder="例如:家庭影院" onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block font-semibold text-ink-600">Emby 服务地址</span>
|
||||
<input
|
||||
className={inputCls}
|
||||
value={url}
|
||||
placeholder="http://192.168.1.10:8096"
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
disabled={!!existing}
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-semibold text-ink-600">接入线路</span>
|
||||
<button type="button" onClick={addLine} className="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2.5 py-1 text-xs font-semibold text-ink-100 hover:bg-gray-50">
|
||||
<Plus size={12} />
|
||||
添加线路
|
||||
</button>
|
||||
</div>
|
||||
{lines.map((line, index) => (
|
||||
<div key={`line-${index}`} className="space-y-2 rounded-2xl border border-sand-200 bg-gray-50/70 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wide text-sand-500">
|
||||
线路 {index + 1}
|
||||
{existing?.emby_active_line === index ? ' · 当前优先' : index === 0 ? ' · 默认优先' : ''}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" onClick={() => moveLine(index, 'up')} disabled={index === 0} className="rounded-lg border border-gray-200 p-1 text-ink-100 hover:bg-white disabled:opacity-30">
|
||||
<ArrowUp size={12} />
|
||||
</button>
|
||||
<button type="button" onClick={() => moveLine(index, 'down')} disabled={index === lines.length - 1} className="rounded-lg border border-gray-200 p-1 text-ink-100 hover:bg-white disabled:opacity-30">
|
||||
<ArrowDown size={12} />
|
||||
</button>
|
||||
<button type="button" onClick={() => removeLine(index)} disabled={lines.length <= 1} className="rounded-lg border border-gray-200 p-1 text-red-500 hover:bg-red-50 disabled:opacity-30">
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
className={inputCls}
|
||||
value={line.name}
|
||||
placeholder="线路名称,如:内网 / 外网 / IPv6"
|
||||
onChange={(e) => updateLine(index, { name: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className={inputCls}
|
||||
value={line.url}
|
||||
placeholder="http://192.168.1.10:8096 或 https://emby.example.com"
|
||||
onChange={(e) => updateLine(index, { url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block font-semibold text-ink-600">用户名</span>
|
||||
@@ -478,7 +545,7 @@ export function EmbyMountPage() {
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
添加远程 Emby 服务器后,按需挂载其媒体库到本项目;播放默认直连原 Emby,可对单个媒体库开启本机代理
|
||||
添加远程 Emby 服务器后,按需挂载其媒体库到本项目;可为同一服务器配置多条线路并自动切换
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setAccountDialog('new')} className="neon-button">
|
||||
@@ -512,6 +579,9 @@ export function EmbyMountPage() {
|
||||
<p className="font-display text-lg font-bold text-ink-600">{acct.name}</p>
|
||||
<p className="text-xs text-sand-500">
|
||||
{acct.has_credential ? '凭据已配置' : '待补全凭据'}
|
||||
{acct.emby_lines && acct.emby_lines.length > 0
|
||||
? ` · ${acct.emby_lines.length} 条线路`
|
||||
: ''}
|
||||
{acct.last_test_result ? ` · 最近测试:${acct.last_test_ok ? '正常' : acct.last_test_result}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ChevronUp, Home } from 'lucide-react'
|
||||
import { filesAPI, type FileEntry, type FileListing } from '../api/files'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library } from '../types'
|
||||
import { settingOn } from './autoOrganizeModel'
|
||||
import { AutoOrganizeSettingsPanel } from './AutoOrganizeSettingsPanel'
|
||||
import { FileBrowserRoots } from './FileBrowserRoots'
|
||||
import { FileEntriesTable } from './FileEntriesTable'
|
||||
@@ -36,7 +35,6 @@ export function FileManagerPage() {
|
||||
() => libraries.filter((library) => !isCloudLibraryPath(library.path)),
|
||||
[libraries],
|
||||
)
|
||||
const autoMoveKeepsSeeding = autoOrganize.moveKeepsSeeding
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true)
|
||||
@@ -68,7 +66,6 @@ export function FileManagerPage() {
|
||||
selectedPath: fileOperations.selected?.path,
|
||||
selectedPaths: fileOperations.selectedPaths,
|
||||
scrapeAfter,
|
||||
keepSeeding: settingOn(autoOrganize.config.keepSeeding),
|
||||
onScrapeAfterChange: setScrapeAfter,
|
||||
onClearSelected: () => fileOperations.setSelectedPaths([]),
|
||||
refresh,
|
||||
@@ -102,7 +99,6 @@ export function FileManagerPage() {
|
||||
loading={autoOrganize.loading}
|
||||
saving={autoOrganize.saving}
|
||||
running={autoOrganize.running}
|
||||
moveKeepsSeeding={autoMoveKeepsSeeding}
|
||||
onRefresh={autoOrganize.refresh}
|
||||
onSave={() => void autoOrganize.save()}
|
||||
onRunNow={autoOrganize.runNow}
|
||||
@@ -119,7 +115,6 @@ export function FileManagerPage() {
|
||||
organizeDestPath={manualOrganize.organizeDestPath}
|
||||
organizeMediaType={manualOrganize.organizeMediaType}
|
||||
organizeTransferMode={manualOrganize.organizeTransferMode}
|
||||
manualMoveKeepsSeeding={manualOrganize.manualMoveKeepsSeeding}
|
||||
scanAfter={manualOrganize.scanAfter}
|
||||
scrapeAfter={scrapeAfter}
|
||||
organizeReady={manualOrganize.organizeReady}
|
||||
|
||||
@@ -575,7 +575,8 @@ export function ContinueWatchingSection({ history }: { history: HistoryItem[] })
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{history.slice(0, 12).map((h) => {
|
||||
const media = h.media!
|
||||
const media = h.media
|
||||
if (!media) return null
|
||||
const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
|
||||
return (
|
||||
<div key={h.id} className="w-64 sm:w-72 shrink-0">
|
||||
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
LibrariesHeader,
|
||||
} from './LibrariesPageSections'
|
||||
import type { LibraryPreview } from './librariesPageModel'
|
||||
import { readPinnedLibraryIds, sortLibraryPreviews, togglePinnedLibraryId } from '../utils/pinnedLibraries'
|
||||
|
||||
export function LibrariesPage() {
|
||||
const [previews, setPreviews] = useState<LibraryPreview[]>([])
|
||||
const [pinnedIds, setPinnedIds] = useState<string[]>(() => readPinnedLibraryIds())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [repairing, setRepairing] = useState(false)
|
||||
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false)
|
||||
@@ -57,6 +59,12 @@ export function LibrariesPage() {
|
||||
loadLibraries().catch(() => undefined)
|
||||
}, [loadLibraries])
|
||||
|
||||
const sortedPreviews = useMemo(() => sortLibraryPreviews(previews, pinnedIds), [previews, pinnedIds])
|
||||
|
||||
const handleTogglePin = useCallback((libraryId: string) => {
|
||||
setPinnedIds(togglePinnedLibraryId(libraryId))
|
||||
}, [])
|
||||
|
||||
const total = useMemo(() => previews.reduce((sum, preview) => sum + preview.total, 0), [previews])
|
||||
|
||||
if (loading) {
|
||||
@@ -79,7 +87,7 @@ export function LibrariesPage() {
|
||||
{previews.length === 0 ? (
|
||||
<LibrariesEmptyState />
|
||||
) : (
|
||||
<LibrariesContent previews={previews} />
|
||||
<LibrariesContent previews={sortedPreviews} pinnedIds={pinnedIds} onTogglePin={handleTogglePin} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react'
|
||||
import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, Pin, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||
@@ -9,6 +9,7 @@ import { MediaCard } from '../components/MediaCard'
|
||||
import { seriesCardLink } from '../utils/groupSeries'
|
||||
import { libraryDisplayPath } from './libraryDisplayModel'
|
||||
import { libraryArtworkItems, type LibraryPreview } from './librariesPageModel'
|
||||
import { isLibraryPinned } from '../utils/pinnedLibraries'
|
||||
|
||||
const TYPE_ICONS: Record<string, ReactNode> = {
|
||||
movie: <Film size={18} />,
|
||||
@@ -102,13 +103,26 @@ export function LibrariesEmptyState() {
|
||||
)
|
||||
}
|
||||
|
||||
export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) {
|
||||
export function LibrariesContent({
|
||||
previews,
|
||||
pinnedIds,
|
||||
onTogglePin,
|
||||
}: {
|
||||
previews: LibraryPreview[]
|
||||
pinnedIds: string[]
|
||||
onTogglePin: (libraryId: string) => void
|
||||
}) {
|
||||
const pinnedCount = previews.filter((preview) => isLibraryPinned(preview.library.id, pinnedIds)).length
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h2 className="font-display text-2xl font-bold text-ink-600">媒体库入口</h2>
|
||||
<p className="text-sm text-ink-50">按目录进入完整媒体库;下方每个目录也会直接展示最新内容。</p>
|
||||
<p className="text-sm text-ink-50">
|
||||
按目录进入完整媒体库;下方每个目录也会直接展示最新内容。
|
||||
{pinnedCount > 0 ? ` 已置顶 ${pinnedCount} 个媒体库。` : ' 点击卡片右上角图钉可置顶常用媒体库。'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-3">
|
||||
{previews.map((preview, index) => (
|
||||
@@ -118,7 +132,11 @@ export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
>
|
||||
<LibraryEntryCard preview={preview} />
|
||||
<LibraryEntryCard
|
||||
preview={preview}
|
||||
pinned={isLibraryPinned(preview.library.id, pinnedIds)}
|
||||
onTogglePin={() => onTogglePin(preview.library.id)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
@@ -132,7 +150,10 @@ export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
>
|
||||
<LibraryShelf preview={preview} />
|
||||
<LibraryShelf
|
||||
preview={preview}
|
||||
pinned={isLibraryPinned(preview.library.id, pinnedIds)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</section>
|
||||
@@ -140,7 +161,15 @@ export function LibrariesContent({ previews }: { previews: LibraryPreview[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
|
||||
function LibraryEntryCard({
|
||||
preview,
|
||||
pinned,
|
||||
onTogglePin,
|
||||
}: {
|
||||
preview: LibraryPreview
|
||||
pinned: boolean
|
||||
onTogglePin: () => void
|
||||
}) {
|
||||
const library = preview.library
|
||||
const artwork = library.cover_url
|
||||
? [{ src: library.cover_url, version: library.updated_at }]
|
||||
@@ -148,10 +177,35 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
|
||||
const displayPath = libraryDisplayPath(library.path)
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/library/${library.id}`}
|
||||
className="group flex overflow-hidden rounded-2xl sm:rounded-3xl border border-sand-200 bg-white p-2.5 sm:p-3 shadow-card transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card-hover"
|
||||
<div
|
||||
className={
|
||||
'group relative flex overflow-hidden rounded-2xl sm:rounded-3xl border bg-white p-2.5 sm:p-3 shadow-card transition-all hover:-translate-y-0.5 hover:shadow-card-hover ' +
|
||||
(pinned ? 'border-brand-300 ring-1 ring-brand-100' : 'border-sand-200 hover:border-brand-200')
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onTogglePin()
|
||||
}}
|
||||
className={
|
||||
'absolute right-2 top-2 z-10 rounded-full border p-1.5 transition ' +
|
||||
(pinned
|
||||
? 'border-brand-200 bg-brand-50 text-brand-600 hover:bg-brand-100'
|
||||
: 'border-sand-200 bg-white/95 text-sand-500 opacity-0 hover:bg-gray-50 hover:text-brand-600 group-hover:opacity-100')
|
||||
}
|
||||
title={pinned ? '取消置顶' : '置顶媒体库'}
|
||||
aria-label={pinned ? '取消置顶' : '置顶媒体库'}
|
||||
aria-pressed={pinned}
|
||||
>
|
||||
<Pin size={14} className={pinned ? 'fill-current' : ''} />
|
||||
</button>
|
||||
<Link
|
||||
to={`/library/${library.id}`}
|
||||
className="flex min-w-0 flex-1"
|
||||
>
|
||||
<div className={`grid h-20 w-24 sm:h-24 sm:w-36 shrink-0 gap-1 overflow-hidden rounded-xl sm:rounded-2xl bg-[linear-gradient(135deg,#fff7ed,#f8fafc)] ${artwork.length > 1 ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{artwork.length > 0 ? (
|
||||
artwork.map(({ src, version }, index) => (
|
||||
@@ -173,7 +227,7 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-between px-3 sm:px-4 py-0.5 sm:py-1">
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between gap-1.5">
|
||||
<div className="mb-1 flex items-center justify-between gap-1.5 pr-8">
|
||||
<span className="inline-flex rounded-full bg-sand-100 px-2 py-0.5 text-[10px] font-bold text-sand-600">
|
||||
{TYPE_LABELS[library.type] ?? library.type}
|
||||
</span>
|
||||
@@ -183,6 +237,7 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
|
||||
</div>
|
||||
<h2 className="line-clamp-2 font-display text-sm font-bold leading-tight text-ink-600 group-hover:text-brand-600 sm:truncate sm:text-xl sm:font-black">
|
||||
{library.name}
|
||||
{pinned ? <span className="ml-1.5 align-middle text-[10px] font-bold text-brand-600">置顶</span> : null}
|
||||
</h2>
|
||||
<p className="mt-0.5 line-clamp-1 break-all text-[11px] text-ink-50 sm:mt-1 sm:text-xs" title={library.path}>
|
||||
{displayPath}
|
||||
@@ -195,11 +250,12 @@ function LibraryEntryCard({ preview }: { preview: LibraryPreview }) {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LibraryShelf({ preview }: { preview: LibraryPreview }) {
|
||||
function LibraryShelf({ preview, pinned }: { preview: LibraryPreview; pinned?: boolean }) {
|
||||
const library = preview.library
|
||||
const cards = preview.cards.slice(0, 10)
|
||||
const displayPath = libraryDisplayPath(library.path)
|
||||
@@ -212,7 +268,10 @@ function LibraryShelf({ preview }: { preview: LibraryPreview }) {
|
||||
{TYPE_ICONS[library.type] ?? <LibraryIcon size={14} />}
|
||||
{TYPE_LABELS[library.type] ?? library.type}
|
||||
</div>
|
||||
<h2 className="line-clamp-2 font-display text-xl sm:text-2xl font-black text-ink-600 sm:truncate">{library.name}</h2>
|
||||
<h2 className="line-clamp-2 font-display text-xl sm:text-2xl font-black text-ink-600 sm:truncate">
|
||||
{library.name}
|
||||
{pinned ? <span className="ml-2 align-middle text-xs font-bold text-brand-600">置顶</span> : null}
|
||||
</h2>
|
||||
<p className="mt-1 line-clamp-1 break-all text-xs text-ink-50">
|
||||
<span title={library.path}>{displayPath}</span> · {preview.total.toLocaleString()} 个条目 · 最新 {cards.length} 部
|
||||
</p>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type SortOrder,
|
||||
} from '../utils/mediaSort'
|
||||
import { LibraryPageDialogs } from './LibraryPageDialogs'
|
||||
import { PageBackButton } from '../components/PageBackButton'
|
||||
import { LibraryPageHeader } from './LibraryPageHeader'
|
||||
import { LibraryMediaSections } from './LibraryMediaSections'
|
||||
import { LibrarySeriesDetailSection } from './LibrarySeriesDetailSection'
|
||||
@@ -45,9 +46,12 @@ export function LibraryPage() {
|
||||
const [historyMap, setHistoryMap] = useState<Map<string, string>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
if (sortField !== 'last_played') return
|
||||
let cancelled = false
|
||||
historyAPI
|
||||
.list(1000)
|
||||
.then((historyItems) => {
|
||||
if (cancelled) return
|
||||
const map = new Map<string, string>()
|
||||
for (const item of historyItems ?? []) {
|
||||
if (item.media_id && item.watched_at) {
|
||||
@@ -59,7 +63,10 @@ export function LibraryPage() {
|
||||
setHistoryMap(map)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [sortField])
|
||||
|
||||
const handleSortChange = (field: SortField, order: SortOrder) => {
|
||||
setSortField(field)
|
||||
@@ -175,6 +182,12 @@ export function LibraryPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!selectedSeries && (
|
||||
<div className="hidden lg:block">
|
||||
<PageBackButton to="/libraries" label="全部媒体库" compact />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedSeries && (
|
||||
<LibraryPageHeader
|
||||
library={library}
|
||||
|
||||
@@ -15,7 +15,6 @@ type ManualOrganizePanelProps = {
|
||||
organizeDestPath: string
|
||||
organizeMediaType: string
|
||||
organizeTransferMode: string
|
||||
manualMoveKeepsSeeding: boolean
|
||||
scanAfter: boolean
|
||||
scrapeAfter: boolean
|
||||
organizeReady: boolean
|
||||
@@ -40,7 +39,6 @@ export function ManualOrganizePanel({
|
||||
organizeDestPath,
|
||||
organizeMediaType,
|
||||
organizeTransferMode,
|
||||
manualMoveKeepsSeeding,
|
||||
scanAfter,
|
||||
scrapeAfter,
|
||||
organizeReady,
|
||||
@@ -119,19 +117,13 @@ export function ManualOrganizePanel({
|
||||
<span className="text-xs text-ink-50">整理方式</span>
|
||||
<select className="input-base w-full" value={organizeTransferMode} onChange={(event) => onTransferModeChange(event.target.value)}>
|
||||
<option value="hardlink">硬链接</option>
|
||||
<option value="move">移动(关闭保种才会移动)</option>
|
||||
<option value="move">移动</option>
|
||||
<option value="copy">复制</option>
|
||||
<option value="symlink">软链接</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{manualMoveKeepsSeeding && (
|
||||
<div className="rounded-xl border border-orange-300 bg-orange-50 px-3 py-2 text-xs text-orange-700">
|
||||
“保种”已开启,选择“移动”时后端会改用硬链接以保留下载源。要执行真正移动,请先在上方自动整理设置里关闭“保种”并保存。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
|
||||
<input type="checkbox" checked={scanAfter} onChange={(event) => onScanAfterChange(event.target.checked)} />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ArrowLeft, Heart, Play, RefreshCw } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { PageBackButton } from '../components/PageBackButton'
|
||||
|
||||
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
|
||||
import { ManualScrapeDialog } from '../components/ManualScrapeDialog'
|
||||
import { MetadataEditDialog } from '../components/MetadataEditDialog'
|
||||
@@ -63,8 +65,11 @@ export function MediaDetailLoading() {
|
||||
|
||||
export function MediaDetailMissing() {
|
||||
return (
|
||||
<div className="text-center py-24 bg-white rounded-2xl border border-gray-200">
|
||||
<p className="text-gray-500">媒体资源已被移除或不存在</p>
|
||||
<div className="space-y-4 py-12 text-center">
|
||||
<PageBackButton label="返回上一页" className="mx-auto" />
|
||||
<div className="rounded-2xl border border-gray-200 bg-white py-16">
|
||||
<p className="text-gray-500">媒体资源已被移除或不存在</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ export function PlayerPage() {
|
||||
const [playerError, setPlayerError] = useState('')
|
||||
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
|
||||
const [directOnly, setDirectOnly] = useState(false)
|
||||
const [resumePosition, setResumePosition] = useState(0)
|
||||
const [initialSeekDone, setInitialSeekDone] = useState(false)
|
||||
|
||||
// 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。
|
||||
const [danmakuOpen, setDanmakuOpen] = useState(false)
|
||||
@@ -254,7 +256,44 @@ export function PlayerPage() {
|
||||
return () => teardownHls(media.id, mode === 'hls')
|
||||
}, [hlsUnavailable, media, mode, params, setParams, teardownHls])
|
||||
|
||||
// Persist resume position every 10 seconds while playing.
|
||||
// 自动拉取已有的播放进度并恢复播放位置
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
setResumePosition(0)
|
||||
setInitialSeekDone(false)
|
||||
playbackAPI
|
||||
.recentHistory()
|
||||
.then((items) => {
|
||||
const found = items.find((h) => h.media_id === id)
|
||||
if (found && found.position_ms > 2000 && !found.completed) {
|
||||
setResumePosition(found.position_ms / 1000)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
const video = ref.current
|
||||
if (!video || !resumePosition || initialSeekDone) return
|
||||
const applyResume = () => {
|
||||
if (resumePosition > 0 && Math.abs(video.currentTime - resumePosition) > 2) {
|
||||
video.currentTime = resumePosition
|
||||
setInitialSeekDone(true)
|
||||
const m = Math.floor(resumePosition / 60)
|
||||
const s = Math.floor(resumePosition % 60)
|
||||
const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
toast.success(`已恢复上次播放进度至 ${timeStr}`, { duration: 2500 })
|
||||
}
|
||||
}
|
||||
if (video.readyState >= 1) {
|
||||
applyResume()
|
||||
} else {
|
||||
video.addEventListener('loadedmetadata', applyResume, { once: true })
|
||||
return () => video.removeEventListener('loadedmetadata', applyResume)
|
||||
}
|
||||
}, [resumePosition, initialSeekDone])
|
||||
|
||||
// Persist resume position every 10 seconds while playing, and immediately upon pause/unmount.
|
||||
useEffect(() => {
|
||||
if (!media || !ref.current) return
|
||||
const video = ref.current
|
||||
@@ -273,6 +312,11 @@ export function PlayerPage() {
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', handler)
|
||||
video.removeEventListener('pause', handler)
|
||||
const positionMs = Math.floor(video.currentTime * 1000)
|
||||
const durationMs = Math.floor((video.duration || 0) * 1000)
|
||||
if (positionMs > 0 && media) {
|
||||
playbackAPI.recordProgress(media.id, positionMs, durationMs).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}, [media])
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import toast from 'react-hot-toast'
|
||||
|
||||
import { playbackAPI, type PlaylistDetail } from '../api/playback'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
|
||||
export function PlaylistDetailPage() {
|
||||
const { id = '' } = useParams()
|
||||
@@ -24,10 +25,12 @@ export function PlaylistDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">{detail.playlist.name}</h1>
|
||||
<p className="text-sm text-ink-50">{detail.items.length} 部影片</p>
|
||||
</header>
|
||||
<PageHeader
|
||||
backTo="/playlists"
|
||||
backLabel="播放列表"
|
||||
title={detail.playlist.name}
|
||||
description={`${detail.items.length} 部影片`}
|
||||
/>
|
||||
|
||||
{detail.items.length === 0 && (
|
||||
<p className="text-ink-50">暂无内容,前往媒体详情页通过「加入播放列表」添加。</p>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ListPlus, Trash2 } from 'lucide-react'
|
||||
import { ChevronRight, ListMusic, ListPlus, Trash2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { playbackAPI } from '../api/playback'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import type { Playlist } from '../types'
|
||||
|
||||
// Landing page for playlists. Lists every playlist owned by the current
|
||||
// user and lets them create / delete one.
|
||||
export function PlaylistsPage() {
|
||||
const [items, setItems] = useState<Playlist[]>([])
|
||||
const [name, setName] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const refresh = () =>
|
||||
playbackAPI
|
||||
@@ -26,67 +26,124 @@ export function PlaylistsPage() {
|
||||
|
||||
const onCreate = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
setCreating(true)
|
||||
try {
|
||||
await playbackAPI.createPlaylist(name)
|
||||
await playbackAPI.createPlaylist(trimmed)
|
||||
toast.success('已创建')
|
||||
setName('')
|
||||
await refresh()
|
||||
} catch {
|
||||
toast.error('创建失败')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isEmpty = !loading && items.length === 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">播放列表</h1>
|
||||
<PageHeader
|
||||
title="播放列表"
|
||||
description="整理你想稍后观看的影片,随时继续播放"
|
||||
/>
|
||||
|
||||
<form onSubmit={onCreate} className="glass-panel grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<form
|
||||
onSubmit={onCreate}
|
||||
className="glass-panel flex flex-col gap-3 sm:flex-row sm:items-center"
|
||||
>
|
||||
<input
|
||||
required
|
||||
className="input-base"
|
||||
className="input-base min-w-0 flex-1"
|
||||
placeholder="新播放列表名称"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={creating}
|
||||
/>
|
||||
<button type="submit" className="neon-button">
|
||||
<ListPlus size={16} /> 新建
|
||||
<button type="submit" className="neon-button shrink-0" disabled={creating}>
|
||||
<ListPlus size={16} />
|
||||
{creating ? '创建中…' : '新建'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{loading && <p className="text-sand-500">加载中…</p>}
|
||||
{!loading && items.length === 0 && <p className="text-ink-50">还没有任何播放列表。</p>}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 py-8 text-ink-50">
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-primary-400 border-t-transparent" />
|
||||
加载中…
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="glass-panel flex items-center justify-between !p-4"
|
||||
>
|
||||
<Link
|
||||
to={`/playlist/${p.id}`}
|
||||
className="font-medium text-ink-600 transition hover:text-brand-500"
|
||||
>
|
||||
{p.name}
|
||||
{p.is_public && (
|
||||
<span className="ml-2 rounded-lg border border-primary-400/40 px-1.5 py-0.5 text-xs text-brand-500">
|
||||
public
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!(await confirmAction({ title: '删除播放列表', message: `删除「${p.name}」?`, confirmText: '删除' }))) return
|
||||
await playbackAPI.deletePlaylist(p.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
}}
|
||||
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
{isEmpty && (
|
||||
<div className="glass-panel flex flex-col items-center gap-4 p-10 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-400/10 text-brand-500">
|
||||
<ListMusic size={28} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-lg font-medium text-ink-100">还没有任何播放列表</p>
|
||||
<p className="text-sm text-sand-500">
|
||||
在上方输入名称创建列表,或在媒体详情页点击「加入播放列表」
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="glass-panel group flex items-center gap-3 !p-4 transition hover:border-primary-400/30"
|
||||
>
|
||||
<Link
|
||||
to={`/playlist/${p.id}`}
|
||||
className="flex min-w-0 flex-1 items-center gap-3"
|
||||
>
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary-400/10 text-brand-500 transition group-hover:bg-primary-400/20">
|
||||
<ListMusic size={20} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-ink-600 transition group-hover:text-brand-500">
|
||||
{p.name}
|
||||
</p>
|
||||
{p.is_public && (
|
||||
<span className="mt-0.5 inline-block rounded-md border border-primary-400/40 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-brand-500">
|
||||
公开
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight
|
||||
size={18}
|
||||
className="shrink-0 text-sand-500 transition group-hover:text-brand-500"
|
||||
/>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (
|
||||
!(await confirmAction({
|
||||
title: '删除播放列表',
|
||||
message: `删除「${p.name}」?`,
|
||||
confirmText: '删除',
|
||||
}))
|
||||
) {
|
||||
return
|
||||
}
|
||||
await playbackAPI.deletePlaylist(p.id)
|
||||
toast.success('已删除')
|
||||
await refresh()
|
||||
}}
|
||||
className="shrink-0 rounded-lg border border-red-400/40 p-2 text-red-400 transition hover:bg-red-400/10"
|
||||
title="删除播放列表"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user