Compare commits

...

35 Commits

Author SHA1 Message Date
github-actions[bot] a50ceaf1a7 chore: bump version to 0.0.78 [skip ci] 2026-09-02 07:51:44 +00:00
truewhile bf49323789 feat(strm): auto-append remote directory tail to local output path (#16)
When adding or editing a cloud sync path, append the last segment of the
remote directory to the local output directory (e.g. /home/ubuntu/strm + 电影).
Remote changes replace the previous auto-appended tail; local browse and blur
commit the base path with the current remote tail.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 15:51:31 +08:00
github-actions[bot] 47118c8a53 chore: bump version to 0.0.77 [skip ci] 2026-09-02 07:16:02 +00:00
truewhile 67b85840dc perf(strm): 优化 OpenList 元数据下载速度 (#15)
* Add favourite buttons to library list and series detail pages

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix favourites list for remote Emby mounted media

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Route favourites to library series detail when appropriate

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix favourites link to library series for remote anime paths

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix remote Emby playback history and continue watching

- Preserve duration_ms when web player reports duration=0
- Add GET /playback/:id/resume for reliable per-item resume
- Switch home continue watching to /watch-history/continue API
- Set library_id on remote media for play-profile visibility
- Keep continue-watching rows when remote metadata hydration fails

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix mobile sort dropdown overflowing off-screen

Align dropdown to button left edge on small screens and cap width
to viewport so sort options stay fully visible on mobile.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Remove stale Windows binary backup files

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix STRM account edit form not echoing saved config

Return config_preview from account API for server/url/username and
secret flags. Merge partial config updates so empty password/token
fields do not wipe stored credentials.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* perf(strm): speed up OpenList metadata downloads with provider-aware concurrency

- Split download semaphore: 115 stays capped at 3 for WAF safety; OpenList/CloudDrive2
  WebDAV metadata uses strm.download_threads (default raised to 6, max 16)
- Scope 115 WAF cooldown to 115 tasks only so OpenList downloads are not paused
- Requeue claimed tasks back to pending when slot acquisition fails or 115 is cooling
- Add tests for separate semaphores and requeue behavior

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 15:15:49 +08:00
github-actions[bot] fbc33862fb chore: bump version to 0.0.76 [skip ci] 2026-09-02 06:14:43 +00:00
truewhile 04fc50a4b0 Add library favourite buttons and fix remote favourites list (#14)
* Add favourite buttons to library list and series detail pages

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix favourites list for remote Emby mounted media

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Route favourites to library series detail when appropriate

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix favourites link to library series for remote anime paths

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 14:14:32 +08:00
github-actions[bot] dab9a1073a chore: bump version to 0.0.75 [skip ci] 2026-09-02 05:48:38 +00:00
truewhile bd3d4ad357 Add favourite buttons to library list and series detail pages (#13)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 13:48:25 +08:00
github-actions[bot] bd5bfdcc10 chore: bump version to 0.0.74 [skip ci] 2026-09-02 03:43:54 +00:00
truewhile 73dc37fef8 Remove obsolete file manager organize options (#12)
Remove UI options for features that no longer exist:
- qB download auto-organize
- downloader smart classification
- seeding (keep seeding)

Also remove related warning banners and simplify move transfer labels.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 11:43:39 +08:00
github-actions[bot] f1b7d99a33 chore: bump version to 0.0.73 [skip ci] 2026-09-02 03:35:57 +00:00
truewhile 638dae0c91 fix(layout): hide duplicate media nav in mobile drawer (#11)
On narrow screens the media drawer duplicated bottom navigation and
user menu links (home, libraries, favourites, playlists, history).
Show only management links in the drawer when browsing media.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 11:35:44 +08:00
truewhile 791722aec6 feat(libraries): add pin-to-top for media library list (#10)
Allow users to pin frequently used libraries to the top of the
Libraries page. Pinned order is stored in localStorage and applied
to both entry cards and preview shelves.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 11:35:20 +08:00
github-actions[bot] 1c1da494b7 chore: bump version to 0.0.72 [skip ci] 2026-09-02 03:20:07 +00:00
truewhile 97b3a7bd7f feat(emby): support multiple connection lines for remote mount (#9)
Add account-level multi-line configuration for emby_remote mounts.
Lines are stored in strm_accounts.config as a urls JSON array with
backward compatibility for the legacy single url field. The backend
automatically fails over to the next line on connection errors and
persists the working active_line index.

Frontend: multi-line editor on Emby mount page and STRM account dialog
with add/remove/reorder, line names, and URL validation.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 11:19:56 +08:00
github-actions[bot] 543615a2a8 chore: bump version to 0.0.71 [skip ci] 2026-09-02 03:02:51 +00:00
truewhile 2b7828e1fe fix: close user menu on navigation and outside click (#8)
Portal the profile dropdown to document.body with a full-screen backdrop
so clicks outside reliably dismiss it above transformed page layers. Also
close the menu whenever the route changes.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 11:02:41 +08:00
github-actions[bot] 0b1fe52e24 chore: bump version to 0.0.70 [skip ci] 2026-09-02 02:49:11 +00:00
truewhile 2fc2c17b05 fix: prevent mobile sidebar nav items from stacking on short screens (#7)
Narrow drawers rendered media links in a flex-1 overflow region above a
non-shrinking admin block, so the browse items were clipped into a
compressed active pill. Scroll media and admin together, and distinguish
/libraries from /libraries?from=admin when highlighting.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 10:48:58 +08:00
github-actions[bot] be96acb035 chore: bump version to 0.0.69 [skip ci] 2026-09-02 02:44:26 +00:00
truewhile 6a72e7ab82 Merge pull request #6 from truewhile/cursor/frontend-nav-mobile-af8f
Improve mobile navigation and fix playlists page routing
2026-09-02 10:44:10 +08:00
Cursor Agent 4ebd477ca2 Fix playlists route matching /play and improve playlists page UI
- Add isPlayerRoute() so /playlists no longer hides header and bottom nav
- Add mobile back navigation for playlists, favourites, and history
- Redesign PlaylistsPage with PageHeader, empty state, and card list

Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 02:30:14 +00:00
github-actions[bot] 0320d956ed chore: bump version to 0.0.68 [skip ci] 2026-09-02 02:23:41 +00:00
truewhile 95395c479a 优化前端返回导航与移动端操作体验
优化前端返回导航与移动端操作体验
2026-09-02 10:23:30 +08:00
Cursor Agent eba7904c99 Improve frontend navigation and mobile UX
- Add mobile bottom tab bar for media browsing (Home, Libraries, Favourites, Playlists, More)
- Split mobile drawer: media links first, admin links under a Management section
- Show contextual back button in mobile header for deep routes
- Add reusable PageBackButton and PageHeader components
- Add back navigation to playlist detail, library page, and missing media state
- Embed queue panels in TaskQueuePage to avoid duplicate page titles
- Expose theme toggle in user menu on small screens

Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 02:19:25 +00:00
github-actions[bot] 1171fe9464 chore: bump version to 0.0.67 [skip ci] 2026-09-02 02:09:43 +00:00
truewhile 06b96102c8 Merge pull request #4 from truewhile/cursor/rewrite-readme-af8f
更新 README 与 Docker 镜像为 truewhile 仓库
2026-09-02 10:09:31 +08:00
Cursor Agent 077458df31 Update Docker images to ghcr.io/truewhile/mmtl
Replace legacy shukebta/shukbet image references across all compose
templates, system update defaults, and security docs.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 01:59:48 +00:00
github-actions[bot] 9124c9be9c chore: bump version to 0.0.66 [skip ci] 2026-09-02 01:56:22 +00:00
truewhile 3ae3c6540d 重写 README:更新 fork 说明与当前项目描述
重写 README:更新 fork 说明与当前项目描述
2026-09-02 09:56:06 +08:00
Cursor Agent 313ebdd402 Rewrite README for truewhile fork and current feature set
- Clarify fork lineage from MediaStationGo
- Document current capabilities: Emby mounts, STRM queues, task queue, etc.
- Add acknowledgements for MediaStationGo and qmediasync
- Update repo/image URLs to truewhile/MMTL
- Streamline deployment docs while keeping essential guidance

Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 01:53:59 +00:00
github-actions[bot] db52792ada chore: bump version to 0.0.65 [skip ci] 2026-09-02 01:49:22 +00:00
truewhile a8065680bb 优化首页/媒体库加载速度与 SQLite 查询性能
优化首页/媒体库加载速度与 SQLite 查询性能
2026-09-02 09:49:07 +08:00
Cursor Agent 3304f09b1d Fix Auto Docker workflow failing on pull requests
The publish workflow bumps VERSION, pushes commits/tags, and publishes
images. It must not run on pull_request events where checkout is a
detached HEAD and git push fails. PR validation is already covered by ci.yml.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 01:45:32 +00:00
Cursor Agent ba621cfc4a Optimize home/library loading and SQLite query performance
- Replace home preview N+1 queries with a single window-function batch query
- Cache library previews and series card lists (15s TTL, shared with media cache)
- Tune SQLite: larger page cache, mmap, wal_autocheckpoint, ANALYZE on migrate
- Defer history fetch on library page until last_played sort is selected
- Yield to browser between paginated library loads to keep UI responsive

Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 01:39:28 +00:00
85 changed files with 2968 additions and 1243 deletions
+1 -3
View File
@@ -4,9 +4,7 @@ name: AuTo Docker Image
on:
push:
branches: [main]
pull_request:
branches: [main]
# 保留手动触发作为备选
workflow_dispatch:
inputs:
+134 -311
View File
@@ -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
View File
@@ -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
View File
@@ -7,7 +7,7 @@ MMTL 是自托管媒体系统,常部署在 NAS、家庭网络、Docker、反
我们优先支持以下版本和部署方式的安全修复:
- 当前 `main` 分支。
- 最新发布镜像:`ghcr.io/shukebta/mmtl:latest`。
- 最新发布镜像:`ghcr.io/truewhile/mmtl:latest`。
- README 中推荐的 Docker Compose 第一档、第二档、第三档部署方式。
历史版本、私有魔改镜像、未公开补丁分支和非标准部署仍可报告,但维护者可能要求先在最新 `main` 或最新镜像中复现。
+1 -1
View File
@@ -1 +1 @@
0.0.64
0.0.78
+2 -6
View File
@@ -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,
+1 -3
View File
@@ -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
+2 -6
View File
@@ -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
View File
@@ -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。
+1 -1
View File
@@ -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)
+9 -1
View File
@@ -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
+4 -1
View File
@@ -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
}
+24
View File
@@ -82,6 +82,30 @@ func playbackProgressHandler(svc *service.Container) gin.HandlerFunc {
}
}
func playbackResumeHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
row, err := svc.Playback.GetProgress(c.Request.Context(), toString(uid), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if row == nil {
c.JSON(http.StatusOK, gin.H{
"position_ms": 0,
"duration_ms": 0,
"completed": false,
})
return
}
c.JSON(http.StatusOK, gin.H{
"position_ms": row.PositionMs,
"duration_ms": row.DurationMs,
"completed": row.Completed,
})
}
}
// externalPlayersHandler returns the list of external player URI
// schemes the UI can offer the user. We lookup the media row to
// produce the per-player launch URL.
@@ -71,6 +71,7 @@ func registerAuthedFavoriteAndMediaActionRoutes(authed *gin.RouterGroup, svc *se
func registerAuthedPlaybackExtraRoutes(authed *gin.RouterGroup, svc *service.Container) {
authed.GET("/playback/:id/info", playbackInfoHandler(svc))
authed.GET("/playback/:id/resume", playbackResumeHandler(svc))
authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
authed.GET("/playback/:id/external-url", externalURLHandler(svc))
+37 -4
View File
@@ -8,6 +8,7 @@ import (
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -28,8 +29,13 @@ type strmAccountView struct {
model.StrmAccount
HasCredential bool `json:"has_credential"`
ProviderLabel string `json:"provider_label"`
// ConfigPreview 非敏感配置字段,供编辑表单回显。
ConfigPreview service.StrmAccountConfigPreview `json:"config_preview,omitempty"`
// 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 {
@@ -41,10 +47,17 @@ func strmAccountViews(svc *service.Container, accounts []model.StrmAccount) []st
HasCredential: service.HasStrmAccountCredential(&a),
ProviderLabel: providerLabelOf(a.Provider),
}
if svc != nil && svc.Strm != nil {
view.ConfigPreview = svc.Strm.StrmAccountConfigPreviewOf(&a)
}
if a.Provider == model.StrmProviderEmbyRemote && svc != nil && svc.EmbyRemote != nil {
if proxyPlay, err := svc.EmbyRemote.ProxyPlayOf(&a); err == nil {
view.ProxyPlay = &proxyPlay
}
if lines, activeLine, err := svc.EmbyRemote.LinesOf(&a); err == nil {
view.EmbyLines = lines
view.EmbyActiveLine = activeLine
}
}
out = append(out, view)
}
@@ -117,13 +130,33 @@ func deleteStrmAccountHandler(svc *service.Container) gin.HandlerFunc {
func testStrmAccountHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
acct := svc.Strm.TestStrmAccount(c.Request.Context(), c.Param("id"))
if acct == nil {
id := c.Param("id")
acct, err := svc.Repo.StrmAccount.FindByID(c.Request.Context(), id)
if err != nil || acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"})
return
}
views := strmAccountViews(svc, []model.StrmAccount{*acct})
c.JSON(http.StatusOK, views[0])
now := time.Now()
acct.LastTestAt = &now
if acct.Provider == model.StrmProviderEmbyRemote && svc.EmbyRemote != nil {
if err := svc.EmbyRemote.TestConnection(c.Request.Context(), acct); err != nil {
acct.LastTestResult = err.Error()
acct.LastTestOK = false
} else {
acct.LastTestResult = "ok"
acct.LastTestOK = true
}
} else {
acct = svc.Strm.TestStrmAccount(c.Request.Context(), id)
if acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"})
return
}
c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0])
return
}
_ = svc.Repo.StrmAccount.Update(c.Request.Context(), acct)
c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0])
}
}
+27 -4
View File
@@ -129,14 +129,23 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
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,
})
if mediaVisibleForRequest(c, svc, rm) {
out = append(out, gin.H{
"history": r,
"media": *rm,
})
}
continue
}
}
}
fallback := fallbackHistoryMedia(r.MediaID)
if fallback != nil {
out = append(out, gin.H{
"history": r,
"media": *fallback,
})
}
continue
}
out = append(out, gin.H{
@@ -148,6 +157,20 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
}
}
func fallbackHistoryMedia(mediaID string) *model.Media {
if mediaID == "" {
return nil
}
title := "媒体"
if service.IsEmbyRemoteID(mediaID) {
title = "远程媒体"
}
return &model.Media{
Base: model.Base{ID: mediaID},
Title: title,
}
}
// historyDeleteHandler removes one or all history rows for the caller.
//
// DELETE /api/watch-history?media_id=xxx → delete just that media's row
+3 -1
View File
@@ -26,7 +26,9 @@ func (r *HistoryRepository) Upsert(ctx context.Context, h *model.PlaybackHistory
return err
}
existing.PositionMs = h.PositionMs
existing.DurationMs = h.DurationMs
if h.DurationMs > 0 {
existing.DurationMs = h.DurationMs
}
existing.WatchedAt = h.WatchedAt
existing.Completed = h.Completed
return r.db.WithContext(ctx).Save(&existing).Error
+87 -2
View File
@@ -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)
}
}
+159 -14
View File
@@ -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 {
+185
View File
@@ -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 &copy
}
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)
}
}
+5
View File
@@ -300,6 +300,11 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
media.SeasonNum = 0
media.EpisodeNum = 0
}
if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" {
libID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
media.DisplayLibraryID = libID
media.LibraryID = libID
}
return media
}
+34
View File
@@ -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
+41 -10
View File
@@ -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
}
+19 -1
View File
@@ -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) {
+87 -9
View File
@@ -9,9 +9,11 @@ package service
import (
"context"
"errors"
"sort"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
@@ -46,18 +48,61 @@ func (p *PlaybackService) RecordProgress(ctx context.Context, userID, mediaID st
if userID == "" || mediaID == "" {
return errors.New("missing user or media")
}
completed := duration > 0 && position >= duration-30_000
dur := p.resolvePlaybackDuration(ctx, userID, mediaID, duration)
completed := dur > 0 && position >= dur-30_000
h := &model.PlaybackHistory{
UserID: userID,
MediaID: mediaID,
PositionMs: position,
DurationMs: duration,
DurationMs: dur,
WatchedAt: time.Now(),
Completed: completed,
}
return p.repo.History.Upsert(ctx, h)
}
// GetProgress returns the saved resume row for one media item, or nil when absent.
func (p *PlaybackService) GetProgress(ctx context.Context, userID, mediaID string) (*model.PlaybackHistory, error) {
if userID == "" || mediaID == "" {
return nil, errors.New("missing user or media")
}
var row model.PlaybackHistory
err := p.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id = ?", userID, mediaID).
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
func (p *PlaybackService) resolvePlaybackDuration(ctx context.Context, userID, mediaID string, duration int64) int64 {
if duration > 0 {
return duration
}
var existing model.PlaybackHistory
if err := p.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id = ?", userID, mediaID).
First(&existing).Error; err == nil && existing.DurationMs > 0 {
return existing.DurationMs
}
if m, _ := p.repo.Media.FindByID(ctx, mediaID); m != nil && m.DurationSec > 0 {
return int64(m.DurationSec) * 1000
}
if p.remote != nil && IsEmbyRemoteID(mediaID) {
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
if mount, acct, _ := p.remote.ResolveMount(ctx, mountID); mount != nil && acct != nil {
if rm, err := p.remote.RemoteMediaDetail(ctx, mount, acct, remoteID); err == nil && rm != nil && rm.DurationSec > 0 {
return int64(rm.DurationSec) * 1000
}
}
}
return duration
}
// HistoryItem joins the playback row with its media so the API consumer
// gets a fully-populated card without a second round-trip.
type HistoryItem struct {
@@ -126,14 +171,47 @@ func (p *PlaybackService) ListFavourites(ctx context.Context, userID string) ([]
if len(favs) == 0 {
return nil, nil
}
ids := make([]string, len(favs))
for i, f := range favs {
ids[i] = f.MediaID
sort.Slice(favs, func(i, j int) bool {
return favs[i].CreatedAt.After(favs[j].CreatedAt)
})
localIDs := make([]string, 0, len(favs))
for _, fav := range favs {
if !IsEmbyRemoteID(fav.MediaID) {
localIDs = append(localIDs, fav.MediaID)
}
}
var out []model.Media
err = p.repo.DB.Where("id IN ?", ids).
Order("created_at desc").Find(&out).Error
return out, err
mediaByID := map[string]model.Media{}
if len(localIDs) > 0 {
var mediaRows []model.Media
if err := p.repo.DB.WithContext(ctx).Where("id IN ?", localIDs).Find(&mediaRows).Error; err != nil {
return nil, err
}
for _, media := range mediaRows {
mediaByID[media.ID] = media
}
}
out := make([]model.Media, 0, len(favs))
for _, fav := range favs {
if media, ok := mediaByID[fav.MediaID]; ok {
out = append(out, media)
continue
}
if p.remote != nil && IsEmbyRemoteID(fav.MediaID) {
mountID, remoteID, _ := DecodeEmbyRemoteID(fav.MediaID)
mount, acct, _ := p.remote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
continue
}
remoteMedia, err := p.remote.RemoteMediaDetail(ctx, mount, acct, remoteID)
if err != nil || remoteMedia == nil {
continue
}
out = append(out, *remoteMedia)
}
}
return out, nil
}
// ─── Playlists ──────────────────────────────────────────────────────────────
+93
View File
@@ -0,0 +1,93 @@
package service
import (
"context"
"testing"
"time"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"go.uber.org/zap"
)
func TestRecordProgressPreservesDurationWhenMissing(t *testing.T) {
db := newServiceTestDB(t, &model.PlaybackHistory{}, &model.Media{})
repos := repository.New(db)
userID := "user-1"
mediaID := "local-movie-1"
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "hist-1"},
UserID: userID,
MediaID: mediaID,
PositionMs: 30_000,
DurationMs: 120_000,
WatchedAt: time.Now(),
}).Error; err != nil {
t.Fatalf("seed history: %v", err)
}
svc := NewPlaybackService(zap.NewNop(), repos)
if err := svc.RecordProgress(context.Background(), userID, mediaID, 60_000, 0); err != nil {
t.Fatalf("RecordProgress: %v", err)
}
var hist model.PlaybackHistory
if err := db.Where("user_id = ? AND media_id = ?", userID, mediaID).First(&hist).Error; err != nil {
t.Fatalf("find history: %v", err)
}
if hist.DurationMs != 120_000 {
t.Fatalf("expected duration preserved, got %d", hist.DurationMs)
}
if hist.PositionMs != 60_000 {
t.Fatalf("expected position updated, got %d", hist.PositionMs)
}
}
func TestGetProgressReturnsNilWhenMissing(t *testing.T) {
db := newServiceTestDB(t, &model.PlaybackHistory{})
repos := repository.New(db)
svc := NewPlaybackService(zap.NewNop(), repos)
row, err := svc.GetProgress(context.Background(), "user-1", "missing-media")
if err != nil {
t.Fatalf("GetProgress: %v", err)
}
if row != nil {
t.Fatalf("expected nil progress, got %#v", row)
}
}
func TestListFavouritesIncludesLocalAndRemoteIDs(t *testing.T) {
db := newServiceTestDB(t, &model.Favorite{}, &model.Media{})
repos := repository.New(db)
userID := "user-1"
local := model.Media{
Base: model.Base{ID: "local-movie-1"},
Title: "Local Movie",
}
if err := db.Create(&local).Error; err != nil {
t.Fatalf("create local media: %v", err)
}
remoteID := EncodeEmbyRemoteID("mount-1", "remote-series-1")
favs := []model.Favorite{
{Base: model.Base{CreatedAt: time.Now().Add(-time.Minute)}, UserID: userID, MediaID: remoteID},
{Base: model.Base{CreatedAt: time.Now()}, UserID: userID, MediaID: local.ID},
}
for i := range favs {
if err := db.Create(&favs[i]).Error; err != nil {
t.Fatalf("create favorite %d: %v", i, err)
}
}
svc := NewPlaybackService(zap.NewNop(), repos)
items, err := svc.ListFavourites(context.Background(), userID)
if err != nil {
t.Fatalf("ListFavourites: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected one hydrated local favourite without remote service, got %d: %#v", len(items), items)
}
if items[0].ID != local.ID {
t.Fatalf("expected local favourite first by created_at desc, got %#v", items[0])
}
}
+76
View File
@@ -0,0 +1,76 @@
package service
import (
"context"
"encoding/json"
"testing"
"github.com/ShukeBta/MMTL/internal/model"
)
func TestStrmAccountConfigPreviewOf(t *testing.T) {
svc := testStrmService(t)
acct := &model.StrmAccount{
Provider: model.StrmProviderOpenList,
Config: mustJSON(map[string]string{
"server": "https://list.example.com",
"username": "alice",
"password": svc.crypto.Encrypt("secret"),
"token": svc.crypto.Encrypt("tok"),
}),
}
preview := svc.StrmAccountConfigPreviewOf(acct)
if preview.Server != "https://list.example.com" {
t.Fatalf("server = %q", preview.Server)
}
if preview.Username != "alice" {
t.Fatalf("username = %q", preview.Username)
}
if !preview.HasPassword || !preview.HasToken {
t.Fatalf("expected secret flags, got %#v", preview)
}
}
func TestUpdateStrmAccountMergesConfigWithoutClearingSecrets(t *testing.T) {
svc := testStrmService(t)
ctx := context.Background()
acct, err := svc.CreateStrmAccount(ctx, "openlist", model.StrmProviderOpenList, map[string]string{
"server": "https://list.example.com",
"username": "alice",
"password": "secret",
"token": "tok",
})
if err != nil {
t.Fatalf("create account: %v", err)
}
updated, err := svc.UpdateStrmAccount(ctx, acct.ID, "openlist-renamed", nil, map[string]string{
"server": "https://list.example.com",
"username": "alice",
})
if err != nil {
t.Fatalf("update account: %v", err)
}
if updated.Name != "openlist-renamed" {
t.Fatalf("name = %q", updated.Name)
}
cfg, err := svc.strmAccountConfig(updated)
if err != nil {
t.Fatalf("decode config: %v", err)
}
if cfg["password"] != "secret" {
t.Fatalf("password was cleared: %#v", cfg)
}
if cfg["token"] != "tok" {
t.Fatalf("token was cleared: %#v", cfg)
}
}
func mustJSON(v any) string {
data, err := json.Marshal(v)
if err != nil {
panic(err)
}
return string(data)
}
+21 -15
View File
@@ -29,9 +29,8 @@ const (
// downloadWorker 下载队列 worker:认领 → 解析直链 → 下载 → 落盘。
//
// 采用「批量认领 + 全局并发限流」:一次认领数个任务,用 StrmService 上的全局信号量
// 限制整个进程「同时换直链+下载」的并发数(与 115 换链风控匹配,见 strmDownloadSemCap),
// 同时让下载充分并行。换链走全局令牌桶(QPS=3)兜底,下载走 CDN 不限速。
// 采用「批量认领 + 按网盘类型限流」:一次认领数个任务,115 与 WebDAV/OpenList
// 使用独立信号量(115 固定 3 防风控;OpenList/CloudDrive2 并发由 download_threads 控制)。
func (s *StrmService) downloadWorker(ctx context.Context) {
const claimBatch = 12 // 每次批量认领的任务数
for {
@@ -42,12 +41,6 @@ func (s *StrmService) downloadWorker(ctx context.Context) {
return
default:
}
// 115 风控/限流熔断:冷却期间整体暂停,不给 WAF 续封机会
if left := s.wafCooldownLeft(); left > 0 {
s.log.Debug("下载队列冷却中", zap.Duration("remaining", left))
sleepContext(ctx, left)
continue
}
tasks, err := s.repo.StrmDownload.ClaimPendingDownload(ctx, claimBatch)
if err != nil {
s.log.Warn("claim strm download task failed", zap.Error(err))
@@ -58,24 +51,37 @@ func (s *StrmService) downloadWorker(ctx context.Context) {
sleepContext(ctx, 2*time.Second)
continue
}
// 并发处理本批任务:每个任务先获取全局下载槽位,槽位内部执行换链+下载。
// 信号量与令牌桶双重限速,确保任意时刻并发换链请求不超过安全阈值。
var wg sync.WaitGroup
for i := range tasks {
wg.Add(1)
go func(i int) {
defer wg.Done()
if !s.acquireDownloadSlot(ctx) {
task := &tasks[i]
if task.Provider == model.StrmProvider115 && s.wafCooldownLeft() > 0 {
s.requeueDownloadTask(task)
return
}
defer s.releaseDownloadSlot()
s.processDownloadTask(ctx, &tasks[i])
if !s.acquireDownloadSlot(ctx, task.Provider) {
s.requeueDownloadTask(task)
return
}
defer s.releaseDownloadSlot(task.Provider)
s.processDownloadTask(ctx, task)
}(i)
}
wg.Wait()
}
}
// requeueDownloadTask 把已认领但未实际执行的任务退回 pending,避免长期停留在 running。
func (s *StrmService) requeueDownloadTask(task *model.StrmDownloadTask) {
task.Status = model.StrmTaskPending
task.StartedAt = nil
if err := s.repo.StrmDownload.Update(context.Background(), task); err != nil {
s.log.Warn("requeue strm download task failed", zap.Error(err), zap.String("id", task.ID))
}
}
func (s *StrmService) processDownloadTask(ctx context.Context, task *model.StrmDownloadTask) {
cleanPath := sanitizeLocalPath(task.LocalPath)
if cleanPath != "" && cleanPath != task.LocalPath {
@@ -648,7 +654,7 @@ func sleepContext(ctx context.Context, d time.Duration) {
// ─── 115 风控/限流熔断 ────────────────────────────────────────────────────────
// triggerWAFCooldown 检测到 115 风控/限流后触发全局冷却,冷却期间下载 worker 暂停。
// triggerWAFCooldown 检测到 115 风控/限流后触发冷却,仅影响 115 下载任务。
// 冷却时间取最大值,避免连续触发时缩短等待。
func (s *StrmService) triggerWAFCooldown() {
s.mu.Lock()
+60
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"testing"
"time"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
@@ -104,3 +105,62 @@ func TestStrmUploadTasksClearAndRetry(t *testing.T) {
t.Fatalf("expected 2 remaining tasks, got %d", count)
}
}
func TestDownloadSemSeparateByProvider(t *testing.T) {
svc := NewStrmService(nil, zap.NewNop(), nil, nil)
svc.initDownloadSems(6)
if cap(svc.downloadSem115) != strm115DownloadSemCap {
t.Fatalf("115 sem cap = %d, want %d", cap(svc.downloadSem115), strm115DownloadSemCap)
}
if cap(svc.downloadSemDAV) != 6 {
t.Fatalf("dav sem cap = %d, want 6", cap(svc.downloadSemDAV))
}
ctx := context.Background()
for i := 0; i < strm115DownloadSemCap; i++ {
if !svc.acquireDownloadSlot(ctx, model.StrmProvider115) {
t.Fatalf("failed to acquire 115 slot %d", i)
}
}
// 115 槽位占满后,OpenList 仍应能独立获取槽位。
if !svc.acquireDownloadSlot(ctx, model.StrmProviderOpenList) {
t.Fatal("openlist slot should remain available while 115 slots are full")
}
for i := 0; i < 5; i++ {
if !svc.acquireDownloadSlot(ctx, model.StrmProviderOpenList) {
t.Fatalf("failed to acquire extra openlist slot %d", i)
}
}
timeoutCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
if svc.acquireDownloadSlot(timeoutCtx, model.StrmProviderOpenList) {
t.Fatal("expected openlist slots to be exhausted after 6 acquisitions")
}
}
func TestRequeueDownloadTask(t *testing.T) {
db := newServiceTestDB(t, &model.StrmDownloadTask{})
repos := repository.New(db)
svc := NewStrmService(nil, zap.NewNop(), repos, nil)
now := time.Now()
task := &model.StrmDownloadTask{
Base: model.Base{ID: "dl-running"},
Status: model.StrmTaskRunning,
Provider: model.StrmProviderOpenList,
StartedAt: &now,
}
if err := db.Create(task).Error; err != nil {
t.Fatalf("insert task: %v", err)
}
svc.requeueDownloadTask(task)
var got model.StrmDownloadTask
if err := db.First(&got, "id = ?", task.ID).Error; err != nil {
t.Fatalf("load task: %v", err)
}
if got.Status != model.StrmTaskPending || got.StartedAt != nil {
t.Fatalf("task not requeued: %+v", got)
}
}
+89 -39
View File
@@ -68,7 +68,7 @@ var StrmSettingDefs = map[string]struct {
StrmSettingUploadMeta: {Default: "false", Label: "上传元数据", Kind: "bool", Help: "同步时把本地元数据上传到远端(需网盘支持写入)"},
StrmSettingDeleteDir: {Default: "false", Label: "清理空目录", Kind: "bool", Help: "清理远端已删除的多余 .strm/元数据后,删除空目录"},
Strm115RelayKeySetting: {Default: "", Label: "115 中继授权共享密钥", Kind: "text", Help: "QMediaSync/MQFamily 中继授权的共享 AES 密钥(OAUTH_RELAY_ENCRYPTION_KEY);不配置则中继授权不可用"},
StrmSettingDownloadThreads: {Default: "3", Label: "下载队列线程数", Kind: "number", Help: "元数据下载并发数"},
StrmSettingDownloadThreads: {Default: "6", Label: "下载队列线程数", Kind: "number", Help: "OpenList/CloudDrive2 元数据下载并发数(115 独立限速为 3)"},
StrmSettingUploadThreads: {Default: "2", Label: "上传队列线程数", Kind: "number", Help: "元数据上传并发数"},
}
@@ -91,33 +91,51 @@ type StrmService struct {
oauthSessions map[string]*strm115AuthSession
wafUntil time.Time // 115 风控/限流熔断截止时间(由 mu 保护)
downloadSem chan struct{} // 全局下载并发信号量:限制整个进程同时进行「换直链+下载」的并发数
downloadSem115 chan struct{} // 115 换直链+下载并发上限(风控兜底)
downloadSemDAV chan struct{} // WebDAV/OpenList/CloudDrive2 元数据下载并发上限
downloadSemOnce sync.Once
}
// strmWAFCooldown 检测到 115 风控/限流后下载队列的全局冷却时长。
// strmWAFCooldown 检测到 115 风控/限流后 115 下载任务的全局冷却时长。
const strmWAFCooldown = 3 * time.Minute
// strmDownloadSemCap 全局同时进行「换直链+下载」的并发上限。
// strm115DownloadSemCap 115 同时进行「换直链+下载」的并发上限。
//
// 115 对换直链接口(/open/ufile/downurl)风控极严:过去把全局 QPS 提到 8 或让多
// worker 高并发换链,会瞬时撞上 WAF 返回 405 阻断页并触发 180 秒冷却,反而更慢。
// 因此用信号量把整个进程同时换直链的并发数压到 3,与令牌桶限速共同兜底:
// 宁可下载稍慢,也绝不触发风控。下载本身走 CDN 不限速。
const strmDownloadSemCap = 3
// 因此把 115 换直链并发压到 3,与令牌桶限速共同兜底;OpenList/CloudDrive2 元数据
// 走 WebDAV,不受此限制,并发由 strm.download_threads 控制。
const strm115DownloadSemCap = 3
// ensureDownloadSem 惰性初始化全局共享的下载并发信号量。
func (s *StrmService) ensureDownloadSem() {
// initDownloadSems 按设置初始化 115 与 WebDAV 两套下载并发信号量。
func (s *StrmService) initDownloadSems(davCap int) {
s.downloadSemOnce.Do(func() {
s.downloadSem = make(chan struct{}, strmDownloadSemCap)
if davCap < 1 {
davCap = 1
}
if davCap > 16 {
davCap = 16
}
s.downloadSem115 = make(chan struct{}, strm115DownloadSemCap)
s.downloadSemDAV = make(chan struct{}, davCap)
})
}
func (s *StrmService) downloadSemForProvider(provider string) chan struct{} {
if provider == model.StrmProvider115 {
return s.downloadSem115
}
return s.downloadSemDAV
}
// acquireDownloadSlot 获取一个下载并发槽位(等待/取消安全)。
func (s *StrmService) acquireDownloadSlot(ctx context.Context) bool {
s.ensureDownloadSem()
func (s *StrmService) acquireDownloadSlot(ctx context.Context, provider string) bool {
sem := s.downloadSemForProvider(provider)
if sem == nil {
return false
}
select {
case s.downloadSem <- struct{}{}:
case sem <- struct{}{}:
return true
case <-ctx.Done():
return false
@@ -125,11 +143,12 @@ func (s *StrmService) acquireDownloadSlot(ctx context.Context) bool {
}
// releaseDownloadSlot 释放一个下载并发槽位。
func (s *StrmService) releaseDownloadSlot() {
if s.downloadSem == nil {
func (s *StrmService) releaseDownloadSlot(provider string) {
sem := s.downloadSemForProvider(provider)
if sem == nil {
return
}
<-s.downloadSem
<-sem
}
// NewStrmService constructs the STRM service.
@@ -151,13 +170,14 @@ func NewStrmService(cfg *config.Config, log *zap.Logger, repos *repository.Conta
func (s *StrmService) Start(ctx context.Context) {
s.sync115RelayKey(ctx)
s.recoverInterruptedSyncs(ctx)
downloadThreads := s.strmIntSetting(ctx, StrmSettingDownloadThreads, 3)
downloadThreads := s.strmIntSetting(ctx, StrmSettingDownloadThreads, 6)
if downloadThreads < 1 {
downloadThreads = 1
}
if downloadThreads > 8 {
downloadThreads = 8
if downloadThreads > 16 {
downloadThreads = 16
}
s.initDownloadSems(downloadThreads)
uploadThreads := s.strmIntSetting(ctx, StrmSettingUploadThreads, 2)
if uploadThreads < 1 {
uploadThreads = 1
@@ -237,9 +257,9 @@ func (s *StrmService) strmAccountConfig(acct *model.StrmAccount) (map[string]str
return cfg, nil
}
// mergeEmbyRemoteConfig 对远程 Emby 账号配置做合并式更新:config 中出现的键
// 覆盖写入(敏感键按明文加密),未出现的键保留原密文;显式空字符串=清除。
func (s *StrmService) mergeEmbyRemoteConfig(existing string, config map[string]string) (string, error) {
// mergeStrmAccountConfig 对账号配置做合并式更新:config 中出现的键覆盖写入(敏感键按明文
// 加密),未出现的键保留原密文;显式空字符串=清除。
func (s *StrmService) mergeStrmAccountConfig(existing string, config map[string]string) (string, error) {
out := map[string]string{}
if strings.TrimSpace(existing) != "" {
if err := json.Unmarshal([]byte(existing), &out); err != nil {
@@ -264,6 +284,44 @@ func (s *StrmService) mergeEmbyRemoteConfig(existing string, config map[string]s
return string(data), nil
}
// StrmAccountConfigPreview 返回可安全回显给前端的非敏感配置字段。
type StrmAccountConfigPreview struct {
URL string `json:"url,omitempty"`
Server string `json:"server,omitempty"`
Username string `json:"username,omitempty"`
HasPassword bool `json:"has_password,omitempty"`
HasToken bool `json:"has_token,omitempty"`
HasAPIKey bool `json:"has_api_key,omitempty"`
}
// StrmAccountConfigPreviewOf 从账号配置提取可回显字段(不含密码/令牌明文)。
func (s *StrmService) StrmAccountConfigPreviewOf(acct *model.StrmAccount) StrmAccountConfigPreview {
var out StrmAccountConfigPreview
if acct == nil || strings.TrimSpace(acct.Config) == "" {
return out
}
raw := map[string]string{}
if err := json.Unmarshal([]byte(acct.Config), &raw); err != nil {
return out
}
out.URL = strings.TrimSpace(raw["url"])
out.Server = strings.TrimSpace(raw["server"])
out.Username = strings.TrimSpace(raw["username"])
for _, k := range []string{"password", "token", "api_key"} {
if v, ok := raw[k]; ok && strings.TrimSpace(v) != "" {
switch k {
case "password":
out.HasPassword = true
case "token":
out.HasToken = true
case "api_key":
out.HasAPIKey = true
}
}
}
return out
}
// HasStrmAccountCredential 报告账号是否已配置核心凭据(用于前端展示)。
func HasStrmAccountCredential(acct *model.StrmAccount) bool {
switch acct.Provider {
@@ -273,8 +331,9 @@ func HasStrmAccountCredential(acct *model.StrmAccount) bool {
case model.StrmProviderOpenList:
return strings.Contains(acct.Config, `"token"`) || strings.Contains(acct.Config, `"password"`)
case model.StrmProviderEmbyRemote:
// 远程 Emby:接入地址 + (自动认证凭据 或 手动 api_key) 即视为已配置。
return strings.Contains(acct.Config, `"url"`) &&
// 远程 Emby:接入线路 + (自动认证凭据 或 手动 api_key) 即视为已配置。
hasURL := strings.Contains(acct.Config, `"url"`) || strings.Contains(acct.Config, `"urls"`)
return hasURL &&
(strings.Contains(acct.Config, `"token"`) || strings.Contains(acct.Config, `"api_key"`) ||
(strings.Contains(acct.Config, `"username"`) && strings.Contains(acct.Config, `"password"`)))
default:
@@ -319,22 +378,13 @@ func (s *StrmService) UpdateStrmAccount(ctx context.Context, id, name string, en
if enabled != nil {
acct.Enabled = *enabled
}
if len(config) > 0 {
var enc string
var err error
if acct.Provider == model.StrmProviderEmbyRemote {
// 远程 Emby 账号:合并式更新。config 中出现的键覆盖(敏感键按明文
// 加密写入),未出现的键保留原密文——避免编辑「代理开关」时把已
// 保存的地址与凭据清空。
enc, err = s.mergeEmbyRemoteConfig(acct.Config, config)
} else {
enc, err = s.strmAccountConfigJSON(config, true)
if len(config) > 0 {
enc, err := s.mergeStrmAccountConfig(acct.Config, config)
if err != nil {
return nil, err
}
acct.Config = enc
}
if err != nil {
return nil, err
}
acct.Config = enc
}
if err := s.repo.StrmAccount.Update(ctx, acct); err != nil {
return nil, err
}
+1 -1
View File
@@ -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"
)
+3 -3
View File
@@ -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)
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+5
View File
@@ -33,6 +33,11 @@ function publicOriginHeader() {
}
export const playbackAPI = {
getResume: (mediaId: string) =>
api
.get<{ position_ms: number; duration_ms: number; completed: boolean }>(`/playback/${mediaId}/resume`)
.then((r) => r.data),
recordProgress: (mediaId: string, positionMs: number, durationMs: number) =>
api
.post('/history', {
+13 -6
View File
@@ -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>
)
+23 -9
View File
@@ -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>
)
+14 -5
View File
@@ -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
+21 -9
View File
@@ -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(
+36 -37
View File
@@ -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>
)
}
+117 -54
View File
@@ -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,57 @@
import { Heart } from 'lucide-react'
type MediaFavouriteButtonProps = {
favourite: boolean
onToggle: () => void
disabled?: boolean
variant?: 'compact' | 'inline'
className?: string
}
export function MediaFavouriteButton({
favourite,
onToggle,
disabled = false,
variant = 'inline',
className = '',
}: MediaFavouriteButtonProps) {
if (variant === 'compact') {
return (
<button
type="button"
title={favourite ? '取消收藏' : '加入收藏'}
disabled={disabled}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onToggle()
}}
className={
'flex h-8 w-8 items-center justify-center rounded-lg border border-white/70 bg-white/90 shadow-sm backdrop-blur transition hover:bg-red-50 disabled:opacity-50 ' +
(favourite ? 'text-red-500' : 'text-gray-700 hover:text-red-500') +
(className ? ` ${className}` : '')
}
>
<Heart size={13} fill={favourite ? 'currentColor' : 'none'} />
</button>
)
}
return (
<button
type="button"
disabled={disabled}
onClick={onToggle}
className={
'btn-outline gap-2 ' +
(favourite
? '!border-red-200 !bg-red-50 !text-red-600 hover:!bg-red-100/50'
: 'hover:border-red-200 hover:text-red-600 hover:bg-red-50/50') +
(className ? ` ${className}` : '')
}
>
<Heart size={14} fill={favourite ? 'currentColor' : 'none'} />
<span>{favourite ? '取消收藏' : '加入收藏'}</span>
</button>
)
}
+1 -1
View File
@@ -84,7 +84,7 @@ export function MediaSortDropdown({
</button>
{isOpen && (
<div className="absolute right-0 z-50 mt-1.5 w-52 origin-top-right rounded-2xl border border-sand-200/80 bg-white/95 p-1.5 shadow-xl backdrop-blur-md transition-all animate-in fade-in-0 zoom-in-95">
<div className="absolute left-0 z-50 mt-1.5 w-52 max-w-[calc(100vw-2rem)] origin-top-left rounded-2xl border border-sand-200/80 bg-white/95 p-1.5 shadow-xl backdrop-blur-md transition-all animate-in fade-in-0 zoom-in-95 sm:left-auto sm:right-0 sm:origin-top-right">
<div className="px-2.5 py-1.5 text-[11px] font-bold text-sand-500 border-b border-sand-100">
排序方式
</div>
+55
View File
@@ -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>
)
}
+48
View File
@@ -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>
)
}
+38
View File
@@ -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>
)
}
+149 -3
View File
@@ -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'
)
}
+50
View File
@@ -0,0 +1,50 @@
import { useCallback, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { playbackAPI } from '../api/playback'
export function useFavourites() {
const [favouriteIDs, setFavouriteIDs] = useState<Set<string>>(() => new Set())
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
playbackAPI
.listFavourites()
.then((items) => {
if (!cancelled) {
setFavouriteIDs(new Set((items ?? []).map((item) => item.id)))
}
})
.catch(() => {
if (!cancelled) toast.error('加载收藏状态失败')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [])
const isFavourite = useCallback((mediaID: string) => favouriteIDs.has(mediaID), [favouriteIDs])
const toggleFavourite = useCallback(async (mediaID: string) => {
try {
const state = await playbackAPI.toggleFavourite(mediaID)
setFavouriteIDs((current) => {
const next = new Set(current)
if (state) next.add(mediaID)
else next.delete(mediaID)
return next
})
toast.success(state ? '已加入我的收藏' : '已取消收藏')
return state
} catch {
toast.error('收藏操作失败')
return favouriteIDs.has(mediaID)
}
}, [favouriteIDs])
return { isFavourite, toggleFavourite, loading }
}
@@ -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}
/>
)}
+1 -13
View File
@@ -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>
</>
)
+87 -17
View File
@@ -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>
+3 -2
View File
@@ -4,6 +4,7 @@ import toast from 'react-hot-toast'
import { playbackAPI } from '../api/playback'
import { MediaCard } from '../components/MediaCard'
import type { Media } from '../types'
import { favouriteMediaLink } from '../utils/mediaNavigation'
export function FavouritesPage() {
const [items, setItems] = useState<Media[]>([])
@@ -66,7 +67,7 @@ export function FavouritesPage() {
<div className="glass-panel flex flex-col items-center gap-3 p-10 text-center">
<p className="text-lg text-ink-100">还没有任何收藏</p>
<p className="text-sm text-sand-500">
点击媒体详情页的「收藏」按钮添加喜欢的内容
在媒体库或详情页点击「加入收藏」即可添加喜欢的内容
</p>
</div>
)}
@@ -74,7 +75,7 @@ export function FavouritesPage() {
{items.length > 0 && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{items.map((m) => (
<MediaCard key={m.id} media={m} />
<MediaCard key={m.id} media={m} linkTo={favouriteMediaLink(m)} />
))}
</div>
)}
-5
View File
@@ -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}
+15 -5
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react'
import { libraryAPI, type LibraryWithPreview } from '../api/library'
import { playbackAPI, type HistoryItem } from '../api/playback'
import { historyAPI } from '../api/history'
import type { HistoryItem } from '../api/playback'
import type { Library, Media } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
import {
@@ -33,15 +34,24 @@ export function HomePage() {
.list({ withPreview: true, previewLimit: 20 })
.then((rows) => asArray<LibraryWithPreview>(rows))
.catch(() => [] as LibraryWithPreview[]),
playbackAPI
.recentHistory()
.then((rows) => asArray<HistoryItem>(rows))
historyAPI
.continueWatching(12)
.then((rows) =>
rows.map(
(row): HistoryItem => ({
...row.history,
created_at: '',
updated_at: '',
media: row.media,
}),
),
)
.catch(() => [] as HistoryItem[]),
])
if (cancelled) return
setLibraries(libs)
setHistory(hist.filter((h) => h && !h.completed && !!h.media))
setHistory(hist.filter((h) => h && !h.completed))
const mapData: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }> = {}
for (const lib of libs) {
+8 -3
View File
@@ -23,6 +23,7 @@ import type { HistoryItem } from '../api/playback'
import type { Library, Media } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
import { seriesCardLink } from '../utils/groupSeries'
import { isRemoteEmbyID } from '../utils/remoteEmby'
import { getLibraryArtworks } from './librariesPageModel'
const TYPE_ICONS: Record<string, ReactNode> = {
@@ -575,8 +576,12 @@ export function ContinueWatchingSection({ history }: { history: HistoryItem[] })
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{history.slice(0, 12).map((h) => {
const media = h.media
if (!media) return null
const media: Media = h.media ?? ({
id: h.media_id,
title: isRemoteEmbyID(h.media_id) ? '远程媒体' : '媒体',
poster_url: '',
updated_at: h.watched_at,
} as Media)
const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
return (
<div key={h.id} className="w-64 sm:w-72 shrink-0">
@@ -592,7 +597,7 @@ export function ContinueWatchingSection({ history }: { history: HistoryItem[] })
function ContinueCard({ media, progress }: { media: Media; progress: number }) {
return (
<Link
to={`/media/${media.id}`}
to={`/play/${media.id}`}
className="group flex items-center gap-3.5 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3 shadow-sm transition-all duration-300 hover:border-brand-500/30 hover:bg-[var(--app-panel-soft)] hover:shadow-md"
>
<div className="relative h-20 w-14 shrink-0 overflow-hidden rounded-xl bg-[var(--app-panel-soft)]">
+9 -1
View File
@@ -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>
)
+72 -13
View File
@@ -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>
+4 -3
View File
@@ -11,7 +11,7 @@ type LibraryMediaSectionsProps = {
seriesCards: SeriesCard[]
selectedSeries: SeriesCard | null
loading: boolean
movieActions: (media: Media) => ReactNode
cardActions: (media: Media) => ReactNode
onSeriesClick: (series: SeriesCard) => void
}
@@ -21,7 +21,7 @@ export function LibraryMediaSections({
seriesCards,
selectedSeries,
loading,
movieActions,
cardActions,
onSeriesClick,
}: LibraryMediaSectionsProps) {
return (
@@ -29,7 +29,7 @@ export function LibraryMediaSections({
{!isSeries && items.length > 0 && (
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
{items.map((media) => (
<MediaCard key={media.id} media={media} actions={movieActions(media)} />
<MediaCard key={media.id} media={media} actions={cardActions(media)} />
))}
</div>
)}
@@ -45,6 +45,7 @@ export function LibraryMediaSections({
key={series.key}
media={series.rep}
count={series.count}
actions={cardActions(series.rep)}
onClick={() => onSeriesClick(series)}
/>
))}
+62 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState, Fragment, type ReactNode } from 'react'
import { useLocation, useParams, useSearchParams } from 'react-router-dom'
import { motion } from 'framer-motion'
@@ -13,6 +13,8 @@ import {
type SortOrder,
} from '../utils/mediaSort'
import { LibraryPageDialogs } from './LibraryPageDialogs'
import { PageBackButton } from '../components/PageBackButton'
import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
import { LibraryPageHeader } from './LibraryPageHeader'
import { LibraryMediaSections } from './LibraryMediaSections'
import { LibrarySeriesDetailSection } from './LibrarySeriesDetailSection'
@@ -20,12 +22,17 @@ import { useLibraryData } from './useLibraryData'
import { useLibraryScanStatus } from './useLibraryScanStatus'
import { useLibrarySeriesSelection } from './useLibrarySeriesSelection'
import { useLibraryAdminActions } from './useLibraryAdminActions'
import { usePermission } from '../hooks/usePermission'
import { useFavourites } from '../hooks/useFavourites'
export function LibraryPage() {
const { id = '' } = useParams()
const [searchParams, setSearchParams] = useSearchParams()
const location = useLocation()
const role = useAuthStore((s) => s.user?.role)
const canFavorite = usePermission('can_favorite')
const { isFavourite, toggleFavourite } = useFavourites()
const [favouriteBusyID, setFavouriteBusyID] = useState('')
const [scrapeDialogOpen, setScrapeDialogOpen] = useState(false)
const [manualSeriesScrapeOpen, setManualSeriesScrapeOpen] = useState(false)
@@ -45,9 +52,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 +69,10 @@ export function LibraryPage() {
setHistoryMap(map)
})
.catch(() => {})
}, [])
return () => {
cancelled = true
}
}, [sortField])
const handleSortChange = (field: SortField, order: SortOrder) => {
setSortField(field)
@@ -162,6 +175,39 @@ export function LibraryPage() {
setManualMovie,
})
const handleToggleFavourite = async (mediaID: string) => {
if (!canFavorite || favouriteBusyID) return
setFavouriteBusyID(mediaID)
try {
await toggleFavourite(mediaID)
} finally {
setFavouriteBusyID('')
}
}
const cardActions = (media: Media): ReactNode => {
const actions: ReactNode[] = []
if (canFavorite) {
actions.push(
<MediaFavouriteButton
key="favourite"
variant="compact"
favourite={isFavourite(media.id)}
disabled={favouriteBusyID === media.id}
onToggle={() => {
void handleToggleFavourite(media.id)
}}
/>,
)
}
const adminActions = movieActions(media)
if (adminActions) {
actions.push(<Fragment key="admin-actions">{adminActions}</Fragment>)
}
if (actions.length === 0) return undefined
return <>{actions}</>
}
if (loading) {
return (
<div className="flex items-center justify-center py-32">
@@ -175,6 +221,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}
@@ -203,7 +255,7 @@ export function LibraryPage() {
seriesCards={sortedSeriesCards}
selectedSeries={selectedSeries}
loading={loading}
movieActions={movieActions}
cardActions={cardActions}
onSeriesClick={handleSeriesClick}
/>
@@ -216,6 +268,13 @@ export function LibraryPage() {
loadingEpisodes={loadingSeriesEpisodes}
playbackFrom={`${location.pathname}${location.search}`}
isAdmin={role === 'admin'}
canFavorite={canFavorite}
favourite={selectedSeries ? isFavourite(selectedSeries.rep.id) : false}
favouriteBusy={!!selectedSeries && favouriteBusyID === selectedSeries.rep.id}
onToggleFavourite={() => {
if (!selectedSeries) return
void handleToggleFavourite(selectedSeries.rep.id)
}}
seriesToolBusy={seriesToolBusy}
onBack={clearSelectedSeries}
onSmartScrape={handleSeriesSmartScrape}
+27 -9
View File
@@ -4,6 +4,7 @@ import { ArrowLeft, Database, FileText, Film, FolderInput, Pencil, Play, Search,
import { imageURL } from '../api/client'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
import type { Media } from '../types'
import { seriesTitle, type SeriesCard } from '../utils/groupSeries'
@@ -13,6 +14,10 @@ type LibrarySeriesDetailHeaderProps = {
allEpisodes: Media[]
playbackFrom: string
isAdmin: boolean
canFavorite: boolean
favourite: boolean
favouriteBusy: boolean
onToggleFavourite: () => void
seriesToolBusy: string
onBack: () => void
onSmartScrape: () => void
@@ -30,6 +35,10 @@ export function LibrarySeriesDetailHeader({
allEpisodes,
playbackFrom,
isAdmin,
canFavorite,
favourite,
favouriteBusy,
onToggleFavourite,
seriesToolBusy,
onBack,
onSmartScrape,
@@ -75,15 +84,24 @@ export function LibrarySeriesDetailHeader({
{series.rep.overview || '暂无简介'}
</p>
{firstEpisode && (
<div className="flex flex-wrap gap-2">
<Link to={`/play/${firstEpisode.id}`} state={{ from: playbackFrom }} className="btn-primary inline-flex">
<Play size={16} fill="currentColor" />
从第一集开始播放
</Link>
<ExternalPlayerButton mediaId={firstEpisode.id} label="外部播放器播放" />
</div>
)}
<div className="flex flex-wrap gap-2">
{firstEpisode && (
<>
<Link to={`/play/${firstEpisode.id}`} state={{ from: playbackFrom }} className="btn-primary inline-flex">
<Play size={16} fill="currentColor" />
从第一集开始播放
</Link>
<ExternalPlayerButton mediaId={firstEpisode.id} label="外部播放器播放" />
</>
)}
{canFavorite && (
<MediaFavouriteButton
favourite={favourite}
disabled={favouriteBusy}
onToggle={onToggleFavourite}
/>
)}
</div>
{isAdmin && allEpisodes.length > 0 && !isRemoteEmbyID(series.rep.id) && (
<div className="rounded-2xl border border-sand-200 bg-white/80 p-4 shadow-sm">
@@ -19,6 +19,10 @@ type LibrarySeriesDetailSectionProps = {
loadingEpisodes: boolean
playbackFrom: string
isAdmin: boolean
canFavorite: boolean
favourite: boolean
favouriteBusy: boolean
onToggleFavourite: () => void
seriesToolBusy: string
onBack: () => void
onSmartScrape: () => void
@@ -40,6 +44,10 @@ export function LibrarySeriesDetailSection({
loadingEpisodes,
playbackFrom,
isAdmin,
canFavorite,
favourite,
favouriteBusy,
onToggleFavourite,
seriesToolBusy,
onBack,
onSmartScrape,
@@ -66,6 +74,10 @@ export function LibrarySeriesDetailSection({
allEpisodes={allEpisodes}
playbackFrom={playbackFrom}
isAdmin={isAdmin}
canFavorite={canFavorite}
favourite={favourite}
favouriteBusy={favouriteBusy}
onToggleFavourite={onToggleFavourite}
seriesToolBusy={seriesToolBusy}
onBack={onBack}
onSmartScrape={onSmartScrape}
+1 -9
View File
@@ -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)} />
+5 -6
View File
@@ -1,14 +1,13 @@
import type { Media } from '../types'
import { getSeriesKey, isEpisodeLike } from '../utils/groupSeries'
import { mediaLibraryTarget } from '../utils/mediaNavigation'
export function mediaLibraryBackTarget(media: Media): string {
const seriesTarget = mediaLibraryTarget(media)
if (seriesTarget) return seriesTarget
const libraryID = media.display_library_id || media.library_id
if (!libraryID) return ''
if (!isEpisodeLike(media)) return `/library/${encodeURIComponent(libraryID)}`
const seriesKey = getSeriesKey(media)
const target = `/library/${encodeURIComponent(libraryID)}`
return seriesKey ? `${target}?series=${encodeURIComponent(seriesKey)}` : target
return `/library/${encodeURIComponent(libraryID)}`
}
export function mediaDetailScrapeMediaType(media: Media): string | undefined {
+7 -2
View File
@@ -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>
)
}
+4 -5
View File
@@ -262,11 +262,10 @@ export function PlayerPage() {
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)
.getResume(id)
.then((progress) => {
if (progress.position_ms > 2000 && !progress.completed) {
setResumePosition(progress.position_ms / 1000)
}
})
.catch(() => undefined)
+7 -4
View File
@@ -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>
+98 -41
View File
@@ -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>
)
}
+3 -2
View File
@@ -60,7 +60,7 @@ const TYPE_LABELS: Record<string, string> = {
const PAGE_SIZE = 50
export function ScraperQueuePage() {
export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
const [snapshot, setSnapshot] = useState<ScrapeQueueSnapshot | null>(null)
const [filter, setFilter] = useState<'all' | ScrapeTaskStatus>('all')
const [search, setSearch] = useState('')
@@ -251,7 +251,7 @@ export function ScraperQueuePage() {
return (
<div className="space-y-6">
{/* 1. Header */}
{!embedded && (
<header className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary-400/30 bg-primary-400/10 text-brand-500 shadow-sm">
@@ -385,6 +385,7 @@ export function ScraperQueuePage() {
</details>
</div>
</header>
)}
{/* 2. Status Cards */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
+223 -25
View File
@@ -1,15 +1,19 @@
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import {
ArrowDown,
ArrowUp,
ChevronRight,
Cloud,
ExternalLink,
FolderPlus,
HardDrive,
Loader2,
Plus,
QrCode,
RefreshCw,
Search,
Trash2,
Tv,
X,
} from 'lucide-react'
@@ -23,6 +27,13 @@ import type { StrmAccount, StrmSyncPath, StrmSyncPathInput, StrmProvider } from
import { STRM_PROVIDER_LABELS } from '../types/strm'
import { apiErrorMessage } from './StrmManagePage'
import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog'
import {
defaultEmbyRemoteLines,
encodeEmbyRemoteConfigLines,
normalizeEmbyRemoteLines,
type EmbyRemoteLine,
} from '../utils/embyRemoteLines'
import { lastPathSegment, syncLocalPathWithRemote } from '../utils/strmPaths'
// ─── 弹框外壳 ────────────────────────────────────────────────────────────────
@@ -85,6 +96,26 @@ function Field({ label, hint, children }: { label: string; hint?: string; childr
const inputCls = 'input-base w-full'
const SECRET_PLACEHOLDER = '已配置,留空则不修改'
function accountDialogDefaults(existing: StrmAccount | null) {
const preview = existing?.config_preview
return {
provider: (existing?.provider ?? 'cloud115') as StrmProvider,
name: existing?.name ?? '',
enabled: existing?.enabled ?? true,
url: preview?.url ?? '',
server: preview?.server ?? '',
username: preview?.username ?? '',
password: '',
token: '',
proxyPlay: existing?.proxy_play ?? false,
embyLines: defaultEmbyRemoteLines(existing?.provider === 'emby_remote' ? existing?.emby_lines : undefined),
hasPassword: Boolean(preview?.has_password),
hasToken: Boolean(preview?.has_token || preview?.has_api_key),
}
}
// ─── 添加/编辑网盘账号 ────────────────────────────────────────────────────────
const PROVIDER_OPTIONS: { provider: StrmProvider; label: string; desc: string }[] = [
@@ -102,15 +133,19 @@ export function StrmAccountDialog({
onClose: () => void
onSaved: () => void
}) {
const [provider, setProvider] = useState<StrmProvider>(existing?.provider ?? 'cloud115')
const [name, setName] = useState(existing?.name ?? '')
const [enabled, setEnabled] = useState(existing?.enabled ?? true)
const [url, setUrl] = useState('')
const [server, setServer] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [token, setToken] = useState('')
const [proxyPlay, setProxyPlay] = useState(existing?.proxy_play ?? false)
const defaults = accountDialogDefaults(existing)
const [provider, setProvider] = useState<StrmProvider>(defaults.provider)
const [name, setName] = useState(defaults.name)
const [enabled, setEnabled] = useState(defaults.enabled)
const [url, setUrl] = useState(defaults.url)
const [embyLines, setEmbyLines] = useState<EmbyRemoteLine[]>(defaults.embyLines)
const [server, setServer] = useState(defaults.server)
const [username, setUsername] = useState(defaults.username)
const [password, setPassword] = useState(defaults.password)
const [token, setToken] = useState(defaults.token)
const [proxyPlay, setProxyPlay] = useState(defaults.proxyPlay)
const [hasPassword, setHasPassword] = useState(defaults.hasPassword)
const [hasToken, setHasToken] = useState(defaults.hasToken)
const [saving, setSaving] = useState(false)
const buildConfig = (): Record<string, string> => {
@@ -128,14 +163,16 @@ export function StrmAccountDialog({
...(password ? { password } : {}),
...(token ? { token } : {}),
}
case 'emby_remote':
case 'emby_remote': {
const lineConfig = encodeEmbyRemoteConfigLines(embyLines)
return {
...(url ? { url } : {}),
...lineConfig,
...(username ? { username } : {}),
...(password ? { password } : {}),
...(token ? { token } : {}),
proxy_play: proxyPlay ? 'true' : 'false',
}
}
default:
return {}
}
@@ -148,7 +185,7 @@ export function StrmAccountDialog({
case 'openlist':
return server !== '' && (token !== '' || password !== '')
case 'emby_remote':
return url !== '' && (token !== '' || password !== '')
return normalizeEmbyRemoteLines(embyLines).length > 0 && (token !== '' || password !== '')
default:
return false
}
@@ -163,7 +200,7 @@ export function StrmAccountDialog({
name,
provider: existing.provider,
enabled,
config: existing.has_credential && !Object.keys(buildConfig()).length ? {} : buildConfig(),
config: buildConfig(),
})
toast.success('网盘账号已更新')
} else {
@@ -255,9 +292,92 @@ export function StrmAccountDialog({
)}
{provider === 'emby_remote' && (
<Field label="Emby 服务地址" hint="远程 Emby 服务器地址,如 http://192.168.1.10:8096">
<input className={inputCls} value={url} placeholder="http://host:8096" onChange={(e) => setUrl(e.target.value)} />
</Field>
<div className="space-y-3">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-medium text-ink-100">接入线路</p>
<p className="text-xs text-sand-500">可配置内网、外网等多条线路,连接失败时按顺序自动切换</p>
</div>
<button
type="button"
onClick={() => setEmbyLines((prev) => [...prev, { name: `线路 ${prev.length + 1}`, url: '' }])}
className="inline-flex shrink-0 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>
{embyLines.map((line, index) => (
<div key={`emby-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={() => {
setEmbyLines((prev) => {
const target = index - 1
if (target < 0) return prev
const next = [...prev]
const [moved] = next.splice(index, 1)
next.splice(target, 0, moved)
return next
})
}}
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={() => {
setEmbyLines((prev) => {
const target = index + 1
if (target >= prev.length) return prev
const next = [...prev]
const [moved] = next.splice(index, 1)
next.splice(target, 0, moved)
return next
})
}}
disabled={index === embyLines.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={() => setEmbyLines((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index)))}
disabled={embyLines.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) =>
setEmbyLines((prev) => prev.map((item, i) => (i === index ? { ...item, name: e.target.value } : item)))
}
/>
<input
className={inputCls}
value={line.url}
placeholder="http://192.168.1.10:8096 或 https://emby.example.com"
onChange={(e) =>
setEmbyLines((prev) => prev.map((item, i) => (i === index ? { ...item, url: e.target.value } : item)))
}
/>
</div>
))}
</div>
)}
{(provider === 'clouddrive2' || provider === 'emby_remote') && (
@@ -266,7 +386,16 @@ export function StrmAccountDialog({
<input className={inputCls} value={username} onChange={(e) => setUsername(e.target.value)} />
</Field>
<Field label="密码">
<input className={inputCls} type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<input
className={inputCls}
type="password"
value={password}
placeholder={existing && hasPassword ? SECRET_PLACEHOLDER : undefined}
onChange={(e) => {
setPassword(e.target.value)
if (e.target.value) setHasPassword(false)
}}
/>
</Field>
</div>
)}
@@ -277,20 +406,45 @@ export function StrmAccountDialog({
<input className={inputCls} value={username} onChange={(e) => setUsername(e.target.value)} />
</Field>
<Field label="密码">
<input className={inputCls} type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<input
className={inputCls}
type="password"
value={password}
placeholder={existing && hasPassword ? SECRET_PLACEHOLDER : undefined}
onChange={(e) => {
setPassword(e.target.value)
if (e.target.value) setHasPassword(false)
}}
/>
</Field>
</div>
)}
{provider === 'emby_remote' && (
<Field label="API Key(可选)" hint="留空则用下方用户名/密码自动认证获取">
<input className={inputCls} value={token} placeholder="留空自动认证" onChange={(e) => setToken(e.target.value)} />
<input
className={inputCls}
value={token}
placeholder={existing && hasToken ? SECRET_PLACEHOLDER : '留空自动认证'}
onChange={(e) => {
setToken(e.target.value)
if (e.target.value) setHasToken(false)
}}
/>
</Field>
)}
{provider === 'openlist' && (
<Field label="Token(可选,优先于密码)">
<input className={inputCls} value={token} onChange={(e) => setToken(e.target.value)} />
<input
className={inputCls}
value={token}
placeholder={existing && hasToken ? SECRET_PLACEHOLDER : undefined}
onChange={(e) => {
setToken(e.target.value)
if (e.target.value) setHasToken(false)
}}
/>
</Field>
)}
@@ -760,10 +914,27 @@ export function StrmSyncPathDialog({
const [saving, setSaving] = useState(false)
const [browsing, setBrowsing] = useState(false)
const [browsingLocal, setBrowsingLocal] = useState<null | 'remote_path' | 'local_path'>(null)
const prevRemoteTailRef = useRef(existing ? lastPathSegment(existing.remote_path) : '')
const set = <K extends keyof StrmSyncPathInput>(key: K, value: StrmSyncPathInput[K]) =>
setForm((f) => ({ ...f, [key]: value }))
const updateRemotePath = (remotePath: string) => {
setForm((f) => {
const synced = syncLocalPathWithRemote(f.local_path, remotePath, prevRemoteTailRef.current)
prevRemoteTailRef.current = synced.remoteTail
return { ...f, remote_path: remotePath, local_path: synced.localPath }
})
}
const commitLocalPath = (localPath: string) => {
setForm((f) => {
const synced = syncLocalPathWithRemote(localPath, f.remote_path, prevRemoteTailRef.current)
prevRemoteTailRef.current = synced.remoteTail
return { ...f, local_path: synced.localPath }
})
}
const isLocal = form.provider === 'local'
const availableAccounts = accounts.filter((a) => a.provider === form.provider && a.has_credential)
@@ -847,7 +1018,15 @@ export function StrmSyncPathDialog({
: '远端路径(可通过浏览选择)'
}
>
<input className={inputCls} value={form.remote_path} placeholder={isLocal ? 'D:\\movies' : '/'} onChange={(e) => set('remote_path', e.target.value)} />
<input
className={inputCls}
value={form.remote_path}
placeholder={isLocal ? 'D:\\movies' : '/'}
onChange={(e) => set('remote_path', e.target.value)}
onBlur={(e) => {
if (!isLocal) updateRemotePath(e.target.value)
}}
/>
</Field>
</div>
{isLocal && (
@@ -870,8 +1049,23 @@ export function StrmSyncPathDialog({
<div className="space-y-1">
<div className="flex items-end gap-2">
<div className="flex-1">
<Field label="本地输出目录" hint="生成的 .strm 与下载的元数据写到这里的对应目录结构下">
<input className={inputCls} value={form.local_path} placeholder="D:\\strm\\movies" onChange={(e) => set('local_path', e.target.value)} />
<Field
label="本地输出目录"
hint={
!isLocal && lastPathSegment(form.remote_path)
? `将自动拼接远端末级目录「${lastPathSegment(form.remote_path)}」`
: '生成的 .strm 与下载的元数据写到这里的对应目录结构下'
}
>
<input
className={inputCls}
value={form.local_path}
placeholder="D:\\strm\\movies"
onChange={(e) => set('local_path', e.target.value)}
onBlur={(e) => {
if (!isLocal) commitLocalPath(e.target.value)
}}
/>
</Field>
</div>
<button
@@ -956,7 +1150,7 @@ export function StrmSyncPathDialog({
accountId={form.account_id}
initialDir={form.remote_path || undefined}
onSelect={(id) => {
set('remote_path', id)
updateRemotePath(id)
setBrowsing(false)
}}
onClose={() => setBrowsing(false)}
@@ -967,7 +1161,11 @@ export function StrmSyncPathDialog({
<LocalDirBrowserDialog
initialDir={form[browsingLocal] || undefined}
onSelect={(path) => {
set(browsingLocal, path)
if (browsingLocal === 'local_path') {
commitLocalPath(path)
} else {
set(browsingLocal, path)
}
setBrowsingLocal(null)
}}
onClose={() => setBrowsingLocal(null)}
+1
View File
@@ -220,6 +220,7 @@ export function StrmManagePage() {
{dialog === 'account' && (
<StrmAccountDialog
key={editingAccount?.id ?? 'new'}
existing={editingAccount}
onClose={() => setDialog(null)}
onSaved={() => { setDialog(null); refresh().catch(() => undefined) }}
+9 -2
View File
@@ -55,7 +55,13 @@ function getFileIcon(filename: string): ReactNode {
return <File size={15} className="text-gray-400 shrink-0" />
}
export function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
export function StrmQueuePanel({
kind,
embedded = false,
}: {
kind: 'download' | 'upload'
embedded?: boolean
}) {
const [snapshot, setSnapshot] = useState<StrmQueueSnapshot | null>(null)
const [filter, setFilter] = useState<'all' | StrmTaskStatus>('all')
const [search, setSearch] = useState('')
@@ -242,7 +248,7 @@ export function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
return (
<div className="space-y-6">
{/* 1. Header */}
{!embedded && (
<header className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary-400/30 bg-primary-400/10 text-brand-500 shadow-sm">
@@ -403,6 +409,7 @@ export function StrmQueuePanel({ kind }: { kind: 'download' | 'upload' }) {
</details>
</div>
</header>
)}
{/* 2. Interactive Status Cards */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
+6 -6
View File
@@ -115,9 +115,9 @@ export function TaskQueuePage() {
</div>
{/* 内容 */}
{type === 'scrape' && <ScraperQueuePage />}
{type === 'download' && <StrmQueuePanel kind="download" />}
{type === 'upload' && <StrmQueuePanel kind="upload" />}
{type === 'scrape' && <ScraperQueuePage embedded />}
{type === 'download' && <StrmQueuePanel kind="download" embedded />}
{type === 'upload' && <StrmQueuePanel kind="upload" embedded />}
{type === 'all' && (
<div className="space-y-10">
<section>
@@ -125,21 +125,21 @@ export function TaskQueuePage() {
<Sparkles size={18} className="text-brand-500" />
刮削队列
</h2>
<ScraperQueuePage />
<ScraperQueuePage embedded />
</section>
<section>
<h2 className="mb-2 flex items-center gap-2 font-display text-xl font-bold text-ink-600">
<Download size={18} className="text-brand-500" />
下载队列
</h2>
<StrmQueuePanel kind="download" />
<StrmQueuePanel kind="download" embedded />
</section>
<section>
<h2 className="mb-2 flex items-center gap-2 font-display text-xl font-bold text-ink-600">
<Upload size={18} className="text-brand-500" />
上传队列
</h2>
<StrmQueuePanel kind="upload" />
<StrmQueuePanel kind="upload" embedded />
</section>
</div>
)}
-12
View File
@@ -2,16 +2,13 @@ import type { Setting } from '../types'
export type AutoOrganizeConfig = {
enabled: string
afterDownload: string
scrapeAfter: string
downloadSmartClassify: string
smartClassify: string
autoAddLibrary: string
sourceDir: string
targetDir: string
transferMode: string
intervalSeconds: string
keepSeeding: string
movieFormat: string
tvFormat: string
animeFormat: string
@@ -24,16 +21,13 @@ export type AutoOrganizeConfig = {
export const AUTO_ORGANIZE_DEFAULTS: AutoOrganizeConfig = {
enabled: 'false',
afterDownload: 'false',
scrapeAfter: 'true',
downloadSmartClassify: 'true',
smartClassify: 'true',
autoAddLibrary: 'true',
sourceDir: '',
targetDir: '',
transferMode: 'hardlink',
intervalSeconds: '300',
keepSeeding: 'true',
movieFormat: '{title} ({year})/{title} ({year})',
tvFormat: '{title} ({year})/Season {season:02}/{title} S{season:02}E{episode:02}',
animeFormat: '{title}/Season {season:02}/{title} S{season:02}E{episode:02}',
@@ -46,16 +40,13 @@ export const AUTO_ORGANIZE_DEFAULTS: AutoOrganizeConfig = {
export const AUTO_ORGANIZE_KEYS: Record<keyof AutoOrganizeConfig, string> = {
enabled: 'organize.auto',
afterDownload: 'organizer.auto_after_download',
scrapeAfter: 'organize.scrape_after',
downloadSmartClassify: 'downloads.smart_classify',
smartClassify: 'organizer.smart_classify',
autoAddLibrary: 'organize.auto_add_library',
sourceDir: 'organize.source_dir',
targetDir: 'organize.target_dir',
transferMode: 'organize.transfer_mode',
intervalSeconds: 'organize.interval_seconds',
keepSeeding: 'organize.keep_seeding',
movieFormat: 'organize.movie_format',
tvFormat: 'organize.tv_format',
animeFormat: 'organize.anime_format',
@@ -72,16 +63,13 @@ export function mergeAutoOrganizeSettings(rows: Setting[]): AutoOrganizeConfig {
const idx = settingIndex(rows)
return {
enabled: idx[AUTO_ORGANIZE_KEYS.enabled] ?? AUTO_ORGANIZE_DEFAULTS.enabled,
afterDownload: idx[AUTO_ORGANIZE_KEYS.afterDownload] ?? AUTO_ORGANIZE_DEFAULTS.afterDownload,
scrapeAfter: idx[AUTO_ORGANIZE_KEYS.scrapeAfter] ?? AUTO_ORGANIZE_DEFAULTS.scrapeAfter,
downloadSmartClassify: idx[AUTO_ORGANIZE_KEYS.downloadSmartClassify] ?? AUTO_ORGANIZE_DEFAULTS.downloadSmartClassify,
smartClassify: idx[AUTO_ORGANIZE_KEYS.smartClassify] ?? AUTO_ORGANIZE_DEFAULTS.smartClassify,
autoAddLibrary: idx[AUTO_ORGANIZE_KEYS.autoAddLibrary] ?? AUTO_ORGANIZE_DEFAULTS.autoAddLibrary,
sourceDir: idx[AUTO_ORGANIZE_KEYS.sourceDir] ?? AUTO_ORGANIZE_DEFAULTS.sourceDir,
targetDir: idx[AUTO_ORGANIZE_KEYS.targetDir] ?? AUTO_ORGANIZE_DEFAULTS.targetDir,
transferMode: idx[AUTO_ORGANIZE_KEYS.transferMode] ?? AUTO_ORGANIZE_DEFAULTS.transferMode,
intervalSeconds: idx[AUTO_ORGANIZE_KEYS.intervalSeconds] ?? AUTO_ORGANIZE_DEFAULTS.intervalSeconds,
keepSeeding: idx[AUTO_ORGANIZE_KEYS.keepSeeding] ?? AUTO_ORGANIZE_DEFAULTS.keepSeeding,
movieFormat: idx[AUTO_ORGANIZE_KEYS.movieFormat] ?? AUTO_ORGANIZE_DEFAULTS.movieFormat,
tvFormat: idx[AUTO_ORGANIZE_KEYS.tvFormat] ?? AUTO_ORGANIZE_DEFAULTS.tvFormat,
animeFormat: idx[AUTO_ORGANIZE_KEYS.animeFormat] ?? AUTO_ORGANIZE_DEFAULTS.animeFormat,
+1 -7
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { adminAPI } from '../api/admin'
@@ -79,11 +79,6 @@ export function useAutoOrganizeSettings({ onScrapeAfterChange }: UseAutoOrganize
}
}, [dirty, save])
const moveKeepsSeeding = useMemo(
() => config.transferMode === 'move' && settingOn(config.keepSeeding),
[config.keepSeeding, config.transferMode],
)
return {
config,
dirty,
@@ -91,7 +86,6 @@ export function useAutoOrganizeSettings({ onScrapeAfterChange }: UseAutoOrganize
running,
loading,
activeTab,
moveKeepsSeeding,
refresh,
save,
runNow,
+12
View File
@@ -145,6 +145,16 @@ function isSeriesLibraryType(type?: string) {
return type === 'tv' || type === 'anime' || type === 'variety'
}
function yieldToBrowser(): Promise<void> {
return new Promise((resolve) => {
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(() => resolve(), { timeout: 48 })
} else {
setTimeout(resolve, 0)
}
})
}
async function loadAllSeriesCards(
libraryID: string,
isRemoteEmby: boolean | undefined,
@@ -161,6 +171,7 @@ async function loadAllSeriesCards(
onPage({ items: collected, total: data.total ?? collected.length, firstPage: page === 1 })
if (collected.length >= (data.total ?? 0) || pageItems.length < pageSize) break
page += 1
await yieldToBrowser()
}
return { items: collected }
}
@@ -181,6 +192,7 @@ async function loadAllMedia(
onPage({ items: collected, total: data.total ?? collected.length, firstPage: page === 1 })
if (collected.length >= (data.total ?? 0) || pageItems.length < pageSize) break
page += 1
await yieldToBrowser()
}
return { items: collected }
}
-4
View File
@@ -17,7 +17,6 @@ type UseManualOrganizeOptions = {
selectedPath?: string
selectedPaths: string[]
scrapeAfter: boolean
keepSeeding: boolean
onScrapeAfterChange: (value: boolean) => void
onClearSelected: () => void
refresh: () => void
@@ -29,7 +28,6 @@ export function useManualOrganize({
selectedPath,
selectedPaths,
scrapeAfter,
keepSeeding,
onScrapeAfterChange,
onClearSelected,
refresh,
@@ -59,7 +57,6 @@ export function useManualOrganize({
)
const organizeSource = organizeSources.length === 1 ? organizeSources[0] : `${organizeSources.length} 个已选项目`
const organizeReady = organizeSources.length > 0 && Boolean(organizeDestPath.trim())
const manualMoveKeepsSeeding = organizeTransferMode === 'move' && keepSeeding
const runManualOrganize = async (dryRun: boolean) => {
if (!organizeReady) {
@@ -124,7 +121,6 @@ export function useManualOrganize({
previewItems,
organizeSource,
organizeReady,
manualMoveKeepsSeeding,
setOrganizeLibraryID,
setOrganizeDestPath,
setOrganizeTransferMode,
+19
View File
@@ -9,6 +9,11 @@ export const STRM_PROVIDER_LABELS: Record<StrmProvider, string> = {
emby_remote: 'Emby 远程挂载',
}
export type EmbyRemoteLine = {
name: string
url: string
}
export interface StrmAccount {
id: string
name: string
@@ -21,8 +26,22 @@ export interface StrmAccount {
last_test_ok: boolean
has_credential: boolean
provider_label: string
config_preview?: StrmAccountConfigPreview
/** 仅远程 Emby 挂载账号返回:播放流量是否经过 MMTL 代理 */
proxy_play?: boolean
/** 仅远程 Emby 挂载账号返回:多线路配置 */
emby_lines?: EmbyRemoteLine[]
/** 当前优先使用的线路下标 */
emby_active_line?: number
}
export interface StrmAccountConfigPreview {
url?: string
server?: string
username?: string
has_password?: boolean
has_token?: boolean
has_api_key?: boolean
}
export interface StrmAccountInput {
+31
View File
@@ -0,0 +1,31 @@
export type EmbyRemoteLine = {
name: string
url: string
}
export function normalizeEmbyRemoteLines(lines: EmbyRemoteLine[]): EmbyRemoteLine[] {
return lines
.map((line) => ({
name: line.name.trim(),
url: line.url.trim().replace(/\/+$/, ''),
}))
.filter((line) => line.url.startsWith('http://') || line.url.startsWith('https://'))
}
export function encodeEmbyRemoteConfigLines(lines: EmbyRemoteLine[]): Record<string, string> {
const normalized = normalizeEmbyRemoteLines(lines)
if (normalized.length === 0) {
return {}
}
return {
urls: JSON.stringify(normalized),
url: normalized[0].url,
}
}
export function defaultEmbyRemoteLines(existing?: EmbyRemoteLine[]): EmbyRemoteLine[] {
if (existing && existing.length > 0) {
return existing.map((line) => ({ name: line.name ?? '', url: line.url ?? '' }))
}
return [{ name: '线路 1', url: '' }]
}
+2 -2
View File
@@ -84,12 +84,12 @@ export function isEpisodeLike(media: Media): boolean {
// 剧集类目录名(电视剧/动漫及其二级分类)。媒体路径落在这些目录下时, 即便
// 季集号未识别出来, 也应按剧集对待, 跳转到 /library 分类视图而非 /media 单页。
const EPISODIC_PATH_RE =
/[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]/i
/[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|anime|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]/i
const SEASON_FOLDER_RE =
/^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*[0-9一二三四五六七八九十百零两]+\s*季|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)$/i
function pathLooksEpisodic(media: Media): boolean {
export function pathLooksEpisodic(media: Media): boolean {
const path = (media.path || media.display_library_path || media.library_path || '')
return EPISODIC_PATH_RE.test(path)
}
+47
View File
@@ -0,0 +1,47 @@
import type { Media } from '../types'
import { isRemoteEmbyID } from './remoteEmby'
import { getSeriesKey, isEpisodeLike, pathLooksEpisodic } from './groupSeries'
function pathLooksLikeMovie(media: Media): boolean {
const path = (media.path || media.display_library_path || media.library_path || '').toLowerCase()
return /[\\/](?:movie|movies|film|films|电影)[\\/]/i.test(path)
}
function isRemoteEmbyLibrarySeries(media: Media): boolean {
if (!isRemoteEmbyID(media.id) || isEpisodeLike(media)) return false
const libraryID = media.display_library_id || media.library_id
if (!libraryID) return false
return !pathLooksLikeMovie(media)
}
/** Whether opening the library series detail view is a better target than /media/:id. */
export function prefersLibrarySeriesView(media: Media): boolean {
if (isEpisodeLike(media)) return true
if (pathLooksEpisodic(media)) return true
if (media.series_id) return true
if (isRemoteEmbyLibrarySeries(media)) return true
return false
}
/** Series key used by /library/:id?series=... for both local and remote libraries. */
export function resolveLibrarySeriesKey(media: Media): string {
if (isRemoteEmbyID(media.id) && !isEpisodeLike(media)) {
return media.id
}
return getSeriesKey(media)
}
/** Best library URL for a media item, or null when /media/:id is preferred. */
export function mediaLibraryTarget(media: Media): string | null {
const libraryID = media.display_library_id || media.library_id
if (!libraryID || !prefersLibrarySeriesView(media)) return null
const seriesKey = resolveLibrarySeriesKey(media)
const base = `/library/${encodeURIComponent(libraryID)}`
return seriesKey ? `${base}?series=${encodeURIComponent(seriesKey)}` : base
}
/** Link target for favourites / cards that should open the library series view when possible. */
export function favouriteMediaLink(media: Media): string {
return mediaLibraryTarget(media) ?? `/media/${media.id}`
}
+48
View File
@@ -0,0 +1,48 @@
const STORAGE_KEY = 'mmtl_pinned_libraries'
function parsePinnedIds(raw: string | null): string[] {
if (!raw) return []
try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter((id): id is string => typeof id === 'string' && id.trim() !== '')
} catch {
return []
}
}
export function readPinnedLibraryIds(): string[] {
if (typeof window === 'undefined') return []
return parsePinnedIds(window.localStorage.getItem(STORAGE_KEY))
}
export function writePinnedLibraryIds(ids: string[]): void {
if (typeof window === 'undefined') return
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(ids))
}
export function togglePinnedLibraryId(id: string): string[] {
const current = readPinnedLibraryIds()
const index = current.indexOf(id)
const next = index >= 0 ? current.filter((item) => item !== id) : [...current, id]
writePinnedLibraryIds(next)
return next
}
export function isLibraryPinned(id: string, pinnedIds: string[]): boolean {
return pinnedIds.includes(id)
}
export function sortLibraryPreviews<T extends { library: { id: string } }>(items: T[], pinnedIds: string[]): T[] {
if (pinnedIds.length === 0) return items
const rank = new Map(pinnedIds.map((id, index) => [id, index]))
return [...items].sort((a, b) => {
const aRank = rank.get(a.library.id)
const bRank = rank.get(b.library.id)
const aPinned = aRank !== undefined
const bPinned = bRank !== undefined
if (aPinned !== bPinned) return aPinned ? -1 : 1
if (aPinned && bPinned) return aRank - bRank
return 0
})
}
+53
View File
@@ -0,0 +1,53 @@
/** 取路径最后一级目录/文件名(兼容 / 与 \\)。 */
export function lastPathSegment(path: string): string {
const trimmed = path.trim().replace(/[\\/]+$/, '')
if (!trimmed) return ''
const parts = trimmed.split(/[/\\]/).filter(Boolean)
return parts[parts.length - 1] ?? ''
}
function pathSeparator(path: string): '/' | '\\' {
return path.includes('\\') ? '\\' : '/'
}
function normalizeSlashes(path: string): string {
return path.replace(/\\/g, '/').replace(/\/+$/, '')
}
/** 去掉 local 末尾与 prevRemoteTail 匹配的自动拼接段。 */
function stripRemoteTail(localPath: string, prevRemoteTail: string): string {
if (!prevRemoteTail) return localPath.trim()
const trimmed = localPath.trim()
if (!trimmed) return ''
const norm = normalizeSlashes(trimmed)
const suffix = '/' + prevRemoteTail
if (norm.endsWith(suffix)) {
const stripped = norm.slice(0, -suffix.length)
if (!stripped) return ''
return stripped.split('/').join(pathSeparator(trimmed))
}
if (norm === prevRemoteTail) return ''
return trimmed
}
/** 将远端最后一级目录名拼到本地输出目录末尾;远端变更时替换旧尾段。 */
export function syncLocalPathWithRemote(
localPath: string,
remotePath: string,
prevRemoteTail = '',
): { localPath: string; remoteTail: string } {
const remoteTail = lastPathSegment(remotePath)
const base = stripRemoteTail(localPath, prevRemoteTail)
if (!remoteTail) {
return { localPath: base || localPath.trim(), remoteTail: '' }
}
if (!base) {
return { localPath: localPath.trim(), remoteTail }
}
const sep = pathSeparator(base)
const normBase = normalizeSlashes(base)
if (normBase.endsWith('/' + remoteTail)) {
return { localPath: base, remoteTail }
}
return { localPath: `${base}${sep}${remoteTail}`, remoteTail }
}