Compare commits

...

113 Commits

Author SHA1 Message Date
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
github-actions[bot] deca7735a7 chore: bump version to 0.0.64 [skip ci] 2026-09-01 15:16:32 +00:00
truewhile b503fdee7a 优化续播 2026-09-01 23:16:11 +08:00
github-actions[bot] 3fe37e050b chore: bump version to 0.0.63 [skip ci] 2026-09-01 14:43:48 +00:00
truewhile d3233a62c0 优化 2026-09-01 22:43:30 +08:00
github-actions[bot] 165eee7b36 chore: bump version to 0.0.62 [skip ci] 2026-09-01 13:25:14 +00:00
truewhile 78526afc9c 优化 2026-09-01 21:24:55 +08:00
github-actions[bot] f534e0607a chore: bump version to 0.0.61 [skip ci] 2026-09-01 10:55:32 +00:00
truewhile db64a6c093 优化 2026-09-01 18:55:05 +08:00
github-actions[bot] 5c9e7fcaa6 chore: bump version to 0.0.60 [skip ci] 2026-09-01 08:26:41 +00:00
truewhile af67f4cd6e 优化 2026-09-01 16:26:25 +08:00
github-actions[bot] 73de139d1f chore: bump version to 0.0.59 [skip ci] 2026-09-01 06:33:57 +00:00
truewhile e0cc481b96 Merge pull request #1 from truewhile/beta
添加emby挂载功能,整合上传,下载,刮削队列到任务队列
2026-09-01 14:33:38 +08:00
truewhile 7ac75ec69a fix: 修复 beta CI 失败的两个平台/网络敏感测试
- TestSanitizePathWithSpecialChars:Windows 盘符断言按 runtime.GOOS 分支
  (Linux 下反斜杠统一为分隔符,期望 D/test/...)
- TestEnrichOneAdultScrapesArtwork:AdultProvider 改用 RoundTripper 全量
  mock,拦截所有外网请求(CI 环境可达 javdb/dmm 时会把真实封面写入
  断言导致失败);断言放宽为封面路径后缀
2026-09-01 14:22:52 +08:00
truewhile 0e73e43cbd ci: 新增 beta 分支自动构建流水线
- 触发:push beta / PR 到 beta / 手动触发
- test-and-build:SPA 构建 + go vet/test/build + 三平台交叉编译
  (linux/amd64、linux/arm64、windows/amd64),版本号为
  {VERSION}-beta.{sha},二进制上传为 artifact
- docker-beta:多架构构建并推送 ghcr.io/{owner}/mmtl:beta
  (PR 事件不推镜像,仅推送 beta / 手动触发时推)
- 与 main 的发布流隔离:不做版本递增、不打 release tag
2026-09-01 14:12:31 +08:00
truewhile bd41ab3fb4 feat: 整合刮削/下载/上传队列为统一的「任务队列」
- 新增 /queue 任务队列页:全部 / 刮削 / 下载 / 上传 类型 Tab(URL 参数
  ?type= 同步),Tab 上实时显示各自任务计数徽章
- 「全部」视图纵向堆叠三类队列,单类视图直接复用原队列组件
  (ScraperQueuePage / StrmQueuePanel,后者已导出)
- 侧边栏导航:删除「刮削队列 / 下载队列 / 上传队列」三个入口,合并为
  「任务队列」单一菜单项;旧路由 /scraper/queue、/strm/downloads、
  /strm/uploads 保留直达
2026-09-01 14:02:25 +08:00
truewhile 73b95a8e38 feat: Emby 挂载独立管理(媒体库级选择挂载 + 每库代理开关)
- 新增 EmbyMount 表与 /admin/emby/mounts CRUD:把远程 Emby 的媒体库按需
  挂载到本项目(同一服务器可选择性挂载/全量挂载),代理开关下沉到每个
  挂载(一个 Emby 服务器可部分库代理、部分库直连)
- 伪装 ID 改为 embyremote~{mountID}~{remoteID}:播放/详情/状态/图片链路
  全部经 ResolveMount 解析挂载与账号
- 新增「Emby 挂载」独立菜单页(/emby-mount):账号管理(添加/测试/编辑/
  删除)+ 挂载列表(代理切换/停用/取消)+ 选择挂载对话框(多选+全量)
- STRM 管理页过滤 emby_remote 账号;账号表单移除 emby 类型
- Boot 幂等 AutoSeedMounts:旧 emby 账号自动全量挂载(沿用旧代理配置),
  升级后媒体库不消失
- 修复:远程流请求注入浏览器 UA(防 Cloudflare 风控)+ 代理强制 Static=true
  (阻止远程 ffmpeg 转码调度,反代纯字节中继)
- 全局搜索改为按挂载逐个搜索(结果归属与伪装 ID 正确)
2026-09-01 13:53:15 +08:00
truewhile 5a5555d28a fix: 远程 Emby 无图条目不再下发无效图片 URL(封面破图修复)
- 远程条目仅当 ImageTags 含 Primary/Backdrop 时才生成图片 URL;
  无图条目 poster_url 为空,前端走 No Poster 优雅占位而非裂图
- 剧集海报回退使用 Emby 的 SeriesPrimaryImageTag(Fields=SeriesPrimaryImage),
  仅当系列真实有图时才回退,避免连环无效请求
- 列表/剧集接口带 SeriesPrimaryImage 字段
2026-09-01 13:26:57 +08:00
truewhile 4dc9cbe2e7 feat: 网页端完整浏览/播放远程 Emby 挂载库
- 后端新增远程库→本地结构映射(emby_remote_web.go):Library/Media/SeriesCard
  与本地完全同构,前端无感知
- /api/libraries(含 with_preview)、/api/libraries/:id、/api/libraries/:id/media、
  /api/libraries/:id/series、/api/libraries/:id/series/episodes、/api/media/:id、
  /api/media/:id/episodes 全部支持远程伪装 ID 透传映射
- /api/stream/:id 对远程条目 302 直连远程 Emby 原地址(播放不经过本机)
- 库内容按 CollectionType 过滤(电影库取 Movie、剧集库取 Series),
  RecursiveItemCount 作为系列集数;修复伪装 ID 双重编码导致的图片/详情 500
- SPA fallback 放行 /library/embyremote~ 前缀(不再被当作 Emby API 路由 404)
- 前端:远程库/条目标记 is_remote_emby,隐藏扫描/刮削/编辑/NFO/回收站/
  HLS 转码等不适用操作;后台管理面板过滤远程库
2026-09-01 11:32:54 +08:00
truewhile 6af0e5fdcf fix: Emby 远程挂载播放地址强制下发 + 凭据状态显示修正
- PlaybackInfo 始终构造 DirectStreamUrl(远程 PlaybackInfo 默认不带该字段,
  此前客户端拿不到播放地址):默认指向远程 Emby 原地址(不代理),
  开启代理时指向 MMTL /Videos/{encoded} 反代端点
- HasStrmAccountCredential 对 emby_remote 放宽为 url+用户名/密码 即视为
  已配置(此前只认 api_key,导致列表误显示「凭据:未配置」)
2026-09-01 11:06:36 +08:00
truewhile f0055b76fe fix: Emby 远程挂载账号编辑保留凭据 + 代理开关回显
- UpdateStrmAccount 对 emby_remote 做合并式更新:只覆盖传入键,未提及的
  地址/用户名/密码/token 密文保留,避免编辑代理开关时清空凭据
- 账号列表/编辑表单回显 proxy_play(服务端解密后下发)
- 前端 StrmAccount 类型与编辑对话框支持 proxy_play 回显
2026-09-01 10:45:52 +08:00
truewhile 0eb3f104f4 feat: Emby 远程挂载(联邦聚合)功能 beta
- 新增 emby_remote 提供方:通过 STRM 账号体系配置远程 Emby 地址/用户名/密码
- EmbyService 聚合远程媒体库 Views/Items/详情/最近添加/全局搜索,远程数据不落库
- 条目 ID 统一伪装为 embyremote~{account}~{id},请求按账号路由回远程
- 播放支持账号级 proxy_play 配置:默认客户端直连远程;开启后由 MMTL 反向代理流/字幕
- 图片/元数据全部实时透传远程,播放状态(已看/收藏)透传回远程 Emby
- cloud 包新增 emby Provider(Ping/List/Resolve),账号测试/目录浏览自动生效
- 前端 STRM 页面支持 Emby 远程挂载账号(地址+凭据+代理开关)
2026-09-01 10:44:01 +08:00
github-actions[bot] c171b38155 chore: bump version to 0.0.58 [skip ci] 2026-08-30 14:32:56 +00:00
truewhile 89d7a6cbb2 优化 2026-08-30 22:32:39 +08:00
github-actions[bot] 85918d1196 chore: bump version to 0.0.57 [skip ci] 2026-08-30 13:41:17 +00:00
truewhile fa9307e2e1 优化 2026-08-30 21:40:58 +08:00
github-actions[bot] 5ab18c2725 chore: bump version to 0.0.56 [skip ci] 2026-08-30 12:05:41 +00:00
truewhile a4a2bde1a4 优化 2026-08-30 20:05:27 +08:00
github-actions[bot] 28113f5fdc chore: bump version to 0.0.55 [skip ci] 2026-08-30 10:50:29 +00:00
truewhile 42b8805e94 优化 2026-08-30 18:50:15 +08:00
github-actions[bot] 51a64f41b9 chore: bump version to 0.0.54 [skip ci] 2026-08-30 09:28:16 +00:00
truewhile a7bd9a942c 优化显示 2026-08-30 17:28:00 +08:00
github-actions[bot] 97491a6175 chore: bump version to 0.0.53 [skip ci] 2026-08-29 14:16:40 +00:00
truewhile ea5cb3a130 优化 2026-08-29 22:16:21 +08:00
github-actions[bot] 921010926b chore: bump version to 0.0.52 [skip ci] 2026-08-28 14:38:24 +00:00
truewhile 7425c3d57b 优化 2026-08-28 22:38:09 +08:00
github-actions[bot] 1b7d4eef46 chore: bump version to 0.0.51 [skip ci] 2026-08-28 14:20:18 +00:00
truewhile c30dab56a3 优化 2026-08-28 22:20:01 +08:00
github-actions[bot] e365250440 chore: bump version to 0.0.50 [skip ci] 2026-08-28 10:28:29 +00:00
truewhile 47d10e1f58 优化 2026-08-28 18:28:09 +08:00
github-actions[bot] e6473300a7 chore: bump version to 0.0.49 [skip ci] 2026-08-28 08:27:32 +00:00
truewhile 994f64f753 优化 2026-08-28 16:27:11 +08:00
github-actions[bot] 90064a5480 chore: bump version to 0.0.48 [skip ci] 2026-08-27 15:40:31 +00:00
truewhile 6e8eac9887 优化 2026-08-27 23:40:12 +08:00
github-actions[bot] d3051eaffe chore: bump version to 0.0.47 [skip ci] 2026-08-27 13:24:18 +00:00
truewhile 496a897782 优化 2026-08-27 21:24:01 +08:00
github-actions[bot] 22b7290ee1 chore: bump version to 0.0.46 [skip ci] 2026-08-27 07:42:16 +00:00
truewhile 14037d5dea 5 2026-08-27 15:42:00 +08:00
github-actions[bot] 152db3fb9f chore: bump version to 0.0.45 [skip ci] 2026-08-27 06:20:25 +00:00
truewhile 0413d123da 4 2026-08-27 14:20:05 +08:00
github-actions[bot] 5f6bd7b5cd chore: bump version to 0.0.44 [skip ci] 2026-08-27 05:41:04 +00:00
truewhile 3c25bb5d61 3 2026-08-27 13:40:46 +08:00
github-actions[bot] 659b91b000 chore: bump version to 0.0.43 [skip ci] 2026-08-27 03:34:05 +00:00
truewhile fe5b3bd56a 2 2026-08-27 11:33:50 +08:00
github-actions[bot] 82bbb116ae chore: bump version to 0.0.42 [skip ci] 2026-08-27 03:07:35 +00:00
truewhile 9f5ff7e6f0 1 2026-08-27 11:07:18 +08:00
github-actions[bot] a00504080a chore: bump version to 0.0.41 [skip ci] 2026-08-27 02:09:16 +00:00
truewhile fc6e2e6f10 优化 strm 同步记录:支持删除记录并展示上传数量统计 2026-08-27 10:08:54 +08:00
github-actions[bot] 65c5f3e4bf chore: bump version to 0.0.40 [skip ci] 2026-08-26 15:35:37 +00:00
truewhile 07e340251b 优化 2026-08-26 23:32:02 +08:00
github-actions[bot] 9b956b928b chore: bump version to 0.0.39 [skip ci] 2026-08-26 13:41:25 +00:00
truewhile c3187f6e3f 优化上传逻辑
优化上传逻辑
2026-08-26 21:41:06 +08:00
github-actions[bot] 60c815a8b3 chore: bump version to 0.0.38 [skip ci] 2026-08-26 09:06:04 +00:00
truewhile 41b155ea31 Merge branch 'main' of https://github.com/truewhile/MMTL 2026-08-26 17:05:45 +08:00
truewhile 2888ae8bf7 8 2026-08-26 17:05:41 +08:00
github-actions[bot] 7363064d89 chore: bump version to 0.0.37 [skip ci] 2026-08-26 08:16:56 +00:00
truewhile 1d53bf2ae1 7 2026-08-26 16:16:39 +08:00
github-actions[bot] 618165ec31 chore: bump version to 0.0.36 [skip ci] 2026-08-26 07:51:14 +00:00
truewhile 87c66a9b8c 6 2026-08-26 15:50:59 +08:00
github-actions[bot] 1ea4724261 chore: bump version to 0.0.35 [skip ci] 2026-08-26 06:50:29 +00:00
truewhile 3d372f039e Merge branch 'main' of https://github.com/truewhile/MMTL 2026-08-26 14:50:10 +08:00
truewhile 0384017e98 6 2026-08-26 14:50:06 +08:00
github-actions[bot] 0332579d5f chore: bump version to 0.0.34 [skip ci] 2026-08-26 04:52:28 +00:00
truewhile 6aefe18caa 5 2026-08-26 12:52:10 +08:00
github-actions[bot] ef72fc8d83 chore: bump version to 0.0.33 [skip ci] 2026-08-26 04:12:13 +00:00
truewhile 4764c09572 4 2026-08-26 12:11:56 +08:00
github-actions[bot] 98ca766a37 chore: bump version to 0.0.32 [skip ci] 2026-08-26 03:38:14 +00:00
truewhile 13c9035b76 3 2026-08-26 11:37:58 +08:00
github-actions[bot] ad6d0ba21d chore: bump version to 0.0.31 [skip ci] 2026-08-26 03:19:09 +00:00
truewhile 431f7f088b 2 2026-08-26 11:18:53 +08:00
github-actions[bot] 3f13ed1113 chore: bump version to 0.0.30 [skip ci] 2026-08-26 01:51:54 +00:00
truewhile 9d359c40dd 1 2026-08-26 09:51:27 +08:00
github-actions[bot] 0e7dbd6215 chore: bump version to 0.0.29 [skip ci] 2026-08-26 00:43:54 +00:00
truewhile 7fd8de91cb Merge branch 'main' of https://github.com/truewhile/MMTL 2026-08-26 08:43:40 +08:00
truewhile 5f323eb2ce 优化
yo 优化
2026-08-26 08:43:36 +08:00
github-actions[bot] c0ac8bf11a chore: bump version to 0.0.28 [skip ci] 2026-08-25 16:26:45 +00:00
truewhile b676733af7 优化strm同步
优化strm同步
2026-08-26 00:26:28 +08:00
247 changed files with 19718 additions and 3273 deletions
+118 -3
View File
@@ -4,9 +4,7 @@ name: AuTo Docker Image
on:
push:
branches: [main]
pull_request:
branches: [main]
# 保留手动触发作为备选
workflow_dispatch:
inputs:
@@ -27,6 +25,9 @@ permissions:
jobs:
version-and-publish:
runs-on: ubuntu-latest
outputs:
new_version: ${{ steps.bump_version.outputs.new_version }}
tag: ${{ steps.bump_version.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
@@ -149,3 +150,117 @@ jobs:
VERSION=${{ steps.bump_version.outputs.new_version }}
cache-from: type=gha
cache-to: type=gha,mode=max
# 单文件可执行构建:把前端打包进二进制(go:embed),交叉编译 Windows /
# Linux / macOS 的 amd64 / arm64 产物,作为 GitHub Release 附件发布。
build-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install
working-directory: web
run: npm ci
- name: Build SPA
working-directory: web
run: npm run build
- name: Upload web/dist
uses: actions/upload-artifact@v4
with:
name: web-dist
path: web/dist
retention-days: 1
# 先创建(幂等)空的 GitHub Release,供后续 build-binaries 并行上传附件,
# 也避免矩阵各 job 并发 upload 时 release 尚不存在而互相竞争。
publish-create-release:
needs: [version-and-publish]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Create release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.version-and-publish.outputs.tag }}
run: |
set -eux
# tag 已由 version-and-publish 推送;若 release 已存在则忽略(--verify-tag 幂等)
gh release create "$RELEASE_TAG" \
--title "MMTL ${{ needs.version-and-publish.outputs.new_version }}" \
--notes "自动化发布 ${{ needs.version-and-publish.outputs.new_version }}" \
--verify-tag --latest || true
build-binaries:
needs: [version-and-publish, build-frontend, publish-create-release]
runs-on: ubuntu-latest
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- goos: linux
goarch: amd64
ext: ""
- goos: linux
goarch: arm64
ext: ""
- goos: windows
goarch: amd64
ext: .exe
- goos: windows
goarch: arm64
ext: .exe
- goos: darwin
goarch: amd64
ext: ""
- goos: darwin
goarch: arm64
ext: ""
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- name: Download web/dist
uses: actions/download-artifact@v4
with:
name: web-dist
path: web/dist
- name: Build binary
run: |
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
go build -trimpath -ldflags="-s -w -X main.version=${{ needs.version-and-publish.outputs.tag }}" \
-o "dist/mmtl-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server
- name: Package
run: |
mkdir -p package/mmtl
cp "dist/mmtl-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" package/mmtl/mmtl${{ matrix.ext }}
cp README.md package/mmtl/ 2>/dev/null || true
if [ "${{ matrix.goos }}" = "windows" ]; then
(cd package && zip -r "../mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.zip" mmtl)
else
tar -czf "mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz" -C package mmtl
fi
- name: Upload to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.version-and-publish.outputs.tag }}
run: |
set -eux
PKG="mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.zip"
TAR="mmtl_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz"
# 并发上传到同一 release 各自文件,--clobber 幂等覆盖
if [ -f "$PKG" ]; then
for i in 1 2 3; do gh release upload "$RELEASE_TAG" "$PKG" --clobber && break || sleep 5; done
fi
if [ -f "$TAR" ]; then
for i in 1 2 3; do gh release upload "$RELEASE_TAG" "$TAR" --clobber && break || sleep 5; done
fi
+133
View File
@@ -0,0 +1,133 @@
# Beta 分支自动构建流水线
#
# 触发:push 到 beta 分支 / PR 到 beta / 手动触发。
# 产出:
# 1. 前端 + 后端编译验证(go vet / go test / go build)
# 2. 多平台可执行二进制 artifact(linux/amd64、linux/arm64、windows/amd64)
# 3. ghcr.io/{owner}/mmtl:beta 多架构 Docker 镜像(linux/amd64 + linux/arm64)
#
# 与 main 分支的发布流(Auto-docker-publish.yml)隔离:beta 不做版本递增、
# 不打 release tag,只构建带 -beta 标识的产物供测试。
name: Beta Build
on:
push:
branches: [beta]
pull_request:
branches: [beta]
workflow_dispatch:
permissions:
contents: read
packages: write
env:
BETA_VERSION_PREFIX: beta
jobs:
# ─────────────────────────────────────────────────────────────────────────────
# 1) 编译验证 + 多平台二进制产物
# ─────────────────────────────────────────────────────────────────────────────
test-and-build:
name: Test & build artifacts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve beta version
id: version
run: |
BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
SHA_SHORT=${GITHUB_SHA:0:7}
echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
# The binary embeds the SPA (web/dist) via go:embed, so dist must exist
# before the Go toolchain touches the web package.
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Build SPA
working-directory: web
run: |
npm ci
npm run build
- uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- name: go vet
run: go vet ./...
- name: go test
run: go test ./...
- name: go build (host)
run: go build ./...
# 多平台可执行文件(嵌入刚构建的 web/dist)
- name: Build linux/amd64
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mmtl-beta-linux-amd64 ./cmd/server
- name: Build linux/arm64
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mmtl-beta-linux-arm64 ./cmd/server
- name: Build windows/amd64
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mmtl-beta-windows-amd64.exe ./cmd/server
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: mmtl-beta-binaries
path: dist/*
if-no-files-found: error
# ─────────────────────────────────────────────────────────────────────────────
# 2) Beta Docker 镜像(ghcr.io/{owner}/mmtl:beta)
# ─────────────────────────────────────────────────────────────────────────────
docker-beta:
name: Build & push beta Docker image
needs: test-and-build
runs-on: ubuntu-latest
# PR 事件不推送镜像,仅 push beta / 手动触发时推送
if: github.event_name != 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Resolve beta version
id: version
run: |
BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
SHA_SHORT=${GITHUB_SHA:0:7}
echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build & push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
provenance: false
sbom: false
tags: ghcr.io/${{ github.repository_owner }}/mmtl:beta
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=${{ github.repository }}
build-args: |
VERSION=${{ steps.version.outputs.full_version }}
cache-from: type=gha
cache-to: type=gha,mode=max
+13
View File
@@ -18,6 +18,19 @@ jobs:
go-version: '1.25'
cache: true
# The binary embeds the SPA (web/dist) via go:embed, so the dist must exist
# before the Go toolchain touches the `web` package.
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Build SPA
working-directory: web
run: |
npm ci
npm run build
- name: go vet
run: go vet ./...
+138 -298
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,383 +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。
后端通过 `go:embed` 嵌入 `web/dist`,**编译前必须先构建前端**。
```bash
# 后端测试
go test ./...
# 前端依赖与构建
npm --prefix web install
npm --prefix web ci
npm --prefix web run build
# 本地运行后端
go run ./cmd/server
# 本地运行前端开发服务器
npm --prefix web run dev
go test ./...
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)。感谢该项目的思路与实践经验。
---
## 贡献与反馈
提交 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.27
0.0.74
+3 -3
View File
@@ -52,7 +52,7 @@ func TestServeSPANoCachesIndexAndServesRoutes(t *testing.T) {
}
router := gin.New()
serveSPA(router, webDir)
serveSPA(router, os.DirFS(webDir))
for _, path := range []string{"/", "/login", "/library/e1c3507e-2878-40ae-a0e1-6b6e44b7fa7a", "/media/abc"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
@@ -93,7 +93,7 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
}
router := gin.New()
serveSPA(router, webDir)
serveSPA(router, os.DirFS(webDir))
assetReq := httptest.NewRequest(http.MethodGet, "/assets/app.js", nil)
assetResp := httptest.NewRecorder()
@@ -155,7 +155,7 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
func TestServeSPAMissingIndexReportsExplicit404(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
serveSPA(router, t.TempDir())
serveSPA(router, os.DirFS(t.TempDir()))
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
+64 -23
View File
@@ -1,6 +1,8 @@
package main
import (
"io/fs"
"mime"
"net/http"
"os"
"path/filepath"
@@ -13,6 +15,8 @@ import (
"github.com/ShukeBta/MMTL/internal/handler"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/ShukeBta/MMTL/web"
)
func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) *gin.Engine {
@@ -29,31 +33,41 @@ func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container)
handler.Register(r, cfg, logger, svc)
if cfg.App.WebDir != "" {
serveSPA(r, cfg.App.WebDir)
// Prefer a directory on disk when configured explicitly (e.g. the Docker image
// mounts web/dist from the build stage, or an operator overrides app.web_dir
// with a custom skin). Otherwise fall back to the SPA embedded into the binary,
// which is what makes the cross-platform single-file artifacts work.
uiFS := webui.DistFS()
if dir := cfg.App.WebDir; dir != "" {
disk := os.DirFS(dir)
if index, err := fs.Stat(disk, "index.html"); err == nil && !index.IsDir() {
uiFS = disk
}
}
serveSPA(r, uiFS)
return r
}
// serveSPA serves the React build artifacts and falls back to index.html for
// non-API, non-asset paths so client-side routing keeps working.
func serveSPA(r *gin.Engine, webDir string) {
// non-API, non-asset paths so client-side routing keeps working. The UI tree
// comes from root, which is either the compiled-in SPA or an on-disk web dir.
func serveSPA(r *gin.Engine, root fs.FS) {
assets := r.Group("/assets")
assets.Use(func(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Next()
})
assets.Static("/", filepath.Join(webDir, "assets"))
assets.GET("/*filepath", serveFSDir(root, "assets"))
brand := r.Group("/brand")
brand.Use(func(c *gin.Context) {
setNoCacheHeaders(c)
c.Next()
})
brand.Static("/", filepath.Join(webDir, "brand"))
brand.GET("/*filepath", serveFSDir(root, "brand"))
for _, rootFile := range []string{"/favicon.ico", "/favicon.svg", "/artwork-cache-sw.js"} {
filePath := filepath.Join(webDir, strings.TrimPrefix(rootFile, "/"))
r.GET(rootFile, serveNoCacheFile(filePath))
r.HEAD(rootFile, serveNoCacheFile(filePath))
name := strings.TrimPrefix(rootFile, "/")
r.GET(rootFile, serveFSFile(root, name))
r.HEAD(rootFile, serveFSFile(root, name))
}
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
@@ -61,28 +75,50 @@ func serveSPA(r *gin.Engine, webDir string) {
c.Status(http.StatusNotFound)
return
}
serveSPAIndex(c, filepath.Join(webDir, "index.html"))
setNoCacheHeaders(c)
data, err := fs.ReadFile(root, "index.html")
if err != nil {
c.String(http.StatusNotFound, "MMTL web UI not found")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
})
}
func serveNoCacheFile(filePath string) gin.HandlerFunc {
// serveFSDir serves a static subdirectory of root. A missing asset returns 404.
func serveFSDir(root fs.FS, dir string) gin.HandlerFunc {
sub, err := fs.Sub(root, dir)
if err != nil {
return func(c *gin.Context) { c.Status(http.StatusNotFound) }
}
handler := http.StripPrefix("/"+dir, http.FileServerFS(sub))
return func(c *gin.Context) {
setNoCacheHeaders(c)
if _, err := os.Stat(filePath); err != nil {
c.Status(http.StatusNotFound)
return
}
c.File(filePath)
handler.ServeHTTP(c.Writer, c.Request)
}
}
func serveSPAIndex(c *gin.Context, indexPath string) {
setNoCacheHeaders(c)
if _, err := os.Stat(indexPath); err != nil {
c.String(http.StatusNotFound, "MMTL web UI not found: %s", indexPath)
return
// serveFSFile serves a single root-level file (favicon / service worker) with
// no-cache headers. It reads from root, which may be the embedded SPA or disk.
func serveFSFile(root fs.FS, name string) gin.HandlerFunc {
return func(c *gin.Context) {
setNoCacheHeaders(c)
data, err := fs.ReadFile(root, name)
if err != nil {
c.Status(http.StatusNotFound)
return
}
c.Data(http.StatusOK, mimeTypeByName(name), data)
}
}
// mimeTypeByName returns an HTTP content type guessed from a file extension.
func mimeTypeByName(name string) string {
switch mime.TypeByExtension(filepath.Ext(name)) {
case "":
return "application/octet-stream"
default:
return mime.TypeByExtension(filepath.Ext(name))
}
c.File(indexPath)
}
func setNoCacheHeaders(c *gin.Context) {
@@ -137,6 +173,11 @@ func isFrontendLibraryRoute(path string) bool {
if strings.Contains(id, "/") {
return false
}
// 远程 Emby 挂载库的伪装 ID(embyremote~account~remote)也是前端库路由,
// 需要交给 SPA 而非当作 Emby API 路径 404。
if strings.HasPrefix(id, "embyremote~") {
return true
}
if len(id) != 36 {
return false
}
+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。
+3 -1
View File
@@ -3,6 +3,7 @@ module github.com/ShukeBta/MMTL
go 1.25.0
require (
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2
github.com/fsnotify/fsnotify v1.7.0
github.com/gin-gonic/gin v1.9.1
github.com/glebarez/sqlite v1.11.0
@@ -16,6 +17,7 @@ require (
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.21.0
golang.org/x/sys v0.20.0
golang.org/x/time v0.15.0
gorm.io/driver/postgres v1.5.7
gorm.io/gorm v1.30.0
)
@@ -66,13 +68,13 @@ require (
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/text v0.20.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+4
View File
@@ -1,3 +1,5 @@
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2 h1:40yUSXwdkWN851BHCq6uiDhleh7A4+0yIBS+IUAqZVY=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -150,6 +152,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
Binary file not shown.

Before

Width:  |  Height:  |  Size: 830 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

+4 -3
View File
@@ -3,8 +3,8 @@ package config
import "github.com/spf13/viper"
const (
defaultDatabaseMaxOpenConns = 4
defaultDatabaseMaxIdleConns = 2
defaultDatabaseMaxOpenConns = 16
defaultDatabaseMaxIdleConns = 4
defaultLicenseServerURL = "https://mgosever.3jzs.com"
defaultLicensePublicKey = "MCowBQYDK2VwAyEABRXnXy+urjrbKit6Yu/HiezWgP0NdsZW3tsegJWRrtI="
)
@@ -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)
@@ -43,6 +43,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("logging.max_backups", 10)
v.SetDefault("cache.cache_dir", "./cache")
v.SetDefault("cache.images_max_size_mb", 500)
v.SetDefault("cache.cleanup_interval_min", 60)
v.SetDefault("cache.redis_url", "")
v.SetDefault("cache.redis_prefix", "mmtl")
+3
View File
@@ -44,6 +44,9 @@ func (c *Config) normalize() error {
if c.Cache.CacheDir == "" {
c.Cache.CacheDir = filepath.Join(c.App.DataDir, "cache")
}
if c.Cache.ImagesMaxSizeMB < 0 {
c.Cache.ImagesMaxSizeMB = 0
}
if c.Cache.RedisPrefix == "" {
c.Cache.RedisPrefix = "mmtl"
}
+41
View File
@@ -0,0 +1,41 @@
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// SaveDatabaseConfig updates or creates config.yaml with the specified database configuration.
func SaveDatabaseConfig(dbType, dsn string) error {
configPath := "config.yaml"
data := make(map[string]any)
content, err := os.ReadFile(configPath)
if err == nil {
if err := yaml.Unmarshal(content, &data); err != nil {
data = make(map[string]any)
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("read config.yaml: %w", err)
}
dbSection, ok := data["database"].(map[string]any)
if !ok {
dbSection = make(map[string]any)
}
dbSection["type"] = dbType
dbSection["dsn"] = dsn
data["database"] = dbSection
out, err := yaml.Marshal(data)
if err != nil {
return fmt.Errorf("marshal config.yaml: %w", err)
}
if err := os.WriteFile(configPath, out, 0644); err != nil {
return fmt.Errorf("write config.yaml: %w", err)
}
return nil
}
+36
View File
@@ -0,0 +1,36 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestSaveDatabaseConfig(t *testing.T) {
dir := t.TempDir()
wd, _ := os.Getwd()
defer func() { _ = os.Chdir(wd) }()
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
dsn := "postgres://admin:pass@127.0.0.1:5432/mmtl?sslmode=disable"
if err := SaveDatabaseConfig("postgres", dsn); err != nil {
t.Fatalf("SaveDatabaseConfig error: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "config.yaml")); err != nil {
t.Fatalf("expected config.yaml to exist: %v", err)
}
loaded, err := Load()
if err != nil {
t.Fatalf("Load error: %v", err)
}
if loaded.Database.Type != "postgres" {
t.Fatalf("expected database.type=postgres, got %s", loaded.Database.Type)
}
if loaded.Database.DSN != dsn {
t.Fatalf("expected dsn=%s, got %s", dsn, loaded.Database.DSN)
}
}
+7 -6
View File
@@ -44,11 +44,11 @@ type TranscoderConfig struct {
// AppConfig 保存运行时应用参数。
type AppConfig struct {
Port int `mapstructure:"port"`
Debug bool `mapstructure:"debug"`
Env string `mapstructure:"env"`
DataDir string `mapstructure:"data_dir"`
WebDir string `mapstructure:"web_dir"`
Port int `mapstructure:"port"`
Debug bool `mapstructure:"debug"`
Env string `mapstructure:"env"`
DataDir string `mapstructure:"data_dir"`
WebDir string `mapstructure:"web_dir"`
// HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。
HTTPSEnabled bool `mapstructure:"https_enabled"`
@@ -59,7 +59,7 @@ type AppConfig struct {
// SSLCertPath 是 SSL 证书文件路径;非空时优先于 SSLCert 从文件读取。
SSLCertPath string `mapstructure:"ssl_cert_path"`
// SSLKeyPath 是 SSL 私钥文件路径;非空时优先于 SSLKey 从文件读取。
SSLKeyPath string `mapstructure:"ssl_key_path"`
SSLKeyPath string `mapstructure:"ssl_key_path"`
FFmpegPath string `mapstructure:"ffmpeg_path"`
FFprobePath string `mapstructure:"ffprobe_path"`
// FFprobeMaxConcurrent limits concurrent ffprobe/ffmpeg metadata probes.
@@ -116,6 +116,7 @@ type LoggingConfig struct {
// CacheConfig 控制磁盘转码/刮削缓存。
type CacheConfig struct {
CacheDir string `mapstructure:"cache_dir"`
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"`
TTLHours int `mapstructure:"ttl_hours"`
AutoCleanup bool `mapstructure:"auto_cleanup"`
+223
View File
@@ -0,0 +1,223 @@
package database
import (
"context"
"fmt"
"net/url"
"strings"
"time"
"go.uber.org/zap"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
)
// DatabaseStatus describes the currently active database engine and runtime metrics.
type DatabaseStatus struct {
Type string `json:"type"`
DSN string `json:"dsn,omitempty"`
DBPath string `json:"db_path,omitempty"`
OpenConns int `json:"open_conns"`
InUse int `json:"in_use"`
Idle int `json:"idle"`
MaxOpenConns int `json:"max_open_conns"`
TableCounts map[string]int64 `json:"table_counts"`
}
// PostgresTestResult returns latency and version info after testing connection.
type PostgresTestResult struct {
Success bool `json:"success"`
LatencyMS int64 `json:"latency_ms"`
Version string `json:"version,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
// DatabaseMigrationResult returns row counts and execution duration of migration.
type DatabaseMigrationResult struct {
Success bool `json:"success"`
TotalRows int64 `json:"total_rows"`
TableRows map[string]int64 `json:"table_rows"`
DurationMS int64 `json:"duration_ms"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
// InspectDatabaseStatus queries the currently active database for metrics and table rows.
func InspectDatabaseStatus(db *gorm.DB, cfg *config.Config) *DatabaseStatus {
st := &DatabaseStatus{
Type: "sqlite",
TableCounts: make(map[string]int64),
}
if cfg != nil {
st.DBPath = cfg.Database.DBPath
if cfg.Database.Type == "postgres" || (cfg.Database.Type == "auto" && strings.TrimSpace(cfg.Database.DSN) != "") {
st.Type = "postgres"
st.DSN = MaskDSN(cfg.Database.DSN)
}
}
if isPostgres(db) {
st.Type = "postgres"
}
if db != nil {
if sqlDB, err := db.DB(); err == nil {
stats := sqlDB.Stats()
st.OpenConns = stats.OpenConnections
st.InUse = stats.InUse
st.Idle = stats.Idle
st.MaxOpenConns = stats.MaxOpenConnections
}
// Count rows for major model tables
for _, m := range model.AllModels() {
if tbl, err := modelTableName(db, m); err == nil {
if db.Migrator().HasTable(tbl) {
var count int64
if err := db.Raw("SELECT COUNT(1) FROM " + quoteIdent(tbl)).Scan(&count).Error; err == nil {
st.TableCounts[tbl] = count
}
}
}
}
}
return st
}
// TestPostgres establishes a temporary connection to verify reachability and permissions.
func TestPostgres(dsn string) (*PostgresTestResult, error) {
dsn = strings.TrimSpace(dsn)
if dsn == "" {
return &PostgresTestResult{
Success: false,
Error: "PostgreSQL DSN 不能为空",
}, nil
}
start := time.Now()
testDB, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return &PostgresTestResult{
Success: false,
Error: fmt.Sprintf("连接失败: %v", err),
}, nil
}
sqlDB, err := testDB.DB()
if err != nil {
return &PostgresTestResult{
Success: false,
Error: fmt.Sprintf("获取底层连接失败: %v", err),
}, nil
}
defer sqlDB.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := sqlDB.PingContext(ctx); err != nil {
return &PostgresTestResult{
Success: false,
Error: fmt.Sprintf("Ping 超时或失败: %v", err),
}, nil
}
var version string
if err := testDB.WithContext(ctx).Raw("SELECT version()").Scan(&version).Error; err != nil {
version = "PostgreSQL (unknown version)"
}
latency := time.Since(start).Milliseconds()
return &PostgresTestResult{
Success: true,
LatencyMS: latency,
Version: version,
Message: "连接成功",
}, nil
}
// MigrateCurrentToPostgres performs schema initialization and full table data copy into target PostgreSQL.
func MigrateCurrentToPostgres(src *gorm.DB, targetDSN string, batchSize int, log *zap.Logger) (*DatabaseMigrationResult, error) {
targetDSN = strings.TrimSpace(targetDSN)
if targetDSN == "" {
return nil, fmt.Errorf("target PostgreSQL DSN cannot be empty")
}
if src == nil {
return nil, fmt.Errorf("current database is not available")
}
started := time.Now()
targetDB, err := gorm.Open(postgres.Open(targetDSN), &gorm.Config{
Logger: newGormLogger(log),
})
if err != nil {
return nil, fmt.Errorf("open target PostgreSQL: %w", err)
}
targetSQLDB, err := targetDB.DB()
if err == nil {
defer targetSQLDB.Close()
}
// 1. 初始化目标库 Schema、类型与索引
if err := AutoMigrate(targetDB); err != nil {
return nil, fmt.Errorf("auto migrate target PostgreSQL: %w", err)
}
// 2. 安全重置目标数据库的初始默认数据
if err := resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src, targetDB, log); err != nil {
return nil, fmt.Errorf("reset target bootstrap data: %w", err)
}
// 3. 执行数据批量复制
tableRows, totalRows, err := copyModelTables(src, targetDB, batchSize)
if err != nil {
return nil, fmt.Errorf("copy tables: %w", err)
}
// 4. 标记迁移完成
if err := markSQLiteMigrationComplete(targetDB); err != nil {
return nil, fmt.Errorf("mark migration complete: %w", err)
}
duration := time.Since(started).Milliseconds()
return &DatabaseMigrationResult{
Success: true,
TotalRows: totalRows,
TableRows: tableRows,
DurationMS: duration,
Message: fmt.Sprintf("成功迁移 %d 条记录至 PostgreSQL", totalRows),
}, nil
}
// MaskDSN masks the password in a connection string for safe API responses.
func MaskDSN(rawDSN string) string {
rawDSN = strings.TrimSpace(rawDSN)
if rawDSN == "" {
return ""
}
if u, err := url.Parse(rawDSN); err == nil && u.User != nil {
if pass, hasPassword := u.User.Password(); hasPassword && pass != "" {
rawUserPass := u.User.String()
user := u.User.Username()
maskedUserPass := user + ":******"
return strings.Replace(rawDSN, rawUserPass+"@", maskedUserPass+"@", 1)
}
}
// Fallback for keyword-style DSN (e.g. host=... password=...)
if strings.Contains(rawDSN, "password=") {
parts := strings.Fields(rawDSN)
for i, p := range parts {
if strings.HasPrefix(p, "password=") {
parts[i] = "password=******"
}
}
return strings.Join(parts, " ")
}
return rawDSN
}
+71
View File
@@ -0,0 +1,71 @@
package database
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
)
func TestMaskDSN(t *testing.T) {
cases := []struct {
in string
want string
}{
{
in: "postgres://admin:secret123@localhost:5432/mmtl?sslmode=disable",
want: "postgres://admin:******@localhost:5432/mmtl?sslmode=disable",
},
{
in: "host=localhost port=5432 user=admin password=secret dbname=mmtl sslmode=disable",
want: "host=localhost port=5432 user=admin password=****** dbname=mmtl sslmode=disable",
},
{
in: "sqlite://data/mmtl.db",
want: "sqlite://data/mmtl.db",
},
{
in: "",
want: "",
},
}
for _, c := range cases {
got := MaskDSN(c.in)
if got != c.want {
t.Errorf("MaskDSN(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestInspectDatabaseStatus(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}, &model.Media{}); err != nil {
t.Fatal(err)
}
_ = db.Create(&model.User{Username: "testuser", PasswordHash: "h", Role: "user"}).Error
cfg := &config.Config{}
cfg.Database.Type = "sqlite"
cfg.Database.DBPath = "./data/mmtl.db"
st := InspectDatabaseStatus(db, cfg)
if st == nil {
t.Fatal("expected non-nil DatabaseStatus")
}
if st.Type != "sqlite" {
t.Fatalf("expected sqlite, got %s", st.Type)
}
if st.DBPath != "./data/mmtl.db" {
t.Fatalf("expected db_path, got %s", st.DBPath)
}
if st.TableCounts["users"] != 1 {
t.Fatalf("expected 1 user, got %d", st.TableCounts["users"])
}
}
+5 -5
View File
@@ -159,7 +159,7 @@ func TestCopyModelTablesMigratesExistingSQLiteRows(t *testing.T) {
t.Fatal(err)
}
copied, err := copyModelTables(src, dst, 2)
_, copied, err := copyModelTables(src, dst, 2)
if err != nil {
t.Fatal(err)
}
@@ -222,7 +222,7 @@ func TestCopyModelTablesResumesPartialSQLiteMigration(t *testing.T) {
t.Fatal(err)
}
copied, err := copyModelTables(src, dst, 2)
_, copied, err := copyModelTables(src, dst, 2)
if err != nil {
t.Fatal(err)
}
@@ -240,7 +240,7 @@ func TestCopyModelTablesResumesPartialSQLiteMigration(t *testing.T) {
t.Fatalf("genres = %q, want %q", got.Genres, media.Genres)
}
copied, err = copyModelTables(src, dst, 2)
_, copied, err = copyModelTables(src, dst, 2)
if err != nil {
t.Fatal(err)
}
@@ -332,7 +332,7 @@ func TestSQLiteMigrationFallsBackToDataDirDefaultPath(t *testing.T) {
if err := resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src2, dst, nil); err != nil {
t.Fatal(err)
}
copied, err := copyModelTables(src2, dst, 2)
_, copied, err := copyModelTables(src2, dst, 2)
if err != nil {
t.Fatal(err)
}
@@ -408,7 +408,7 @@ func TestOpenSQLiteMigrationSourceUsesFallbackSourcePath(t *testing.T) {
_ = sqlDB2.Close()
}
}()
copied, err := copyModelTables(src2, dst, 2)
_, copied, err := copyModelTables(src2, dst, 2)
if err != nil {
t.Fatal(err)
}
+34 -1
View File
@@ -20,12 +20,23 @@ func AutoMigrate(db *gorm.DB) error {
if err := ensureLibraryRootsCompatibility(db); err != nil {
return err
}
if err := ensureEmbyMountsCompatibility(db); err != nil {
return err
}
if isSQLite(db) {
return ensureMediaSearchIndex(db)
if err := ensureMediaSearchIndex(db); err != nil {
return err
}
return ensureSQLiteQueryOptimizer(db)
}
return nil
}
func ensureSQLiteQueryOptimizer(db *gorm.DB) error {
// Refresh planner statistics so indexes on large media tables are used.
return db.Exec("ANALYZE").Error
}
func ensurePostgresColumnCompatibility(db *gorm.DB) error {
if !isPostgres(db) {
return nil
@@ -77,3 +88,25 @@ func ensurePerformanceIndexes(db *gorm.DB) error {
}
return nil
}
func ensureEmbyMountsCompatibility(db *gorm.DB) error {
if !db.Migrator().HasTable(&model.EmbyMount{}) {
return nil
}
if !db.Migrator().HasColumn(&model.EmbyMount{}, "sort_order") {
if err := db.Migrator().AddColumn(&model.EmbyMount{}, "sort_order"); err != nil {
return err
}
}
// 针对已有数据:如果存在多个 sort_order=0/NULL 的记录,按创建时间顺序赋予稳定递增的序号
var zeroCount int64
if err := db.Model(&model.EmbyMount{}).Where("sort_order = 0 OR sort_order IS NULL").Count(&zeroCount).Error; err == nil && zeroCount > 1 {
var mounts []model.EmbyMount
if err := db.Order("created_at asc, id asc").Find(&mounts).Error; err == nil {
for i, m := range mounts {
_ = db.Exec("UPDATE emby_mounts SET sort_order = ? WHERE id = ?", i, m.ID).Error
}
}
}
return nil
}
@@ -0,0 +1,62 @@
package database
import (
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/model"
)
func TestEnsureEmbyMountsCompatibility(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
// Create a table without sort_order simulating an older schema
if err := db.Exec(`CREATE TABLE emby_mounts (
id varchar(36) PRIMARY KEY,
created_at datetime,
updated_at datetime,
deleted_at datetime,
account_id text,
remote_view_id text,
remote_view_name text,
collection_type text,
name text,
proxy_play numeric DEFAULT false,
enabled numeric DEFAULT true
)`).Error; err != nil {
t.Fatal(err)
}
// Insert older rows
now := time.Now()
_ = db.Exec("INSERT INTO emby_mounts (id, name, created_at) VALUES (?, ?, ?)", "m1", "Mount 1", now.Add(-2*time.Hour)).Error
_ = db.Exec("INSERT INTO emby_mounts (id, name, created_at) VALUES (?, ?, ?)", "m2", "Mount 2", now.Add(-1*time.Hour)).Error
// Run compatibility migration
if err := ensureEmbyMountsCompatibility(db); err != nil {
t.Fatalf("ensureEmbyMountsCompatibility failed: %v", err)
}
// Verify column sort_order exists and values are initialized sequentially
if !db.Migrator().HasColumn(&model.EmbyMount{}, "sort_order") {
t.Fatal("expected sort_order column to be added")
}
var m1, m2 model.EmbyMount
if err := db.Where("id = ?", "m1").First(&m1).Error; err != nil {
t.Fatal(err)
}
if err := db.Where("id = ?", "m2").First(&m2).Error; err != nil {
t.Fatal(err)
}
if m1.SortOrder != 0 || m2.SortOrder != 1 {
t.Fatalf("unexpected sort orders: m1=%d, m2=%d", m1.SortOrder, m2.SortOrder)
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ func MigrateSQLiteToCurrentIfNeeded(cfg *config.Config, target *gorm.DB, log *za
if err := resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src, target, log); err != nil {
return err
}
copied, err := copyModelTables(src, target, 500)
_, copied, err := copyModelTables(src, target, 500)
if err != nil {
return err
}
+16 -13
View File
@@ -13,52 +13,53 @@ import (
"github.com/ShukeBta/MMTL/internal/model"
)
func copyModelTables(src, target *gorm.DB, batchSize int) (int64, error) {
func copyModelTables(src, target *gorm.DB, batchSize int) (map[string]int64, int64, error) {
if batchSize <= 0 {
batchSize = 500
}
var copied int64
tableCounts := make(map[string]int64)
var totalCopied int64
for _, m := range model.AllModels() {
table, err := modelTableName(src, m)
if err != nil {
return copied, err
return tableCounts, totalCopied, err
}
primaryColumns, err := modelPrimaryColumns(src, m)
if err != nil {
return copied, fmt.Errorf("inspect model %T primary keys: %w", m, err)
return tableCounts, totalCopied, fmt.Errorf("inspect model %T primary keys: %w", m, err)
}
exists, err := sqliteTableExists(src, table)
if err != nil {
return copied, err
return tableCounts, totalCopied, err
}
if !exists {
continue
}
var sourceCount int64
if err := src.Raw("SELECT COUNT(1) FROM " + quoteIdent(table)).Scan(&sourceCount).Error; err != nil {
return copied, fmt.Errorf("count sqlite table %s: %w", table, err)
return tableCounts, totalCopied, fmt.Errorf("count sqlite table %s: %w", table, err)
}
if sourceCount == 0 {
continue
}
var targetCount int64
if err := target.Raw("SELECT COUNT(1) FROM " + quoteIdent(table)).Scan(&targetCount).Error; err != nil {
return copied, fmt.Errorf("count target table %s: %w", table, err)
return tableCounts, totalCopied, fmt.Errorf("count target table %s: %w", table, err)
}
modelType := reflect.TypeOf(m)
if modelType.Kind() != reflect.Ptr {
return copied, fmt.Errorf("model %T is not a pointer", m)
return tableCounts, totalCopied, fmt.Errorf("model %T is not a pointer", m)
}
sliceType := reflect.SliceOf(modelType.Elem())
slicePtr := reflect.New(sliceType)
if err := src.Unscoped().Find(slicePtr.Interface()).Error; err != nil {
return copied, fmt.Errorf("read sqlite table %s: %w", table, err)
return tableCounts, totalCopied, fmt.Errorf("read sqlite table %s: %w", table, err)
}
filtered := slicePtr.Elem()
if targetCount > 0 {
primaryKeySet, err := targetPrimaryKeySet(target, table, primaryColumns)
if err != nil {
return copied, err
return tableCounts, totalCopied, err
}
filtered = filterRowsMissingInTarget(target, table, primaryColumns, filtered, primaryKeySet)
}
@@ -68,11 +69,13 @@ func copyModelTables(src, target *gorm.DB, batchSize int) (int64, error) {
filteredPtr := reflect.New(filtered.Type())
filteredPtr.Elem().Set(filtered)
if err := target.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(filteredPtr.Interface(), batchSize).Error; err != nil {
return copied, fmt.Errorf("copy sqlite table %s: %w", table, err)
return tableCounts, totalCopied, fmt.Errorf("copy sqlite table %s: %w", table, err)
}
copied += int64(filtered.Len())
copiedForTable := int64(filtered.Len())
tableCounts[table] = copiedForTable
totalCopied += copiedForTable
}
return copied, nil
return tableCounts, totalCopied, nil
}
func modelPrimaryColumns(db *gorm.DB, m any) ([]string, error) {
+28 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"path/filepath"
"strings"
"gorm.io/gorm"
@@ -32,16 +33,37 @@ func installSQLiteWriteGate(db *gorm.DB) {
gate.Unlock()
}
}
rawLock := func(tx *gorm.DB) {
if tx.Statement != nil && isReadOnlySQL(tx.Statement.SQL.String()) {
return
}
lock(tx)
}
_ = db.Callback().Create().Before("gorm:create").Register("mmtl:sqlite_write_lock", lock)
_ = db.Callback().Create().After("gorm:create").Register("mmtl:sqlite_write_unlock", unlock)
_ = db.Callback().Update().Before("gorm:update").Register("mmtl:sqlite_write_lock", lock)
_ = db.Callback().Update().After("gorm:update").Register("mmtl:sqlite_write_unlock", unlock)
_ = db.Callback().Delete().Before("gorm:delete").Register("mmtl:sqlite_write_lock", lock)
_ = db.Callback().Delete().After("gorm:delete").Register("mmtl:sqlite_write_unlock", unlock)
_ = db.Callback().Raw().Before("gorm:raw").Register("mmtl:sqlite_write_lock", lock)
_ = db.Callback().Raw().Before("gorm:raw").Register("mmtl:sqlite_write_lock", rawLock)
_ = db.Callback().Raw().After("gorm:raw").Register("mmtl:sqlite_write_unlock", unlock)
}
func isReadOnlySQL(sql string) bool {
trimmed := strings.TrimSpace(sql)
if len(trimmed) == 0 {
return false
}
upper := strings.ToUpper(trimmed)
if strings.HasPrefix(upper, "SELECT") || strings.HasPrefix(upper, "EXPLAIN") {
return true
}
if strings.HasPrefix(upper, "WITH") && !strings.Contains(upper, "INSERT") && !strings.Contains(upper, "UPDATE") && !strings.Contains(upper, "DELETE") {
return true
}
return false
}
// sqliteWriteGate serializes in-process SQLite writes while respecting the
// statement context, so request cancellation can break out of a queued write.
type sqliteWriteGate struct {
@@ -84,7 +106,7 @@ func buildSQLiteDSN(cfg *config.Config) string {
}
dsn := dbPath + "?_pragma=foreign_keys(1)"
if cfg.Database.WALMode {
dsn += "&_pragma=journal_mode(WAL)"
dsn += "&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)"
}
if cfg.Database.BusyTimeout > 0 {
dsn += fmt.Sprintf("&_pragma=busy_timeout(%d)", cfg.Database.BusyTimeout)
@@ -92,6 +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(536870912)"
if cfg.Database.WALMode {
dsn += "&_pragma=wal_autocheckpoint(1000)"
}
return dsn
}
+62
View File
@@ -3,6 +3,7 @@ package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
@@ -27,6 +28,9 @@ func listUsersHandler(svc *service.Container) gin.HandlerFunc {
if svc.Sessions != nil {
svc.Sessions.ApplyToUsers(c.Request.Context(), users)
}
for i := range users {
users[i].PopulateComputedFields()
}
c.JSON(http.StatusOK, users)
}
}
@@ -198,6 +202,64 @@ func updateUserStatusHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
updated.PopulateComputedFields()
c.JSON(http.StatusOK, updated)
}
}
type adminUpdateUserLibrariesReq struct {
AllowedLibraryIDs *[]string `json:"allowed_library_ids"`
}
func updateUserLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req adminUpdateUserLibrariesReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.Param("id")
user, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
var rawJSON string
if req.AllowedLibraryIDs != nil && len(*req.AllowedLibraryIDs) > 0 {
var cleanIDs []string
for _, id := range *req.AllowedLibraryIDs {
trimmed := strings.TrimSpace(id)
if trimmed != "" {
cleanIDs = append(cleanIDs, trimmed)
}
}
if len(cleanIDs) > 0 {
data, err := json.Marshal(cleanIDs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
rawJSON = string(data)
}
}
updates := map[string]any{"allowed_library_ids": rawJSON}
if err := svc.Repo.User.UpdateFields(c.Request.Context(), userID, updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil || updated == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload user"})
return
}
updated.PopulateComputedFields()
c.JSON(http.StatusOK, updated)
}
}
+145
View File
@@ -0,0 +1,145 @@
package handler
import (
"fmt"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
)
type DatabaseConnectionPayload struct {
Type string `json:"type"`
DSN string `json:"dsn"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
DBName string `json:"dbname"`
SSLMode string `json:"sslmode"`
}
func (p *DatabaseConnectionPayload) BuildDSN() string {
raw := strings.TrimSpace(p.DSN)
if raw != "" {
return raw
}
host := strings.TrimSpace(p.Host)
if host == "" {
return ""
}
port := p.Port
if port <= 0 {
port = 5432
}
user := strings.TrimSpace(p.User)
dbname := strings.TrimSpace(p.DBName)
if dbname == "" {
dbname = "mmtl"
}
sslmode := strings.TrimSpace(p.SSLMode)
if sslmode == "" {
sslmode = "disable"
}
userInfo := url.User(user)
if p.Password != "" {
userInfo = url.UserPassword(user, p.Password)
}
u := url.URL{
Scheme: "postgres",
User: userInfo,
Host: fmt.Sprintf("%s:%d", host, port),
Path: "/" + dbname,
RawQuery: "sslmode=" + url.QueryEscape(sslmode),
}
return u.String()
}
func getDatabaseStatusHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc.Database == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database service unavailable"})
return
}
status := svc.Database.GetStatus(c.Request.Context())
c.JSON(http.StatusOK, status)
}
}
func testDatabaseHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req DatabaseConnectionPayload
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
return
}
dsn := req.BuildDSN()
if dsn == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供有效的 PostgreSQL 连接信息或 DSN"})
return
}
res, err := svc.Database.TestPostgres(c.Request.Context(), dsn)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
func migrateDatabaseHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req DatabaseConnectionPayload
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
return
}
dsn := req.BuildDSN()
if dsn == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供目标 PostgreSQL 连接信息或 DSN"})
return
}
res, err := svc.Database.MigrateToPostgres(c.Request.Context(), dsn)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "迁移失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
func saveDatabaseConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req DatabaseConnectionPayload
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
return
}
dbType := strings.ToLower(strings.TrimSpace(req.Type))
if dbType == "" {
dbType = "postgres"
}
var dsn string
if dbType == "postgres" {
dsn = req.BuildDSN()
if dsn == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供有效的 PostgreSQL 连接信息或 DSN"})
return
}
}
if err := svc.Database.SaveConfig(c.Request.Context(), dbType, dsn); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "数据库配置已成功保存至配置文件,重启服务后将以新数据库运行",
"type": dbType,
})
}
}
+101
View File
@@ -0,0 +1,101 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/service"
)
func TestBuildDSN(t *testing.T) {
cases := []struct {
payload DatabaseConnectionPayload
want string
}{
{
payload: DatabaseConnectionPayload{
DSN: "postgres://myuser:mypass@10.0.0.1:5432/mydb?sslmode=require",
},
want: "postgres://myuser:mypass@10.0.0.1:5432/mydb?sslmode=require",
},
{
payload: DatabaseConnectionPayload{
Host: "127.0.0.1",
Port: 5432,
User: "postgres",
Password: "secretpassword",
DBName: "mmtl_prod",
SSLMode: "disable",
},
want: "postgres://postgres:secretpassword@127.0.0.1:5432/mmtl_prod?sslmode=disable",
},
}
for _, c := range cases {
got := c.payload.BuildDSN()
if got != c.want {
t.Errorf("BuildDSN() = %q, want %q", got, c.want)
}
}
}
func TestGetDatabaseStatusHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Database.Type = "sqlite"
cfg.Database.DBPath = "./data/mmtl.db"
svc := &service.Container{
Database: service.NewDatabaseAdminService(cfg, nil, nil, nil),
}
r := gin.New()
r.GET("/api/admin/database/status", getDatabaseStatusHandler(svc))
req := httptest.NewRequest(http.MethodGet, "/api/admin/database/status", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if resp["type"] != "sqlite" {
t.Fatalf("expected type=sqlite, got %v", resp["type"])
}
}
func TestSaveDatabaseConfigHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
dir := t.TempDir()
cfg := &config.Config{}
cfg.App.DataDir = dir
cfg.Database.Type = "sqlite"
svc := &service.Container{
Database: service.NewDatabaseAdminService(cfg, nil, nil, nil),
}
r := gin.New()
r.POST("/api/admin/database/save-config", saveDatabaseConfigHandler(svc))
body := bytes.NewBufferString(`{"type":"postgres","host":"localhost","port":5432,"user":"admin","password":"pwd","dbname":"mmtl"}`)
req := httptest.NewRequest(http.MethodPost, "/api/admin/database/save-config", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
}
+3
View File
@@ -64,6 +64,9 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
if req.Key == "transcode.hw_enabled" || req.Key == "transcode.hw_accel" || req.Key == "transcoder.hardware_accel" || req.Key == "transcoder.encoder" {
svc.Transcoder.StopAll()
}
if req.Key == "cache.images_max_size_mb" && svc.Scheduler != nil {
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup")
}
c.Status(http.StatusNoContent)
}
}
+86
View File
@@ -3,6 +3,7 @@ package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
@@ -47,3 +48,88 @@ func TestDeleteUserRefusesRecentRealtimeSession(t *testing.T) {
t.Fatal("recent realtime user should not be deleted")
}
}
func TestUpdateUserLibraries(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
user := model.User{Base: model.Base{ID: "u1"}, Username: "alice", PasswordHash: "x", Role: "user", IsActive: true}
lib1 := model.Library{Base: model.Base{ID: "lib-1"}, Name: "电影", Type: "movie", Path: "/movie"}
lib2 := model.Library{Base: model.Base{ID: "lib-2"}, Name: "剧集", Type: "tv", Path: "/tv"}
lib3 := model.Library{Base: model.Base{ID: "lib-3"}, Name: "动漫", Type: "anime", Path: "/anime"}
if err := repos.DB.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := repos.DB.Create(&[]model.Library{lib1, lib2, lib3}).Error; err != nil {
t.Fatal(err)
}
svc := &service.Container{Repo: repos}
router := gin.New()
router.PATCH("/admin/users/:id/libraries", updateUserLibrariesHandler(svc))
// 1. 设置限制为 lib-1 和 lib-2
body := `{"allowed_library_ids":["lib-1","lib-2"]}`
req := httptest.NewRequest(http.MethodPatch, "/admin/users/u1/libraries", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
found, err := repos.User.FindByID(t.Context(), "u1")
if err != nil || found == nil {
t.Fatal("user not found")
}
allowed := found.DecodeAllowedLibraryIDs()
if len(allowed) != 2 || allowed[0] != "lib-1" || allowed[1] != "lib-2" {
t.Fatalf("expected [lib-1, lib-2], got %v", allowed)
}
// 验证可见性
vis := service.UserDefaultMediaVisibility(t.Context(), repos, "u1")
if len(vis.AllowedLibraryIDs) != 2 {
t.Fatalf("expected 2 allowed libraries, got %v", vis.AllowedLibraryIDs)
}
if !service.LibraryVisibleForUser(t.Context(), repos, lib1, vis) {
t.Fatal("lib1 should be visible")
}
if !service.LibraryVisibleForUser(t.Context(), repos, lib2, vis) {
t.Fatal("lib2 should be visible")
}
if service.LibraryVisibleForUser(t.Context(), repos, lib3, vis) {
t.Fatal("lib3 should not be visible")
}
// 2. 清空限制,恢复全部可见
bodyEmpty := `{"allowed_library_ids":[]}`
reqEmpty := httptest.NewRequest(http.MethodPatch, "/admin/users/u1/libraries", strings.NewReader(bodyEmpty))
reqEmpty.Header.Set("Content-Type", "application/json")
wEmpty := httptest.NewRecorder()
router.ServeHTTP(wEmpty, reqEmpty)
if wEmpty.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", wEmpty.Code, wEmpty.Body.String())
}
foundReset, _ := repos.User.FindByID(t.Context(), "u1")
if len(foundReset.DecodeAllowedLibraryIDs()) != 0 {
t.Fatalf("expected nil or empty, got %v", foundReset.DecodeAllowedLibraryIDs())
}
visReset := service.UserDefaultMediaVisibility(t.Context(), repos, "u1")
if len(visReset.AllowedLibraryIDs) != 0 {
t.Fatalf("expected no library restrictions, got %v", visReset.AllowedLibraryIDs)
}
if !service.LibraryVisibleForUser(t.Context(), repos, lib3, visReset) {
t.Fatal("lib3 should now be visible")
}
}
+1
View File
@@ -89,6 +89,7 @@ func meHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
u.PopulateComputedFields()
c.JSON(http.StatusOK, u)
}
}
@@ -0,0 +1,148 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/golang-jwt/jwt/v5"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
)
func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
user := &model.User{
Base: model.Base{ID: "user-1"},
Username: "test_viewer",
PasswordHash: "x",
Role: "user",
Tier: "free",
IsActive: true,
}
if err := repos.User.Create(t.Context(), user); err != nil {
t.Fatalf("create user: %v", err)
}
cfg := &config.Config{}
logger := zap.NewNop()
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(cfg, logger, repos),
Sessions: service.NewSessionTrackerService(logger),
Playback: service.NewPlaybackService(logger, repos),
}
router := gin.New()
// 注册带认证的路由,模拟已登录用户
router.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, user.ID)
c.Next()
})
router.POST("/Sessions/Playing/Progress", embyPlayingProgressHandler(svc))
router.GET("/Items", embyItemsHandler(svc))
router.GET("/Users/:userId/Items/Resume", embyResumeItemsHandler(svc))
router.GET("/Sessions", embySessionsHandler(svc))
remoteMediaID := service.EncodeEmbyRemoteID("mount-1", "remote-item-123")
// 1. 测试上报进度:客户端使用小写 query 参数 itemId / positionTicks
progressReq := httptest.NewRequest(
http.MethodPost,
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000",
nil,
)
wProgress := httptest.NewRecorder()
router.ServeHTTP(wProgress, progressReq)
if wProgress.Code != http.StatusNoContent {
t.Fatalf("progress status = %d, body = %s", wProgress.Code, wProgress.Body.String())
}
// 验证已持久化到 PlaybackHistory
var hist model.PlaybackHistory
if err := db.Where("user_id = ? AND media_id = ?", user.ID, remoteMediaID).First(&hist).Error; err != nil {
t.Fatalf("playback history not saved: %v", err)
}
if hist.PositionMs != 30000 {
t.Fatalf("expected position_ms = 30000, got %d", hist.PositionMs)
}
// 2. 测试 Filters=IsResumable 能够包含该远程条目
resumableReq := httptest.NewRequest(
http.MethodGet,
"/Items?Filters=IsResumable",
nil,
)
wResumable := httptest.NewRecorder()
router.ServeHTTP(wResumable, resumableReq)
if wResumable.Code != http.StatusOK {
t.Fatalf("items resumable status = %d, body = %s", wResumable.Code, wResumable.Body.String())
}
var resumableEnvelope map[string]any
if err := json.Unmarshal(wResumable.Body.Bytes(), &resumableEnvelope); err != nil {
t.Fatalf("decode resumable: %v", err)
}
// 因为没有配置真实的远程客户端连接,该远程条目在当前离线测试中不会 panic 崩溃,并且正常响应 Envelope
if resumableEnvelope["TotalRecordCount"] == nil {
t.Fatalf("missing TotalRecordCount in resumable envelope")
}
// 3. 测试 /Users/:userId/Items/Resume 别名路由
resumeAliasReq := httptest.NewRequest(
http.MethodGet,
"/Users/"+user.ID+"/Items/Resume",
nil,
)
wResumeAlias := httptest.NewRecorder()
router.ServeHTTP(wResumeAlias, resumeAliasReq)
if wResumeAlias.Code != http.StatusOK {
t.Fatalf("resume alias status = %d, body = %s", wResumeAlias.Code, wResumeAlias.Body.String())
}
// 4. 测试 /Sessions 返回 NowPlayingItem
sessionsReq := httptest.NewRequest(http.MethodGet, "/Sessions", nil)
wSessions := httptest.NewRecorder()
router.ServeHTTP(wSessions, sessionsReq)
if wSessions.Code != http.StatusOK {
t.Fatalf("sessions status = %d, body = %s", wSessions.Code, wSessions.Body.String())
}
var sessionsList []map[string]any
if err := json.Unmarshal(wSessions.Body.Bytes(), &sessionsList); err != nil {
t.Fatalf("decode sessions: %v", err)
}
if len(sessionsList) == 0 {
t.Fatalf("expected at least 1 session")
}
nowPlaying, ok := sessionsList[0]["NowPlayingItem"].(map[string]any)
if !ok || nowPlaying["Id"] != remoteMediaID {
t.Fatalf("expected NowPlayingItem with id %q, got %#v", remoteMediaID, sessionsList[0]["NowPlayingItem"])
}
}
func signMockToken(secret, userID string) string {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": userID,
"exp": time.Now().Add(time.Hour).Unix(),
})
s, _ := token.SignedString([]byte(secret))
return s
}
+222
View File
@@ -0,0 +1,222 @@
// Emby 挂载管理 HTTP 层:远程 Emby 服务器(账号)下的媒体库挂载 CRUD,
// 以及账号远程媒体库(View)列表预览。
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/service"
)
// embyMountView 挂载的对外 JSON(附带账号信息)。
type embyMountView struct {
model.EmbyMount
AccountName string `json:"account_name"`
}
// embyMountInput 创建挂载的请求体(单个或批量)。
type embyMountInput struct {
AccountID string `json:"account_id" binding:"required"`
Views []embyViewInput `json:"views" binding:"required,min=1"`
}
type embyViewInput struct {
RemoteViewID string `json:"remote_view_id" binding:"required"`
RemoteViewName string `json:"remote_view_name"`
CollectionType string `json:"collection_type"`
Name string `json:"name"`
ProxyPlay bool `json:"proxy_play"`
}
func embyMountViews(mounts []model.EmbyMount, accounts map[string]string) []embyMountView {
out := make([]embyMountView, 0, len(mounts))
for _, m := range mounts {
out = append(out, embyMountView{EmbyMount: m, AccountName: accounts[m.AccountID]})
}
return out
}
// embyAccountViewsHandler 列出账号上的远程媒体库(View),供挂载选择。
func embyAccountViewsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
acct := svc.EmbyRemote.AccountByID(c.Request.Context(), c.Param("id"))
if acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "账号不存在或已禁用"})
return
}
views, err := svc.EmbyRemote.RemoteViews(c.Request.Context(), acct)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
type viewEntry struct {
RemoteViewID string `json:"remote_view_id"`
RemoteViewName string `json:"remote_view_name"`
CollectionType string `json:"collection_type"`
ChildCount int `json:"child_count"`
AlreadyMounted bool `json:"already_mounted"`
}
mounted := map[string]bool{}
if mounts, err := svc.EmbyRemote.ListMountsByAccount(c.Request.Context(), acct.ID); err == nil {
for _, m := range mounts {
mounted[m.RemoteViewID] = true
}
}
out := make([]viewEntry, 0, len(views))
for _, v := range views {
viewID := service.RemoteItemIDString(v)
if strings.TrimSpace(viewID) == "" {
continue
}
out = append(out, viewEntry{
RemoteViewID: viewID,
RemoteViewName: service.RemoteItemNameString(v),
CollectionType: service.RemoteItemCollectionType(v),
ChildCount: service.RemoteItemChildCount(v),
AlreadyMounted: mounted[viewID],
})
}
c.JSON(http.StatusOK, out)
}
}
// listEmbyMountsHandler 列出全部挂载。
func listEmbyMountsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
mounts, err := svc.EmbyRemote.ListMounts(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
names := map[string]string{}
if accounts, err := svc.EmbyRemote.ListAccounts(c.Request.Context()); err == nil {
for _, a := range accounts {
names[a.ID] = a.Name
}
}
out := embyMountViews(mounts, names)
if out == nil {
out = []embyMountView{}
}
c.JSON(http.StatusOK, out)
}
}
// createEmbyMountsHandler 批量创建挂载(同一账号下的多个远程媒体库)。
func createEmbyMountsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req embyMountInput
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
mounts := make([]*model.EmbyMount, 0, len(req.Views))
for _, v := range req.Views {
mounts = append(mounts, &model.EmbyMount{
AccountID: req.AccountID,
RemoteViewID: v.RemoteViewID,
RemoteViewName: v.RemoteViewName,
CollectionType: v.CollectionType,
Name: v.Name,
ProxyPlay: v.ProxyPlay,
Enabled: true,
})
}
if _, err := svc.EmbyRemote.CreateMounts(c.Request.Context(), mounts); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "created": len(mounts)})
}
}
// fullMountEmbyAccountHandler 全量挂载:把账号所有远程媒体库一次挂载进来。
func fullMountEmbyAccountHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
acct := svc.EmbyRemote.AccountByID(c.Request.Context(), c.Param("id"))
if acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "账号不存在或已禁用"})
return
}
proxy := c.Query("proxy") == "1" || c.Query("proxy") == "true"
n, err := svc.EmbyRemote.FullMountAccount(c.Request.Context(), acct, proxy)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "created": n})
}
}
// updateEmbyMountHandler 更新挂载(显示名 / 代理开关 / 启用)。
func updateEmbyMountHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Name *string `json:"name"`
ProxyPlay *bool `json:"proxy_play"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
mount, err := svc.EmbyRemote.MountByID(c.Request.Context(), c.Param("id"))
if err != nil || mount == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "挂载不存在"})
return
}
if req.Name != nil {
mount.Name = *req.Name
}
if req.ProxyPlay != nil {
mount.ProxyPlay = *req.ProxyPlay
}
if req.Enabled != nil {
mount.Enabled = *req.Enabled
}
if _, err := svc.EmbyRemote.UpdateMount(c.Request.Context(), mount.ID, mount); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, mount)
}
}
// deleteEmbyMountHandler 删除挂载。
func deleteEmbyMountHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.EmbyRemote.DeleteMount(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
type reorderEmbyMountsReq struct {
IDs []string `json:"ids" binding:"required"`
}
// reorderEmbyMountsHandler 批量重排挂载媒体库顺序。
func reorderEmbyMountsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req reorderEmbyMountsReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if svc.EmbyRemote == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "emby remote service not available"})
return
}
if err := svc.EmbyRemote.ReorderMounts(c.Request.Context(), req.IDs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
@@ -0,0 +1,65 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/database"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
)
func TestReorderEmbyMountsHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
ctx := t.Context()
m1 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-1", Name: "Mount 1"}
m2 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-2", Name: "Mount 2"}
_ = repos.EmbyMount.Create(ctx, m1)
_ = repos.EmbyMount.Create(ctx, m2)
svc := &service.Container{
Repo: repos,
EmbyRemote: service.NewEmbyRemoteService(nil, zap.NewNop(), repos, nil),
}
router := gin.New()
router.PUT("/admin/emby/mounts/reorder", reorderEmbyMountsHandler(svc))
body, _ := json.Marshal(map[string]any{
"ids": []string{m2.ID, m1.ID},
})
req := httptest.NewRequest(http.MethodPut, "/admin/emby/mounts/reorder", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
list, err := repos.EmbyMount.List(ctx)
if err != nil {
t.Fatal(err)
}
if len(list) != 2 || list[0].ID != m2.ID || list[1].ID != m1.ID {
t.Fatalf("expected order [m2, m1], got [m%s, m%s]", list[0].ID, list[1].ID)
}
}
+37 -5
View File
@@ -34,10 +34,19 @@ func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
// embySubtitleStreamHandler serves an external subtitle track advertised in a
// MediaSource's MediaStreams via its Emby index
// (/Videos/:id/Subtitles/:index/Stream). The index maps to a discovered
// sideloaded subtitle file next to the video (SRT/ASS/SSA/VTT, local or
// cloud://), following the same layout appended by mediaStreams.
// sideloaded subtitle track next to the video (SRT/ASS/SSA/VTT, local or
// cloud://), following the same layout appended by mediaStreams. 远程 Emby
// 条目的字幕直接反向代理远程。
func embySubtitleStreamHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
encodedID := c.Param("id")
if accountID, remoteID, ok := service.DecodeEmbyRemoteID(encodedID); ok {
if err := svc.Emby.ProxyRemoteSubtitle(c.Request.Context(), c.Writer, c.Request, accountID, remoteID, c.Param("index")); err != nil {
embyError(c, http.StatusNotFound, "subtitle not found")
return
}
return
}
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
@@ -213,12 +222,26 @@ func embyAppendAPIKey(raw, token string) string {
return u.String()
}
// embyVideoStreamHandler 是 GET /Videos/{id}/stream 的入口,
// 直接代理到我们的 /api/stream/{id}(同一个 ServeFile)。
// embyVideoStreamHandler 是 GET /Videos/{id}/stream 的入口。
// 远程 Emby 条目(embyremote~ 前缀)走反向代理;本地条目直接代理到
// /api/stream/{id}(同一个 ServeFile)。
func embyVideoStreamHandler(svc *service.Container, cloudMode string) gin.HandlerFunc {
return func(c *gin.Context) {
encodedID := c.Param("id")
if accountID, remoteID, ok := service.DecodeEmbyRemoteID(encodedID); ok {
if err := svc.Emby.ProxyRemoteVideoStream(c.Request.Context(), c.Writer, c.Request, accountID, remoteID); err != nil {
if errors.Is(err, service.ErrEmbyRemoteNotFound) {
c.Status(http.StatusNotFound)
return
}
if !c.Writer.Written() {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
return
}
uid := embyUserID(c)
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
item, err := svc.Emby.Item(c.Request.Context(), encodedID, uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -296,6 +319,11 @@ func embyShouldRedirectVideoStreamToSTRM(c *gin.Context, svc *service.Container,
func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
// 远程 Emby 条目不做本地转码(播放地址已由 PlaybackInfo 指向远程/代理直连)。
if service.IsEmbyRemoteID(c.Param("id")) {
c.Status(http.StatusNotFound)
return
}
uid := embyUserID(c)
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
if err != nil || item == nil || svc.Stream == nil {
@@ -319,6 +347,10 @@ func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
func embyVideoHLSSegmentHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if service.IsEmbyRemoteID(c.Param("id")) {
c.Status(http.StatusNotFound)
return
}
uid := embyUserID(c)
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
if err != nil || item == nil || svc.Stream == nil {
+28 -11
View File
@@ -12,8 +12,13 @@ import (
type embyPlayingReq struct {
ItemId string `json:"ItemId"`
ItemIDLower string `json:"itemId"`
ID string `json:"Id"`
IDLower string `json:"id"`
PositionTicks int64 `json:"PositionTicks"`
PositionLower int64 `json:"positionTicks"`
RunTimeTicks int64 `json:"RunTimeTicks"`
RunTimeLower int64 `json:"runTimeTicks"`
}
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
@@ -25,16 +30,25 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
}
var req embyPlayingReq
_ = c.ShouldBindJSON(&req)
if req.ItemId == "" {
req.ItemId = c.Query("ItemId")
itemID := embyFirstNonEmptyString(req.ItemId, req.ItemIDLower, req.ID, req.IDLower)
if itemID == "" {
itemID = embyFirstNonEmptyString(firstQueryValue(c, "ItemId", "itemId", "Id", "id"))
}
if req.PositionTicks == 0 {
req.PositionTicks, _ = strconv.ParseInt(c.Query("PositionTicks"), 10, 64)
pos := req.PositionTicks
if pos == 0 {
pos = req.PositionLower
}
if req.RunTimeTicks == 0 {
req.RunTimeTicks, _ = strconv.ParseInt(c.Query("RunTimeTicks"), 10, 64)
if pos == 0 {
pos, _ = strconv.ParseInt(firstQueryValue(c, "PositionTicks", "positionTicks"), 10, 64)
}
if req.ItemId == "" {
runTime := req.RunTimeTicks
if runTime == 0 {
runTime = req.RunTimeLower
}
if runTime == 0 {
runTime, _ = strconv.ParseInt(firstQueryValue(c, "RunTimeTicks", "runTimeTicks"), 10, 64)
}
if itemID == "" {
c.Status(http.StatusOK)
return
}
@@ -43,7 +57,10 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusUnauthorized)
return
}
_ = svc.Emby.RecordProgress(c.Request.Context(), uid, req.ItemId, req.PositionTicks, req.RunTimeTicks)
if err := svc.Emby.RecordProgress(c.Request.Context(), uid, itemID, pos, runTime); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
stopped := strings.Contains(strings.ToLower(c.FullPath()+" "+c.Request.URL.Path), "stopped")
if svc.Sessions != nil {
svc.Sessions.RecordPlayback(c.Request.Context(), uid, "",
@@ -51,9 +68,9 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
clientInfo.DeviceName,
clientInfo.Client,
c.ClientIP(),
req.ItemId,
req.PositionTicks,
req.RunTimeTicks,
itemID,
pos,
runTime,
stopped)
}
if svc.Device != nil && !stopped {
+2
View File
@@ -161,6 +161,8 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
auth.GET("/Users/:userId/Items/Counts", embyItemsCountsHandler(svc))
auth.GET("/Items/Latest", embyLatestItemsHandler(svc))
auth.GET("/Items/Resume", embyResumeItemsHandler(svc))
auth.GET("/Users/:userId/Items/Resume", embyResumeItemsHandler(svc))
auth.GET("/UserItems/Resume", embyResumeItemsHandler(svc))
auth.GET("/Items/:id", embyItemByIDHandler(svc))
auth.GET("/Users/:userId/Items/:id", embyUserItemByIDHandler(svc))
auth.GET("/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
@@ -32,6 +32,8 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
auth.GET("/users/:userId/items/counts", embyItemsCountsHandler(svc))
auth.GET("/items/latest", embyLatestItemsHandler(svc))
auth.GET("/items/resume", embyResumeItemsHandler(svc))
auth.GET("/users/:userId/items/resume", embyResumeItemsHandler(svc))
auth.GET("/useritems/resume", embyResumeItemsHandler(svc))
auth.GET("/items/:id", embyItemByIDHandler(svc))
auth.GET("/users/:userId/items/:id", embyUserItemByIDHandler(svc))
auth.GET("/shows/:id/seasons", embyShowSeasonsHandler(svc))
+11 -1
View File
@@ -41,7 +41,17 @@ func embySessionsHandler(svc *service.Container) gin.HandlerFunc {
"SupportsRemoteControl": true,
}
if itemID != "" && sess.IsPlaying {
row["NowPlayingItem"] = gin.H{"Id": itemID}
nowPlaying := gin.H{"Id": itemID}
if svc.Emby != nil {
if item, _ := svc.Emby.Item(c.Request.Context(), itemID, sess.UserID); item != nil {
for _, key := range []string{"Name", "Type", "RunTimeTicks", "PrimaryImageItemId", "ImageTags", "SeriesName", "SeasonName", "IndexNumber", "ParentIndexNumber"} {
if val, ok := item[key]; ok && val != nil {
nowPlaying[key] = val
}
}
}
}
row["NowPlayingItem"] = nowPlaying
}
out = append(out, row)
}
+268 -21
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"github.com/gin-gonic/gin"
@@ -16,17 +17,41 @@ import (
)
type createLibraryReq struct {
Name string `json:"name" binding:"required"`
Path string `json:"path"`
Paths []string `json:"paths"`
Roots []service.LibraryRootInput `json:"roots"`
Type string `json:"type"`
CoverURL string `json:"cover_url"`
Name string `json:"name"`
Path string `json:"path"`
Paths []string `json:"paths"`
Roots []service.LibraryRootInput `json:"roots"`
Type string `json:"type"`
CoverURL string `json:"cover_url"`
CreatePerSubfolder bool `json:"create_per_subfolder"`
}
// webLibraryPayload 是 /api/libraries 返回的库条目:本地库与远程 Emby 挂载库
// 统一结构(远程库附加 is_remote_emby / remote_source 只读标记)。
type webLibraryPayload struct {
model.Library
IsRemoteEmby bool `json:"is_remote_emby,omitempty"`
RemoteSource string `json:"remote_source,omitempty"`
Total int64 `json:"total,omitempty"`
Cards []service.SeriesCard `json:"cards,omitempty"`
}
// remoteLibraryItemTypes 远程库内容拉取时按 CollectionType 过滤直属条目,
// 避免电影库里的合集文件夹(Folder) 漏出为电影卡片。
func remoteLibraryItemTypes(collectionType string) string {
switch collectionType {
case "movies":
return "Movie"
case "tvshows":
return "Series"
}
return ""
}
func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libs, err := svc.Media.ListLibraries(c.Request.Context())
ctx := c.Request.Context()
libs, err := svc.Media.ListLibraries(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -34,23 +59,102 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
role, _ := c.Get(middleware.CtxUserRole)
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1")
if !includeHidden {
libs = service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, libs)
libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs)
visibility := mediaVisibilityForRequest(c, svc)
filtered := libs[:0]
for _, lib := range libs {
if service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, lib, visibility) {
if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) {
filtered = append(filtered, lib)
}
}
libs = filtered
}
c.JSON(http.StatusOK, libs)
withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true"
limit := 10
if withPreview {
limit, _ = strconv.Atoi(c.DefaultQuery("preview_limit", c.DefaultQuery("limit", "10")))
if limit <= 0 {
limit = 10
} else if limit > 100 {
limit = 100
}
}
out := make([]webLibraryPayload, 0, len(libs)+8)
if withPreview {
previews, err := svc.Media.ListLibrariesWithPreview(ctx, libs, mediaVisibilityForRequest(c, svc), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for _, p := range previews {
out = append(out, webLibraryPayload{Library: p.Library, Total: p.Total, Cards: p.Cards})
}
} else {
for _, l := range libs {
out = append(out, webLibraryPayload{Library: l})
}
}
// 远程 Emby 挂载库追加在本地库之后。
if svc.EmbyRemote != nil {
if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil {
remotePayloads := make([]webLibraryPayload, len(views))
for i, v := range views {
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
}
if withPreview && len(views) > 0 {
const maxRemotePreviewWorkers = 6
sem := make(chan struct{}, maxRemotePreviewWorkers)
var wg sync.WaitGroup
for i, v := range views {
i, v := i, v
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return
}
acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID)
if acct == nil {
return
}
tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}}
itemTypes := remoteLibraryItemTypes(v.CollectionType)
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
remotePayloads[i].Total = total
}
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
remotePayloads[i].Cards = cards
}
}()
}
wg.Wait()
}
out = append(out, remotePayloads...)
}
}
c.JSON(http.StatusOK, out)
}
}
func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
lib, err := svc.Repo.Library.FindByID(c.Request.Context(), c.Param("id"))
ctx := c.Request.Context()
id := c.Param("id")
// 远程 Emby 挂载库详情。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, mountID, remoteID)
if err != nil || view == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, webLibraryPayload{Library: view.Library, IsRemoteEmby: true, RemoteSource: view.AccountName})
return
}
lib, err := svc.Repo.Library.FindByID(ctx, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -62,14 +166,14 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
role, _ := c.Get(middleware.CtxUserRole)
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1")
if !includeHidden {
libs := service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, []model.Library{*lib})
if len(libs) == 0 || !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, libs[0], mediaVisibilityForRequest(c, svc)) {
libs := service.FilterDisplayCloudLibraries(ctx, svc.Repo, []model.Library{*lib})
if len(libs) == 0 || !service.LibraryVisibleForUser(ctx, svc.Repo, libs[0], mediaVisibilityForRequest(c, svc)) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, libs[0])
c.JSON(http.StatusOK, webLibraryPayload{Library: libs[0]})
} else {
c.JSON(http.StatusOK, lib)
c.JSON(http.StatusOK, webLibraryPayload{Library: *lib})
}
}
}
@@ -90,6 +194,35 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
if len(roots) == 0 && strings.TrimSpace(req.Path) != "" {
roots = append(roots, service.LibraryRootInput{Path: req.Path})
}
var l *model.Library
if req.CreatePerSubfolder {
parent := ""
if len(roots) > 0 {
parent = roots[0].Path
} else if strings.TrimSpace(req.Path) != "" {
parent = req.Path
}
created, err := svc.Media.CreateLibrariesPerSubfolder(c.Request.Context(), parent, req.Type, req.CoverURL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get("ctx_user_id")
for i := range created {
lib := &created[i]
svc.Audit.Record(c.Request.Context(), toString(uid), "library.create", lib.ID, c.ClientIP(), lib.Path)
if svc.Watcher != nil {
go func() { _ = svc.Watcher.Refresh(context.Background()) }()
}
for _, root := range lib.Roots {
if root.Enabled {
queueLibraryRootScan(svc, lib.ID, root.ID)
}
}
}
c.JSON(http.StatusCreated, gin.H{"libraries": created})
return
}
l, err := svc.Media.CreateLibraryWithRootsAndCover(c.Request.Context(), req.Name, req.Type, req.CoverURL, roots)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -117,7 +250,9 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
}
type updateLibraryReq struct {
CoverURL string `json:"cover_url"`
CoverURL *string `json:"cover_url"`
SortOrder *int `json:"sort_order"`
CarouselEnabled *bool `json:"carousel_enabled"`
}
func updateLibraryHandler(svc *service.Container) gin.HandlerFunc {
@@ -127,9 +262,17 @@ func updateLibraryHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Media.UpdateLibraryCover(c.Request.Context(), c.Param("id"), req.CoverURL); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
if req.CoverURL != nil {
if err := svc.Media.UpdateLibraryCover(c.Request.Context(), c.Param("id"), *req.CoverURL); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if req.SortOrder != nil || req.CarouselEnabled != nil {
if err := svc.Media.UpdateLibraryFields(c.Request.Context(), c.Param("id"), req.SortOrder, req.CarouselEnabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
lib, err := svc.Repo.Library.FindByID(c.Request.Context(), c.Param("id"))
if err != nil || lib == nil {
@@ -140,6 +283,25 @@ func updateLibraryHandler(svc *service.Container) gin.HandlerFunc {
}
}
type reorderLibrariesReq struct {
IDs []string `json:"ids" binding:"required"`
}
func reorderLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req reorderLibrariesReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Media.ReorderLibraries(c.Request.Context(), req.IDs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"updated": len(req.IDs)})
}
}
func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
@@ -157,8 +319,37 @@ func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
func listMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
ctx := c.Request.Context()
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
// 远程 Emby 库:转发远程直属条目并映射为本地 Media 结构(分页由远程承接)。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
itemTypes := ""
if view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, mountID, remoteID); err == nil && view != nil {
itemTypes = remoteLibraryItemTypes(view.CollectionType)
}
items, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, mount, acct, remoteID, itemTypes, (page-1)*size, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if items == nil {
items = []model.Media{}
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"page": page,
"page_size": size,
})
return
}
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
if !groupVersions {
items, total, err := svc.Media.ListMediaVisible(c.Request.Context(), id, page, size, mediaVisibilityForRequest(c, svc))
@@ -196,7 +387,33 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
func getMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
ctx := c.Request.Context()
id := c.Param("id")
// 远程 Emby 条目:拉远程详情并映射为本地 Media 结构。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
m, err := svc.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if m == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, m)
return
}
m, err := svc.Media.GetMedia(ctx, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -290,7 +507,37 @@ func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
func streamHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
ctx := c.Request.Context()
id := c.Param("id")
// 远程 Emby 条目:按挂载代理配置分流——代理走 MMTL 反代,否则 302 直连。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
if !enforceScopedPlaybackToken(c, id) {
return
}
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if mount.ProxyPlay {
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
if !c.Writer.Written() {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
return
}
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
setRedirectNoStoreHeaders(c)
c.Redirect(http.StatusFound, target)
return
}
m, err := svc.Media.GetMedia(ctx, id)
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
+25 -4
View File
@@ -19,21 +19,40 @@ import (
"github.com/ShukeBta/MMTL/internal/service"
)
func findMediaForPlaybackEndpoint(c *gin.Context, svc *service.Container, id string) (*model.Media, error) {
ctx := c.Request.Context()
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
return nil, nil
}
return svc.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID)
}
return svc.Repo.Media.FindByID(ctx, id)
}
// playbackInfoHandler returns the media row + a `stream_url` the React
// player can hit. Mirrors the Python project's surface.
func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
id := c.Param("id")
m, err := findMediaForPlaybackEndpoint(c, svc, id)
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
token := externalPlaybackToken(c, svc, m.ID, m.DurationSec)
profileQuery := externalProfileQuery(c)
hlsURL := "/api/hls/" + m.ID + "/index.m3u8?token=" + url.QueryEscape(token) + profileQuery
if service.IsEmbyRemoteID(m.ID) || service.IsStrmMediaRow(m) {
// Emby 远程挂载与 STRM 媒体一样,默认直连播放,不提供转码地址
hlsURL = ""
}
c.JSON(http.StatusOK, gin.H{
"media": m,
"stream_url": "/api/stream/" + m.ID + "?token=" + url.QueryEscape(token) + profileQuery,
"hls_url": "/api/hls/" + m.ID + "/index.m3u8?token=" + url.QueryEscape(token) + profileQuery,
"hls_url": hlsURL,
})
}
}
@@ -68,7 +87,8 @@ func playbackProgressHandler(svc *service.Container) gin.HandlerFunc {
// produce the per-player launch URL.
func externalPlayersHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
id := c.Param("id")
m, err := findMediaForPlaybackEndpoint(c, svc, id)
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
@@ -93,7 +113,8 @@ func externalPlayersHandler(svc *service.Container) gin.HandlerFunc {
// token query string the external player needs.
func externalURLHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
id := c.Param("id")
m, err := findMediaForPlaybackEndpoint(c, svc, id)
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
+69
View File
@@ -404,8 +404,77 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
router := gin.New()
api := router.Group("/api")
api.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
api.GET("/playback/:id/info", playbackInfoHandler(svc))
api.GET("/playback/:id/external-url", externalURLHandler(svc))
api.GET("/playback/:id/external-players", externalPlayersHandler(svc))
api.GET("/stream/:id", streamHandler(svc))
api.GET("/hls/:id/index.m3u8", hlsPlaylistHandler(svc))
api.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
return router, svc, cfg.Secrets.JWTSecret
}
func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
router, _, secret := newPlaybackScopeTestRouter(t)
loginToken := signedTestToken(t, secret)
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-1/info", nil)
req.Header.Set("Authorization", "Bearer "+loginToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var payload struct {
StreamURL string `json:"stream_url"`
HlsURL string `json:"hls_url"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v", err)
}
if payload.StreamURL == "" {
t.Fatalf("expected non-empty stream_url")
}
if payload.HlsURL != "" {
t.Fatalf("expected empty hls_url for STRM media, got %q", payload.HlsURL)
}
}
func TestHLSPlaylistForRemoteEmbyMediaDisabled(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
svc.EmbyRemote = &service.EmbyRemoteService{}
loginToken := signedTestToken(t, secret)
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/hls/embyremote~acct1~item1/index.m3u8", nil)
req.Header.Set("Authorization", "Bearer "+loginToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("status = %d, want %d (409 StatusConflict)", w.Code, http.StatusConflict)
}
}
func TestListSubtitlesForRemoteEmbyMediaReturnsEmptyTracks(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
svc.EmbyRemote = &service.EmbyRemoteService{}
loginToken := signedTestToken(t, secret)
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/media/embyremote~acct1~item1/subtitles", nil)
req.Header.Set("Authorization", "Bearer "+loginToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 OK", w.Code)
}
var payload struct {
Tracks []any `json:"tracks"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v", err)
}
if payload.Tracks == nil || len(payload.Tracks) != 0 {
t.Fatalf("expected empty tracks array, got %v", payload.Tracks)
}
}
+52
View File
@@ -20,9 +20,41 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
registerAdminAPIConfigRoutes(admin, svc)
registerAdminRecognitionWordRoutes(admin, svc)
registerAdminStrmRoutes(admin, svc)
registerAdminScraperRoutes(admin, svc)
registerAdminDatabaseRoutes(admin, svc)
// FFmpeg/FFprobe 工具:状态查询 + 一键下载安装(自动匹配当前平台)。
admin.GET("/tools/ffmpeg/status", ffToolsStatusHandler(svc))
admin.POST("/tools/ffmpeg/install", ffToolsInstallHandler(svc))
}
func registerAdminScraperRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.GET("/scraper/queue", listScrapeQueueHandler(svc))
admin.POST("/scraper/queue/:id/cancel", cancelScrapeTaskHandler(svc))
admin.POST("/scraper/queue/:id/retry", retryScrapeTaskHandler(svc))
admin.DELETE("/scraper/queue/:id", deleteScrapeTaskHandler(svc))
admin.POST("/scraper/queue/batch", batchActionScrapeTasksHandler(svc))
admin.POST("/scraper/queue/clear-done", clearDoneScrapeTasksHandler(svc))
admin.POST("/scraper/queue/clear-finished", clearFinishedScrapeTasksHandler(svc))
admin.POST("/scraper/queue/clear-canceled", clearCanceledScrapeTasksHandler(svc))
admin.POST("/scraper/queue/retry-failed", retryAllFailedScrapeTasksHandler(svc))
admin.POST("/scraper/queue/cancel-pending", cancelPendingScrapeTasksHandler(svc))
admin.POST("/scraper/queue/enqueue-library/:id", enqueueLibraryScrapeHandler(svc))
admin.POST("/scraper/queue/enqueue-all", enqueueAllScrapeHandler(svc))
admin.POST("/media/repair-rescrape", enqueueAllScrapeHandler(svc))
admin.POST("/libraries/:id/repair-rescrape", enqueueLibraryScrapeHandler(svc))
}
func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
// Emby 挂载管理:远程 Emby 媒体库挂载(账号复用 strm/accounts)
admin.GET("/emby/accounts/:id/views", embyAccountViewsHandler(svc))
admin.POST("/emby/accounts/:id/full-mount", fullMountEmbyAccountHandler(svc))
admin.GET("/emby/mounts", listEmbyMountsHandler(svc))
admin.POST("/emby/mounts", createEmbyMountsHandler(svc))
admin.PUT("/emby/mounts/reorder", reorderEmbyMountsHandler(svc))
admin.PUT("/emby/mounts/:id", updateEmbyMountHandler(svc))
admin.DELETE("/emby/mounts/:id", deleteEmbyMountHandler(svc))
admin.GET("/strm/accounts", listStrmAccountsHandler(svc))
admin.POST("/strm/accounts", createStrmAccountHandler(svc))
admin.PUT("/strm/accounts/:id", updateStrmAccountHandler(svc))
@@ -43,18 +75,30 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.POST("/strm/paths/:id/sync", startStrmSyncHandler(svc))
admin.POST("/strm/paths/:id/cancel", cancelStrmSyncHandler(svc))
admin.GET("/strm/records", listStrmSyncRecordsHandler(svc))
admin.DELETE("/strm/records/:id", deleteStrmSyncRecordHandler(svc))
admin.DELETE("/strm/records", clearStrmSyncRecordsHandler(svc))
admin.GET("/strm/local-dirs", listStrmLocalDirsHandler(svc))
admin.GET("/strm/downloads", downloadQueueHandler(svc))
admin.POST("/strm/downloads/:id/cancel", cancelStrmDownloadHandler(svc))
admin.POST("/strm/downloads/:id/retry", retryStrmDownloadHandler(svc))
admin.DELETE("/strm/downloads/:id", deleteStrmDownloadHandler(svc))
admin.POST("/strm/downloads/batch", batchActionDownloadsHandler(svc))
admin.POST("/strm/downloads/clear-done", clearDoneDownloadsHandler(svc))
admin.POST("/strm/downloads/clear-finished", clearFinishedDownloadsHandler(svc))
admin.POST("/strm/downloads/clear-canceled", clearCanceledDownloadsHandler(svc))
admin.POST("/strm/downloads/retry-failed", retryAllFailedDownloadsHandler(svc))
admin.POST("/strm/downloads/cancel-pending", cancelPendingDownloadsHandler(svc))
admin.GET("/strm/uploads", uploadQueueHandler(svc))
admin.POST("/strm/uploads/:id/cancel", cancelStrmUploadHandler(svc))
admin.POST("/strm/uploads/:id/retry", retryStrmUploadHandler(svc))
admin.DELETE("/strm/uploads/:id", deleteStrmUploadHandler(svc))
admin.POST("/strm/uploads/batch", batchActionUploadsHandler(svc))
admin.POST("/strm/uploads/clear-done", clearDoneUploadsHandler(svc))
admin.POST("/strm/uploads/clear-finished", clearFinishedUploadsHandler(svc))
admin.POST("/strm/uploads/clear-canceled", clearCanceledUploadsHandler(svc))
admin.POST("/strm/uploads/retry-failed", retryAllFailedUploadsHandler(svc))
admin.POST("/strm/uploads/cancel-pending", cancelPendingUploadsHandler(svc))
}
func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) {
@@ -64,6 +108,7 @@ func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.PATCH("/users/:id/password", resetUserPasswordHandler(svc))
admin.PATCH("/users/:id/status", updateUserStatusHandler(svc))
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
admin.PATCH("/users/:id/libraries", updateUserLibrariesHandler(svc))
admin.DELETE("/users/:id", deleteUserHandler(svc))
admin.GET("/settings", listSettingsHandler(svc))
admin.PUT("/settings", updateSettingHandler(svc))
@@ -111,3 +156,10 @@ func registerAdminRecognitionWordRoutes(admin *gin.RouterGroup, svc *service.Con
admin.POST("/recognition-words/sync", syncRecognitionWordsHandler(svc))
admin.POST("/recognition-words/test", testRecognitionWordsHandler(svc))
}
func registerAdminDatabaseRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.GET("/database/status", getDatabaseStatusHandler(svc))
admin.POST("/database/test", testDatabaseHandler(svc))
admin.POST("/database/migrate", migrateDatabaseHandler(svc))
admin.POST("/database/save-config", saveDatabaseConfigHandler(svc))
}
@@ -21,6 +21,7 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
authed.GET("/libraries/:id", getLibraryHandler(svc))
authed.PATCH("/libraries/:id", middleware.AdminRequired(), updateLibraryHandler(svc))
authed.PUT("/libraries/reorder", middleware.AdminRequired(), reorderLibrariesHandler(svc))
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
authed.GET("/libraries/:id/roots", middleware.AdminRequired(), listLibraryRootsHandler(svc))
authed.POST("/libraries/:id/roots", middleware.AdminRequired(), createLibraryRootHandler(svc))
@@ -38,6 +39,7 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container
func registerAuthedMediaRoutes(authed *gin.RouterGroup, svc *service.Container) {
authed.GET("/media/:id", getMediaHandler(svc))
authed.GET("/media/:id/episodes", listMediaEpisodesHandler(svc))
authed.GET("/media", searchMediaHandler(svc))
authed.PATCH("/media/:id/metadata", middleware.AdminRequired(), updateMediaMetadataHandler(svc))
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
+167
View File
@@ -0,0 +1,167 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
)
func listScrapeQueueHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
snap, err := svc.Scraper.ScrapeQueueSnapshot(c.Request.Context(), c.Query("status"), page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, snap)
}
}
func cancelScrapeTaskHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Scraper.CancelScrapeTask(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func retryScrapeTaskHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Scraper.RetryScrapeTask(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func deleteScrapeTaskHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Scraper.DeleteScrapeTask(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func batchActionScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req queueBatchReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
n, err := svc.Scraper.BatchActionScrapeTasks(c.Request.Context(), req.Action, req.IDs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"affected": n, "action": req.Action})
}
}
func clearDoneScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Scraper.ClearDoneScrapeTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
}
func clearFinishedScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Scraper.ClearFinishedScrapeTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
}
func clearCanceledScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Scraper.ClearCanceledScrapeTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
}
func retryAllFailedScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Scraper.RetryAllFailedScrapeTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"retried": n})
}
}
func cancelPendingScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Scraper.CancelPendingScrapeTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"canceled": n})
}
}
type enqueueScrapeReq struct {
EpisodeImages bool `json:"episode_images"`
EpisodeArtwork bool `json:"episode_artwork"`
RefreshMatched bool `json:"refresh_matched"`
IncludeMatched bool `json:"include_matched"`
}
func (r enqueueScrapeReq) toOptions() service.ScrapeOptions {
epArtwork := r.EpisodeImages || r.EpisodeArtwork
return service.ScrapeOptions{
EpisodeArtwork: &epArtwork,
IncludeMatched: r.IncludeMatched || r.RefreshMatched,
RetryNoMatch: true,
}
}
func enqueueLibraryScrapeHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req enqueueScrapeReq
_ = c.ShouldBindJSON(&req)
libID := c.Param("id")
n, err := svc.Scraper.EnqueueLibrary(c.Request.Context(), libID, req.toOptions())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"enqueued": n})
}
}
func enqueueAllScrapeHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req enqueueScrapeReq
_ = c.ShouldBindJSON(&req)
n, err := svc.Scraper.EnqueueAll(c.Request.Context(), req.toOptions())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"enqueued": n})
}
}
+100 -2
View File
@@ -65,7 +65,49 @@ func listSeasonsHandler(svc *service.Container) gin.HandlerFunc {
func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libID := c.Param("id")
if lib, err := svc.Repo.Library.FindByID(c.Request.Context(), libID); err == nil && lib != nil {
ctx := c.Request.Context()
// 远程剧集库:远程 Series 映射为系列卡片。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(libID) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(libID)
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
cards, err := svc.EmbyRemote.RemoteSeriesCards(ctx, mount, acct, remoteID)
if err != nil {
writeInternalOrCanceled(c, err)
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "500"))
if page < 1 {
page = 1
}
if size <= 0 || size > 1000 {
size = 500
}
start := (page - 1) * size
if start > len(cards) {
start = len(cards)
}
end := start + size
if end > len(cards) {
end = len(cards)
}
pageItems := cards[start:end]
if pageItems == nil {
pageItems = []service.SeriesCard{}
}
c.JSON(http.StatusOK, gin.H{
"items": pageItems,
"total": len(cards),
"page": page,
"page_size": size,
})
return
}
if lib, err := svc.Repo.Library.FindByID(ctx, libID); err == nil && lib != nil {
if !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, *lib, mediaVisibilityForRequest(c, svc)) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
@@ -114,7 +156,27 @@ func listLibrarySeriesEpisodesHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "key is required"})
return
}
if lib, err := svc.Repo.Library.FindByID(c.Request.Context(), libID); err == nil && lib != nil {
ctx := c.Request.Context()
// 远程系列 key(伪装系列 ID):转发远程该系列全部剧集。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(key) {
mountID, remoteSeriesID, _ := service.DecodeEmbyRemoteID(key)
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
items, err := svc.EmbyRemote.RemoteEpisodes(ctx, mount, acct, remoteSeriesID)
if err != nil {
writeInternalOrCanceled(c, err)
return
}
if items == nil {
items = []model.Media{}
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
return
}
if lib, err := svc.Repo.Library.FindByID(ctx, libID); err == nil && lib != nil {
if !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, *lib, mediaVisibilityForRequest(c, svc)) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
@@ -128,3 +190,39 @@ func listLibrarySeriesEpisodesHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
}
}
func listMediaEpisodesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
return
}
ctx := c.Request.Context()
// 远程条目:单集→同系列集列表;系列/季/文件夹→子集;电影→自身单条。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
items, err := svc.EmbyRemote.RemoteEpisodes(ctx, mount, acct, remoteID)
if err != nil {
writeInternalOrCanceled(c, err)
return
}
if items == nil {
items = []model.Media{}
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
return
}
items, err := svc.Media.ListMediaEpisodes(ctx, id, mediaVisibilityForRequest(c, svc))
if err != nil {
writeInternalOrCanceled(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
}
}
+21 -55
View File
@@ -2,7 +2,6 @@
package handler
import (
"context"
"errors"
"io"
"net/http"
@@ -14,7 +13,13 @@ import (
func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
id := c.Param("id")
// 远程 Emby 挂载媒体与 STRM 一样,默认直连播放,不进行转码。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
return
}
m, err := svc.Media.GetMedia(c.Request.Context(), id)
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
@@ -44,7 +49,12 @@ func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
func hlsSegmentHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
id := c.Param("id")
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
return
}
m, err := svc.Media.GetMedia(c.Request.Context(), id)
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
@@ -135,28 +145,12 @@ func scrapeOneHandler(svc *service.Container) gin.HandlerFunc {
return
}
options.IncludeMatched = true
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
if err != nil || m == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
task := startScrapeHTTPTask(svc, "手动刮削媒体", m.Title, m.Path)
if err := svc.Scraper.EnrichOneWithOptions(c.Request.Context(), m, options); err != nil {
finishHTTPTask(task, err, "scrape", "手动刮削媒体失败", nil, nil)
task, err := svc.Scraper.EnqueueMedia(c.Request.Context(), c.Param("id"), options)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
reclassified := reclassifyMediaAfterScrape(c.Request.Context(), svc, m.ID)
refreshed, _ := svc.Repo.Media.FindByID(c.Request.Context(), m.ID)
metrics := map[string]int64{"processed": 1}
if refreshed != nil && refreshed.ScrapeStatus == "matched" {
metrics["matched"] = 1
}
if reclassified > 0 {
metrics["reclassified"] = int64(reclassified)
}
finishHTTPTask(task, nil, "completed", "手动刮削媒体结束", metrics, nil)
c.JSON(http.StatusOK, refreshed)
c.JSON(http.StatusOK, task)
}
}
@@ -170,40 +164,12 @@ func scrapeLibraryHandler(svc *service.Container) gin.HandlerFunc {
return
}
options.IncludeMatched = true
var task *service.TaskHandle
if lib, err := svc.Repo.Library.FindByID(c.Request.Context(), libID); err == nil && lib != nil {
task = startScrapeHTTPTask(svc, "手动刮削媒体库", lib.Name, lib.Path)
} else {
task = startScrapeHTTPTask(svc, "手动刮削媒体库", libID, "")
n, err := svc.Scraper.EnqueueLibrary(c.Request.Context(), libID, options)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Run in the background so HTTP returns instantly; the WS hub
// pushes per-item progress on the "scrape" topic.
go func(libID string, task *service.TaskHandle, options service.ScrapeOptions) {
result, err := svc.Scraper.EnrichLibraryDetailedWithOptions(context.Background(), libID, options)
reclassified := 0
if result.Processed > 0 {
reclassified = reclassifyLibraryAfterScrape(context.Background(), svc, libID)
}
metrics := map[string]int64{
"matched": int64(result.Matched),
"processed": int64(result.Processed),
"candidates": int64(result.Candidates),
}
if reclassified > 0 {
metrics["reclassified"] = int64(reclassified)
}
if result.Failed > 0 {
metrics["errors"] = int64(result.Failed)
}
stage := "completed"
message := "手动刮削媒体库结束"
if err != nil {
stage = "scrape"
message = "手动刮削媒体库失败"
}
finishHTTPTask(task, err, stage, message, metrics, nil)
}(libID, task, options)
c.JSON(http.StatusAccepted, gin.H{"status": "scraping"})
c.JSON(http.StatusOK, gin.H{"status": "queued", "enqueued": n})
}
}
+206 -11
View File
@@ -8,6 +8,7 @@ import (
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -28,17 +29,32 @@ type strmAccountView struct {
model.StrmAccount
HasCredential bool `json:"has_credential"`
ProviderLabel string `json:"provider_label"`
// ProxyPlay 仅远程 Emby 挂载账号返回:播放流量是否经过 MMTL 代理(编辑回显用)。
ProxyPlay *bool `json:"proxy_play,omitempty"`
// EmbyLines 仅远程 Emby 挂载账号返回:多线路配置(不含凭据)。
EmbyLines []service.EmbyRemoteLine `json:"emby_lines,omitempty"`
EmbyActiveLine int `json:"emby_active_line,omitempty"`
}
func strmAccountViews(accounts []model.StrmAccount) []strmAccountView {
func strmAccountViews(svc *service.Container, accounts []model.StrmAccount) []strmAccountView {
out := make([]strmAccountView, 0, len(accounts))
for i := range accounts {
a := accounts[i]
out = append(out, strmAccountView{
view := strmAccountView{
StrmAccount: a,
HasCredential: service.HasStrmAccountCredential(&a),
ProviderLabel: providerLabelOf(a.Provider),
})
}
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)
}
return out
}
@@ -58,7 +74,7 @@ func listStrmAccountsHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, strmAccountViews(accounts))
c.JSON(http.StatusOK, strmAccountViews(svc, accounts))
}
}
@@ -74,7 +90,7 @@ func createStrmAccountHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
views := strmAccountViews([]model.StrmAccount{*acct})
views := strmAccountViews(svc, []model.StrmAccount{*acct})
c.JSON(http.StatusOK, views[0])
}
}
@@ -92,7 +108,7 @@ func updateStrmAccountHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
views := strmAccountViews([]model.StrmAccount{*acct})
views := strmAccountViews(svc, []model.StrmAccount{*acct})
c.JSON(http.StatusOK, views[0])
}
}
@@ -109,13 +125,33 @@ func deleteStrmAccountHandler(svc *service.Container) gin.HandlerFunc {
func testStrmAccountHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
acct := svc.Strm.TestStrmAccount(c.Request.Context(), c.Param("id"))
if acct == nil {
id := c.Param("id")
acct, err := svc.Repo.StrmAccount.FindByID(c.Request.Context(), id)
if err != nil || acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"})
return
}
views := strmAccountViews([]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])
}
}
@@ -178,6 +214,7 @@ type strmSyncPathReq struct {
DeleteDir *bool `json:"delete_dir"`
Cron string `json:"cron"`
EnableCron *bool `json:"enable_cron"`
SyncMode string `json:"sync_mode"`
Enabled *bool `json:"enabled"`
}
@@ -261,7 +298,16 @@ func deleteStrmSyncPathHandler(svc *service.Container) gin.HandlerFunc {
func startStrmSyncHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Strm.StartSync(c.Request.Context(), c.Param("id")); err != nil {
mode := c.Query("mode")
if mode == "" {
var body struct {
Mode string `json:"mode"`
}
if err := c.ShouldBindJSON(&body); err == nil && body.Mode != "" {
mode = body.Mode
}
}
if err := svc.Strm.StartSync(c.Request.Context(), c.Param("id"), mode); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -290,6 +336,31 @@ func listStrmSyncRecordsHandler(svc *service.Container) gin.HandlerFunc {
}
}
func deleteStrmSyncRecordHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if c.Param("id") == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少记录 ID"})
return
}
if err := svc.Strm.DeleteSyncRecord(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func clearStrmSyncRecordsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
deleted, err := svc.Strm.ClearSyncRecords(c.Request.Context(), c.Query("path_id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "deleted": deleted})
}
}
// ─── 下载/上传队列 ─────────────────────────────────────────────────────────────
func downloadQueueHandler(svc *service.Container) gin.HandlerFunc {
@@ -358,6 +429,63 @@ func retryStrmUploadHandler(svc *service.Container) gin.HandlerFunc {
}
}
func deleteStrmDownloadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Strm.DeleteDownloadTask(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func deleteStrmUploadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Strm.DeleteUploadTask(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
type queueBatchReq struct {
Action string `json:"action" binding:"required"`
IDs []string `json:"ids" binding:"required"`
}
func batchActionDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req queueBatchReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
n, err := svc.Strm.BatchActionDownloadTasks(c.Request.Context(), req.Action, req.IDs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"affected": n, "action": req.Action})
}
}
func batchActionUploadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req queueBatchReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
n, err := svc.Strm.BatchActionUploadTasks(c.Request.Context(), req.Action, req.IDs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"affected": n, "action": req.Action})
}
}
// ─── 下载队列批量操作 ─────────────────────────────────────────────────────────
func clearDoneDownloadsHandler(svc *service.Container) gin.HandlerFunc {
@@ -382,6 +510,50 @@ func clearFinishedDownloadsHandler(svc *service.Container) gin.HandlerFunc {
}
}
func clearCanceledDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.ClearCanceledDownloadTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
}
func clearCanceledUploadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.ClearCanceledUploadTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
}
func clearDoneUploadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.ClearDoneUploadTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
}
func clearFinishedUploadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.ClearFinishedUploadTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
}
func retryAllFailedDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.RetryAllFailedDownloadTasks(c.Request.Context())
@@ -393,6 +565,17 @@ func retryAllFailedDownloadsHandler(svc *service.Container) gin.HandlerFunc {
}
}
func retryAllFailedUploadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.RetryAllFailedUploadTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"retried": n})
}
}
func cancelPendingDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.CancelPendingDownloadTasks(c.Request.Context())
@@ -404,6 +587,17 @@ func cancelPendingDownloadsHandler(svc *service.Container) gin.HandlerFunc {
}
}
func cancelPendingUploadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Strm.CancelPendingUploadTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"canceled": n})
}
}
// ─── 公开播放端点 ──────────────────────────────────────────────────────────────
// strmPlayHandler 处理 strm 文件指向的播放请求(Emby/Infuse 直接请求,无 JWT)。
@@ -458,6 +652,7 @@ func strmSyncPathFromReq(req strmSyncPathReq) *model.StrmSyncPath {
DeleteDir: boolValue(req.DeleteDir, false),
Cron: strings.TrimSpace(req.Cron),
EnableCron: boolValue(req.EnableCron, false),
SyncMode: strings.TrimSpace(req.SyncMode),
Enabled: boolValue(req.Enabled, true),
}
}
+9
View File
@@ -50,9 +50,18 @@ func TestStrmAdminRoutesAreRegistered(t *testing.T) {
"GET /api/admin/strm/downloads",
"POST /api/admin/strm/downloads/:id/cancel",
"POST /api/admin/strm/downloads/:id/retry",
"POST /api/admin/strm/downloads/clear-finished",
"POST /api/admin/strm/downloads/clear-canceled",
"POST /api/admin/strm/downloads/retry-failed",
"POST /api/admin/strm/downloads/cancel-pending",
"GET /api/admin/strm/uploads",
"POST /api/admin/strm/uploads/:id/cancel",
"POST /api/admin/strm/uploads/:id/retry",
"POST /api/admin/strm/uploads/clear-done",
"POST /api/admin/strm/uploads/clear-finished",
"POST /api/admin/strm/uploads/clear-canceled",
"POST /api/admin/strm/uploads/retry-failed",
"POST /api/admin/strm/uploads/cancel-pending",
"GET /api/strm/play/:provider/:file",
} {
if !routes[want] {
+7 -2
View File
@@ -11,7 +11,12 @@ import (
func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
tracks, err := svc.Subtitle.Discover(c.Request.Context(), c.Param("id"))
id := c.Param("id")
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
c.JSON(http.StatusOK, gin.H{"tracks": []service.SubtitleTrack{}})
return
}
tracks, err := svc.Subtitle.Discover(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
@@ -31,7 +36,7 @@ func serveSubtitleHandler(svc *service.Container) gin.HandlerFunc {
return
}
c.Header("Content-Type", "text/vtt; charset=utf-8")
c.Header("Cache-Control", "public, max-age=3600")
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
if err := svc.Subtitle.Serve(c.Request.Context(), c.Param("id"), path, c.Writer); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
-144
View File
@@ -1,144 +0,0 @@
// Package handler — system tools detection.
package handler
import (
"net/http"
"os/exec"
"strings"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/service"
)
// SystemHandler handles system-related endpoints.
type SystemHandler struct {
cfg *config.Config
log *zap.Logger
svc *service.Container
}
// NewSystemHandler is the constructor.
func NewSystemHandler(cfg *config.Config, log *zap.Logger, svc *service.Container) *SystemHandler {
return &SystemHandler{cfg: cfg, log: log, svc: svc}
}
// ToolStatus represents the detection status of a system tool.
type ToolStatus struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
ConfigKey string `json:"config_key"`
Path string `json:"path,omitempty"`
Detected bool `json:"detected"`
Version string `json:"version,omitempty"`
}
// GetToolsStatus returns the status of system tools.
func (h *SystemHandler) GetToolsStatus(c *gin.Context) {
tools := []ToolStatus{
{Name: "ffprobe", DisplayName: "FFprobe", ConfigKey: "app.ffprobe_path"},
{Name: "ffmpeg", DisplayName: "FFmpeg", ConfigKey: "app.ffmpeg_path"},
}
for i := range tools {
// Check configured path first
var configuredPath string
switch tools[i].ConfigKey {
case "app.ffprobe_path":
configuredPath = h.cfg.App.FFprobePath
if configuredPath == "" {
configuredPath = "ffprobe"
}
case "app.ffmpeg_path":
configuredPath = h.cfg.App.FFmpegPath
if configuredPath == "" {
configuredPath = "ffmpeg"
}
}
// Try to find the tool
path, err := exec.LookPath(configuredPath)
if err == nil {
tools[i].Detected = true
tools[i].Path = path
// Try to get version
tools[i].Version = getToolVersion(path)
}
}
c.JSON(http.StatusOK, gin.H{
"tools": tools,
})
}
// getToolVersion attempts to get the version of a tool.
func getToolVersion(path string) string {
out, err := exec.Command(path, "-version").Output()
if err != nil {
return ""
}
// Extract first line as version info
lines := strings.Split(string(out), "\n")
if len(lines) > 0 {
return strings.TrimSpace(lines[0])
}
return ""
}
// InstallTools attempts to auto-install system tools (ffmpeg/ffprobe)
func (h *SystemHandler) InstallTools(c *gin.Context) {
h.log.Info("Received tools auto-install request")
// Call service layer to auto-install
ffprobePath, ffmpegPath := service.AutoInstallFFmpeg(h.log, h.cfg)
result := gin.H{
"installed": ffprobePath != "" || ffmpegPath != "",
}
if ffprobePath != "" {
result["ffprobe_path"] = ffprobePath
result["ffprobe_installed"] = true
}
if ffmpegPath != "" {
result["ffmpeg_path"] = ffmpegPath
result["ffmpeg_installed"] = true
}
// Re-detect tool status
tools := []ToolStatus{
{Name: "ffprobe", DisplayName: "FFprobe", ConfigKey: "app.ffprobe_path"},
{Name: "ffmpeg", DisplayName: "FFmpeg", ConfigKey: "app.ffmpeg_path"},
}
for i := range tools {
var configuredPath string
switch tools[i].ConfigKey {
case "app.ffprobe_path":
configuredPath = h.cfg.App.FFprobePath
if configuredPath == "" {
configuredPath = "ffprobe"
}
case "app.ffmpeg_path":
configuredPath = h.cfg.App.FFmpegPath
if configuredPath == "" {
configuredPath = "ffmpeg"
}
}
path, err := exec.LookPath(configuredPath)
if err == nil {
tools[i].Detected = true
tools[i].Path = path
tools[i].Version = getToolVersion(path)
}
}
result["tools"] = tools
h.log.Info("Tool installation completed", zap.Any("result", result))
c.JSON(http.StatusOK, result)
}
+38
View File
@@ -0,0 +1,38 @@
// Package handler — FFmpeg/FFprobe 工具安装端点。
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
)
// ffToolsStatusHandler 返回 ffmpeg/ffprobe 当前安装状态
// (GET /api/admin/tools/ffmpeg/status)。
func ffToolsStatusHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.FFTools == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "FFmpeg 工具服务不可用"})
return
}
c.JSON(http.StatusOK, svc.FFTools.Status(c.Request.Context()))
}
}
// ffToolsInstallHandler 触发后台下载安装(POST /api/admin/tools/ffmpeg/install)。
// 自动匹配当前运行环境(OS+架构),安装到 data/tools/ffmpeg/ 并把路径写入设置。
func ffToolsInstallHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.FFTools == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "FFmpeg 工具服务不可用"})
return
}
if err := svc.FFTools.StartInstall(c.Request.Context()); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, svc.FFTools.Status(c.Request.Context()))
}
}
+8 -1
View File
@@ -32,7 +32,14 @@ func mediaVisibilityForRequest(c *gin.Context, svc *service.Container) service.M
return visibility
}
visibility.IncludeNSFW = adultEnabled && profile.AllowAdult && !userHidesAdult
visibility.AllowedLibraryIDs = profileAllowedLibraryIDs(*profile)
profileAllowed := profileAllowedLibraryIDs(*profile)
if len(profileAllowed) > 0 {
if len(visibility.AllowedLibraryIDs) > 0 {
visibility.AllowedLibraryIDs = service.IntersectStrings(visibility.AllowedLibraryIDs, profileAllowed)
} else {
visibility.AllowedLibraryIDs = profileAllowed
}
}
if !visibility.IncludeNSFW {
visibility.HiddenLibraryIDs = service.AdultLibraryIDs(c.Request.Context(), svc.Repo)
} else {
+13 -1
View File
@@ -125,6 +125,18 @@ func historyContinueHandler(svc *service.Container) gin.HandlerFunc {
for _, r := range rows {
m, ok := mIdx[r.MediaID]
if !ok {
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(r.MediaID) {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(r.MediaID)
if mount, acct, _ := svc.EmbyRemote.ResolveMount(c.Request.Context(), mountID); mount != nil && acct != nil {
if rm, err := svc.EmbyRemote.RemoteMediaDetail(c.Request.Context(), mount, acct, remoteID); err == nil && rm != nil {
out = append(out, gin.H{
"history": r,
"media": *rm,
})
continue
}
}
}
continue
}
out = append(out, gin.H{
@@ -163,7 +175,7 @@ func historyDeleteHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "status must be completed or incomplete"})
return
}
res := q.Delete(&model.PlaybackHistory{})
res := q.Unscoped().Delete(&model.PlaybackHistory{})
if err := res.Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+20
View File
@@ -0,0 +1,20 @@
// Emby 媒体库挂载模型。
//
// 远程 Emby 账号(StrmAccount.Provider = emby_remote)只是一个服务器连接;
// 「挂载」才决定把该服务器的哪个媒体库(View)暴露到本项目的媒体库中。
// 这样同一个 Emby 服务器可以按库选择挂载,且每个挂载独立控制是否由 MMTL
// 代理播放流量。
package model
// EmbyMount 是远程 Emby 服务器上一个媒体库(View)的挂载配置。
type EmbyMount struct {
Base
AccountID string `gorm:"size:36;index" json:"account_id"` // StrmAccount.ID(provider=emby_remote)
RemoteViewID string `gorm:"size:128" json:"remote_view_id"` // 远程 Emby 的 View Id
RemoteViewName string `gorm:"size:255" json:"remote_view_name"` // 远程媒体库原名(展示冗余)
CollectionType string `gorm:"size:32" json:"collection_type"` // movies / tvshows / music ...
Name string `gorm:"size:255" json:"name,omitempty"` // 覆盖显示名(可选,默认「账号 · 库名」)
SortOrder int `gorm:"default:0;index" json:"sort_order"` // 手动排序用,越小越靠前
ProxyPlay bool `gorm:"default:false" json:"proxy_play"` // 该挂载播放流量是否经 MMTL 反向代理
Enabled bool `gorm:"default:true" json:"enabled"` // 是否在媒体库中展示
}
+8 -6
View File
@@ -3,12 +3,14 @@ package model
// Library 表示一个逻辑媒体库。Path 保留为兼容字段,指向第一条 LibraryRoot。
type Library struct {
Base
Name string `gorm:"size:128;not null" json:"name"`
Path string `gorm:"size:1024;not null" json:"path"`
Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
Enabled bool `gorm:"default:true" json:"enabled"`
Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"`
Name string `gorm:"size:128;not null" json:"name"`
Path string `gorm:"size:1024;not null" json:"path"`
Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
Enabled bool `gorm:"default:true" json:"enabled"`
SortOrder int `gorm:"index;default:0" json:"sort_order"` // 手动拖拽排序用,越小越靠前
CarouselEnabled bool `gorm:"default:false" json:"carousel_enabled"` // 是否参与首页海报轮播(默认不参与)
Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"`
}
// LibraryRoot 是逻辑媒体库下的一条真实物理/挂载路径。
+3
View File
@@ -56,5 +56,8 @@ func AllModels() []interface{} {
&StrmSyncRecord{},
&StrmDownloadTask{},
&StrmUploadTask{},
&StrmDirCache{},
&ScrapeTask{},
&EmbyMount{},
}
}
+34
View File
@@ -0,0 +1,34 @@
package model
import "time"
const (
ScrapeTaskPending = "pending"
ScrapeTaskRunning = "running"
ScrapeTaskDone = "done"
ScrapeTaskFailed = "failed"
ScrapeTaskCanceled = "canceled"
)
// ScrapeTask 表示一条持久化的媒体刮削任务。
type ScrapeTask struct {
Base
MediaID string `gorm:"index;size:36" json:"media_id"`
LibraryID string `gorm:"index;size:36" json:"library_id"`
LibraryName string `gorm:"size:128" json:"library_name"`
MediaTitle string `gorm:"size:255;not null" json:"media_title"`
MediaPath string `gorm:"size:1024;not null" json:"media_path"`
MediaType string `gorm:"size:16" json:"media_type"` // movie / tv / anime / adult
Provider string `gorm:"size:32" json:"provider"` // tmdb / douban / bangumi / thetvdb / metatube
MatchedTitle string `gorm:"size:255" json:"matched_title"`
MatchedYear int `json:"matched_year"`
PosterURL string `gorm:"size:1024" json:"poster_url"`
BackdropURL string `gorm:"size:1024" json:"backdrop_url"`
Status string `gorm:"index;size:16;default:pending" json:"status"` // pending / running / done / failed / canceled
Error string `gorm:"type:text" json:"error"`
RetryCount int `gorm:"default:0" json:"retry_count"`
EpisodeImages bool `gorm:"default:true" json:"episode_images"`
RefreshMatched bool `gorm:"default:false" json:"refresh_matched"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
}
+28 -11
View File
@@ -13,6 +13,7 @@ const (
StrmProviderCloudDrive = "clouddrive2" // CloudDrive2(WebDAV 桥接)
StrmProviderOpenList = "openlist" // OpenList / AList 兼容桥接
StrmProviderLocal = "local" // 本地目录(无账号)
StrmProviderEmbyRemote = "emby_remote" // 远程 Emby 服务器(API 网关聚合挂载,不走 STRM 同步)
)
// StrmAccount 是一个网盘账号(STRM 同步数据源凭据)。
@@ -37,23 +38,30 @@ type StrmSyncPath struct {
RemotePath string `gorm:"size:1024" json:"remote_path"` // 远端目录:115=目录ID,OpenList/CD2=路径,local=源目录
LocalPath string `gorm:"size:1024" json:"local_path"` // STRM/元数据本地输出目录
// STRM 链接配置(空值继承全局 strm.* 设置)
StrmBaseURL string `gorm:"size:512" json:"strm_base_url"` // 覆盖 strm.base_url
VideoExt string `gorm:"size:512" json:"video_ext"` // 逗号分隔,覆盖 strm.video_ext
MetaExt string `gorm:"size:512" json:"meta_ext"` // 逗号分隔,覆盖 strm.meta_ext
ExcludeName string `gorm:"size:512" json:"exclude_name"` // 逗号分隔,文件名包含即跳过
MinVideoSizeMB int64 `json:"min_video_size_mb"` // 小于该大小(MB)的视频不生成 STRM
AddPath int `json:"add_path"` // STRM 链接 path 参数:1=完整远端路径 2=仅文件名 3=不带
DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕)
UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端
DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录
Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选)
EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步
StrmBaseURL string `gorm:"size:512" json:"strm_base_url"` // 覆盖 strm.base_url
VideoExt string `gorm:"size:512" json:"video_ext"` // 逗号分隔,覆盖 strm.video_ext
MetaExt string `gorm:"size:512" json:"meta_ext"` // 逗号分隔,覆盖 strm.meta_ext
ExcludeName string `gorm:"size:512" json:"exclude_name"` // 逗号分隔,文件名包含即跳过
MinVideoSizeMB int64 `json:"min_video_size_mb"` // 小于该大小(MB)的视频不生成 STRM
AddPath int `json:"add_path"` // STRM 链接 path 参数:1=完整远端路径 2=仅文件名 3=不带
DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕)
UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端
DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录
Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选)
EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步
SyncMode string `gorm:"size:32;default:'incremental'" json:"sync_mode"` // 默认同步模式:incremental / full
Enabled bool `gorm:"default:true" json:"enabled"`
LastSyncAt *time.Time `json:"last_sync_at"`
LastSyncStatus string `gorm:"size:16" json:"last_sync_status"` // idle/running/ok/error/canceled
LastSyncMessage string `gorm:"size:1024" json:"last_sync_message"`
}
// STRM 同步类型。
const (
StrmSyncTypeIncremental = "incremental"
StrmSyncTypeFull = "full"
)
// StrmSyncRecord 是一次同步执行的记录。
const (
StrmSyncRecordPending = "pending"
@@ -66,6 +74,7 @@ const (
type StrmSyncRecord struct {
Base
SyncPathID string `gorm:"size:36;index" json:"sync_path_id"`
SyncType string `gorm:"size:32;default:'incremental'" json:"sync_type"` // incremental / full
Status string `gorm:"size:16;index" json:"status"`
Total int64 `json:"total"` // 远端发现的文件总数
NewStrm int64 `json:"new_strm"` // 本次新建/更新的 strm 数
@@ -123,3 +132,11 @@ type StrmUploadTask struct {
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
}
// StrmDirCache 缓存远端网盘目录 ID 与相对路径映射(支持 115 增量同步秒级寻址)。
type StrmDirCache struct {
Base
SyncPathID string `gorm:"size:36;index:idx_strm_dir_cache,priority:1" json:"sync_path_id"`
DirID string `gorm:"size:128;index:idx_strm_dir_cache,priority:2" json:"dir_id"`
Path string `gorm:"size:1024" json:"path"` // 相对根目录的路径
}
+36 -1
View File
@@ -1,6 +1,10 @@
package model
import "time"
import (
"encoding/json"
"strings"
"time"
)
// User 是本地账户。第一个注册的管理员(或种子管理员)获得 "admin" 角色;
// 其他所有用户默认为 "user"。
@@ -17,6 +21,10 @@ type User struct {
ForcePasswordReset bool `gorm:"default:false" json:"force_password_reset"`
IsActive bool `gorm:"default:true" json:"is_active"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
// AllowedLibraryIDs 存储管理员为该用户指定的受限可访问媒体库 ID 列表(JSON 字符串)。
// 为空时代表不限制(全库可访问)。
AllowedLibraryIDs string `gorm:"type:text" json:"-"`
AllowedLibraryList []string `gorm:"-" json:"allowed_library_ids,omitempty"`
// ExpiredAt is the account expiry time. Nil means the account never
// expires. When set and in the past, the account is treated as expired
// (login blocked) until an admin or a redemption code renews it.
@@ -31,3 +39,30 @@ type User struct {
RealtimeOnline bool `gorm:"-" json:"realtime_online,omitempty"`
RealtimeDeviceCount int `gorm:"-" json:"realtime_device_count,omitempty"`
}
// DecodeAllowedLibraryIDs 解析 AllowedLibraryIDs 字段。
func (u *User) DecodeAllowedLibraryIDs() []string {
if u == nil || strings.TrimSpace(u.AllowedLibraryIDs) == "" {
return nil
}
var ids []string
if err := json.Unmarshal([]byte(u.AllowedLibraryIDs), &ids); err != nil {
return nil
}
var out []string
for _, id := range ids {
trimmed := strings.TrimSpace(id)
if trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
// PopulateComputedFields 填充非 DB 虚拟计算字段(如 AllowedLibraryList)。
func (u *User) PopulateComputedFields() {
if u == nil {
return
}
u.AllowedLibraryList = u.DecodeAllowedLibraryIDs()
}
+2 -2
View File
@@ -62,9 +62,9 @@ func (r *ApiConfigRepository) Update(ctx context.Context, c *model.ApiConfig) er
}).Error
}
// Delete removes an API config.
// Delete 物理删除 API 配置。
func (r *ApiConfigRepository) Delete(ctx context.Context, provider string) error {
return r.db.WithContext(ctx).Where("provider = ?", provider).Delete(&model.ApiConfig{}).Error
return r.db.WithContext(ctx).Unscoped().Where("provider = ?", provider).Delete(&model.ApiConfig{}).Error
}
// UpdateTestResult 更新测试结果。
@@ -0,0 +1,127 @@
package repository
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/model"
)
// EmbyMountRepository 持久化远程 Emby 媒体库挂载。
type EmbyMountRepository struct{ db *gorm.DB }
func (r *EmbyMountRepository) Create(ctx context.Context, m *model.EmbyMount) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if m != nil && m.SortOrder == 0 {
var maxSort int
_ = tx.Model(&model.EmbyMount{}).Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort)
m.SortOrder = maxSort + 1
}
return tx.Create(m).Error
})
})
}
func (r *EmbyMountRepository) CreateInBatches(ctx context.Context, mounts []*model.EmbyMount, batchSize int) error {
if len(mounts) == 0 {
return nil
}
if batchSize <= 0 {
batchSize = 50
}
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var maxSort int
_ = tx.Model(&model.EmbyMount{}).Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort)
for _, m := range mounts {
if m != nil && m.SortOrder == 0 {
maxSort++
m.SortOrder = maxSort
}
}
return tx.CreateInBatches(mounts, batchSize).Error
})
})
}
func (r *EmbyMountRepository) FindByID(ctx context.Context, id string) (*model.EmbyMount, error) {
var m model.EmbyMount
err := r.db.WithContext(ctx).Where("id = ?", id).First(&m).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &m, nil
}
func (r *EmbyMountRepository) List(ctx context.Context) ([]model.EmbyMount, error) {
var rows []model.EmbyMount
err := r.db.WithContext(ctx).Order("sort_order asc, created_at asc").Find(&rows).Error
return rows, err
}
func (r *EmbyMountRepository) ListByAccountID(ctx context.Context, accountID string) ([]model.EmbyMount, error) {
var rows []model.EmbyMount
err := r.db.WithContext(ctx).Where("account_id = ?", accountID).Order("sort_order asc, created_at asc").Find(&rows).Error
return rows, err
}
func (r *EmbyMountRepository) SetSortOrder(ctx context.Context, ids []string) error {
if len(ids) == 0 {
return nil
}
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for i, id := range ids {
if err := tx.Model(&model.EmbyMount{}).Where("id = ?", id).
Update("sort_order", i).Error; err != nil {
return err
}
}
return nil
})
})
}
func (r *EmbyMountRepository) CountByAccountID(ctx context.Context, accountID string) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.EmbyMount{}).Where("account_id = ?", accountID).Count(&count).Error
return count, err
}
func (r *EmbyMountRepository) Update(ctx context.Context, m *model.EmbyMount) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.EmbyMount{}).Where("id = ?", m.ID).Updates(map[string]any{
"name": m.Name,
"proxy_play": m.ProxyPlay,
"enabled": m.Enabled,
"remote_view_id": m.RemoteViewID,
"remote_view_name": m.RemoteViewName,
"collection_type": m.CollectionType,
"updated_at": time.Now(),
}).Error
})
}
func (r *EmbyMountRepository) Delete(ctx context.Context, id string) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.EmbyMount{}).Error
})
}
// DeleteByAccountID 删除账号下全部挂载(删除账号时级联清理)。
func (r *EmbyMountRepository) DeleteByAccountID(ctx context.Context, accountID string) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Where("account_id = ?", accountID).Delete(&model.EmbyMount{})
count = res.RowsAffected
return res.Error
})
return count, err
}
+74
View File
@@ -0,0 +1,74 @@
package repository
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/database"
"github.com/ShukeBta/MMTL/internal/model"
)
func TestEmbyMountSortOrderAndReorder(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := New(db)
ctx := t.Context()
// 1. Create mounts and verify auto-assigned sort_order
m1 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-1", Name: "Mount 1"}
m2 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-2", Name: "Mount 2"}
m3 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-3", Name: "Mount 3"}
if err := repos.EmbyMount.Create(ctx, m1); err != nil {
t.Fatalf("create m1: %v", err)
}
if err := repos.EmbyMount.Create(ctx, m2); err != nil {
t.Fatalf("create m2: %v", err)
}
if err := repos.EmbyMount.Create(ctx, m3); err != nil {
t.Fatalf("create m3: %v", err)
}
if m1.SortOrder >= m2.SortOrder || m2.SortOrder >= m3.SortOrder {
t.Fatalf("expected ascending sort order on create: m1=%d, m2=%d, m3=%d",
m1.SortOrder, m2.SortOrder, m3.SortOrder)
}
// 2. Query list and verify initial order
list, err := repos.EmbyMount.List(ctx)
if err != nil {
t.Fatalf("list mounts: %v", err)
}
if len(list) != 3 || list[0].ID != m1.ID || list[1].ID != m2.ID || list[2].ID != m3.ID {
t.Fatalf("unexpected list order: %+v", list)
}
// 3. Reorder: m3, m1, m2
if err := repos.EmbyMount.SetSortOrder(ctx, []string{m3.ID, m1.ID, m2.ID}); err != nil {
t.Fatalf("SetSortOrder failed: %v", err)
}
// 4. Query list again and verify updated order
reordered, err := repos.EmbyMount.List(ctx)
if err != nil {
t.Fatalf("list mounts after reorder: %v", err)
}
if len(reordered) != 3 {
t.Fatalf("expected 3 mounts, got %d", len(reordered))
}
if reordered[0].ID != m3.ID || reordered[1].ID != m1.ID || reordered[2].ID != m2.ID {
t.Fatalf("expected order [m3, m1, m2], got: %s, %s, %s",
reordered[0].ID, reordered[1].ID, reordered[2].ID)
}
if reordered[0].SortOrder != 0 || reordered[1].SortOrder != 1 || reordered[2].SortOrder != 2 {
t.Fatalf("unexpected sort orders: %d, %d, %d",
reordered[0].SortOrder, reordered[1].SortOrder, reordered[2].SortOrder)
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ func (r *FavoriteRepository) Toggle(ctx context.Context, userID, mediaID string)
if err != nil {
return false, err
}
return false, r.db.WithContext(ctx).Delete(&f).Error
return false, r.db.WithContext(ctx).Unscoped().Delete(&f).Error
}
// ListByUser returns all favourite media IDs for a user.
+31 -5
View File
@@ -15,6 +15,11 @@ type LibraryRepository struct{ db *gorm.DB }
// Create persists a new library row.
func (r *LibraryRepository) Create(ctx context.Context, l *model.Library) error {
if l != nil && l.SortOrder == 0 {
var maxSort int
_ = r.db.WithContext(ctx).Model(&model.Library{}).Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort)
l.SortOrder = maxSort + 1
}
return r.db.WithContext(ctx).Create(l).Error
}
@@ -23,6 +28,11 @@ func (r *LibraryRepository) CreateWithRoots(ctx context.Context, l *model.Librar
return r.Create(ctx, l)
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if l != nil && l.SortOrder == 0 {
var maxSort int
_ = tx.Model(&model.Library{}).Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort)
l.SortOrder = maxSort + 1
}
if err := tx.Create(l).Error; err != nil {
return err
}
@@ -50,7 +60,7 @@ func (r *LibraryRepository) CreateWithRoots(ctx context.Context, l *model.Librar
// List returns all enabled+disabled libraries.
func (r *LibraryRepository) List(ctx context.Context) ([]model.Library, error) {
var ls []model.Library
q := r.db.WithContext(ctx).Order("created_at asc")
q := r.db.WithContext(ctx).Order("sort_order asc, created_at asc")
if r.hasLibraryRootsTable() {
q = q.Preload("Roots", func(db *gorm.DB) *gorm.DB {
return db.Order("sort_order asc, created_at asc")
@@ -60,6 +70,23 @@ func (r *LibraryRepository) List(ctx context.Context) ([]model.Library, error) {
return ls, err
}
// SetSortOrder assigns sort_order to libraries, preserving position order for
// any library not present in the provided map.
func (r *LibraryRepository) SetSortOrder(ctx context.Context, ids []string) error {
if len(ids) == 0 {
return nil
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for i, id := range ids {
if err := tx.Model(&model.Library{}).Where("id = ?", id).
Update("sort_order", i).Error; err != nil {
return err
}
}
return nil
})
}
// FindByID returns the library, or (nil, nil) when missing.
func (r *LibraryRepository) FindByID(ctx context.Context, id string) (*model.Library, error) {
var l model.Library
@@ -79,10 +106,9 @@ func (r *LibraryRepository) FindByID(ctx context.Context, id string) (*model.Lib
return &l, nil
}
// Delete removes a library and (soft) cascades to its media via repository
// callers; we do not run CASCADE here to keep this method narrow.
// Delete 物理删除媒体库。
func (r *LibraryRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&model.Library{}, "id = ?", id).Error
return r.db.WithContext(ctx).Unscoped().Delete(&model.Library{}, "id = ?", id).Error
}
func (r *LibraryRepository) ListRoots(ctx context.Context, libraryID string) ([]model.LibraryRoot, error) {
@@ -149,7 +175,7 @@ func (r *LibraryRepository) DeleteRoot(ctx context.Context, libraryID, rootID st
if !r.hasLibraryRootsTable() {
return nil
}
return r.db.WithContext(ctx).Where("library_id = ?", libraryID).Delete(&model.LibraryRoot{}, "id = ?", rootID).Error
return r.db.WithContext(ctx).Unscoped().Where("library_id = ?", libraryID).Delete(&model.LibraryRoot{}, "id = ?", rootID).Error
}
func (r *LibraryRepository) hasLibraryRootsTable() bool {
+119 -5
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,14 +130,112 @@ 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"`
}
// CountByLibraries returns a map of library_id -> total media count for the given library IDs.
func (r *MediaRepository) CountByLibraries(ctx context.Context, libraryIDs []string, filter MediaQueryFilter) (map[string]int64, error) {
out := make(map[string]int64, len(libraryIDs))
if len(libraryIDs) == 0 {
return out, nil
}
var rows []libraryCountRow
q := r.db.WithContext(ctx).Model(&model.Media{}).
Select("library_id, count(*) as total")
if len(libraryIDs) == 1 {
q = q.Where("library_id = ?", libraryIDs[0])
} else {
q = q.Where("library_id IN ?", libraryIDs)
}
q = applyMediaQueryFilter(q, filter)
if err := q.Group("library_id").Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
out[row.LibraryID] = row.Total
}
return out, nil
}
// DeleteByLibrary purges all media tied to a library.
func (r *MediaRepository) DeleteByLibrary(ctx context.Context, libraryID string) error {
// FTS 行由 media 表上的触发器同步清理(软删/硬删都覆盖)。
return r.db.WithContext(ctx).Where("library_id = ?", libraryID).Delete(&model.Media{}).Error
// FTS 行由 media 表上的触发器同步清理(物理删除触发 FTS 清理)。
return r.db.WithContext(ctx).Unscoped().Where("library_id = ?", libraryID).Delete(&model.Media{}).Error
}
func (r *MediaRepository) DeleteByLibraryRoot(ctx context.Context, libraryID, rootID string) error {
return r.db.WithContext(ctx).
return r.db.WithContext(ctx).Unscoped().
Where("library_id = ? AND library_root_id = ?", libraryID, rootID).
Delete(&model.Media{}).Error
}
@@ -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)
}
}
+2 -2
View File
@@ -51,9 +51,9 @@ func (r *PermissionRepository) Upsert(ctx context.Context, p *model.UserPermissi
})
}
// Delete removes a permission record.
// Delete 物理删除权限记录。
func (r *PermissionRepository) Delete(ctx context.Context, userID string) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&model.UserPermission{}).Error
return r.db.WithContext(ctx).Unscoped().Where("user_id = ?", userID).Delete(&model.UserPermission{}).Error
})
}
+2 -2
View File
@@ -59,9 +59,9 @@ func (r *PlayProfileRepository) Update(ctx context.Context, id string, patch map
Where("id = ?", id).Updates(patch).Error
}
// Delete soft-deletes a profile.
// Delete 物理删除播放档案。
func (r *PlayProfileRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&model.PlayProfile{}, "id = ?", id).Error
return r.db.WithContext(ctx).Unscoped().Delete(&model.PlayProfile{}, "id = ?", id).Error
}
// ClearDefaultsFor resets is_default for all of a user's profiles.
@@ -72,10 +72,10 @@ func (r *RefreshTokenRepository) RevokeOldestActiveByUserID(ctx context.Context,
})
}
// DeleteExpired removes all expired refresh tokens.
// DeleteExpired 物理清理所有过期的 refresh tokens。
func (r *RefreshTokenRepository) DeleteExpired(ctx context.Context) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Where("expires_at < ?", time.Now()).Delete(&model.RefreshToken{}).Error
return r.db.WithContext(ctx).Unscoped().Where("expires_at < ?", time.Now()).Delete(&model.RefreshToken{}).Error
})
}
+6
View File
@@ -31,6 +31,9 @@ type Container struct {
StrmSyncRecord *StrmSyncRecordRepository
StrmDownload *StrmDownloadTaskRepository
StrmUpload *StrmUploadTaskRepository
StrmDirCache *StrmDirCacheRepository
ScrapeTask *ScrapeTaskRepository
EmbyMount *EmbyMountRepository
}
// New 将每个 repository 连接到单个 *gorm.DB。
@@ -58,5 +61,8 @@ func New(db *gorm.DB) *Container {
StrmSyncRecord: &StrmSyncRecordRepository{db: db},
StrmDownload: &StrmDownloadTaskRepository{db: db},
StrmUpload: &StrmUploadTaskRepository{db: db},
StrmDirCache: &StrmDirCacheRepository{db: db},
ScrapeTask: &ScrapeTaskRepository{db: db},
EmbyMount: &EmbyMountRepository{db: db},
}
}
@@ -0,0 +1,274 @@
package repository
import (
"context"
"errors"
"strings"
"sync"
"time"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/model"
)
var scrapeClaimMu sync.Mutex
// ScrapeTaskRepository persists model.ScrapeTask.
type ScrapeTaskRepository struct{ db *gorm.DB }
func (r *ScrapeTaskRepository) Create(ctx context.Context, t *model.ScrapeTask) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Create(t).Error
})
}
func (r *ScrapeTaskRepository) CreateBatch(ctx context.Context, tasks []model.ScrapeTask) error {
if len(tasks) == 0 {
return nil
}
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).CreateInBatches(tasks, 100).Error
})
}
func (r *ScrapeTaskRepository) FindByID(ctx context.Context, id string) (*model.ScrapeTask, error) {
var t model.ScrapeTask
err := r.db.WithContext(ctx).Where("id = ?", id).First(&t).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &t, err
}
func (r *ScrapeTaskRepository) FindActiveByMediaID(ctx context.Context, mediaID string) (*model.ScrapeTask, error) {
var t model.ScrapeTask
err := r.db.WithContext(ctx).
Where("media_id = ? AND status IN ?", mediaID, []string{model.ScrapeTaskPending, model.ScrapeTaskRunning}).
First(&t).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &t, err
}
func (r *ScrapeTaskRepository) List(ctx context.Context, status string, page, pageSize int) ([]model.ScrapeTask, int64, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 200 {
pageSize = 50
}
q := r.db.WithContext(ctx).Model(&model.ScrapeTask{})
if strings.TrimSpace(status) != "" && status != "all" {
q = q.Where("status = ?", strings.TrimSpace(status))
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.ScrapeTask
err := q.Order("created_at desc").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&rows).Error
return rows, total, err
}
func (r *ScrapeTaskRepository) CountByStatus(ctx context.Context) (map[string]int64, error) {
var rows []struct {
Status string
Count int64
}
err := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
Select("status, count(*) as count").
Group("status").Scan(&rows).Error
if err != nil {
return nil, err
}
out := map[string]int64{}
for _, row := range rows {
out[row.Status] = row.Count
}
return out, nil
}
// ClaimPending picks pending scrape tasks and marks them running.
func (r *ScrapeTaskRepository) ClaimPending(ctx context.Context, limit int) ([]model.ScrapeTask, error) {
scrapeClaimMu.Lock()
defer scrapeClaimMu.Unlock()
var rows []model.ScrapeTask
err := withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("status = ?", model.ScrapeTaskPending).
Order("created_at asc").Limit(limit).Find(&rows).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
ids := make([]string, 0, len(rows))
now := time.Now()
for i := range rows {
ids = append(ids, rows[i].ID)
rows[i].Status = model.ScrapeTaskRunning
rows[i].StartedAt = &now
}
return tx.Model(&model.ScrapeTask{}).Where("id IN ?", ids).
Updates(map[string]any{"status": model.ScrapeTaskRunning, "started_at": now}).Error
})
})
if err != nil {
return nil, err
}
return rows, nil
}
func (r *ScrapeTaskRepository) Update(ctx context.Context, t *model.ScrapeTask) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.ScrapeTask{}).Where("id = ?", t.ID).Updates(map[string]any{
"status": t.Status,
"error": t.Error,
"provider": t.Provider,
"matched_title": t.MatchedTitle,
"matched_year": t.MatchedYear,
"poster_url": t.PosterURL,
"backdrop_url": t.BackdropURL,
"retry_count": t.RetryCount,
"started_at": t.StartedAt,
"finished_at": t.FinishedAt,
"updated_at": time.Now(),
}).Error
})
}
func (r *ScrapeTaskRepository) Delete(ctx context.Context, id string) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.ScrapeTask{}).Error
})
}
func (r *ScrapeTaskRepository) DeleteBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Delete(&model.ScrapeTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
func (r *ScrapeTaskRepository) RetryBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
Where("id IN ? AND status IN ?", ids, []string{model.ScrapeTaskFailed, model.ScrapeTaskCanceled}).
Updates(map[string]any{
"status": model.ScrapeTaskPending,
"error": "",
"retry_count": 0,
"started_at": nil,
"finished_at": nil,
"updated_at": time.Now(),
})
count = res.RowsAffected
return res.Error
})
return count, err
}
func (r *ScrapeTaskRepository) CancelBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
now := time.Now()
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
Where("id IN ? AND status IN ?", ids, []string{model.ScrapeTaskPending, model.ScrapeTaskRunning}).
Updates(map[string]any{
"status": model.ScrapeTaskCanceled,
"error": "已批量取消",
"finished_at": now,
"updated_at": now,
})
count = res.RowsAffected
return res.Error
})
return count, err
}
func (r *ScrapeTaskRepository) ClearDone(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.ScrapeTaskDone).Delete(&model.ScrapeTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
func (r *ScrapeTaskRepository) ClearFinished(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status IN ?", []string{model.ScrapeTaskDone, model.ScrapeTaskFailed, model.ScrapeTaskCanceled}).
Delete(&model.ScrapeTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
func (r *ScrapeTaskRepository) ClearCanceled(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.ScrapeTaskCanceled).Delete(&model.ScrapeTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
func (r *ScrapeTaskRepository) RetryAllFailed(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
Where("status = ?", model.ScrapeTaskFailed).
Updates(map[string]any{
"status": model.ScrapeTaskPending,
"error": "",
"retry_count": 0,
"started_at": nil,
"finished_at": nil,
"updated_at": time.Now(),
})
count = res.RowsAffected
return res.Error
})
return count, err
}
func (r *ScrapeTaskRepository) CancelPending(ctx context.Context) (int64, error) {
now := time.Now()
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
Where("status IN ?", []string{model.ScrapeTaskPending, model.ScrapeTaskRunning}).
Updates(map[string]any{
"status": model.ScrapeTaskCanceled,
"error": "已批量取消",
"finished_at": now,
"updated_at": now,
})
count = res.RowsAffected
return res.Error
})
return count, err
}
+2 -2
View File
@@ -29,9 +29,9 @@ func (r *SettingRepository) Set(ctx context.Context, key, value string) error {
return r.db.WithContext(ctx).Save(&s).Error
}
// Delete removes a setting key.
// Delete 物理删除设置键。
func (r *SettingRepository) Delete(ctx context.Context, key string) error {
return r.db.WithContext(ctx).Where("key = ?", key).Delete(&model.Setting{}).Error
return r.db.WithContext(ctx).Unscoped().Where("key = ?", key).Delete(&model.Setting{}).Error
}
// All returns every key/value pair (used by the admin UI).
+2 -2
View File
@@ -66,9 +66,9 @@ func (r *StorageConfigRepository) Upsert(ctx context.Context, c *model.StorageCo
}).Error
}
// Delete removes a storage config by ID.
// Delete 物理删除存储配置。
func (r *StorageConfigRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StorageConfig{}).Error
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StorageConfig{}).Error
}
// FindByID returns a storage config by ID.
+526 -141
View File
@@ -3,6 +3,7 @@ package repository
import (
"context"
"errors"
"sync"
"time"
"gorm.io/gorm"
@@ -10,13 +11,17 @@ import (
"github.com/ShukeBta/MMTL/internal/model"
)
var strmClaimMu sync.Mutex
// ─── StrmAccount ───────────────────────────────────────────────────────────────
// StrmAccountRepository persists model.StrmAccount.
type StrmAccountRepository struct{ db *gorm.DB }
func (r *StrmAccountRepository) Create(ctx context.Context, a *model.StrmAccount) error {
return r.db.WithContext(ctx).Create(a).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Create(a).Error
})
}
func (r *StrmAccountRepository) FindByID(ctx context.Context, id string) (*model.StrmAccount, error) {
@@ -38,20 +43,24 @@ func (r *StrmAccountRepository) List(ctx context.Context) ([]model.StrmAccount,
}
func (r *StrmAccountRepository) Update(ctx context.Context, a *model.StrmAccount) error {
return r.db.WithContext(ctx).Model(&model.StrmAccount{}).Where("id = ?", a.ID).Updates(map[string]any{
"name": a.Name,
"provider": a.Provider,
"config": a.Config,
"enabled": a.Enabled,
"last_test_at": a.LastTestAt,
"last_test_result": a.LastTestResult,
"last_test_ok": a.LastTestOK,
"updated_at": time.Now(),
}).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.StrmAccount{}).Where("id = ?", a.ID).Updates(map[string]any{
"name": a.Name,
"provider": a.Provider,
"config": a.Config,
"enabled": a.Enabled,
"last_test_at": a.LastTestAt,
"last_test_result": a.LastTestResult,
"last_test_ok": a.LastTestOK,
"updated_at": time.Now(),
}).Error
})
}
func (r *StrmAccountRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StrmAccount{}).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StrmAccount{}).Error
})
}
// ─── StrmSyncPath ──────────────────────────────────────────────────────────────
@@ -60,7 +69,9 @@ func (r *StrmAccountRepository) Delete(ctx context.Context, id string) error {
type StrmSyncPathRepository struct{ db *gorm.DB }
func (r *StrmSyncPathRepository) Create(ctx context.Context, p *model.StrmSyncPath) error {
return r.db.WithContext(ctx).Create(p).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Create(p).Error
})
}
func (r *StrmSyncPathRepository) FindByID(ctx context.Context, id string) (*model.StrmSyncPath, error) {
@@ -82,33 +93,38 @@ func (r *StrmSyncPathRepository) List(ctx context.Context) ([]model.StrmSyncPath
}
func (r *StrmSyncPathRepository) Update(ctx context.Context, p *model.StrmSyncPath) error {
return r.db.WithContext(ctx).Model(&model.StrmSyncPath{}).Where("id = ?", p.ID).Updates(map[string]any{
"name": p.Name,
"account_id": p.AccountID,
"provider": p.Provider,
"remote_path": p.RemotePath,
"local_path": p.LocalPath,
"strm_base_url": p.StrmBaseURL,
"video_ext": p.VideoExt,
"meta_ext": p.MetaExt,
"exclude_name": p.ExcludeName,
"min_video_size_mb": p.MinVideoSizeMB,
"add_path": p.AddPath,
"download_meta": p.DownloadMeta,
"upload_meta": p.UploadMeta,
"delete_dir": p.DeleteDir,
"cron": p.Cron,
"enable_cron": p.EnableCron,
"enabled": p.Enabled,
"last_sync_at": p.LastSyncAt,
"last_sync_status": p.LastSyncStatus,
"last_sync_message": p.LastSyncMessage,
"updated_at": time.Now(),
}).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.StrmSyncPath{}).Where("id = ?", p.ID).Updates(map[string]any{
"name": p.Name,
"account_id": p.AccountID,
"provider": p.Provider,
"remote_path": p.RemotePath,
"local_path": p.LocalPath,
"strm_base_url": p.StrmBaseURL,
"video_ext": p.VideoExt,
"meta_ext": p.MetaExt,
"exclude_name": p.ExcludeName,
"min_video_size_mb": p.MinVideoSizeMB,
"add_path": p.AddPath,
"download_meta": p.DownloadMeta,
"upload_meta": p.UploadMeta,
"delete_dir": p.DeleteDir,
"cron": p.Cron,
"enable_cron": p.EnableCron,
"sync_mode": p.SyncMode,
"enabled": p.Enabled,
"last_sync_at": p.LastSyncAt,
"last_sync_status": p.LastSyncStatus,
"last_sync_message": p.LastSyncMessage,
"updated_at": time.Now(),
}).Error
})
}
func (r *StrmSyncPathRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StrmSyncPath{}).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StrmSyncPath{}).Error
})
}
// ─── StrmSyncRecord ────────────────────────────────────────────────────────────
@@ -117,23 +133,28 @@ func (r *StrmSyncPathRepository) Delete(ctx context.Context, id string) error {
type StrmSyncRecordRepository struct{ db *gorm.DB }
func (r *StrmSyncRecordRepository) Create(ctx context.Context, rec *model.StrmSyncRecord) error {
return r.db.WithContext(ctx).Create(rec).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Create(rec).Error
})
}
func (r *StrmSyncRecordRepository) Update(ctx context.Context, rec *model.StrmSyncRecord) error {
return r.db.WithContext(ctx).Model(&model.StrmSyncRecord{}).Where("id = ?", rec.ID).Updates(map[string]any{
"status": rec.Status,
"total": rec.Total,
"new_strm": rec.NewStrm,
"new_meta": rec.NewMeta,
"uploaded": rec.Uploaded,
"pruned": rec.Pruned,
"skipped": rec.Skipped,
"message": rec.Message,
"started_at": rec.StartedAt,
"finished_at": rec.FinishedAt,
"updated_at": time.Now(),
}).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.StrmSyncRecord{}).Where("id = ?", rec.ID).Updates(map[string]any{
"sync_type": rec.SyncType,
"status": rec.Status,
"total": rec.Total,
"new_strm": rec.NewStrm,
"new_meta": rec.NewMeta,
"uploaded": rec.Uploaded,
"pruned": rec.Pruned,
"skipped": rec.Skipped,
"message": rec.Message,
"started_at": rec.StartedAt,
"finished_at": rec.FinishedAt,
"updated_at": time.Now(),
}).Error
})
}
func (r *StrmSyncRecordRepository) List(ctx context.Context, syncPathID string, limit int) ([]model.StrmSyncRecord, error) {
@@ -149,13 +170,45 @@ func (r *StrmSyncRecordRepository) List(ctx context.Context, syncPathID string,
return rows, err
}
// Delete 删除单条同步记录(物理删除)。
func (r *StrmSyncRecordRepository) Delete(ctx context.Context, id string) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StrmSyncRecord{}).Error
})
}
// DeleteBySyncPathID 删除某同步目录下的全部同步记录(删除同步目录时级联清理)。
func (r *StrmSyncRecordRepository) DeleteBySyncPathID(ctx context.Context, syncPathID string) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("sync_path_id = ?", syncPathID).Delete(&model.StrmSyncRecord{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// ─── StrmDownloadTask ──────────────────────────────────────────────────────────
// StrmDownloadTaskRepository persists model.StrmDownloadTask.
type StrmDownloadTaskRepository struct{ db *gorm.DB }
func (r *StrmDownloadTaskRepository) Create(ctx context.Context, t *model.StrmDownloadTask) error {
return r.db.WithContext(ctx).Create(t).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Create(t).Error
})
}
func (r *StrmDownloadTaskRepository) CreateInBatches(ctx context.Context, tasks []*model.StrmDownloadTask, batchSize int) error {
if len(tasks) == 0 {
return nil
}
if batchSize <= 0 {
batchSize = 100
}
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).CreateInBatches(tasks, batchSize).Error
})
}
func (r *StrmDownloadTaskRepository) FindByID(ctx context.Context, id string) (*model.StrmDownloadTask, error) {
@@ -205,24 +258,29 @@ func (r *StrmDownloadTaskRepository) CountByStatus(ctx context.Context) (map[str
// ClaimPendingDownload picks the oldest pending task and marks it running.
// Returns (nil, nil) when the queue is empty.
func (r *StrmDownloadTaskRepository) ClaimPendingDownload(ctx context.Context, limit int) ([]model.StrmDownloadTask, error) {
strmClaimMu.Lock()
defer strmClaimMu.Unlock()
var rows []model.StrmDownloadTask
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("status = ? AND (next_try_at IS NULL OR next_try_at <= ?)", model.StrmTaskPending, time.Now()).
Order("created_at asc").Limit(limit).Find(&rows).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
ids := make([]string, 0, len(rows))
now := time.Now()
for i := range rows {
ids = append(ids, rows[i].ID)
rows[i].Status = model.StrmTaskRunning
rows[i].StartedAt = &now
}
return tx.Model(&model.StrmDownloadTask{}).Where("id IN ?", ids).
Updates(map[string]any{"status": model.StrmTaskRunning, "started_at": now}).Error
err := withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("status = ? AND (next_try_at IS NULL OR next_try_at <= ?)", model.StrmTaskPending, time.Now()).
Order("created_at asc").Limit(limit).Find(&rows).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
ids := make([]string, 0, len(rows))
now := time.Now()
for i := range rows {
ids = append(ids, rows[i].ID)
rows[i].Status = model.StrmTaskRunning
rows[i].StartedAt = &now
}
return tx.Model(&model.StrmDownloadTask{}).Where("id IN ?", ids).
Updates(map[string]any{"status": model.StrmTaskRunning, "started_at": now}).Error
})
})
if err != nil {
return nil, err
@@ -231,62 +289,157 @@ func (r *StrmDownloadTaskRepository) ClaimPendingDownload(ctx context.Context, l
}
func (r *StrmDownloadTaskRepository) Update(ctx context.Context, t *model.StrmDownloadTask) error {
return r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).Where("id = ?", t.ID).Updates(map[string]any{
"status": t.Status,
"error": t.Error,
"retry_count": t.RetryCount,
"next_try_at": t.NextTryAt,
"started_at": t.StartedAt,
"finished_at": t.FinishedAt,
"updated_at": time.Now(),
}).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).Where("id = ?", t.ID).Updates(map[string]any{
"status": t.Status,
"error": t.Error,
"retry_count": t.RetryCount,
"next_try_at": t.NextTryAt,
"started_at": t.StartedAt,
"finished_at": t.FinishedAt,
"updated_at": time.Now(),
}).Error
})
}
func (r *StrmDownloadTaskRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StrmDownloadTask{}).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StrmDownloadTask{}).Error
})
}
// DeleteBatch 批量删除指定 ID 的下载任务。
func (r *StrmDownloadTaskRepository) DeleteBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Delete(&model.StrmDownloadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// RetryBatch 批量重试指定 ID 的失败/已取消下载任务。
func (r *StrmDownloadTaskRepository) RetryBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskFailed, model.StrmTaskCanceled}).
Updates(map[string]any{
"status": model.StrmTaskPending,
"error": "",
"retry_count": 0,
"next_try_at": nil,
"started_at": nil,
"finished_at": nil,
"updated_at": time.Now(),
})
count = res.RowsAffected
return res.Error
})
return count, err
}
// CancelBatch 批量取消指定 ID 的排队/进行中下载任务。
func (r *StrmDownloadTaskRepository) CancelBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
now := time.Now()
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskPending, model.StrmTaskRunning}).
Updates(map[string]any{
"status": model.StrmTaskCanceled,
"error": "已批量取消",
"finished_at": now,
"updated_at": now,
})
count = res.RowsAffected
return res.Error
})
return count, err
}
// ClearDone 清空全部已完成下载任务。
func (r *StrmDownloadTaskRepository) ClearDone(ctx context.Context) (int64, error) {
res := r.db.WithContext(ctx).Where("status = ?", model.StrmTaskDone).Delete(&model.StrmDownloadTask{})
return res.RowsAffected, res.Error
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.StrmTaskDone).Delete(&model.StrmDownloadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// ClearFinished 清空全部已完成与失败下载任务。
func (r *StrmDownloadTaskRepository) ClearFinished(ctx context.Context) (int64, error) {
res := r.db.WithContext(ctx).Where("status IN ?", []string{model.StrmTaskDone, model.StrmTaskFailed}).
Delete(&model.StrmDownloadTask{})
return res.RowsAffected, res.Error
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status IN ?", []string{model.StrmTaskDone, model.StrmTaskFailed, model.StrmTaskCanceled}).
Delete(&model.StrmDownloadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// ClearCanceled 清空全部已取消下载任务。
func (r *StrmDownloadTaskRepository) ClearCanceled(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.StrmTaskCanceled).Delete(&model.StrmDownloadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// RetryAllFailed 把所有失败任务重置回待处理,清空错误与重试计数。
func (r *StrmDownloadTaskRepository) RetryAllFailed(ctx context.Context) (int64, error) {
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Where("status = ?", model.StrmTaskFailed).
Updates(map[string]any{
"status": model.StrmTaskPending,
"error": "",
"retry_count": 0,
"next_try_at": nil,
"started_at": nil,
"finished_at": nil,
"updated_at": time.Now(),
})
return res.RowsAffected, res.Error
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Where("status = ?", model.StrmTaskFailed).
Updates(map[string]any{
"status": model.StrmTaskPending,
"error": "",
"retry_count": 0,
"next_try_at": nil,
"started_at": nil,
"finished_at": nil,
"updated_at": time.Now(),
})
count = res.RowsAffected
return res.Error
})
return count, err
}
// CancelPending 批量取消所有排队中的任务。
// CancelPending 批量取消所有排队中和进行中的任务。
func (r *StrmDownloadTaskRepository) CancelPending(ctx context.Context) (int64, error) {
now := time.Now()
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Where("status = ?", model.StrmTaskPending).
Updates(map[string]any{
"status": model.StrmTaskCanceled,
"error": "已批量取消",
"finished_at": now,
"updated_at": now,
})
return res.RowsAffected, res.Error
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Where("status IN ?", []string{model.StrmTaskPending, model.StrmTaskRunning}).
Updates(map[string]any{
"status": model.StrmTaskCanceled,
"error": "已批量取消",
"finished_at": now,
"updated_at": now,
})
count = res.RowsAffected
return res.Error
})
return count, err
}
// CountActive 统计某同步目录下目标仍在排队/进行的任务数(用于去重)。
@@ -299,10 +452,28 @@ func (r *StrmDownloadTaskRepository) CountActive(ctx context.Context, syncPathID
return count
}
// GetActiveLocalPathMap 一次性获取某同步目录下正在排队或执行中的 local_path 集合,供同步时 O(1) 内存去重。
func (r *StrmDownloadTaskRepository) GetActiveLocalPathMap(ctx context.Context, syncPathID string) (map[string]bool, error) {
var paths []string
err := r.db.WithContext(ctx).Model(&model.StrmDownloadTask{}).
Where("sync_path_id = ? AND status IN ?", syncPathID, []string{model.StrmTaskPending, model.StrmTaskRunning}).
Pluck("local_path", &paths).Error
if err != nil {
return nil, err
}
out := make(map[string]bool, len(paths))
for _, p := range paths {
out[p] = true
}
return out, nil
}
func (r *StrmDownloadTaskRepository) DeleteFinishedOlderThan(ctx context.Context, before time.Time) error {
return r.db.WithContext(ctx).Where("status IN ? AND finished_at < ?",
[]string{model.StrmTaskDone, model.StrmTaskFailed, model.StrmTaskCanceled}, before).
Delete(&model.StrmDownloadTask{}).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Unscoped().Where("status IN ? AND finished_at < ?",
[]string{model.StrmTaskDone, model.StrmTaskFailed, model.StrmTaskCanceled}, before).
Delete(&model.StrmDownloadTask{}).Error
})
}
// ─── StrmUploadTask ────────────────────────────────────────────────────────────
@@ -311,7 +482,21 @@ func (r *StrmDownloadTaskRepository) DeleteFinishedOlderThan(ctx context.Context
type StrmUploadTaskRepository struct{ db *gorm.DB }
func (r *StrmUploadTaskRepository) Create(ctx context.Context, t *model.StrmUploadTask) error {
return r.db.WithContext(ctx).Create(t).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Create(t).Error
})
}
func (r *StrmUploadTaskRepository) CreateInBatches(ctx context.Context, tasks []*model.StrmUploadTask, batchSize int) error {
if len(tasks) == 0 {
return nil
}
if batchSize <= 0 {
batchSize = 100
}
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).CreateInBatches(tasks, batchSize).Error
})
}
func (r *StrmUploadTaskRepository) FindByID(ctx context.Context, id string) (*model.StrmUploadTask, error) {
@@ -380,24 +565,29 @@ func (r *StrmUploadTaskRepository) CountByStatus(ctx context.Context) (map[strin
// ClaimPendingUpload picks the oldest pending task and marks it running.
// Returns (nil, nil) when the queue is empty.
func (r *StrmUploadTaskRepository) ClaimPendingUpload(ctx context.Context, limit int) ([]model.StrmUploadTask, error) {
strmClaimMu.Lock()
defer strmClaimMu.Unlock()
var rows []model.StrmUploadTask
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("status = ? AND (next_try_at IS NULL OR next_try_at <= ?)", model.StrmTaskPending, time.Now()).
Order("created_at asc").Limit(limit).Find(&rows).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
ids := make([]string, 0, len(rows))
now := time.Now()
for i := range rows {
ids = append(ids, rows[i].ID)
rows[i].Status = model.StrmTaskRunning
rows[i].StartedAt = &now
}
return tx.Model(&model.StrmUploadTask{}).Where("id IN ?", ids).
Updates(map[string]any{"status": model.StrmTaskRunning, "started_at": now}).Error
err := withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("status = ? AND (next_try_at IS NULL OR next_try_at <= ?)", model.StrmTaskPending, time.Now()).
Order("created_at asc").Limit(limit).Find(&rows).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
ids := make([]string, 0, len(rows))
now := time.Now()
for i := range rows {
ids = append(ids, rows[i].ID)
rows[i].Status = model.StrmTaskRunning
rows[i].StartedAt = &now
}
return tx.Model(&model.StrmUploadTask{}).Where("id IN ?", ids).
Updates(map[string]any{"status": model.StrmTaskRunning, "started_at": now}).Error
})
})
if err != nil {
return nil, err
@@ -406,19 +596,157 @@ func (r *StrmUploadTaskRepository) ClaimPendingUpload(ctx context.Context, limit
}
func (r *StrmUploadTaskRepository) Update(ctx context.Context, t *model.StrmUploadTask) error {
return r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).Where("id = ?", t.ID).Updates(map[string]any{
"status": t.Status,
"error": t.Error,
"retry_count": t.RetryCount,
"next_try_at": t.NextTryAt,
"started_at": t.StartedAt,
"finished_at": t.FinishedAt,
"updated_at": time.Now(),
}).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).Where("id = ?", t.ID).Updates(map[string]any{
"status": t.Status,
"error": t.Error,
"retry_count": t.RetryCount,
"next_try_at": t.NextTryAt,
"started_at": t.StartedAt,
"finished_at": t.FinishedAt,
"updated_at": time.Now(),
}).Error
})
}
func (r *StrmUploadTaskRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.StrmUploadTask{}).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.StrmUploadTask{}).Error
})
}
// DeleteBatch 批量删除指定 ID 的上传任务。
func (r *StrmUploadTaskRepository) DeleteBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Delete(&model.StrmUploadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// RetryBatch 批量重试指定 ID 的失败/已取消上传任务。
func (r *StrmUploadTaskRepository) RetryBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskFailed, model.StrmTaskCanceled}).
Updates(map[string]any{
"status": model.StrmTaskPending,
"error": "",
"retry_count": 0,
"next_try_at": nil,
"started_at": nil,
"finished_at": nil,
"updated_at": time.Now(),
})
count = res.RowsAffected
return res.Error
})
return count, err
}
// CancelBatch 批量取消指定 ID 的排队/进行中上传任务。
func (r *StrmUploadTaskRepository) CancelBatch(ctx context.Context, ids []string) (int64, error) {
if len(ids) == 0 {
return 0, nil
}
now := time.Now()
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
Where("id IN ? AND status IN ?", ids, []string{model.StrmTaskPending, model.StrmTaskRunning}).
Updates(map[string]any{
"status": model.StrmTaskCanceled,
"error": "已批量取消",
"finished_at": now,
"updated_at": now,
})
count = res.RowsAffected
return res.Error
})
return count, err
}
// ClearDone 清空全部已完成上传任务。
func (r *StrmUploadTaskRepository) ClearDone(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.StrmTaskDone).Delete(&model.StrmUploadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// ClearFinished 清空全部已完成与失败上传任务(包括已完成、失败及取消)。
func (r *StrmUploadTaskRepository) ClearFinished(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status IN ?", []string{model.StrmTaskDone, model.StrmTaskFailed, model.StrmTaskCanceled}).
Delete(&model.StrmUploadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// ClearCanceled 清空全部已取消上传任务。
func (r *StrmUploadTaskRepository) ClearCanceled(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.StrmTaskCanceled).Delete(&model.StrmUploadTask{})
count = res.RowsAffected
return res.Error
})
return count, err
}
// RetryAllFailed 把所有失败任务重置回待处理,清空错误与重试计数。
func (r *StrmUploadTaskRepository) RetryAllFailed(ctx context.Context) (int64, error) {
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
Where("status = ?", model.StrmTaskFailed).
Updates(map[string]any{
"status": model.StrmTaskPending,
"error": "",
"retry_count": 0,
"next_try_at": nil,
"started_at": nil,
"finished_at": nil,
"updated_at": time.Now(),
})
count = res.RowsAffected
return res.Error
})
return count, err
}
// CancelPending 批量取消所有排队中和进行中的任务。
func (r *StrmUploadTaskRepository) CancelPending(ctx context.Context) (int64, error) {
now := time.Now()
var count int64
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
Where("status IN ?", []string{model.StrmTaskPending, model.StrmTaskRunning}).
Updates(map[string]any{
"status": model.StrmTaskCanceled,
"error": "已批量取消",
"finished_at": now,
"updated_at": now,
})
count = res.RowsAffected
return res.Error
})
return count, err
}
// CountActive 统计某同步目录下目标仍在排队/进行的任务数(用于去重)。
@@ -431,8 +759,65 @@ func (r *StrmUploadTaskRepository) CountActive(ctx context.Context, syncPathID,
return count
}
func (r *StrmUploadTaskRepository) DeleteFinishedOlderThan(ctx context.Context, before time.Time) error {
return r.db.WithContext(ctx).Where("status IN ? AND finished_at < ?",
[]string{model.StrmTaskDone, model.StrmTaskFailed, model.StrmTaskCanceled}, before).
Delete(&model.StrmUploadTask{}).Error
// GetActiveLocalPathMap 一次性获取某同步目录下正在排队或执行中的 local_path 集合,供同步时 O(1) 内存去重。
func (r *StrmUploadTaskRepository) GetActiveLocalPathMap(ctx context.Context, syncPathID string) (map[string]bool, error) {
var paths []string
err := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
Where("sync_path_id = ? AND status IN ?", syncPathID, []string{model.StrmTaskPending, model.StrmTaskRunning}).
Pluck("local_path", &paths).Error
if err != nil {
return nil, err
}
out := make(map[string]bool, len(paths))
for _, p := range paths {
out[p] = true
}
return out, nil
}
func (r *StrmUploadTaskRepository) DeleteFinishedOlderThan(ctx context.Context, before time.Time) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Unscoped().Where("status IN ? AND finished_at < ?",
[]string{model.StrmTaskDone, model.StrmTaskFailed, model.StrmTaskCanceled}, before).
Delete(&model.StrmUploadTask{}).Error
})
}
// ─── StrmDirCache ─────────────────────────────────────────────────────────────
// StrmDirCacheRepository persists model.StrmDirCache.
type StrmDirCacheRepository struct{ db *gorm.DB }
func (r *StrmDirCacheRepository) ListBySyncPathID(ctx context.Context, syncPathID string) ([]model.StrmDirCache, error) {
var rows []model.StrmDirCache
err := r.db.WithContext(ctx).Where("sync_path_id = ?", syncPathID).Find(&rows).Error
return rows, err
}
func (r *StrmDirCacheRepository) Set(ctx context.Context, syncPathID, dirID, path string) error {
return withSQLiteBusyRetry(ctx, func() error {
var row model.StrmDirCache
err := r.db.WithContext(ctx).Where("sync_path_id = ? AND dir_id = ?", syncPathID, dirID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
row = model.StrmDirCache{
SyncPathID: syncPathID,
DirID: dirID,
Path: path,
}
return r.db.WithContext(ctx).Create(&row).Error
}
if err != nil {
return err
}
return r.db.WithContext(ctx).Model(&model.StrmDirCache{}).Where("id = ?", row.ID).Updates(map[string]any{
"path": path,
"updated_at": time.Now(),
}).Error
})
}
func (r *StrmDirCacheRepository) DeleteBySyncPathID(ctx context.Context, syncPathID string) error {
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Unscoped().Where("sync_path_id = ?", syncPathID).Delete(&model.StrmDirCache{}).Error
})
}
+11 -20
View File
@@ -133,26 +133,17 @@ func (r *UserRepository) TouchLogin(ctx context.Context, id string) error {
})
}
// Delete removes a user (soft-delete via gorm.DeletedAt), releases the unique
// username, and drops Telegram bindings so future re-created users bind cleanly.
// Delete 物理删除用户并级联清理其关联记录。
func (r *UserRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var user model.User
if err := tx.Where("id = ?", id).First(&user).Error; err != nil {
return err
}
released := user.Username + "__deleted__" + time.Now().Format("20060102150405.000000000")
if len(released) > 64 {
sum := sha256.Sum256([]byte(user.ID + user.Username))
base := user.Username
if len(base) > 43 {
base = base[:43]
}
released = base + "__deleted__" + hex.EncodeToString(sum[:])[:10]
}
if err := tx.Model(&model.User{}).Where("id = ?", id).Update("username", released).Error; err != nil {
return err
}
return tx.Delete(&model.User{}, "id = ?", id).Error
return withSQLiteBusyRetry(ctx, func() error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
_ = tx.Unscoped().Where("user_id = ?", id).Delete(&model.RefreshToken{})
_ = tx.Unscoped().Where("user_id = ?", id).Delete(&model.UserPermission{})
_ = tx.Unscoped().Where("user_id = ?", id).Delete(&model.PlayProfile{})
_ = tx.Unscoped().Where("user_id = ?", id).Delete(&model.PlaybackHistory{})
_ = tx.Unscoped().Where("user_id = ?", id).Delete(&model.Favorite{})
_ = tx.Unscoped().Where("user_id = ?", id).Delete(&model.UserDevice{})
return tx.Unscoped().Delete(&model.User{}, "id = ?", id).Error
})
})
}
+96
View File
@@ -4,8 +4,11 @@
package service
import (
"errors"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
@@ -42,3 +45,96 @@ func walkAndPrune(root string, cutoff time.Time) error {
}
return nil
}
// PruneImageCacheResult holds stats from an image cache prune operation.
type PruneImageCacheResult struct {
TotalFilesBefore int
TotalBytesBefore int64
DeletedFiles int
FreedBytes int64
RemainingBytes int64
}
type imageCacheFileEntry struct {
path string
size int64
modTime time.Time
}
// PruneImageCache scans imagesDir for cached image files. If the total disk usage
// exceeds maxSizeBytes, it removes files starting from the oldest (by ModTime)
// until disk usage falls to or below targetSizeBytes (80% of maxSizeBytes).
//
// In-flight temporary files (*.tmp) are skipped to avoid corrupting concurrent writes.
// Empty subdirectories left behind are best-effort removed.
func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResult, error) {
var result PruneImageCacheResult
if imagesDir == "" || maxSizeBytes <= 0 {
return result, nil
}
if _, err := os.Stat(imagesDir); err != nil {
return result, nil
}
var (
dirs []string
entries []imageCacheFileEntry
)
_ = filepath.Walk(imagesDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
if path != imagesDir {
dirs = append(dirs, path)
}
return nil
}
// Skip temporary files created during image download.
name := info.Name()
if strings.HasSuffix(name, ".tmp") || strings.HasPrefix(name, "img-") && strings.Contains(name, ".tmp") {
return nil
}
size := info.Size()
result.TotalFilesBefore++
result.TotalBytesBefore += size
entries = append(entries, imageCacheFileEntry{
path: path,
size: size,
modTime: info.ModTime(),
})
return nil
})
result.RemainingBytes = result.TotalBytesBefore
if result.TotalBytesBefore <= maxSizeBytes {
return result, nil
}
// High/Low watermark: prune down to 80% of max size to leave headroom
// and prevent disk thrashing on consecutive writes.
targetSizeBytes := maxSizeBytes * 80 / 100
sort.Slice(entries, func(i, j int) bool {
return entries[i].modTime.Before(entries[j].modTime)
})
for _, entry := range entries {
if result.RemainingBytes <= targetSizeBytes {
break
}
if err := os.Remove(entry.path); err == nil || errors.Is(err, os.ErrNotExist) {
result.DeletedFiles++
result.FreedBytes += entry.size
result.RemainingBytes -= entry.size
}
}
// Clean up emptied subdirectories from deepest to shallowest.
for i := len(dirs) - 1; i >= 0; i-- {
_ = os.Remove(dirs[i])
}
return result, nil
}
+162
View File
@@ -0,0 +1,162 @@
package service
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"go.uber.org/zap"
)
func TestPruneImageCache_UnderLimit(t *testing.T) {
dir := t.TempDir()
file1 := filepath.Join(dir, "img1")
file2 := filepath.Join(dir, "img2")
if err := os.WriteFile(file1, make([]byte, 100), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(file2, make([]byte, 200), 0o600); err != nil {
t.Fatal(err)
}
// Max limit is 500 bytes, total is 300 bytes -> no prune
res, err := PruneImageCache(dir, 500)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.DeletedFiles != 0 {
t.Fatalf("expected 0 deleted files, got %d", res.DeletedFiles)
}
if res.TotalFilesBefore != 2 || res.TotalBytesBefore != 300 || res.RemainingBytes != 300 {
t.Fatalf("unexpected stats: %+v", res)
}
}
func TestPruneImageCache_OverLimitLRU(t *testing.T) {
dir := t.TempDir()
now := time.Now()
// Create 4 files of 100 bytes each, with distinct mtime
fOldest := filepath.Join(dir, "oldest")
fMidOld := filepath.Join(dir, "mid_old")
fMidNew := filepath.Join(dir, "mid_new")
fNewest := filepath.Join(dir, "newest")
for _, f := range []string{fOldest, fMidOld, fMidNew, fNewest} {
if err := os.WriteFile(f, make([]byte, 100), 0o600); err != nil {
t.Fatal(err)
}
}
_ = os.Chtimes(fOldest, now.Add(-4*time.Hour), now.Add(-4*time.Hour))
_ = os.Chtimes(fMidOld, now.Add(-3*time.Hour), now.Add(-3*time.Hour))
_ = os.Chtimes(fMidNew, now.Add(-2*time.Hour), now.Add(-2*time.Hour))
_ = os.Chtimes(fNewest, now.Add(-1*time.Hour), now.Add(-1*time.Hour))
// Total = 400 bytes. Max limit = 300 bytes.
// Target = 300 * 80 / 100 = 240 bytes.
// Deleting oldest (100) brings total to 300 (> 240).
// Deleting mid_old (100) brings total to 200 (<= 240).
// Total deleted = 2 files (200 bytes), remaining = 200 bytes.
res, err := PruneImageCache(dir, 300)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.DeletedFiles != 2 {
t.Fatalf("expected 2 deleted files, got %d", res.DeletedFiles)
}
if res.FreedBytes != 200 {
t.Fatalf("expected 200 freed bytes, got %d", res.FreedBytes)
}
if res.RemainingBytes != 200 {
t.Fatalf("expected 200 remaining bytes, got %d", res.RemainingBytes)
}
// Verify oldest and mid_old were deleted, mid_new and newest still exist
if _, err := os.Stat(fOldest); !os.IsNotExist(err) {
t.Fatalf("expected oldest file to be deleted, got err=%v", err)
}
if _, err := os.Stat(fMidOld); !os.IsNotExist(err) {
t.Fatalf("expected mid_old file to be deleted, got err=%v", err)
}
if _, err := os.Stat(fMidNew); err != nil {
t.Fatalf("expected mid_new file to exist, got err=%v", err)
}
if _, err := os.Stat(fNewest); err != nil {
t.Fatalf("expected newest file to exist, got err=%v", err)
}
}
func TestPruneImageCache_SkipsTmpFiles(t *testing.T) {
dir := t.TempDir()
fTmp := filepath.Join(dir, "img-123.tmp")
fImg := filepath.Join(dir, "cached_img")
if err := os.WriteFile(fTmp, make([]byte, 500), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(fImg, make([]byte, 100), 0o600); err != nil {
t.Fatal(err)
}
// Limit is 200 bytes. fTmp (500) is ignored, only fImg (100) is counted <= 200.
res, err := PruneImageCache(dir, 200)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.DeletedFiles != 0 {
t.Fatalf("expected 0 deleted files, got %d", res.DeletedFiles)
}
if _, err := os.Stat(fTmp); err != nil {
t.Fatalf("expected tmp file to remain untouched, got %v", err)
}
}
func TestPruneImageCache_ZeroOrNegativeLimit(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "img")
if err := os.WriteFile(f, make([]byte, 100), 0o600); err != nil {
t.Fatal(err)
}
res, err := PruneImageCache(dir, 0)
if err != nil || res.DeletedFiles != 0 {
t.Fatalf("expected no-op for 0 limit, got %+v, err=%v", res, err)
}
res, err = PruneImageCache(dir, -10)
if err != nil || res.DeletedFiles != 0 {
t.Fatalf("expected no-op for negative limit, got %+v, err=%v", res, err)
}
}
func TestSchedulerJobCleanImageCache(t *testing.T) {
cacheRoot := t.TempDir()
imagesDir := filepath.Join(cacheRoot, "images")
if err := os.MkdirAll(imagesDir, 0o750); err != nil {
t.Fatal(err)
}
f := filepath.Join(imagesDir, "old_poster")
if err := os.WriteFile(f, make([]byte, 2*1024*1024), 0o600); err != nil {
t.Fatal(err)
}
scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, cacheRoot)
// Set limit to 1MB; our file is 2MB -> should be pruned
scheduler.SetImagesMaxSizeMBProvider(func() int {
return 1
})
if err := scheduler.jobCleanImageCache(context.Background()); err != nil {
t.Fatalf("jobCleanImageCache failed: %v", err)
}
if _, err := os.Stat(f); !os.IsNotExist(err) {
t.Fatalf("expected file to be pruned, got err=%v", err)
}
}
+5 -1
View File
@@ -30,6 +30,7 @@ const (
Type115 = "cloud115" // 115 网盘
TypeCloudDrive2 = "clouddrive2" // CloudDrive2 桥接网盘
TypeOpenList = "openlist" // OpenList / AList-compatible bridge
TypeEmbyRemote = "emby_remote" // 远程 Emby 服务器(API 网关挂载)
)
// ErrUnsupported is returned for an unknown provider type.
@@ -41,6 +42,7 @@ type FileEntry struct {
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
MTime int64 `json:"mtime,omitempty"`
// PickCode is 115-specific; other providers use ID directly.
PickCode string `json:"pick_code,omitempty"`
}
@@ -100,6 +102,8 @@ func New(typ string, cfg map[string]any, client *http.Client) (Provider, error)
return newCloudDrive2(cfg, client), nil
case TypeOpenList:
return newOpenList(cfg, client), nil
case TypeEmbyRemote:
return newEmby(cfg, client), nil
default:
return nil, ErrUnsupported
}
@@ -107,7 +111,7 @@ func New(typ string, cfg map[string]any, client *http.Client) (Provider, error)
// IsCloudType reports whether typ is a cloud-disk provider.
func IsCloudType(typ string) bool {
return typ == Type115 || typ == TypeCloudDrive2 || typ == TypeOpenList
return typ == Type115 || typ == TypeCloudDrive2 || typ == TypeOpenList || typ == TypeEmbyRemote
}
// str coerces a config value to a trimmed string.
+246
View File
@@ -0,0 +1,246 @@
// Emby remote provider: exposes a remote Emby server through the same
// Provider interface used by cloud disks, so account CRUD / connectivity
// test / directory browser work unchanged. This is a thin adapter — the
// federated Emby API aggregation (Views / Items / PlaybackInfo / streaming
// proxy) lives in service.EmbyRemoteService and does not go through the
// cloud-disk sync machinery.
package cloud
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// Emby 远程挂载类型(service 层聚合走 EmbyRemoteService,不走 STRM 同步)。
// embyProvider implements Provider against a remote Emby server using an
// api_key (token) for authentication. DirectLink.Resolve returns the remote
// stream URL; whether MMTL reverse-proxies the bytes is decided by the
// emby.proxy_play account config (defaults to off).
type embyProvider struct {
base string // e.g. http://host:8096(自动补 /emby 前缀)
username string
password string
token string // api_key
userID string // 远程用户 Id
proxyPlay bool
client *http.Client
}
type embyUserPayload struct {
Id string `json:"Id"`
}
type embyLoginResponse struct {
AccessToken string `json:"AccessToken"`
User embyUserPayload `json:"User"`
}
type embyPingResponse struct {
ServerName string `json:"ServerName"`
}
// newEmby builds the provider from the account config map.
func newEmby(cfg map[string]any, client *http.Client) Provider {
p := &embyProvider{
base: strings.TrimRight(str(cfg["url"]), "/"),
username: str(cfg["username"]),
password: str(cfg["password"]),
token: firstNonEmpty(str(cfg["api_key"]), str(cfg["token"])),
userID: str(cfg["remote_user_id"]),
proxyPlay: boolish(cfg["proxy_play"]),
client: client,
}
if p.client == nil {
p.client = &http.Client{Transport: &embyUATransport{base: http.DefaultTransport}}
}
return p
}
// embyUATransport 给远程 Emby 请求注入浏览器 UA(防 Cloudflare 风控拦截)。
type embyUATransport struct {
base http.RoundTripper
}
func (t *embyUATransport) RoundTrip(req *http.Request) (*http.Response, error) {
if strings.TrimSpace(req.Header.Get("User-Agent")) == "" {
req.Header.Set("User-Agent", defaultUA)
}
return t.base.RoundTrip(req)
}
// embyBase normalizes the address so requests go to /emby/... endpoints.
func (p *embyProvider) embyBase() string {
base := strings.TrimRight(p.base, "/")
if !strings.Contains(base, "/emby") {
base += "/emby"
}
return base
}
// externalBase 不追加 /emby(内嵌媒体资源 URL 使用 /emby 会更贴近习惯,此处
// 与 embyBase 保持一致:所有端点统一以 /emby 开头)。
func (p *embyProvider) apiBase() string { return p.embyBase() }
func (p *embyProvider) Type() string { return TypeEmbyRemote }
// Ping 验证地址连通性与凭据(/System/Info)。
func (p *embyProvider) Ping(ctx context.Context) error {
if p.base == "" {
return errors.New("缺少 Emby 地址")
}
token, err := p.ensureToken(ctx)
if err != nil {
return err
}
return p.doJSON(ctx, http.MethodGet, "/System/Info", nil, token, &embyPingResponse{})
}
// doJSON 向远程 Emby 发起带 api_key 的请求并解析 JSON 响应。
func (p *embyProvider) doJSON(ctx context.Context, method, path string, body io.Reader, token string, out any) error {
endpoint := p.apiBase() + path
if token != "" {
sep := "?"
if strings.Contains(endpoint, "?") {
sep = "&"
}
endpoint += sep + "api_key=" + url.QueryEscape(token)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
if err != nil {
return err
}
req.Header.Set("X-Emby-Token", token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := p.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
if resp.StatusCode == http.StatusUnauthorized {
return ErrEmbyUnauthorized
}
data, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("emby 请求失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
}
if out == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
// ErrEmbyUnauthorized 表示远程凭据失效(触发重新认证/打回测试)。
var ErrEmbyUnauthorized = errors.New("emby 认证失败或凭据已失效")
// ensureToken 返回可用 api_key:已有则直接用,否则尝试账号密码认证。
func (p *embyProvider) ensureToken(ctx context.Context) (string, error) {
if strings.TrimSpace(p.token) != "" {
return p.token, nil
}
if strings.TrimSpace(p.username) == "" {
return "", errors.New("缺少 Emby 凭据(token 或 用户名/密码)")
}
payload := map[string]string{"Username": p.username, "Pw": p.password}
data, err := json.Marshal(payload)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.apiBase()+"/Users/AuthenticateByName", strings.NewReader(string(data)))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Emby-Authorization", `MediaBrowser Client="MMTL", Device="MMTL-Federated", DeviceId="mmtl-federated", Version="1.0"`)
resp, err := p.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return "", fmt.Errorf("emby 登录失败(%d)", resp.StatusCode)
}
var login embyLoginResponse
if err := json.NewDecoder(resp.Body).Decode(&login); err != nil {
return "", err
}
if strings.TrimSpace(login.AccessToken) == "" {
return "", errors.New("emby 登录成功但未返回 AccessToken")
}
p.token = login.AccessToken
if login.User.Id != "" {
p.userID = login.User.Id
}
return p.token, nil
}
// embyItemSummary 目录浏览所需的最小 Emby 条目字段。
type embyItemSummary struct {
Id string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
IsFolder bool `json:"IsFolder"`
ChildCount int `json:"ChildCount"`
RunTimeTicks int64 `json:"RunTimeTicks"`
}
type embyItemListResponse struct {
Items []embyItemSummary `json:"Items"`
}
// List 把远程媒体库(View)展开为目录树:dirID 为空=媒体库列表;否则返回该
// 目录(Movie/Series/Season/Folder)下的条目。用于账号「浏览目录」调试入口。
func (p *embyProvider) List(ctx context.Context, dirID string) ([]FileEntry, error) {
token, err := p.ensureToken(ctx)
if err != nil {
return nil, err
}
userID := p.userID
if userID == "" {
userID = "0" // 某些 Emby 允许用 0 代表管理员
}
path := "/Users/" + url.PathEscape(userID) + "/Items"
if dirID != "" {
path += "?ParentId=" + url.QueryEscape(dirID)
} else {
path += "?IncludeItemTypes=CollectionFolder"
}
var out embyItemListResponse
if err := p.doJSON(ctx, http.MethodGet, path, nil, token, &out); err != nil {
return nil, err
}
entries := make([]FileEntry, 0, len(out.Items))
for _, it := range out.Items {
size := int64(0)
if it.RunTimeTicks > 0 {
size = it.RunTimeTicks / 10_000_000 // 秒
}
entries = append(entries, FileEntry{
ID: it.Id,
Name: it.Name,
IsDir: it.IsFolder || it.Type != "Movie",
Size: size,
})
}
return entries, nil
}
// Resolve 返回远程 Emby 直链。Proxy=true 时由调用方(StrmService.ProxyDirect)
// 反向代理流量;false 时 302 到直链。默认不代理(播放字节不经过 MMTL)。
func (p *embyProvider) Resolve(ctx context.Context, fileRef string) (*DirectLink, error) {
token, err := p.ensureToken(ctx)
if err != nil {
return nil, err
}
u := p.apiBase() + "/Videos/" + url.PathEscape(fileRef) + "/stream"
u += "?api_key=" + url.QueryEscape(token) + "&Static=true&MediaSourceId=" + url.QueryEscape(fileRef)
return &DirectLink{URL: u, Headers: map[string]string{"X-Emby-Token": token}, Proxy: p.proxyPlay}, nil
}
+136
View File
@@ -0,0 +1,136 @@
package cloud
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fakeEmbyServer 记录请求,按路径返回远程 Emby 风格响应。
func fakeEmbyServer(t *testing.T) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/emby/Users/AuthenticateByName":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"AccessToken":"remote-token","User":{"Id":"user-9"}}`))
case r.URL.Path == "/emby/System/Info":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ServerName":"RemoteEmby"}`))
case r.URL.Path == "/emby/Users/user-9/Items" && r.URL.Query().Get("ParentId") == "":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"Items":[{"Id":"view-1","Name":"Movies","Type":"CollectionFolder","IsFolder":true}]}`))
case r.URL.Path == "/emby/Users/user-9/Items":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"Items":[{"Id":"movie-1","Name":"Avatar","Type":"Movie","IsFolder":false}]}`))
case strings.Contains(r.URL.Path, "/emby/Videos/movie-1/stream"):
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write([]byte("fake-video-bytes"))
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
}
func TestEmbyProviderPingAuthenticatesAndGetsToken(t *testing.T) {
srv := fakeEmbyServer(t)
defer srv.Close()
p, err := New(TypeEmbyRemote, map[string]any{
"url": srv.URL,
"username": "alice",
"password": "secret",
}, srv.Client())
if err != nil {
t.Fatal(err)
}
if err := p.Ping(context.Background()); err != nil {
t.Fatalf("ping: %v", err)
}
// 认证成功后 token 被记住,第二次 Ping 不应再走登录。
if err := p.Ping(context.Background()); err != nil {
t.Fatalf("ping 2: %v", err)
}
}
func TestEmbyProviderListViewsAndChildren(t *testing.T) {
srv := fakeEmbyServer(t)
defer srv.Close()
p, err := New(TypeEmbyRemote, map[string]any{
"url": srv.URL,
"api_key": "fixed-token",
"remote_user_id": "user-9",
}, srv.Client())
if err != nil {
t.Fatal(err)
}
root, err := p.List(context.Background(), "")
if err != nil {
t.Fatalf("list root: %v", err)
}
if len(root) != 1 || root[0].Name != "Movies" || !root[0].IsDir {
t.Fatalf("root listing = %+v", root)
}
children, err := p.List(context.Background(), "view-1")
if err != nil {
t.Fatalf("list children: %v", err)
}
if len(children) != 1 || children[0].Name != "Avatar" || children[0].ID != "movie-1" {
t.Fatalf("children = %+v", children)
}
}
func TestEmbyProviderResolveDirectURLByDefault(t *testing.T) {
srv := fakeEmbyServer(t)
defer srv.Close()
p, err := New(TypeEmbyRemote, map[string]any{
"url": srv.URL,
"api_key": "fixed-token",
"remote_user_id": "user-9",
}, srv.Client())
if err != nil {
t.Fatal(err)
}
link, err := p.Resolve(context.Background(), "movie-1")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if !strings.Contains(link.URL, "/emby/Videos/movie-1/stream") {
t.Fatalf("url = %q", link.URL)
}
if !strings.Contains(link.URL, "api_key=fixed-token") {
t.Fatalf("url missing api_key: %q", link.URL)
}
// 默认不代理播放流量。
if link.Proxy {
t.Fatal("emby remote must not proxy by default")
}
}
func TestEmbyProviderResolveProxyWhenConfigured(t *testing.T) {
srv := fakeEmbyServer(t)
defer srv.Close()
p, err := New(TypeEmbyRemote, map[string]any{
"url": srv.URL,
"api_key": "fixed-token",
"remote_user_id": "user-9",
"proxy_play": "true",
}, srv.Client())
if err != nil {
t.Fatal(err)
}
link, err := p.Resolve(context.Background(), "movie-1")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if !link.Proxy {
t.Fatal("proxy_play=true must mark link as proxied")
}
if link.URL == "" {
t.Fatal("proxy link must still carry the remote URL")
}
}
+43
View File
@@ -17,11 +17,20 @@ package cloud
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/ShukeBta/MMTL/internal/service/cloud115"
)
// OpenAPI115Provider 暴露 115 开放平台驱动接口。
type OpenAPI115Provider interface {
Provider
OpenClient() *cloud115.OpenClient
}
// openAPI115Provider 实现 Provider 接口:List 列目录、Resolve 用 pickcode
// 换下载直链(302 offload,无需代理)、Ping 探测根目录。
type openAPI115Provider struct {
@@ -61,6 +70,7 @@ func (p *openAPI115Provider) List(ctx context.Context, dirID string) ([]FileEntr
Name: f.FileName,
IsDir: f.Category == cloud115.TypeDir,
Size: f.FileSize,
MTime: f.Utime,
PickCode: f.PickCode,
})
}
@@ -98,6 +108,39 @@ func (p *openAPI115Provider) ResolveWithUA(ctx context.Context, fileRef, ua stri
// OpenClient 暴露底层客户端(token 刷新用)。
func (p *openAPI115Provider) OpenClient() *cloud115.OpenClient { return p.c }
// PutFileNamed 把本地元数据上传到 115 指定父目录(parentCID 为父目录 cid)。
// io.Reader 无法携带文件名,因此走独立的 named 上传接口。将内容落为临时文件后
// 重命名为目标文件名,再交给 115 上传(/open/upload/init 的 file_name 取真实文件名)。
func (p *openAPI115Provider) PutFileNamed(ctx context.Context, parentCID, fileName string, r io.Reader) error {
tmp, err := os.CreateTemp("", "mmtl-upload-*")
if err != nil {
return fmt.Errorf("115: 创建临时文件失败:%w", err)
}
tmpPath := tmp.Name()
defer func() {
_ = tmp.Close()
_ = os.Remove(tmpPath)
}()
if _, err := io.Copy(tmp, r); err != nil {
return fmt.Errorf("115: 写入临时文件失败:%w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("115: 关闭临时文件失败:%w", err)
}
// 重命名为目标文件名,保证上传到 115 后保留原始文件名
if fileName != "" && fileName != filepath.Base(tmpPath) {
namedPath := filepath.Join(filepath.Dir(tmpPath), fileName)
if err := os.Rename(tmpPath, namedPath); err == nil {
tmpPath = namedPath
}
}
_, err = p.c.Upload(ctx, tmpPath, parentCID, "", "")
if err != nil {
return err
}
return nil
}
// RefreshToken 刷新访问令牌并返回新令牌;refresh_token 失效时返回
// cloud115.IsRefreshTokenDead(err) 为 true 的错误。
func (p *openAPI115Provider) RefreshToken(refreshToken string) (*cloud115.TokenData, error) {
+34 -9
View File
@@ -90,6 +90,7 @@ type RespBase struct {
Errno int `json:"errno"`
Message string `json:"message"`
Error string `json:"error"`
Count int64 `json:"count"`
Data json.RawMessage `json:"data"`
Raw json.RawMessage `json:"-"` // 原始响应体(外层附加字段用)
}
@@ -184,7 +185,7 @@ func (c *OpenClient) doJSON(ctx context.Context, method, rawURL string, form map
// refresh_token 刷新后重试一次。刷新失败或重试后仍失败才返回,
// 避免长时间同步因 token 过期而整体失败。
if isTokenCode(base.Code) {
if access && c.tryRefreshTokenLocked() {
if access && c.tryRefreshTokenLocked(ctx) {
continue
}
if access {
@@ -258,19 +259,43 @@ func (c *OpenClient) doAuthJSONWithUA(ctx context.Context, method, rawURL string
}
// tryRefreshTokenLocked 并发安全地刷新 access_token;成功返回 true(调用方
// 应使用内存中的新 token 重试原请求)。refresh_token 已失效时也会清空内存 token。
func (c *OpenClient) tryRefreshTokenLocked() bool {
// 应使用内存中的新 token 重试原请求)。
//
// 对"refresh_token 本身已失效/被吊销"(IsRefreshTokenDead,如 40140114/116/119/120)
// 这类不可恢复的错误直接放弃并清空内存 token(提示需重新授权)。
// 对其它失败(网络瞬时抖动、刷新接口可重试错误码等)做指数退避重试几次再放弃,
// 避免同步长任务中途 token 到期时恰好撞上一个短暂的刷新失败就整体失败。
func (c *OpenClient) tryRefreshTokenLocked(ctx context.Context) bool {
c.tokenMu.Lock()
defer c.tokenMu.Unlock()
token, err := c.RefreshToken(c.RefreshTokenStr)
if err != nil {
for attempt := 0; attempt < refreshAttempts; attempt++ {
token, err := c.RefreshToken(c.RefreshTokenStr)
if err == nil {
c.SetAuthToken(token.AccessToken, token.RefreshToken)
return true
}
if IsRefreshTokenDead(err) {
c.SetAuthToken("", "")
return false
}
// 可恢复失败:退避后重试。ctx 取消时立即放弃。
if attempt < refreshAttempts-1 {
select {
case <-ctx.Done():
return false
case <-time.After(refreshBackoff(attempt)):
}
}
return false
}
c.SetAuthToken(token.AccessToken, token.RefreshToken)
return true
return false
}
// refreshAttempts 是刷新 access_token 失败时的最大尝试次数(含首次)。
const refreshAttempts = 3
// refreshBackoff 返回第 attempt 次(从 0 计)刷新失败后的退避时长(指数退避)。
func refreshBackoff(attempt int) time.Duration {
return time.Duration(200*(1<<attempt)) * time.Millisecond // 200ms, 400ms
}
// IsThrottleCode 判断是否为限流错误码。
@@ -280,7 +305,7 @@ func IsThrottleCode(code int) bool {
func isTokenCode(code int) bool {
switch code {
case AccessTokenAuthFail, AccessAuthInvalid, AccessTokenExpiryCode, RefreshTokenInvalid:
case AccessTokenAuthFail, AccessAuthInvalid, AccessTokenExpiryCode, AccessTokenFormatInvalid, RefreshTokenInvalid:
return true
}
return false
@@ -375,3 +375,100 @@ func TestThrottleCodeHandling(t *testing.T) {
t.Fatal("code 770004 should trigger throttle status")
}
}
func TestRemoteFileDetailRelativePath(t *testing.T) {
rootCID := "3238787832374488117" // 影视库
// 场景 1:115 目录 paths 中只有祖先目录链,不包含自身
d1 := &RemoteFileDetail{
FileId: "3251154147730910635",
FileName: "出包王女",
Paths: []struct {
FileId string `json:"file_id"`
Name string `json:"file_name"`
}{
{FileId: "0", Name: "根目录"},
{FileId: "3238787832374488117", Name: "影视库"},
{FileId: "3238787913223892116", Name: "动漫"},
},
}
if got := d1.RelativePath(rootCID); got != "动漫/出包王女" {
t.Errorf("d1.RelativePath = %q, want %q", got, "动漫/出包王女")
}
// 场景 2:祖先中间目录,自身在 paths 末尾
d2 := &RemoteFileDetail{
FileId: "3238787913223892116",
FileName: "动漫",
Paths: []struct {
FileId string `json:"file_id"`
Name string `json:"file_name"`
}{
{FileId: "0", Name: "根目录"},
{FileId: "3238787832374488117", Name: "影视库"},
{FileId: "3238787913223892116", Name: "动漫"},
},
}
if got := d2.RelativePath(rootCID); got != "动漫" {
t.Errorf("d2.RelativePath = %q, want %q", got, "动漫")
}
// 场景 3:根同步目录自身
d3 := &RemoteFileDetail{
FileId: rootCID,
FileName: "影视库",
Paths: []struct {
FileId string `json:"file_id"`
Name string `json:"file_name"`
}{
{FileId: "0", Name: "根目录"},
{FileId: rootCID, Name: "影视库"},
},
}
if got := d3.RelativePath(rootCID); got != "" {
t.Errorf("d3.RelativePath = %q, want %q", got, "")
}
}
// TestFsListRefreshContinue 验证 access_token 在请求中途过期(40140126)时:
// 自动用 refresh_token 刷新得到新 token,然后对原请求重试成功(同步得以继续)。
func TestFsListRefreshContinue(t *testing.T) {
var filesCalls int
var refreshCalls int
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/open/refreshToken":
refreshCalls++
w.Write([]byte(`{"state":true,"data":{"access_token":"at2","refresh_token":"rt2","expires_in":7200}}`))
case "/open/ufile/files":
filesCalls++
switch filesCalls {
case 1:
// 第一次用旧 access_token,返回过期错误,应触发刷新
w.Write([]byte(`{"state":false,"code":40140126,"message":"access_token 校验失败"}`))
default:
// 刷新后续请求应使用新 access_token
if got := r.Header.Get("Authorization"); got != "Bearer at2" {
t.Errorf("retried request auth = %q, want Bearer at2", got)
}
w.Write([]byte(`{"state":true,"path":[],"data":[{"fid":"200","fc":"1","fn":"a.mkv","fs":123,"pc":"pickA"}]}`))
}
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
})
c := NewOpenClient("100195125", "at1", "rt1")
files, _, err := c.GetFsList(context.Background(), "0", 0, 100)
if err != nil {
t.Fatalf("expected sync to continue after refresh, got error: %v", err)
}
if filesCalls != 2 {
t.Fatalf("want 2 files calls (original + retried), got %d", filesCalls)
}
if refreshCalls == 0 {
t.Fatal("expected refresh_token to be used once")
}
if len(files) != 1 {
t.Fatalf("want 1 file, got %d", len(files))
}
}
+8 -7
View File
@@ -21,13 +21,14 @@ var (
const (
// 业务错误码
AccessTokenAuthFail = 40140126 // 访问过期,需刷新
AccessTokenExpiryCode = 40140125 // 访问过期,需刷新
AccessAuthInvalid = 40140124 // 访问无效,需刷新
RefreshTokenInvalid = 40140116 // 需重新授权
TokenRefreshFail = 40140121 // 刷新失败,可重试
RequestMaxLimitCode = 770004 // 访问频率过高
RequestRateLimitCode = 406 // 达到访问上限
AccessTokenAuthFail = 40140126 // 访问过期,需刷新
AccessTokenExpiryCode = 40140125 // 访问过期,需刷新
AccessAuthInvalid = 40140124 // 访问无效,需刷新
AccessTokenFormatInvalid = 40140123 // access_token 格式错误,需刷新
RefreshTokenInvalid = 40140116 // 需重新授权
TokenRefreshFail = 40140121 // 刷新失败,可重试
RequestMaxLimitCode = 770004 // 访问频率过高
RequestRateLimitCode = 406 // 达到访问上限
// 刷新 token 的错误码
RefreshTokenFormatInvalid = 40140114
+72
View File
@@ -89,6 +89,36 @@ func (c *OpenClient) GetFsList(ctx context.Context, cid string, offset, limit in
return files, strings.Join(pathStr, "/"), nil
}
// GetFsListFlat 递归扁平化列出 cid 下的所有文件(跨越所有子目录,不包含文件夹节点),并返回文件列表与该树下的总文件数。
// 类似于 QMediaSync 的 115 扁平化批量拉取机制,极大地降低多层级子目录下的 API 请求次数。
func (c *OpenClient) GetFsListFlat(ctx context.Context, cid string, offset, limit int) ([]RemoteFile, int64, error) {
if cid == "" {
cid = "0"
}
if limit <= 0 {
limit = 1150
}
params := map[string]string{
"cid": cid,
"limit": fmt.Sprint(limit),
"offset": fmt.Sprint(offset),
"cur": "0",
"show_dir": "0",
}
resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+"/open/ufile/files", params, 2)
if err != nil {
return nil, 0, err
}
if !resp.State {
return nil, 0, NewOpenAPIResponseError(resp.Code, resp.Errno, resp.Message, resp.Error, "115 接口调用失败")
}
files, err := openList[RemoteFile](resp.Data)
if err != nil {
return nil, 0, fmt.Errorf("115: 解析文件列表失败:%w", err)
}
return files, resp.Count, nil
}
// GetFsDetailByCid 查询文件(夹)详情。
func (c *OpenClient) GetFsDetailByCid(ctx context.Context, fileId string) (*RemoteFileDetail, error) {
params := map[string]string{"file_id": fileId}
@@ -113,6 +143,48 @@ type RemoteFileDetail struct {
} `json:"paths"`
}
// RelativePath 计算该目录相对于根同步目录(rootCID)的相对路径。
func (d *RemoteFileDetail) RelativePath(rootCID string) string {
if d == nil {
return ""
}
if rootCID == "" {
rootCID = "0"
}
if d.FileId == rootCID {
return ""
}
rootIdx := -1
for i, p := range d.Paths {
if p.FileId == rootCID {
rootIdx = i
break
}
}
var segments []string
start := 0
if rootIdx >= 0 {
start = rootIdx + 1
} else if len(d.Paths) > 0 && (d.Paths[0].FileId == "0" || d.Paths[0].FileId == "") {
start = 1
}
hasSelf := false
for i := start; i < len(d.Paths); i++ {
if d.Paths[i].FileId == d.FileId {
hasSelf = true
}
name := strings.TrimSpace(d.Paths[i].Name)
if name != "" {
segments = append(segments, name)
}
}
// 若 115 返回的 paths 祖先链未包含当前目录自身,则将其自身目录名 FileName 补在末尾
if !hasSelf && strings.TrimSpace(d.FileName) != "" && d.FileId != rootCID {
segments = append(segments, strings.TrimSpace(d.FileName))
}
return strings.Join(segments, "/")
}
// ─── 下载直链 ──────────────────────────────────────────────────────────────────
type downloadURLData struct {
+367
View File
@@ -0,0 +1,367 @@
// 阿里云 OSS multipart 分片上传(用于 115 元数据上传直传)。
// 使用 115 下发的临时 STS 凭证,将本地文件分片上传到 OSS,并经 complete 回调
// 通知 115 完成落盘。参考 QMediaSync 的 OSSMultipartUploader 实现。
package cloud115
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"sort"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
)
const (
defaultMultipartPartSize int64 = 32 * 1024 * 1024
multipartPartAlign int64 = 1024 * 1024
maxMultipartParts int64 = 9999
maxMultipartPartSize int64 = 5 * 1024 * 1024 * 1024
)
type ossMultipartClient interface {
InitiateMultipartUpload(context.Context, *oss.InitiateMultipartUploadRequest, ...func(*oss.Options)) (*oss.InitiateMultipartUploadResult, error)
UploadPart(context.Context, *oss.UploadPartRequest, ...func(*oss.Options)) (*oss.UploadPartResult, error)
ListParts(context.Context, *oss.ListPartsRequest, ...func(*oss.Options)) (*oss.ListPartsResult, error)
CompleteMultipartUpload(context.Context, *oss.CompleteMultipartUploadRequest, ...func(*oss.Options)) (*oss.CompleteMultipartUploadResult, error)
AbortMultipartUpload(context.Context, *oss.AbortMultipartUploadRequest, ...func(*oss.Options)) (*oss.AbortMultipartUploadResult, error)
}
// OSSMultipartUploader 封装 OSS multipart 上传。
type OSSMultipartUploader struct {
client ossMultipartClient
}
// OSSMultipartUploadInput 是 multipart 上传输入。
type OSSMultipartUploadInput struct {
Bucket string
Object string
Callback string
CallbackVar string
FilePath string
FileSize int64
UploadId string
PartSize int64
PartRetryMax int
refreshClient func(context.Context) (ossMultipartClient, error)
}
// OSSMultipartUploadResult 是 multipart 上传后的结果。
type OSSMultipartUploadResult struct {
CallbackResult map[string]any
UploadId string
PartSize int64
TotalParts int
UploadedBytes int64
UploadedParts int
}
// CalculateMultipartPartSize 计算 OSS multipart 分片大小与分片数量。
func CalculateMultipartPartSize(fileSize int64) (int64, int, error) {
if fileSize < 0 {
return 0, 0, fmt.Errorf("文件大小不能为负数:%d", fileSize)
}
partSize := defaultMultipartPartSize
minPartSize := ceilDiv(fileSize, maxMultipartParts)
if minPartSize > partSize {
partSize = roundUp(minPartSize, multipartPartAlign)
}
if partSize > maxMultipartPartSize {
return 0, 0, fmt.Errorf("文件过大,所需分片大小 %d 超过 OSS 上限 %d", partSize, maxMultipartPartSize)
}
totalParts := int(ceilDiv(fileSize, partSize))
if totalParts == 0 {
totalParts = 1
}
if int64(totalParts) > maxMultipartParts {
return 0, 0, fmt.Errorf("分片数量 %d 超过上限 %d", totalParts, maxMultipartParts)
}
return partSize, totalParts, nil
}
// NewOSSMultipartUploader 创建 OSS multipart 上传器。
func NewOSSMultipartUploader(endpoint, accessKeyId, accessKeySecret, securityToken string) *OSSMultipartUploader {
return &OSSMultipartUploader{client: newOSSMultipartClient(endpoint, accessKeyId, accessKeySecret, securityToken)}
}
func newOSSMultipartClient(endpoint, accessKeyId, accessKeySecret, securityToken string) ossMultipartClient {
cfg := oss.LoadDefaultConfig().
WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKeyId, accessKeySecret, securityToken)).
WithRegion("cn-shenzhen").
WithEndpoint(endpoint)
return oss.NewClient(cfg)
}
// UploadFile 上传文件并完成 OSS multipart,返回 complete callback 结果。
func (u *OSSMultipartUploader) UploadFile(ctx context.Context, input OSSMultipartUploadInput) (map[string]any, error) {
result, err := u.UploadFileWithResult(ctx, input)
if err != nil {
return nil, err
}
return result.CallbackResult, nil
}
// UploadFileWithResult 上传文件并返回 multipart 结果。
func (u *OSSMultipartUploader) UploadFileWithResult(ctx context.Context, input OSSMultipartUploadInput) (OSSMultipartUploadResult, error) {
if input.PartRetryMax <= 0 {
input.PartRetryMax = 3
}
partSize := input.PartSize
totalParts := 0
var err error
if partSize <= 0 {
partSize, totalParts, err = CalculateMultipartPartSize(input.FileSize)
if err != nil {
return OSSMultipartUploadResult{}, err
}
} else {
totalParts = int(ceilDiv(input.FileSize, partSize))
}
uploadId := input.UploadId
if uploadId == "" {
initResult, err := u.client.InitiateMultipartUpload(ctx, &oss.InitiateMultipartUploadRequest{
Bucket: oss.Ptr(input.Bucket),
Key: oss.Ptr(input.Object),
RequestCommon: oss.RequestCommon{
Parameters: map[string]string{"sequential": "1"},
},
})
if err != nil {
return OSSMultipartUploadResult{}, fmt.Errorf("初始化 OSS multipart 失败:%w", err)
}
if initResult.UploadId == nil || *initResult.UploadId == "" {
return OSSMultipartUploadResult{}, fmt.Errorf("初始化 OSS multipart 返回空 upload_id")
}
uploadId = *initResult.UploadId
}
existingPartMap := make(map[int32]int64)
existingParts, err := u.ListUploadedParts(ctx, input.Bucket, input.Object, uploadId)
if err == nil {
for _, part := range existingParts {
existingPartMap[part.PartNumber] = part.Size
}
}
file, err := os.Open(input.FilePath)
if err != nil {
return OSSMultipartUploadResult{}, fmt.Errorf("打开待上传文件失败:%w", err)
}
defer file.Close()
var uploadedBytes int64
uploadedParts := 0
completeParts := make([]oss.UploadPart, 0, totalParts)
for partNumber := 1; partNumber <= totalParts; partNumber++ {
offset := int64(partNumber-1) * partSize
length := minInt64(partSize, input.FileSize-offset)
if length < 0 {
length = 0
}
if existingSize, ok := existingPartMap[int32(partNumber)]; ok && existingSize == length {
uploadedBytes += length
uploadedParts++
}
etag, err := u.uploadPartWithRetry(ctx, input, uploadId, int32(partNumber), file, offset, length)
if err != nil {
return OSSMultipartUploadResult{}, err
}
uploadedBytes += length
uploadedParts++
completeParts = append(completeParts, oss.UploadPart{
PartNumber: int32(partNumber),
ETag: oss.Ptr(etag),
})
}
sort.Slice(completeParts, func(i, j int) bool {
return completeParts[i].PartNumber < completeParts[j].PartNumber
})
// 115 下发的 callback / callback_var 是 JSON 字符串,而 OSS CompleteMultipartUpload
// 要求 callback 参数为 Base64 编码后的 JSON,否则报 "The callback configuration is
// not base64 encoded"。这里把两者转为 Base64 后再提交(参考 QMediaSync 的
// BuildOSSCallbackHeaders)。
cb := input.Callback
cbVar := input.CallbackVar
if cb == "" {
return OSSMultipartUploadResult{}, errors.New("OSS callback 为空")
}
if !json.Valid([]byte(cb)) {
return OSSMultipartUploadResult{}, errors.New("解析 callback 失败:不是合法 JSON")
}
if cbVar == "" {
cbVar = "{}"
}
if !json.Valid([]byte(cbVar)) {
return OSSMultipartUploadResult{}, errors.New("解析 callback_var 失败:不是合法 JSON")
}
completeResult, err := u.client.CompleteMultipartUpload(ctx, &oss.CompleteMultipartUploadRequest{
Bucket: oss.Ptr(input.Bucket),
Key: oss.Ptr(input.Object),
UploadId: oss.Ptr(uploadId),
CompleteMultipartUpload: &oss.CompleteMultipartUpload{
Parts: completeParts,
},
Callback: oss.Ptr(base64.StdEncoding.EncodeToString([]byte(cb))),
CallbackVar: oss.Ptr(base64.StdEncoding.EncodeToString([]byte(cbVar))),
})
if err != nil {
return OSSMultipartUploadResult{}, fmt.Errorf("完成 OSS multipart 失败:%w", err)
}
return OSSMultipartUploadResult{
CallbackResult: completeResult.CallbackResult,
UploadId: uploadId,
PartSize: partSize,
TotalParts: totalParts,
UploadedBytes: uploadedBytes,
UploadedParts: uploadedParts,
}, nil
}
// ListUploadedParts 查询 OSS 已上传分片。
func (u *OSSMultipartUploader) ListUploadedParts(ctx context.Context, bucket, object, uploadId string) ([]struct {
PartNumber int32
Size int64
}, error) {
parts := []struct {
PartNumber int32
Size int64
}{}
result, err := u.client.ListParts(ctx, &oss.ListPartsRequest{
Bucket: oss.Ptr(bucket),
Key: oss.Ptr(object),
UploadId: oss.Ptr(uploadId),
MaxParts: 1000,
})
if err != nil {
return nil, fmt.Errorf("查询 OSS 已上传分片失败:%w", err)
}
for _, part := range result.Parts {
parts = append(parts, struct {
PartNumber int32
Size int64
}{PartNumber: part.PartNumber, Size: part.Size})
}
return parts, nil
}
func (u *OSSMultipartUploader) uploadPartWithRetry(
ctx context.Context,
input OSSMultipartUploadInput,
uploadId string,
partNumber int32,
file *os.File,
offset, length int64,
) (string, error) {
var lastErr error
for attempt := 0; attempt < input.PartRetryMax; attempt++ {
reader := io.NewSectionReader(file, offset, length)
result, err := u.client.UploadPart(ctx, &oss.UploadPartRequest{
Bucket: oss.Ptr(input.Bucket),
Key: oss.Ptr(input.Object),
PartNumber: partNumber,
UploadId: oss.Ptr(uploadId),
Body: reader,
ContentLength: oss.Ptr(length),
})
if err == nil {
if result.ETag == nil || *result.ETag == "" {
return "", fmt.Errorf("OSS part %d 返回空 ETag", partNumber)
}
return *result.ETag, nil
}
lastErr = err
if attempt < input.PartRetryMax-1 && input.refreshClient != nil {
refreshed, refreshErr := input.refreshClient(ctx)
if refreshErr != nil {
lastErr = refreshErr
continue
}
u.client = refreshed
}
}
return "", fmt.Errorf("上传 OSS part %d 失败:%w", partNumber, lastErr)
}
// ParseCompleteCallbackResult 校验并解析 OSS complete 后的 115 callback 结果。
func ParseCompleteCallbackResult(result map[string]any) (UploadCompleteResult, error) {
if result == nil {
return UploadCompleteResult{}, errors.New("OSS complete callback 结果为空")
}
if state, ok := result["state"].(bool); ok && !state {
return UploadCompleteResult{}, fmt.Errorf("115 callback 返回失败:%s", anyToString(result["message"]))
}
if message := anyToString(result["message"]); message != "" {
return UploadCompleteResult{}, fmt.Errorf("115 callback 返回错误:%s", message)
}
data, ok := result["data"].(map[string]any)
if !ok {
return UploadCompleteResult{}, errors.New("115 callback 缺少 data")
}
complete := UploadCompleteResult{
FileId: anyToString(data["file_id"]),
PickCode: anyToString(data["pick_code"]),
ParentId: anyToString(data["parent_id"]),
Sha1: anyToString(data["sha1"]),
Size: anyToInt64(data["size"]),
Mtime: anyToInt64(data["mtime"]),
}
if complete.FileId == "" || complete.PickCode == "" {
return UploadCompleteResult{}, errors.New("115 callback 缺少 file_id/pick_code")
}
return complete, nil
}
func ceilDiv(n, d int64) int64 {
if d <= 0 {
return 0
}
if n <= 0 {
return 0
}
return (n + d - 1) / d
}
func roundUp(n, align int64) int64 {
if align <= 0 {
return n
}
return ceilDiv(n, align) * align
}
func minInt64(a, b int64) int64 {
if a < b {
return a
}
return b
}
func anyToInt64(v any) int64 {
switch t := v.(type) {
case string:
var n int64
fmt.Sscanf(t, "%d", &n)
return n
case float64:
return int64(t)
case int64:
return t
case int:
return int64(t)
default:
return 0
}
}
func anyToString(v any) string {
if s, ok := v.(string); ok {
return s
}
return fmt.Sprintf("%v", v)
}
+7 -2
View File
@@ -26,10 +26,15 @@ var (
executorOnce sync.Once
)
// GetGlobalExecutor 获取全局队列执行器单例(默认 QPS=2, QPM=120, QPH=6000,保障 115 API 调用安全不超频)。
// GetGlobalExecutor 获取全局队列执行器单例(默认 QPS=3, QPM=200, QPH=12000,保障 115 API 调用安全不超频)。
//
// 历史教训:QPS 提到 8 后,下载换直链接口(/open/ufile/downurl,WAF 重点盯防对象)
// 瞬时突发撞上 115 风控,返回阿里云 405 阻断页(HTTP 405),导致全量同步失败。
// 因此回调到 3——这是经过实测的安全上限:宁慢勿触发风控,一旦 405 冷却 180 秒,
// 整体吞吐反而更低。下载实际走 CDN 不受此限速影响,瓶颈仅在换链环节。
func GetGlobalExecutor() *QueueExecutor {
executorOnce.Do(func() {
globalExecutor = NewQueueExecutor(2, 120, 6000)
globalExecutor = NewQueueExecutor(3, 200, 12000)
})
return globalExecutor
}
+49
View File
@@ -0,0 +1,49 @@
package cloud115
import (
"crypto/sha1"
"encoding/hex"
"io"
"os"
)
// FileSHA1 计算文件完整 SHA1(小写 hex)。
func FileSHA1(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha1.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// FileSHA1Partial 计算文件 [start,end](含)字节区间的 SHA1(小写 hex)。
// 用于 115 上传二次签名按 sign_check 指定的区间重算哈希。
func FileSHA1Partial(path string, start, end int64) (string, error) {
if start < 0 {
start = 0
}
if end < start {
end = start
}
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
if _, err := f.Seek(start, io.SeekStart); err != nil {
return "", err
}
length := end - start + 1
h := sha1.New()
// io.CopyN 在文件不足 length 字节时会返回 io.EOF,导致小文件(如小于 128 KiB 的
// 元数据图片)无法上传。这里只拷贝实际读到的字节,文件尾对齐到区间终点即可。
if _, err := io.CopyN(h, f, length); err != nil && err != io.EOF {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
+355
View File
@@ -0,0 +1,355 @@
// 115 网盘元数据上传能力:115 开放平台调度 + 阿里云 OSS 直传。
// 参考 QMediaSync 的上传流程实现:
//
// POST /open/upload/init 上传初始化/秒传调度(含二次签名)
// GET /open/upload/get_token 获取 OSS 临时上传凭证(STS)
// OSS multipart 分片直传 + callback 完成
//
// 上传目标父目录为 115 目录 ID(cid),而非路径字符串。
package cloud115
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"strings"
)
// 115 上传状态码。
const (
UploadInitStatusNeedUpload = 1 // 需要真实上传
UploadInitStatusRapidUploaded = 2 // 秒传成功
UploadInitStatusSignFailed = 6 // 签名验证失败
UploadInitStatusNeedSign = 7 // 需要二次签名
UploadInitStatusSignRejected = 8 // 签名认证失败
)
// UploadInitRequest 是 /open/upload/init 的结构化请求。
type UploadInitRequest struct {
FileName string
FileSize int64
ParentFileId string
FileSha1 string
Preid string
PickCode string
TopUpload string
SignKey string
SignVal string
}
// UploadInitResult 是 /open/upload/init 的调度结果。
type UploadInitResult struct {
PickCode string
Status int
FileId string
Target string
Bucket string
Object string
SignKey string
SignCheck string
Callback UploadResultCallBack
}
type uploadScheduleAPIResult struct {
PickCode string `json:"pick_code"`
Status int `json:"status"`
FileId string `json:"file_id"`
Target string `json:"target"`
Version string `json:"version"`
Bucket string `json:"bucket"`
Object string `json:"object"`
SignKey string `json:"sign_key"`
SignCheck string `json:"sign_check"`
Callback json.RawMessage `json:"callback"`
}
// UploadResultCallBack 是 init 返回给 OSS complete 使用的 callback 内容。
type UploadResultCallBack struct {
Callback string `json:"callback"`
CallbackVar string `json:"callback_var"`
}
// UploadToken 是 /open/upload/get_token 返回的 OSS STS 临时凭证。
type UploadToken struct {
Endpoint string `json:"endpoint"`
AccessKeySecret string `json:"AccessKeySecret"`
AccessKeySecrett string `json:"AccessKeySecrett"`
SecurityToken string `json:"SecurityToken"`
Expiration string `json:"Expiration"`
AccessKeyId string `json:"AccessKeyId"`
}
func (token *UploadToken) normalize() {
if token == nil {
return
}
if token.AccessKeySecret == "" {
token.AccessKeySecret = token.AccessKeySecrett
}
}
// UploadCompleteResult 是 OSS complete callback 成功后的远端文件定位结果。
type UploadCompleteResult struct {
FileId string
PickCode string
ParentId string
Sha1 string
Size int64
Mtime int64
}
// SignCheckRange 是 115 二次认证要求的闭区间 [start,end]。
type SignCheckRange struct {
Start int64
End int64
}
// UploadInit 调用 115 上传初始化/秒传调度接口。
func (c *OpenClient) UploadInit(ctx context.Context, input UploadInitRequest) (*UploadInitResult, error) {
params := buildUploadInitForm(input)
resp, err := c.doAuthJSON(ctx, "POST", ProAPIBase+"/open/upload/init", params, 2)
if err != nil {
return nil, err
}
var raw uploadScheduleAPIResult
if err := json.Unmarshal(resp.Data, &raw); err != nil {
return nil, fmt.Errorf("115: 解析 upload/init 结果失败:%w", err)
}
callback, err := decodeUploadCallback(raw.Callback)
if err != nil {
return nil, err
}
return &UploadInitResult{
PickCode: raw.PickCode,
Status: raw.Status,
FileId: raw.FileId,
Target: raw.Target,
Bucket: raw.Bucket,
Object: raw.Object,
SignKey: raw.SignKey,
SignCheck: raw.SignCheck,
Callback: callback,
}, nil
}
func buildUploadInitForm(input UploadInitRequest) map[string]string {
topUpload := input.TopUpload
if topUpload == "" {
topUpload = "0"
}
params := map[string]string{
"file_name": input.FileName,
"file_size": strconv.FormatInt(input.FileSize, 10),
"target": fmt.Sprintf("U_1_%s", input.ParentFileId),
"fileid": input.FileSha1,
"preid": input.Preid,
"topupload": topUpload,
}
if input.PickCode != "" {
params["pick_code"] = input.PickCode
}
if input.SignKey != "" && input.SignVal != "" {
params["sign_key"] = input.SignKey
params["sign_val"] = input.SignVal
}
return params
}
func decodeUploadCallback(raw json.RawMessage) (UploadResultCallBack, error) {
if len(raw) == 0 || string(raw) == "null" {
return UploadResultCallBack{}, nil
}
if raw[0] == '[' {
var callbacks []UploadResultCallBack
if err := json.Unmarshal(raw, &callbacks); err != nil {
return UploadResultCallBack{}, err
}
if len(callbacks) == 0 {
return UploadResultCallBack{}, nil
}
return callbacks[0], nil
}
var callback UploadResultCallBack
if err := json.Unmarshal(raw, &callback); err != nil {
return UploadResultCallBack{}, err
}
return callback, nil
}
func parseSignCheckRange(value string) (SignCheckRange, error) {
parts := strings.Split(value, "-")
if len(parts) != 2 {
return SignCheckRange{}, fmt.Errorf("sign_check 格式错误:%s", value)
}
start, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64)
if err != nil {
return SignCheckRange{}, err
}
end, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
if err != nil {
return SignCheckRange{}, err
}
if start < 0 || end < start {
return SignCheckRange{}, fmt.Errorf("sign_check 范围非法:%s", value)
}
return SignCheckRange{Start: start, End: end}, nil
}
// GetUploadToken 获取 115 下发的 OSS 临时上传凭证。
func (c *OpenClient) GetUploadToken(ctx context.Context) (*UploadToken, error) {
resp, err := c.doAuthJSON(ctx, "GET", ProAPIBase+"/open/upload/get_token", nil, 2)
if err != nil {
return nil, err
}
var token UploadToken
if err := json.Unmarshal(resp.Data, &token); err != nil {
return nil, fmt.Errorf("115: 解析 get_token 结果失败:%w", err)
}
token.normalize()
return &token, nil
}
// Upload 上传单个本地文件到 115 指定父目录(cid),返回成功后的远端文件信息。
// filePath 必须是落到磁盘的真实文件路径(调用方负责把 io.Reader 落盘为临时文件)。
func (c *OpenClient) Upload(ctx context.Context, filePath, parentCID, signKey, signVal string) (*UploadCompleteResult, error) {
fileSize := fileSizeOf(filePath)
if fileSize < 0 {
return nil, fmt.Errorf("115: 无法获取文件大小:%s", filePath)
}
fileSha1, err := FileSHA1(filePath)
if err != nil {
return nil, fmt.Errorf("115: 计算文件 SHA1 失败:%w", err)
}
preSha1, err := FileSHA1Partial(filePath, 0, 128*1024-1)
if err != nil {
return nil, fmt.Errorf("115: 计算文件前 128 KiB SHA1 失败:%w", err)
}
request := UploadInitRequest{
FileName: baseNameOf(filePath),
FileSize: fileSize,
ParentFileId: parentCID,
FileSha1: fileSha1,
Preid: preSha1,
TopUpload: "0",
SignKey: signKey,
SignVal: signVal,
}
initResult, err := c.UploadInit(ctx, request)
if err != nil {
return nil, fmt.Errorf("115: 上传初始化失败:%w", err)
}
status := initResult.Status
if status == UploadInitStatusNeedSign {
// 二次签名:按 sign_check 指定区间重算 sha1
rng, err := parseSignCheckRange(initResult.SignCheck)
if err != nil {
return nil, err
}
signValue, err := FileSHA1Partial(filePath, rng.Start, rng.End)
if err != nil {
return nil, err
}
request.SignKey = initResult.SignKey
request.SignVal = signValue
initResult, err = c.UploadInit(ctx, request)
if err != nil {
return nil, fmt.Errorf("115: 上传二次签名失败:%w", err)
}
status = initResult.Status
}
switch status {
case UploadInitStatusRapidUploaded:
// 秒传成功
return &UploadCompleteResult{FileId: initResult.FileId, PickCode: initResult.PickCode}, nil
case UploadInitStatusSignFailed:
return nil, fmt.Errorf("115: 签名验证后失败")
case UploadInitStatusSignRejected:
return nil, fmt.Errorf("115: 签名认证失败")
case UploadInitStatusNeedUpload:
// 真实上传:OSS multipart
default:
return &UploadCompleteResult{FileId: initResult.FileId, PickCode: initResult.PickCode}, nil
}
if initResult.Bucket == "" || initResult.Object == "" {
return nil, fmt.Errorf("115: upload/init 缺少 bucket/object 信息")
}
token, err := c.GetUploadToken(ctx)
if err != nil {
return nil, fmt.Errorf("115: 获取上传凭证失败:%w", err)
}
if token == nil || token.Endpoint == "" || token.AccessKeyId == "" || token.AccessKeySecret == "" {
return nil, fmt.Errorf("115: 上传凭证不完整")
}
uploader := NewOSSMultipartUploader(token.Endpoint, token.AccessKeyId, token.AccessKeySecret, token.SecurityToken)
result, err := uploader.UploadFile(ctx, OSSMultipartUploadInput{
Bucket: initResult.Bucket,
Object: initResult.Object,
Callback: initResult.Callback.Callback,
CallbackVar: initResult.Callback.CallbackVar,
FilePath: filePath,
FileSize: fileSize,
refreshClient: func(ctx context.Context) (ossMultipartClient, error) {
refreshed, rerr := c.GetUploadToken(ctx)
if rerr != nil || refreshed == nil {
return nil, rerr
}
return newOSSMultipartClient(refreshed.Endpoint, refreshed.AccessKeyId, refreshed.AccessKeySecret, refreshed.SecurityToken), nil
},
})
if err != nil {
return nil, fmt.Errorf("115: OSS 上传失败:%w", err)
}
complete, err := ParseCompleteCallbackResult(result)
if err != nil {
return nil, err
}
return &complete, nil
}
// MkDir 在 115 的 parentCid 下创建目录,返回新目录 cid。
func (c *OpenClient) MkDir(ctx context.Context, parentCID, name string) (string, error) {
params := map[string]string{
"cname": name,
"pid": parentCID,
}
resp, err := c.doAuthJSON(ctx, "POST", ProAPIBase+"/open/folder/add", params, 2)
if err != nil {
return "", err
}
// /open/folder/add 结构:{ aid, cid, fid, name, pid, ... },单一对象
var r struct {
Cid string `json:"cid"`
}
if err := json.Unmarshal(resp.Data, &r); err != nil {
return "", fmt.Errorf("115: 解析 folder/add 结果失败:%w", err)
}
if r.Cid == "" {
return "", errors.New("115: folder/add 未返回 cid")
}
return r.Cid, nil
}
func fileSizeOf(path string) int64 {
info, err := os.Stat(path)
if err != nil {
return -1
}
if info.IsDir() {
return -1
}
return info.Size()
}
func baseNameOf(path string) string {
s := path
for i := len(s) - 1; i >= 0; i-- {
if s[i] == '/' || s[i] == '\\' {
return s[i+1:]
}
}
return s
}
+197
View File
@@ -0,0 +1,197 @@
package cloud115
import (
"context"
"encoding/base64"
"io"
"os"
"path/filepath"
"testing"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
)
func TestFileSHA1(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "a.txt")
if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
sum, err := FileSHA1(path)
if err != nil {
t.Fatal(err)
}
// sha1("hello") = aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d
if sum != "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d" {
t.Errorf("unexpected sha1: %s", sum)
}
}
func TestFileSHA1Partial(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "b.txt")
// 10 bytes: "0123456789"
if err := os.WriteFile(path, []byte("0123456789"), 0o644); err != nil {
t.Fatal(err)
}
// bytes [2,4] = "234"
sum, err := FileSHA1Partial(path, 2, 4)
if err != nil {
t.Fatal(err)
}
if sum != "0ec09ef9836da03f1add21e3ef607627e687e790" {
t.Errorf("unexpected partial sha1: %s", sum)
}
}
// TestFileSHA1PartialSmallerThanWindow 回归测试:经典 bug 是 io.CopyN 在文件不足
// length 字节时返回 io.EOF。115 上传固定用 [0,128*1024-1] 窗口计算 preid,导致所有
// 小于 128 KiB 的元数据文件(如海报/缩略图)上传必然失败。
func TestFileSHA1PartialSmallerThanWindow(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "small.bin")
// 6 字节小文件,不足 128 KiB 窗口
if err := os.WriteFile(path, []byte("abcdef"), 0o644); err != nil {
t.Fatal(err)
}
sum, err := FileSHA1Partial(path, 0, 128*1024-1)
if err != nil {
t.Fatalf("compute partial sha1 for small file should not fail: %v", err)
}
// 应等于整个文件(6 字节)的 sha1
if sum != "1f8ac10f23c5b5bc1167bda84b833e5c057a77d2" {
t.Errorf("unexpected partial sha1: %s", sum)
}
}
func TestParseSignCheckRange(t *testing.T) {
rng, err := parseSignCheckRange("0-131071")
if err != nil {
t.Fatal(err)
}
if rng.Start != 0 || rng.End != 131071 {
t.Errorf("unexpected range: %+v", rng)
}
if _, err := parseSignCheckRange("bad"); err == nil {
t.Error("expected error for bad range")
}
if _, err := parseSignCheckRange("100-50"); err == nil {
t.Error("expected error for end<start")
}
}
func TestCalculateMultipartPartSize(t *testing.T) {
// small file: 1 MiB -> partSize 32MiB, 1 part
ps, parts, err := CalculateMultipartPartSize(1 << 20)
if err != nil {
t.Fatal(err)
}
if ps != defaultMultipartPartSize {
t.Errorf("partSize=%d, want %d", ps, defaultMultipartPartSize)
}
if parts != 1 {
t.Errorf("parts=%d, want 1", parts)
}
// zero-size -> 1 part
_, parts, err = CalculateMultipartPartSize(0)
if err != nil {
t.Fatal(err)
}
if parts != 1 {
t.Errorf("zero-size parts=%d, want 1", parts)
}
// negative -> error
if _, _, err := CalculateMultipartPartSize(-1); err == nil {
t.Error("expected error for negative size")
}
}
func TestBaseNameOf(t *testing.T) {
if got := baseNameOf("/a/b/file.nfo"); got != "file.nfo" {
t.Errorf("got %s", got)
}
if got := baseNameOf("a\\b\\c.jpg"); got != "c.jpg" {
t.Errorf("got %s", got)
}
if got := baseNameOf("top.txt"); got != "top.txt" {
t.Errorf("got %s", got)
}
}
// fakeCallbackOSSClient 捕获 CompleteMultipartUpload 收到的 callback / callback_var,
// 用于断言已经 Base64 编码(116 要求 callback 必须是 Base64 后的 JSON,否则报
// "The callback configuration is not base64 encoded")。
type fakeCallbackOSSClient struct {
capturedCallback string
capturedCallbackVar string
}
func (c *fakeCallbackOSSClient) InitiateMultipartUpload(_ context.Context, _ *oss.InitiateMultipartUploadRequest, _ ...func(*oss.Options)) (*oss.InitiateMultipartUploadResult, error) {
return &oss.InitiateMultipartUploadResult{UploadId: oss.Ptr("upload-new")}, nil
}
func (c *fakeCallbackOSSClient) UploadPart(_ context.Context, r *oss.UploadPartRequest, _ ...func(*oss.Options)) (*oss.UploadPartResult, error) {
if r.Body != nil {
_, _ = io.Copy(io.Discard, r.Body)
}
return &oss.UploadPartResult{ETag: oss.Ptr("etag-1")}, nil
}
func (c *fakeCallbackOSSClient) ListParts(context.Context, *oss.ListPartsRequest, ...func(*oss.Options)) (*oss.ListPartsResult, error) {
return &oss.ListPartsResult{}, nil
}
func (c *fakeCallbackOSSClient) CompleteMultipartUpload(_ context.Context, r *oss.CompleteMultipartUploadRequest, _ ...func(*oss.Options)) (*oss.CompleteMultipartUploadResult, error) {
c.capturedCallback = *r.Callback
c.capturedCallbackVar = *r.CallbackVar
return &oss.CompleteMultipartUploadResult{
CallbackResult: map[string]any{
"state": true,
"data": map[string]any{"file_id": "file-1", "pick_code": "pick-1"},
},
}, nil
}
func (c *fakeCallbackOSSClient) AbortMultipartUpload(context.Context, *oss.AbortMultipartUploadRequest, ...func(*oss.Options)) (*oss.AbortMultipartUploadResult, error) {
return &oss.AbortMultipartUploadResult{}, nil
}
// TestCompleteMultipartUploadCallbackBase64 回归测试:OSS CompleteMultipartUpload 的
// callback 必须 Base64 编码,否则报 "The callback configuration is not base64 encoded",
// 导致大于 128 KiB 的元数据文件上传失败。
func TestCompleteMultipartUploadCallbackBase64(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "big.bin")
data := make([]byte, 8) // 8 字节,PartSize=8 → 1 part
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatal(err)
}
fake := &fakeCallbackOSSClient{}
uploader := &OSSMultipartUploader{client: fake}
callback := `{"callbackUrl":"http://uplb.115.com/3.0/completeupload.php"}`
callbackVar := `{"x:pick_code":"abc"}`
_, err := uploader.UploadFileWithResult(context.Background(), OSSMultipartUploadInput{
Bucket: "bucket-1",
Object: "object-1",
Callback: callback,
CallbackVar: callbackVar,
FilePath: path,
FileSize: int64(len(data)),
PartSize: 8,
})
if err != nil {
t.Fatalf("multipart 上传失败:%v", err)
}
// 捕获的 callback 必须是合法 Base64,且解码后与原 JSON 一致
cbBytes, err := base64.StdEncoding.DecodeString(fake.capturedCallback)
if err != nil {
t.Fatalf("callback 未 Base64 编码:%v (raw=%q)", err, fake.capturedCallback)
}
if string(cbBytes) != callback {
t.Errorf("callback 解码后 = %s,期望 %s", cbBytes, callback)
}
cbvBytes, err := base64.StdEncoding.DecodeString(fake.capturedCallbackVar)
if err != nil {
t.Fatalf("callback_var 未 Base64 编码:%v (raw=%q)", err, fake.capturedCallbackVar)
}
if string(cbvBytes) != callbackVar {
t.Errorf("callback_var 解码后 = %s,期望 %s", cbvBytes, callbackVar)
}
}
+1 -1
View File
@@ -62,4 +62,4 @@ func xorDecode(hexStr string) string {
func dandanplaySignature(appID, appSecret string, ts int64, path string) string {
sum := sha256.Sum256([]byte(appID + strconv.FormatInt(ts, 10) + path + appSecret))
return base64.StdEncoding.EncodeToString(sum[:])
}
}
+1 -1
View File
@@ -81,4 +81,4 @@ func TestDanmakuCredentialsSelection(t *testing.T) {
require.False(t, ok)
require.Empty(t, id)
require.Empty(t, key)
}
}
+162 -60
View File
@@ -278,69 +278,171 @@ func TestDanmakuSameBase(t *testing.T) {
require.False(t, sameDanmakuBase("", "https://api.dandanplay.net"))
}
// fetchCommentWithFallback:配置源与官方同源时不重复请求;
// 全失败时带出最后一跳错误。
func TestDanmakuFetchCommentWithFallback(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
// fetchCommentWithFallback:配置源与官方同源时不重复请求;
// 全失败时带出最后一跳错误。
func TestDanmakuFetchCommentWithFallback(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(srv.Close)
svc := newDanmakuTestService(t)
ctx := context.Background()
raw, st, err := svc.fetchCommentWithFallback(ctx, srv.URL, srv.URL, "25484")
require.Error(t, err)
require.Empty(t, raw)
require.Equal(t, "auto", st)
}
svc := newDanmakuTestService(t)
ctx := context.Background()
raw, st, err := svc.fetchCommentWithFallback(ctx, srv.URL, srv.URL, "25484")
require.Error(t, err)
require.Empty(t, raw)
require.Equal(t, "auto", st)
}
// 视频即便能命中 Hash 自动识别,当用户传入手动搜索关键词时应跳过 Hash 匹配,走关键词搜索。
func TestDanmakuFetchHashMatchSkippedOnManualKeyword(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
// 视频即便能命中 Hash 自动识别,当用户传入手动搜索关键词时应跳过 Hash 匹配,走关键词搜索。
func TestDanmakuFetchHashMatchSkippedOnManualKeyword(t *testing.T) {
videoPath, _ := writeDanmakuTestVideo(t, "测试动画.第01话.mkv")
// 官方服务同时提供 match 和 search:
// match 会返回 episodeId=25484(动画A)
// search 会根据关键词返回 episodeId=99999(动画B)
mux := http.NewServeMux()
var matchCalled bool
mux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
matchCalled = true
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"自动识别动画A","episodeTitle":"第1话"}]}`)
})
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":2002,"animeTitle":"手动搜索动画B","episodes":[{"episodeId":99999,"episodeTitle":"第1话"}]}]}`)
})
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">自动识别弹幕</d></i>`)
})
mux.HandleFunc("/api/v2/comment/99999", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user2">手动搜索弹幕</d></i>`)
})
official := httptest.NewServer(mux)
t.Cleanup(official.Close)
overrideDanmakuOfficialBase(t, official.URL)
// 官方服务同时提供 match 和 search:
// match 会返回 episodeId=25484(动画A)
// search 会根据关键词返回 episodeId=99999(动画B)
mux := http.NewServeMux()
var matchCalled bool
mux.HandleFunc("/api/v2/match", func(w http.ResponseWriter, r *http.Request) {
matchCalled = true
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":1001,"animeTitle":"自动识别动画A","episodeTitle":"第1话"}]}`)
})
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":2002,"animeTitle":"手动搜索动画B","episodes":[{"episodeId":99999,"episodeTitle":"第1话"}]}]}`)
})
mux.HandleFunc("/api/v2/comment/25484", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user1">自动识别弹幕</d></i>`)
})
mux.HandleFunc("/api/v2/comment/99999", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.5,1,16777215,user2">手动搜索弹幕</d></i>`)
})
official := httptest.NewServer(mux)
t.Cleanup(official.Close)
overrideDanmakuOfficialBase(t, official.URL)
svc := newDanmakuTestService(t)
ctx := context.Background()
seedDanmakuVideoMedia(t, svc, "mManual", "自动识别动画A", videoPath, 32000, 1)
svc := newDanmakuTestService(t)
ctx := context.Background()
seedDanmakuVideoMedia(t, svc, "mManual", "自动识别动画A", videoPath, 32000, 1)
// 1) 默认自动识别:命中 Hash 识别
resAuto, err := svc.Fetch(ctx, "mManual", "", "")
require.NoError(t, err)
require.True(t, matchCalled)
require.Equal(t, "hash", resAuto.MatchMode)
require.Equal(t, int64(25484), resAuto.EpisodeID)
require.Contains(t, resAuto.Raw, "自动识别弹幕")
// 1) 默认自动识别:命中 Hash 识别
resAuto, err := svc.Fetch(ctx, "mManual", "", "")
require.NoError(t, err)
require.True(t, matchCalled)
require.Equal(t, "hash", resAuto.MatchMode)
require.Equal(t, int64(25484), resAuto.EpisodeID)
require.Contains(t, resAuto.Raw, "自动识别弹幕")
// 2) 用户传入手动搜索关键词:跳过 Hash 识别,命中搜索结果动画B
resManual, err := svc.Fetch(ctx, "mManual", "手动搜索动画B", "")
require.NoError(t, err)
require.Equal(t, "search", resManual.MatchMode)
require.Equal(t, int64(99999), resManual.EpisodeID)
require.Equal(t, "手动搜索动画B", resManual.AnimeTitle)
require.Contains(t, resManual.Raw, "手动搜索弹幕")
}
// 2) 用户传入手动搜索关键词:跳过 Hash 识别,命中搜索结果动画B
resManual, err := svc.Fetch(ctx, "mManual", "手动搜索动画B", "")
require.NoError(t, err)
require.Equal(t, "search", resManual.MatchMode)
require.Equal(t, int64(99999), resManual.EpisodeID)
require.Equal(t, "手动搜索动画B", resManual.AnimeTitle)
require.Contains(t, resManual.Raw, "手动搜索弹幕")
}
// Emby 远程挂载条目:通过伪装 ID 解析出流直链,通过 Range 提取 16MB 前缀计算 hash 并匹配弹幕。
func TestDanmakuFetchEmbyRemoteHashViaDirectLink(t *testing.T) {
content := bytes.Repeat([]byte("emby-remote-video-bytes-9876543210"), 300)
sum := md5.Sum(content)
wantHash := hex.EncodeToString(sum[:])
var gotRange string
var rangeHits int
rangeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rangeHits++
gotRange = r.Header.Get("Range")
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(content)
}))
t.Cleanup(rangeSrv.Close)
var seen string
official := danmakuOfficialServer(t,
`{"success":true,"isMatched":true,"matches":[{"episodeId":25484,"animeId":2001,"animeTitle":"芙莉莲","episodeTitle":"第1话"}]}`,
`<?xml version="1.0"?><i><d p="1.2,1,16777215,user1">Emby远程弹幕命中</d></i>`,
&seen)
overrideDanmakuOfficialBase(t, official.URL)
remoteMediaID := EncodeEmbyRemoteID("mount-123", "remote-item-456")
svc := newDanmakuTestService(t)
svc.SetRemoteMediaResolver(func(_ context.Context, encodedID string) (*model.Media, string, error) {
require.Equal(t, remoteMediaID, encodedID)
return &model.Media{
Base: model.Base{ID: remoteMediaID},
Title: "葬送的芙莉莲",
EpisodeTitle: "第1话",
EpisodeNum: 1,
Path: "/mnt/emby/anime/Frieren/S01E01.mkv",
SizeBytes: int64(len(content)),
DurationSec: 1400,
}, rangeSrv.URL, nil
})
ctx := context.Background()
res, err := svc.Fetch(ctx, remoteMediaID, "", "")
require.NoError(t, err)
require.True(t, res.Enabled)
require.Equal(t, "hash", res.MatchMode)
require.Equal(t, int64(25484), res.EpisodeID)
require.Equal(t, "芙莉莲", res.AnimeTitle)
require.Contains(t, res.Raw, "Emby远程弹幕命中")
require.Contains(t, gotRange, "bytes=0-")
require.Contains(t, seen, `"fileHash":"`+wantHash+`"`)
require.Contains(t, seen, `"fileName":"`+url.QueryEscape("S01E01")+`"`)
require.Equal(t, 1, rangeHits)
// 第二次拉取验证 hashCache 命中,不重复请求 rangeSrv
res2, err := svc.Fetch(ctx, remoteMediaID, "", "")
require.NoError(t, err)
require.Equal(t, "hash", res2.MatchMode)
require.Equal(t, 1, rangeHits)
}
// Emby 远程直链拉取失败时(如网络异常),能平滑降级走番剧原名/标题关键词搜索。
func TestDanmakuFetchEmbyRemoteStreamFailedFallsBackToSearch(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search/episodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// 文件名搜索 ep01 时无结果,模拟文件名未匹配
if r.URL.Query().Get("anime") == "ep01" {
fmt.Fprint(w, `{"hasMore":false,"animes":[]}`)
return
}
// 降级到番剧名搜索命中
fmt.Fprint(w, `{"hasMore":false,"animes":[{"animeId":3001,"animeTitle":"降级搜索番剧","episodes":[{"episodeId":7799,"episodeTitle":"第1话"}]}]}`)
})
mux.HandleFunc("/api/v2/comment/7799", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, `<?xml version="1.0"?><i><d p="0.8,1,16777215,user1">降级搜索弹幕</d></i>`)
})
official := httptest.NewServer(mux)
t.Cleanup(official.Close)
overrideDanmakuOfficialBase(t, official.URL)
remoteMediaID := EncodeEmbyRemoteID("mount-123", "remote-item-789")
svc := newDanmakuTestService(t)
// 返回一个不存在的流服务地址模拟 Range 拉取失败
svc.SetRemoteMediaResolver(func(_ context.Context, encodedID string) (*model.Media, string, error) {
return &model.Media{
Base: model.Base{ID: remoteMediaID},
Title: "降级搜索番剧",
EpisodeNum: 1,
Path: "/mnt/emby/anime/fallback/ep01.mkv",
DurationSec: 1200,
}, "http://127.0.0.1:1/invalid-stream", nil
})
ctx := context.Background()
res, err := svc.Fetch(ctx, remoteMediaID, "", "")
require.NoError(t, err)
require.True(t, res.Enabled)
require.Equal(t, "search", res.MatchMode)
require.Equal(t, int64(7799), res.EpisodeID)
require.Equal(t, "降级搜索番剧", res.AnimeTitle)
require.Contains(t, res.Raw, "降级搜索弹幕")
}
+176 -83
View File
@@ -94,6 +94,10 @@ type DanmakuEpisode struct {
EpisodeTitle string `json:"episodeTitle"`
}
// DanmakuRemoteMediaResolver resolves an Emby remote pseudo-ID (e.g. embyremote~mount~id)
// into a memory model.Media and a direct stream URL.
type DanmakuRemoteMediaResolver func(ctx context.Context, encodedID string) (*model.Media, string, error)
// DanmakuService fetches danmaku for a media item through the dandanplay
// protocol: match by 16MB-prefix hash, then search for an episode id by the
// video's name, then fetch the comment library XML. The React player parses
@@ -108,6 +112,10 @@ type DanmakuService struct {
// StrmService.ResolvePlay; nil means strm sources are skipped.
strmResolve func(ctx context.Context, provider string, q url.Values) (*StrmPlayResult, error)
// remoteResolve resolves an Emby remote pseudo-ID into *model.Media and
// direct stream URL for range hashing.
remoteResolve DanmakuRemoteMediaResolver
hashCacheMu sync.Mutex
hashCache map[string]string // stamp → 16MB-prefix MD5
}
@@ -136,6 +144,14 @@ func (s *DanmakuService) SetStrmResolver(resolve func(ctx context.Context, provi
}
}
// SetRemoteMediaResolver wires the resolver used to fetch metadata and direct
// stream URLs for Emby remote mounted media.
func (s *DanmakuService) SetRemoteMediaResolver(resolve DanmakuRemoteMediaResolver) {
if s != nil {
s.remoteResolve = resolve
}
}
// Config reads danmaku settings from the runtime settings table.
func (s *DanmakuService) Config(ctx context.Context) DanmakuRenderConfig {
cfg := DanmakuRenderConfig{
@@ -190,90 +206,94 @@ func (s *DanmakuService) Fetch(ctx context.Context, mediaID, keyword, episodeID
configured := strings.TrimRight(strings.TrimSpace(res.Source), "/")
official := danmakuOfficialBase
// 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。
if target := strings.TrimSpace(episodeID); target != "" {
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
if err != nil {
s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err))
return res, err
}
res.Raw, res.SourceType = raw, st
if id, parseErr := strconv.ParseInt(target, 10, 64); parseErr == nil {
res.EpisodeID = id
}
res.MatchMode = "manual"
return res, nil
}
term, media, err := s.searchTerms(ctx, mediaID)
// 手动指定弹幕库:跳过识别,直接拉取该库(自定义源失败回退官方)。
if target := strings.TrimSpace(episodeID); target != "" {
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
if err != nil {
s.log.Warn("danmaku comment fetch failed", zap.String("media_id", mediaID), zap.String("episode_id", target), zap.Error(err))
return res, err
}
manualKeyword := strings.TrimSpace(keyword) != ""
if kw := strings.TrimSpace(keyword); kw != "" {
term.name = kw
res.Raw, res.SourceType = raw, st
if id, parseErr := strconv.ParseInt(target, 10, 64); parseErr == nil {
res.EpisodeID = id
}
if strings.TrimSpace(term.name) == "" {
res.MatchMode = "manual"
return res, nil
}
term, media, err := s.searchTerms(ctx, mediaID)
if err != nil {
return res, err
}
manualKeyword := strings.TrimSpace(keyword) != ""
if kw := strings.TrimSpace(keyword); kw != "" {
term.name = kw
}
if strings.TrimSpace(term.name) == "" {
return res, nil
}
target := ""
// 1) hash 识别:始终走官方 /api/v2/match(keyword 手动覆盖时跳过,直接走第 3 层)。
if target == "" && !manualKeyword && media != nil && (media.Path != "" || IsEmbyRemoteID(media.ID)) {
if hash, ok := s.mediaHash(ctx, media); ok {
fileSize := media.SizeBytes
if media.Path != "" && strings.EqualFold(filepath.Ext(media.Path), ".strm") {
fileSize = 0 // strm 行的 SizeBytes 是文本大小,不是视频大小
}
matchName := danmakuMatchFileName(media.Path)
if matchName == "" {
matchName = term.name
}
matches, err := s.matchOfficial(ctx, matchName, hash, fileSize, media.DurationSec)
if err != nil {
s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err))
} else if len(matches) > 0 {
target = fmt.Sprintf("%d", matches[0].EpisodeID)
res.AnimeTitle = matches[0].AnimeTitle
res.EpisodeTitle = matches[0].EpisodeTitle
res.EpisodeID = matches[0].EpisodeID
res.MatchMode = "hash"
}
}
}
// 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。
if target == "" && !manualKeyword && media != nil && media.Path != "" {
if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name {
if candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, fileName, term.episode); err == nil &&
len(candidates) == 1 && len(candidates[0].Episodes) > 0 {
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
res.AnimeTitle = candidates[0].AnimeTitle
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
res.MatchMode = "filename"
}
}
}
// 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
// 多结果返回候选列表交给播放器(歧义处理)。
if target == "" {
candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode)
if err != nil {
s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err))
return res, err
}
if len(candidates) != 1 {
res.Candidates = candidates
return res, nil
}
target := ""
// 1) hash 识别:始终走官方 /api/v2/match(keyword 手动覆盖时跳过,直接走第 3 层)。
if target == "" && !manualKeyword && media != nil && media.Path != "" {
if hash, ok := s.mediaHash(ctx, media); ok {
fileSize := media.SizeBytes
if strings.EqualFold(filepath.Ext(media.Path), ".strm") {
fileSize = 0 // strm 行的 SizeBytes 是文本大小,不是视频大小
}
matches, err := s.matchOfficial(ctx, danmakuMatchFileName(media.Path), hash, fileSize, media.DurationSec)
if err != nil {
s.log.Warn("danmaku hash match failed", zap.String("media_id", mediaID), zap.Error(err))
} else if len(matches) > 0 {
target = fmt.Sprintf("%d", matches[0].EpisodeID)
res.AnimeTitle = matches[0].AnimeTitle
res.EpisodeTitle = matches[0].EpisodeTitle
res.EpisodeID = matches[0].EpisodeID
res.MatchMode = "hash"
}
}
}
// 2) 按播放的文件名 + 集数搜索(keyword 手动覆盖时跳过,直接走第 3 层)。
if target == "" && !manualKeyword && media != nil && media.Path != "" {
if fileName := danmakuMatchFileName(media.Path); fileName != "" && fileName != term.name {
if candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, fileName, term.episode); err == nil &&
len(candidates) == 1 && len(candidates[0].Episodes) > 0 {
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
res.AnimeTitle = candidates[0].AnimeTitle
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
res.MatchMode = "filename"
}
}
}
// 3) 现有自动识别:标题层级(original_name → title → 文件名)+ 集数,
// 多结果返回候选列表交给播放器(歧义处理)。
if target == "" {
candidates, err := s.searchCandidatesWithFallback(ctx, configured, official, term.name, term.episode)
if err != nil {
s.log.Warn("danmaku search failed", zap.String("media_id", mediaID), zap.String("name", term.name), zap.String("episode", term.episode), zap.Error(err))
return res, err
}
if len(candidates) != 1 {
res.Candidates = candidates
return res, nil
}
if len(candidates[0].Episodes) == 0 {
return res, errors.New("no danmaku library found for this video")
}
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
res.AnimeTitle = candidates[0].AnimeTitle
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
res.MatchMode = "search"
if len(candidates[0].Episodes) == 0 {
return res, errors.New("no danmaku library found for this video")
}
target = fmt.Sprintf("%d", candidates[0].Episodes[0].EpisodeID)
res.AnimeTitle = candidates[0].AnimeTitle
res.EpisodeTitle = candidates[0].Episodes[0].EpisodeTitle
res.EpisodeID = candidates[0].Episodes[0].EpisodeID
res.MatchMode = "search"
}
raw, st, err := s.fetchCommentWithFallback(ctx, configured, official, target)
if err != nil {
@@ -311,6 +331,29 @@ type danmakuSearchTerms struct {
// (movies / unknown) is left empty so the search does not filter by episode.
func (s *DanmakuService) searchTerms(ctx context.Context, mediaID string) (danmakuSearchTerms, *model.Media, error) {
var term danmakuSearchTerms
if IsEmbyRemoteID(mediaID) {
if s == nil || s.remoteResolve == nil {
return term, nil, errors.New("remote emby resolver unavailable")
}
m, _, err := s.remoteResolve(ctx, mediaID)
if err != nil || m == nil {
if err != nil {
return term, nil, err
}
return term, nil, errors.New("media not found")
}
if name := strings.TrimSpace(m.OriginalName); name != "" {
term.name = name
} else if name := strings.TrimSpace(m.Title); name != "" {
term.name = name
} else {
term.name = danmakuMatchFileName(m.Path)
}
if m.EpisodeNum > 0 {
term.episode = strconv.Itoa(m.EpisodeNum)
}
return term, m, nil
}
if s == nil || s.repo == nil || s.repo.Media == nil {
return term, nil, errors.New("media repository unavailable")
}
@@ -484,7 +527,14 @@ func (s *DanmakuService) hashCachePut(stamp, hash string) {
// ("xxx.mkv.strm") — so a second strip removes a real video extension only
// (filepath.Ext would misread names like "xxx.第01话" as having an extension).
func danmakuMatchFileName(path string) string {
base := filepath.Base(path)
if path == "" {
return ""
}
clean := strings.ReplaceAll(path, "\\", "/")
if idx := strings.LastIndex(clean, "/"); idx >= 0 {
clean = clean[idx+1:]
}
base := filepath.Base(clean)
if ext := filepath.Ext(base); ext != "" {
base = strings.TrimSuffix(base, ext)
}
@@ -493,14 +543,20 @@ func danmakuMatchFileName(path string) string {
base = strings.TrimSuffix(base, filepath.Ext(base))
}
}
return base
return strings.TrimSpace(base)
}
// mediaHash returns the dandanplay match hash (MD5 of the first 16MB of the
// video). Local videos are hashed straight from disk; .strm indirections are
// resolved (local path / direct link) and only the 16MB prefix is downloaded.
// video). Local videos are hashed straight from disk; .strm indirections and
// remote Emby streams are range-fetched and only the 16MB prefix is downloaded.
func (s *DanmakuService) mediaHash(ctx context.Context, media *model.Media) (string, bool) {
if media == nil || media.Path == "" {
if media == nil {
return "", false
}
if IsEmbyRemoteID(media.ID) {
return s.hashEmbyRemote(ctx, media)
}
if media.Path == "" {
return "", false
}
if strings.EqualFold(filepath.Ext(media.Path), ".strm") {
@@ -517,6 +573,43 @@ func (s *DanmakuService) mediaHash(ctx context.Context, media *model.Media) (str
return s.hashLocalFile(media.Path)
}
// hashEmbyRemote computes the 16MB-prefix MD5 of a remote Emby stream via HTTP Range.
func (s *DanmakuService) hashEmbyRemote(ctx context.Context, media *model.Media) (string, bool) {
if media == nil || media.ID == "" {
return "", false
}
if h, ok := s.hashCacheGet("e|" + media.ID); ok {
return h, true
}
if s.remoteResolve == nil {
return "", false
}
_, streamURL, err := s.remoteResolve(ctx, media.ID)
if err != nil || strings.TrimSpace(streamURL) == "" {
if err != nil {
s.log.Warn("danmaku emby stream url resolve failed, hash layer skipped",
zap.String("media_id", media.ID), zap.Error(err))
}
return "", false
}
body, err := s.openRangeBody(ctx, streamURL, nil)
if err != nil || body == nil {
if err != nil {
s.log.Warn("danmaku emby range fetch failed, hash layer skipped",
zap.String("media_id", media.ID), zap.Error(err))
}
return "", false
}
defer body.Close()
h := md5.New()
if _, err := io.Copy(h, io.LimitReader(body, danmakuHashPrefixBytes)); err != nil {
return "", false
}
hash := hex.EncodeToString(h.Sum(nil))
s.hashCachePut("e|"+media.ID, hash)
return hash, true
}
// hashLocalFile computes the MD5 of the first 16MB of a local video, cached
// by path+size+mtime so repeated danmaku loads skip the disk read.
func (s *DanmakuService) hashLocalFile(path string) (string, bool) {
@@ -694,7 +787,7 @@ func (s *DanmakuService) matchOfficial(ctx context.Context, fileName, fileHash s
return nil, fmt.Errorf("danmaku match returned HTTP %d", resp.StatusCode)
}
var out struct {
Success bool `json:"success"`
Success bool `json:"success"`
Matches []danmakuMatch `json:"matches"`
}
if err := json.Unmarshal(raw, &out); err != nil {

Some files were not shown because too many files have changed in this diff Show More