Compare commits

..

723 Commits

Author SHA1 Message Date
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
github-actions[bot] 7a2027a3a7 chore: bump version to 0.0.27 [skip ci] 2026-08-25 15:19:28 +00:00
truewhile 9ffb74adce 优化
优化
2026-08-25 23:19:07 +08:00
github-actions[bot] 13faff7078 chore: bump version to 0.0.26 [skip ci] 2026-08-25 14:55:08 +00:00
truewhile a8a4e88d86 Merge branch 'main' of https://github.com/truewhile/MMTL 2026-08-25 22:54:50 +08:00
truewhile 8fa5db88ff 优化
优化
2026-08-25 22:54:46 +08:00
github-actions[bot] 3c325f81c8 chore: bump version to 0.0.25 [skip ci] 2026-08-25 14:12:30 +00:00
truewhile 585434010c 优化
优化
2026-08-25 22:12:05 +08:00
github-actions[bot] eb1a705cae chore: bump version to 0.0.24 [skip ci] 2026-08-25 11:41:17 +00:00
truewhile 10770b2b77 b u g
b u g
2026-08-25 19:41:02 +08:00
github-actions[bot] b61c51e064 chore: bump version to 0.0.23 [skip ci] 2026-08-25 11:19:05 +00:00
truewhile 019ecbec7b 优化
优化
2026-08-25 19:18:49 +08:00
github-actions[bot] 6a96c5640e chore: bump version to 0.0.22 [skip ci] 2026-08-25 11:06:23 +00:00
truewhile 4025e92cb4 Merge branch 'main' of https://github.com/truewhile/MMTL 2026-08-25 19:06:02 +08:00
truewhile 2355419ef9 优化
优化
2026-08-25 19:05:59 +08:00
github-actions[bot] 86214ea796 chore: bump version to 0.0.21 [skip ci] 2026-08-25 08:40:09 +00:00
truewhile 774f2d4695 Merge branch 'main' of https://github.com/truewhile/MMTL 2026-08-25 16:39:51 +08:00
truewhile efa64051ea 优化
优化
2026-08-25 16:39:47 +08:00
github-actions[bot] 5095347ace chore: bump version to 0.0.20 [skip ci] 2026-08-25 07:22:05 +00:00
truewhile ad9260f8fe Merge branch 'main' of https://github.com/truewhile/MMTL 2026-08-25 15:21:46 +08:00
truewhile 4ae2502096 优化字幕
优化字幕
2026-08-25 15:21:43 +08:00
github-actions[bot] c05b5259ef chore: bump version to 0.0.19 [skip ci] 2026-08-25 06:13:18 +00:00
truewhile da1fb02c9d 优化
优化
2026-08-25 14:12:59 +08:00
github-actions[bot] eb09251424 chore: bump version to 0.0.18 [skip ci] 2026-08-25 03:33:45 +00:00
truewhile 5584862352 处理bug
处理bug
2026-08-25 11:30:51 +08:00
github-actions[bot] d5251c1e4e chore: bump version to 0.0.17 [skip ci] 2026-08-24 16:26:50 +00:00
truewhile 96a554b4b5 优化弹幕识别逻辑
优化弹幕识别逻辑
2026-08-25 00:26:32 +08:00
github-actions[bot] 3bd441c288 chore: bump version to 0.0.16 [skip ci] 2026-08-24 15:06:52 +00:00
truewhile c514fb360e 优化
优化
2026-08-24 23:06:35 +08:00
github-actions[bot] c0c1412f4a chore: bump version to 0.0.15 [skip ci] 2026-08-24 14:39:03 +00:00
truewhile 360f9c173b Merge branch 'main' of https://github.com/truewhile/MMTL 2026-08-24 22:38:41 +08:00
truewhile 60b11bc2bc 优化
优化
2026-08-24 22:38:23 +08:00
github-actions[bot] ac546f1197 chore: bump version to 0.0.14 [skip ci] 2026-08-24 14:16:49 +00:00
truewhile 93c7487c96 优化播放体验
优化播放体验
2026-08-24 22:16:31 +08:00
github-actions[bot] 2e8b1cb7a1 chore: bump version to 0.0.13 [skip ci] 2026-08-24 13:25:42 +00:00
truewhile cdb1564826 Merge branch 'main' of https://github.com/truewhile/MMTL 2026-08-24 21:25:27 +08:00
truewhile 90c10137e9 优化
优化
2026-08-24 21:25:24 +08:00
github-actions[bot] 20ceef1fcf chore: bump version to 0.0.12 [skip ci] 2026-08-24 13:06:56 +00:00
truewhile 43fdfe8202 处理无法播放的问题
处理无法播放的问题
2026-08-24 21:06:44 +08:00
github-actions[bot] 97d162569d chore: bump version to 0.0.11 [skip ci] 2026-08-24 12:11:26 +00:00
truewhile c5994ed8a0 Merge branch 'main' of https://github.com/truewhile/MMTL 2026-08-24 20:11:06 +08:00
truewhile 8b0260e0e1 优化
优化
2026-08-24 20:11:03 +08:00
github-actions[bot] 56cdb63978 chore: bump version to 0.0.10 [skip ci] 2026-08-24 09:51:26 +00:00
truewhile a81fbaa6a6 Update tag format to use lowercase 'mmtl'
Fix tag format in Auto-docker-publish workflow.
2026-08-24 17:51:17 +08:00
truewhile 8c3f5c53be 优化
优化
2026-08-24 17:41:21 +08:00
truewhile 44ca451cd4 优化
优化
2026-08-24 17:40:43 +08:00
github-actions[bot] 94057ef3e7 chore: bump version to 0.0.9 [skip ci] 2026-08-24 09:03:24 +00:00
truewhile 785f18d4b2 Change Docker tag format in workflow 2026-08-24 17:03:13 +08:00
github-actions[bot] 0367d3c8b5 chore: bump version to 0.0.8 [skip ci] 2026-08-24 09:00:38 +00:00
truewhile 99ef2ed410 Merge branch 'main' of https://github.com/truewhile/MMTL 2026-08-24 17:00:21 +08:00
truewhile 0aff87c69d 优化
优化
2026-08-24 17:00:18 +08:00
github-actions[bot] 32fdf3fe61 chore: bump version to 0.0.7 [skip ci] 2026-08-24 07:05:16 +00:00
truewhile 11685101eb Simplify version-and-publish job conditions
Removed conditional execution for the version-and-publish job.
2026-08-24 15:05:04 +08:00
truewhile 3641d084aa Remove GitHub Release creation step from workflow
Removed the optional step for creating a GitHub Release in the workflow.
2026-08-24 15:04:04 +08:00
truewhile afae22ffe2 优化
优化
2026-08-24 15:00:58 +08:00
truewhile a59cb5b858 Merge branch 'main' of https://github.com/truewhile/MMTL 2026-08-24 14:38:37 +08:00
truewhile c55d29ae84 添加strm生成
添加strm
2026-08-24 14:36:17 +08:00
github-actions[bot] 3493aeff0b chore: bump version to 0.0.6 [skip ci] 2026-08-24 00:56:14 +00:00
truewhile deec4ead6d Update Auto-docker-publish workflow triggers
Removed workflow_run trigger and added push and pull_request triggers for main branch.
2026-08-24 08:54:00 +08:00
truewhile e657a5aa84 Change Docker image name in workflow 2026-08-24 08:42:19 +08:00
truewhile b1d792d091 修改说明
修改说明
2026-08-24 08:39:03 +08:00
truewhile cd1297a30f 添加弹幕功能
添加弹幕功能
2026-08-24 00:37:25 +08:00
truewhile 71bf60c69c 初始化
初始化项目
2026-08-23 22:12:32 +08:00
truewhile 0bcb1fec87 优化网盘图片读取逻辑
优化网盘图片读取逻辑
2026-08-21 22:21:14 +08:00
github-actions[bot] cbe0d7e88f chore: bump version to 0.0.5 [skip ci] 2026-08-20 16:12:27 +00:00
truewhile a8706df827 Merge branch 'main' of https://github.com/truewhile/MediaStationGo 2026-08-21 00:06:53 +08:00
truewhile 65d1ad5ae5 优化番号处理逻辑,添加对MetaTube的支持
优化番号处理逻辑,添加对MetaTube的支持
2026-08-21 00:06:49 +08:00
github-actions[bot] b489bc491b chore: bump version to 0.0.4 [skip ci] 2026-08-20 14:32:37 +00:00
truewhile aa15cfe1f1 优化番号海报图片获取逻辑
优化番号海报图片获取逻辑
2026-08-20 22:31:00 +08:00
truewhile 111c38c36d Merge branch 'main' of https://github.com/truewhile/MediaStationGo 2026-08-20 22:09:08 +08:00
truewhile cc7c898277 优化
优化
2026-08-20 22:09:04 +08:00
github-actions[bot] 7a0d880be9 chore: bump version to 0.0.3 [skip ci] 2026-08-20 12:54:07 +00:00
truewhile d4c753b08a 优化
优化
2026-08-20 20:52:34 +08:00
github-actions[bot] 27c86afff4 chore: bump version to 0.0.2 [skip ci] 2026-08-20 09:57:32 +00:00
truewhile 6872e9f646 Add CI Success job to workflow
Signed-off-by: truewhile <62226914+truewhile@users.noreply.github.com>
2026-08-20 17:56:03 +08:00
github-actions[bot] 5bf2c1f5d4 chore: bump version to 0.0.1 [skip ci] 2026-08-20 09:55:02 +00:00
truewhile 91cee6cb37 添加自动构建工作流
Signed-off-by: truewhile <62226914+truewhile@users.noreply.github.com>
2026-08-20 17:53:52 +08:00
truewhile 801b2fa59d bug处理
bug处理
2026-08-20 17:35:55 +08:00
truewhile dc6cb316c1 bug处理
bug处理
2026-08-20 17:01:10 +08:00
truewhile 495292ab3b bug处理优化
bug处理优化
2026-08-20 15:43:39 +08:00
truewhile d368353656 修改刮削逻辑,添加保存元数据功能
修改刮削逻辑,添加保存元数据功能
2026-08-20 11:24:40 +08:00
truewhile 1dc9086bb4 cl编译bug处理
cl编译bug处理
2026-08-20 09:27:33 +08:00
truewhile 541de2165e 优化客户端封面获取逻辑
修改emby客户端获取封面逻辑,修复没有设置封面导致封面为空的问题
2026-08-20 09:20:42 +08:00
truewhile 0d9ba2c33a 处理删库重建库没有媒体数据bug
本地文件入库媒体库删除后重新入库导致0条目bug
2026-08-19 22:33:23 +08:00
truewhile 2f1f427b9e 添加对外挂字幕的支持
添加对字幕的支持
2026-08-19 21:36:35 +08:00
truewhile 3edeb94c93 Update Docker image metadata extraction to use repo owner
Signed-off-by: truewhile <62226914+truewhile@users.noreply.github.com>
2026-08-18 09:48:58 +08:00
truewhile b203719eb7 Merge pull request #1 from truewhile/patch-1
Update Docker image repository in workflow
2026-08-18 09:23:42 +08:00
truewhile 8af0c676b5 Update Docker image repository in workflow
Signed-off-by: truewhile <62226914+truewhile@users.noreply.github.com>
2026-08-18 09:02:13 +08:00
truewhile 8071285ff2 fix(emby): extract pure JWT from combined UserId+Token auth header (RodelPlayer 401)
fix(emby): extract pure JWT from combined UserId+Token auth header (RodelPlayer 401)
2026-08-17 23:31:15 +08:00
ShukeBta 42ef19b7e5 fix(downloads): support Transmission and aria2 end to end
Fixes #65
2026-08-10 21:04:04 +08:00
ShukeBta c0d743d89d fix(subscriptions): reject concurrent duplicate rules
Fixes #66
2026-08-10 19:30:17 +08:00
ShukeBta c32f23a626 fix(emby): preserve cloud source paths in PlaybackInfo
Fixes #64
2026-08-10 19:20:22 +08:00
ShukeBta f436267572 fix(media): stabilize series grouping across library layouts 2026-08-10 19:16:19 +08:00
ShukeBta 5c6fe91742 fix(organize): repair classification and forced rescrape 2026-08-10 19:16:12 +08:00
ShukeBta 60d2cfe2f1 fix(media): scan new library roots immediately 2026-08-10 19:15:58 +08:00
ShukeBta a64a98be23 Test legacy ISO scrape recovery 2026-07-16 13:45:12 +08:00
ShukeBta 0dd9f8399f Support metadata scraping for ISO disc images 2026-07-16 13:33:31 +08:00
ShukeBta 06b25564ea Add library covers and configurable footer visibility 2026-07-16 13:17:52 +08:00
ShukeBta cc056077ee Add ISO media scan support 2026-07-16 12:47:31 +08:00
ShukeBta 19381eb270 Fix media classification scraping and Chinese naming 2026-07-15 20:45:24 +08:00
Shuke 923198ccea Fix formatting in README.md links and text
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-07-04 22:34:40 +08:00
Shuke 8ea278cfb7 Fix link formatting in the README
Updated link formatting in the README for clarity.

Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-07-04 22:33:46 +08:00
ShukeBta b610dda098 fix: align scraped media naming and release sorting 2026-07-03 02:55:33 +08:00
ShukeBta 78de029ef3 fix: embed release version in system update status 2026-07-03 01:55:30 +08:00
ShukeBta 470a639091 feat: add STRM tree recognize rename option 2026-07-03 01:46:13 +08:00
ShukeBta 9833c60e38 feat: scrape STRM libraries after refresh 2026-07-03 01:18:27 +08:00
ShukeBta a6f2d0208d feat: add STRM output directory presets 2026-07-03 00:55:06 +08:00
Shuke 862443dda6 Update README.md
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-07-03 00:10:11 +08:00
ShukeBta 077bb10d25 feat: skip existing media in STRM tree generation 2026-07-02 23:35:24 +08:00
ShukeBta e700962f2a feat: refresh libraries after STRM updates 2026-07-02 23:20:08 +08:00
ShukeBta 2df7c34cf7 feat: add STRM repair workflow 2026-07-02 23:01:36 +08:00
ShukeBta e7d988ebf8 feat: transfer STRM tree subtitle links 2026-07-02 22:41:37 +08:00
ShukeBta ccd9756b6f feat: report ignored STRM tree sidecars 2026-07-02 22:14:16 +08:00
ShukeBta 1afc69baa0 feat: report STRM tree batch progress 2026-07-02 21:48:53 +08:00
ShukeBta 87928cfa4d feat: batch STRM tree generation 2026-07-02 21:35:50 +08:00
ShukeBta d11f784438 feat: add STRM tree dry-run preview 2026-07-02 21:15:11 +08:00
ShukeBta c07290619c fix: improve media grouping and bot startup 2026-07-02 20:36:45 +08:00
ShukeBta 77b6e448e0 fix directory hardlink transfer 2026-07-02 18:38:26 +08:00
ShukeBta a1687eb746 fix docker compose paths and subscription robustness 2026-07-02 03:35:52 +08:00
ShukeBta 204f7fb676 chore remove unused docs and assets 2026-07-02 02:11:10 +08:00
ShukeBta 1b56469beb chore clean repository deployment files 2026-07-02 01:56:40 +08:00
ShukeBta aec1ca3e1b docs simplify single image compose 2026-07-02 01:32:36 +08:00
ShukeBta 5daee11c77 fix library preview series counts 2026-07-02 01:16:37 +08:00
ShukeBta 5ecb5e2144 fix system update current version display 2026-07-02 00:04:04 +08:00
ShukeBta 3bea1ca276 fix scanner missing library errors 2026-07-01 23:21:49 +08:00
ShukeBta 0493823b99 fix legacy library scan and empty pages 2026-07-01 22:54:13 +08:00
Shuke e18f73ed3c Fix formatting and update content in README.md
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-07-01 19:57:44 +08:00
Shuke 99edbe82b5 Update formatting of acquisition methods in README
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-07-01 19:56:38 +08:00
Shuke 5cdc556c80 Update README.md
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-07-01 19:55:39 +08:00
Shuke bdf29103f0 Update docker-compose.standard.yml
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-07-01 16:57:50 +08:00
ShukeBta 7ebeddfa38 fix docker media path normalization 2026-07-01 14:24:24 +08:00
ShukeBta 7cf0e468ff chore remove sse debug logging 2026-06-30 20:24:40 +08:00
ShukeBta 941bb273b9 fix media organize and subscription workflows 2026-06-30 20:18:02 +08:00
ShukeBta b41f62d572 docs: split compose deployment tiers 2026-06-29 09:09:37 +08:00
ShukeBta ca6abdc392 chore: ignore local tmp docker context 2026-06-28 16:53:35 +08:00
ShukeBta 6bb06fac14 ci: disable automatic release on tag push 2026-06-28 16:44:37 +08:00
ShukeBta a97dc35790 fix: stabilize library paths and subscription matching 2026-06-28 12:15:10 +08:00
ShukeBta e4abb03ca5 fix subscription management loading 2026-06-27 19:17:27 +08:00
ShukeBta 5455d4d2e4 fix download notification privacy 2026-06-27 18:08:04 +08:00
ShukeBta f386333b9c fix emby cloud library merging 2026-06-27 17:58:37 +08:00
ShukeBta 1ee10caa5a fix nexusphp search and library labels 2026-06-27 17:31:32 +08:00
ShukeBta a2be6a2127 fix library display and docker compose update 2026-06-27 16:54:33 +08:00
ShukeBta 41f39d9701 fix review regressions in titles and strm paths 2026-06-27 16:07:30 +08:00
ShukeBta e22b537de4 fix library deletion and strm path handling 2026-06-27 15:58:15 +08:00
ShukeBta d18d4239db refactor admin library table 2026-06-27 15:26:21 +08:00
ShukeBta a5a4baeaf6 refactor login page layout 2026-06-27 15:20:05 +08:00
ShukeBta c8fe2f6516 fix active subscription visibility after updates 2026-06-27 15:14:43 +08:00
ShukeBta f88b833cbc refactor strm generate section 2026-06-27 15:14:24 +08:00
ShukeBta d45a8ceeec split admin library panel 2026-06-27 14:45:37 +08:00
ShukeBta cf6688a5be split play profile model helpers 2026-06-27 13:46:41 +08:00
ShukeBta 5dfe42b253 split admin settings handlers 2026-06-27 13:35:22 +08:00
ShukeBta 1d259f5135 split cloud mount handler 2026-06-27 13:29:16 +08:00
ShukeBta 81e42a5b8a split ffprobe parsing helpers 2026-06-27 13:22:46 +08:00
ShukeBta 24808f6802 split clouddrive2 dav helpers 2026-06-27 13:16:33 +08:00
ShukeBta 3b1e797893 split database migration helpers 2026-06-27 13:10:01 +08:00
ShukeBta 53f659e113 split douban response parsing helpers 2026-06-27 13:01:22 +08:00
ShukeBta e47304f96d split qbittorrent adapter list handling 2026-06-27 12:55:30 +08:00
ShukeBta 05826e2fc7 split service boot helpers 2026-06-27 12:46:55 +08:00
ShukeBta 3b74a68c83 split telegram help commands 2026-06-27 12:36:16 +08:00
ShukeBta 9b48c6d57f split cloud text reader 2026-06-27 12:28:01 +08:00
ShukeBta 6d5a141096 split cloud qr handlers 2026-06-27 12:20:05 +08:00
ShukeBta 49e8a0c34e split media repository upsert strategy 2026-06-27 12:13:52 +08:00
ShukeBta e5bdc080b4 split openlist helper types 2026-06-27 12:05:47 +08:00
ShukeBta c229268263 split pan115 qr login helpers 2026-06-27 11:58:52 +08:00
ShukeBta c9cc4a6ee9 split site adapter helpers 2026-06-27 11:51:39 +08:00
ShukeBta f25873e1aa split ai runtime config helpers 2026-06-27 11:43:41 +08:00
ShukeBta 649339ac95 split bot cleanup rule config helpers 2026-06-27 11:38:13 +08:00
ShukeBta 674263c0d8 split download runtime config helpers 2026-06-27 11:31:23 +08:00
ShukeBta c5116c1a3b split cloud path rescrape helpers 2026-06-27 11:25:03 +08:00
ShukeBta 7887ea6dc5 split media series key helpers 2026-06-27 11:18:04 +08:00
ShukeBta b536cd1a6e split qbittorrent conversion helpers 2026-06-27 11:04:07 +08:00
ShukeBta e8ecf764d4 split telegram api client helpers 2026-06-27 10:56:31 +08:00
ShukeBta 1d984d75bb split organizer target template helpers 2026-06-27 10:49:36 +08:00
ShukeBta d26b557ea0 split discover provider rails 2026-06-27 10:42:39 +08:00
ShukeBta ea9845622c split auth token issuance helpers 2026-06-27 10:34:54 +08:00
ShukeBta 9db0be2d79 split duplicate grouping and hash helpers 2026-06-27 10:28:18 +08:00
ShukeBta 7cf27ab509 split storage config secret helpers 2026-06-27 10:20:46 +08:00
ShukeBta 94ac6fa6ce split mteam adapter response parser 2026-06-27 10:15:17 +08:00
ShukeBta 300d3fd502 split image proxy remote fetch helpers 2026-06-27 10:03:37 +08:00
ShukeBta fd721157db split subscription episode query helpers 2026-06-27 09:55:59 +08:00
ShukeBta 246eaa7b53 split scanner cloud entry helpers 2026-06-27 09:49:42 +08:00
ShukeBta 36d2d2fbf4 split organize pipeline helpers 2026-06-27 09:42:52 +08:00
ShukeBta bdbb2a079d split telegram command menus 2026-06-27 09:36:08 +08:00
ShukeBta 94e179ce48 split subscription rss run helpers 2026-06-27 09:28:56 +08:00
ShukeBta b2b7bd6c0e split organizer reclassify helpers 2026-06-27 09:21:51 +08:00
ShukeBta 560bae2b5d split organizer library classification helpers 2026-06-27 09:13:52 +08:00
ShukeBta 91b9543dc1 split filemanager root helpers 2026-06-27 09:05:53 +08:00
ShukeBta c6432cf0c0 split task tracker organize helpers 2026-06-27 08:57:39 +08:00
ShukeBta 6351eba0ee split api config service helpers 2026-06-27 08:50:24 +08:00
ShukeBta d049fee084 split token pending store helpers 2026-06-27 08:42:33 +08:00
ShukeBta ebd43f150c split sqlite migration helpers 2026-06-27 08:32:02 +08:00
ShukeBta 47069fb751 split core model definitions 2026-06-27 08:21:46 +08:00
ShukeBta fb78a167e1 split middleware helpers 2026-06-27 08:12:26 +08:00
ShukeBta ccfa50e3a9 split session tracker helpers 2026-06-27 03:47:02 +08:00
ShukeBta 0762f9fb5c split media service read paths 2026-06-27 03:39:15 +08:00
ShukeBta 8322808741 split config loading helpers 2026-06-27 03:30:20 +08:00
ShukeBta d53d01b419 split helper http utilities 2026-06-27 03:14:38 +08:00
ShukeBta a0b8ab3807 split metadata enrichment helpers 2026-06-27 02:58:26 +08:00
ShukeBta 008f3f2336 split transcoder service helpers 2026-06-27 02:42:42 +08:00
ShukeBta c32af8ff66 split server startup helpers 2026-06-27 02:34:06 +08:00
ShukeBta 0c4d871464 split subtitle service helpers 2026-06-27 02:24:52 +08:00
ShukeBta 44ebe47f64 default exclude incompatible subscription releases 2026-06-27 02:10:36 +08:00
ShukeBta d675c4e8ed split adult scraper helpers 2026-06-27 01:54:42 +08:00
ShukeBta b3c8b38f7a split aria2 adapter helpers 2026-06-27 01:45:18 +08:00
ShukeBta 9f22e4060b split transmission adapter helpers 2026-06-27 01:35:23 +08:00
ShukeBta ad1c3e55ba split telegram notification formatting 2026-06-27 01:27:34 +08:00
ShukeBta 2d7cb55c02 split ffmpeg install helpers 2026-06-27 01:15:06 +08:00
ShukeBta 5895d135a8 split notify channel dispatch 2026-06-27 01:08:02 +08:00
ShukeBta e5b946487f split telegram binding service 2026-06-27 00:55:06 +08:00
ShukeBta 8d0aa2b589 fix issue 51 library reclassify regressions 2026-06-27 00:40:39 +08:00
ShukeBta 4c839b3f4e split media handler scan routes 2026-06-27 00:18:02 +08:00
ShukeBta ce9c7638fa split frontend domain types 2026-06-27 00:07:03 +08:00
ShukeBta dd438d1ca4 split media recycle service 2026-06-26 23:59:12 +08:00
ShukeBta 185f5a31d9 fix library series episode display 2026-06-26 23:50:16 +08:00
ShukeBta 5c59664ed2 split license handler helpers 2026-06-26 23:32:49 +08:00
ShukeBta 8dff7f8a91 move file manager parent navigation 2026-06-26 23:28:04 +08:00
ShukeBta 2e660325d6 fix file manager parent navigation 2026-06-26 23:16:45 +08:00
ShukeBta e41105910e complete multi-root library path support 2026-06-26 23:02:07 +08:00
ShukeBta 2fbfed3ac9 split subscription availability helpers 2026-06-26 22:43:37 +08:00
ShukeBta 5beb2df342 split organize pipeline audit helpers 2026-06-26 22:34:23 +08:00
ShukeBta 06bb295f66 split media library root service 2026-06-26 22:26:30 +08:00
ShukeBta 5ef831f830 support multiple roots per library 2026-06-26 22:18:26 +08:00
ShukeBta bdc3a169f7 fix subscription dedup availability confirmation 2026-06-26 21:36:24 +08:00
ShukeBta 5450bc7c49 refactor organizer source flow by responsibility 2026-06-26 21:03:20 +08:00
ShukeBta a158039a13 refactor scraper query helpers by responsibility 2026-06-26 20:51:42 +08:00
ShukeBta ba5262b648 refactor download add responsibilities 2026-06-26 20:42:44 +08:00
ShukeBta 45ede69d7e fix subscription dedup progress tracking 2026-06-26 20:31:11 +08:00
ShukeBta bd35e5adf7 refactor emby api model types by topic 2026-06-26 20:13:59 +08:00
ShukeBta a28438fac6 refactor tmdb provider by responsibility 2026-06-26 20:00:42 +08:00
ShukeBta bc5eed4dec fix subscription dedup availability tracking 2026-06-26 19:46:44 +08:00
ShukeBta cf07a5167a refactor storage upload tests by topic 2026-06-26 19:46:29 +08:00
ShukeBta 4536b6199c refactor manual scrape service by responsibility 2026-06-26 19:17:29 +08:00
ShukeBta 4b50b5d166 refactor cloud metadata helpers by topic 2026-06-26 19:08:53 +08:00
ShukeBta 3bfb05450f fix subscription availability aliases 2026-06-26 18:58:56 +08:00
ShukeBta cdb532ac55 refactor site service by responsibility 2026-06-26 18:44:58 +08:00
ShukeBta e24fe022a4 refactor telegram bot user tests 2026-06-26 18:39:01 +08:00
ShukeBta cec89cad3b refactor telegram mgo commands by topic 2026-06-26 18:32:50 +08:00
ShukeBta c4f85e7b2c refactor scheduler tests by topic 2026-06-26 18:24:20 +08:00
ShukeBta 902758e8af refactor organizer directory tests 2026-06-26 18:16:02 +08:00
ShukeBta 67eefde17f fix subscription episode pack dedup 2026-06-26 18:15:30 +08:00
ShukeBta 2770a531f1 refactor download tests by topic 2026-06-26 17:59:46 +08:00
ShukeBta 49c146048c refactor media tests by topic 2026-06-26 17:49:42 +08:00
ShukeBta 83325eecea fix subscription existing torrent tracking 2026-06-26 17:31:15 +08:00
ShukeBta 0a5c65dd33 refactor scraper query tests 2026-06-26 17:30:54 +08:00
ShukeBta a587054443 fix subscription stale download dedup 2026-06-26 17:05:31 +08:00
ShukeBta 0e11d64a2f refactor organizer reclassify tests 2026-06-26 17:04:34 +08:00
ShukeBta 53b4552c72 refactor download add tests 2026-06-26 16:24:22 +08:00
ShukeBta 6f65544542 fix subscription range dedup 2026-06-26 16:05:03 +08:00
ShukeBta 82186eb093 refactor stream service helpers 2026-06-26 15:37:38 +08:00
ShukeBta d184e687c7 refactor scraper service wiring 2026-06-26 15:27:21 +08:00
ShukeBta f5e36c9a5c fix subscription episode range dedup 2026-06-26 15:20:48 +08:00
ShukeBta a7e7dcf764 refactor local metadata helpers 2026-06-26 15:20:38 +08:00
ShukeBta d67dab1f4c refactor scheduler service helpers 2026-06-26 14:54:26 +08:00
ShukeBta 1c0e51135e refactor qbittorrent client helpers 2026-06-26 14:44:05 +08:00
ShukeBta 75fed9532c refactor media classifier helpers 2026-06-26 14:35:13 +08:00
ShukeBta 6534347be6 fix subscription download dedup scope 2026-06-26 14:25:42 +08:00
ShukeBta 335a7fa14b refactor device service helpers 2026-06-26 14:25:27 +08:00
ShukeBta 09c4e9252c refactor scraper service helpers 2026-06-26 14:08:37 +08:00
ShukeBta c08460baa7 refactor media service helpers 2026-06-26 13:58:19 +08:00
ShukeBta 7d883515d4 fix subscription episode range dedup 2026-06-26 13:50:53 +08:00
ShukeBta b71ff818fa split system update execution helpers 2026-06-26 13:28:12 +08:00
ShukeBta 93be60ed99 add docker hot update management 2026-06-26 13:02:40 +08:00
ShukeBta 65adca04df fix media workflows and download organization 2026-06-26 08:42:16 +08:00
ShukeBta 0da96f7e4e feat: verify license responses with public key 2026-06-25 14:46:28 +08:00
ShukeBta a4a93b7fed fix: report licensed instances on startup 2026-06-25 14:11:13 +08:00
ShukeBta 74aa654c92 fix: enforce license-backed user capacity 2026-06-25 13:27:15 +08:00
ShukeBta bce0143dbc merge: integrate media workflow fixes 2026-06-25 12:45:45 +08:00
ShukeBta d1307bfcc1 fix: honor explicit scrape media type 2026-06-25 12:23:53 +08:00
ShukeBta 13cca43813 refactor: split library page details 2026-06-25 11:50:39 +08:00
ShukeBta e16fecb3ec fix: correct organize media type classification 2026-06-25 11:50:02 +08:00
ShukeBta 16c3ed239a fix: normalize organize category roots 2026-06-25 08:54:42 +08:00
ShukeBta 89af1dad32 refactor: split layout shell sections 2026-06-25 07:57:01 +08:00
ShukeBta c61d0190f2 refactor: split media detail page 2026-06-25 07:32:52 +08:00
ShukeBta 087a00b5d0 fix: guard series metadata from episode identity pollution 2026-06-25 07:22:00 +08:00
ShukeBta 3419077b67 refactor: split manual scrape dialog 2026-06-25 06:13:59 +08:00
ShukeBta d476f8ac8b docs: remove stale nfo todo 2026-06-25 05:45:42 +08:00
ShukeBta b2c04bb7b6 fix: clean media classification and series grouping 2026-06-25 05:37:31 +08:00
ShukeBta 64a7f78e1b refactor: split discover detail modal sections 2026-06-25 04:57:33 +08:00
ShukeBta 42ed6960a1 refactor: move page model helpers out of components 2026-06-25 04:45:35 +08:00
ShukeBta 7efa5d20b0 fix: tighten frontend quality checks 2026-06-25 04:33:41 +08:00
ShukeBta 8a57410626 fix: keep explicit source category after rejected scrape 2026-06-25 04:24:35 +08:00
ShukeBta 4e00c4c14d fix: refresh stale scan-derived media metadata 2026-06-25 03:40:50 +08:00
ShukeBta 5eb0d4c8af fix: reject low-confidence automatic metadata matches 2026-06-25 02:58:07 +08:00
ShukeBta 9021007027 fix: harden playback and security edge cases 2026-06-25 02:33:30 +08:00
ShukeBta 02fe4fd374 refactor: tighten media upsert metadata handling 2026-06-25 02:33:12 +08:00
ShukeBta 3d3679c21c fix: prevent episode metadata from splitting series 2026-06-25 02:32:55 +08:00
ShukeBta baa4a38b0a fix: refresh emby sessions in realtime 2026-06-25 02:32:32 +08:00
ShukeBta 29f5df673c fix: refresh emby realtime user sessions 2026-06-25 00:36:41 +08:00
ShukeBta f49ef7f1df refactor: split organize source file flow 2026-06-25 00:17:09 +08:00
ShukeBta ab6b8178f4 refactor: split storage config responsibilities 2026-06-25 00:05:52 +08:00
ShukeBta 9462f3b021 refactor: split storage upload clients 2026-06-25 00:00:20 +08:00
ShukeBta 82df266c4e refactor: split license client 2026-06-24 23:51:01 +08:00
ShukeBta 326d7f2eba refactor: reuse notify config controls 2026-06-24 23:47:51 +08:00
ShukeBta 92b5fbf5b5 refactor: split assistant chat sections 2026-06-24 23:41:36 +08:00
ShukeBta b31e22bfb5 refactor: split stats page sections 2026-06-24 23:37:51 +08:00
ShukeBta 21a0ba023d refactor: split cloud browser hooks 2026-06-24 23:32:12 +08:00
ShukeBta 359ee5003d refactor: split settings groups 2026-06-24 23:25:32 +08:00
ShukeBta e267781ab6 refactor: split sites page sections 2026-06-24 23:19:22 +08:00
ShukeBta 076dc1269f refactor: split profile form sections 2026-06-24 23:16:36 +08:00
ShukeBta 2693c04123 refactor: split license page sections 2026-06-24 23:11:32 +08:00
ShukeBta 711b538f3d refactor: split manual file organize hook 2026-06-24 23:07:55 +08:00
ShukeBta 6f3e678f79 refactor: split discover page sections 2026-06-24 22:59:17 +08:00
ShukeBta 95e723a931 refactor: split libraries page sections 2026-06-24 22:55:06 +08:00
ShukeBta c00c55cff4 refactor: split home page sections 2026-06-24 22:46:37 +08:00
ShukeBta ce2d194661 refactor: split task runtime tables 2026-06-24 22:13:32 +08:00
ShukeBta 996030eae8 refactor: split local scan flow 2026-06-24 22:06:39 +08:00
ShukeBta 070fd7459e refactor: split rss subscription run flow 2026-06-24 21:58:16 +08:00
ShukeBta bbc1d542bb refactor: split cloud scan import flow 2026-06-24 21:50:47 +08:00
ShukeBta d344446406 refactor: split subscription site search completion 2026-06-24 21:42:30 +08:00
ShukeBta dd3d4c1aec fix: load real discover posters 2026-06-24 21:30:44 +08:00
ShukeBta 9bdf1a73e7 refactor: split ffmpeg transcode args 2026-06-24 20:43:36 +08:00
ShukeBta 68e6b5050b refactor: split cloud scan candidate collection 2026-06-24 20:28:01 +08:00
ShukeBta a7c8d46589 fix: show latest discover posters 2026-06-24 20:17:38 +08:00
ShukeBta e5b5ecce93 refactor: split subscription site search enqueue 2026-06-24 19:48:22 +08:00
ShukeBta 35f8a166c6 refactor: split image proxy tests 2026-06-24 19:36:08 +08:00
ShukeBta aa506d6566 fix: harden discover artwork caching 2026-06-24 19:26:32 +08:00
ShukeBta d93a28f18f fix: retry failed discover artwork 2026-06-24 17:24:15 +08:00
ShukeBta 55673b1675 refactor: split subscription run tests 2026-06-24 16:58:40 +08:00
ShukeBta 13d61200fc refactor: split scanner cloud tests 2026-06-24 16:48:20 +08:00
ShukeBta 520b99d50f refactor: split emby handler compatibility tests 2026-06-24 16:37:11 +08:00
ShukeBta e898605f3a refactor: split emby compatibility tests 2026-06-24 16:11:44 +08:00
ShukeBta e56eed0758 fix: improve discover and manual scrape controls 2026-06-24 15:56:35 +08:00
ShukeBta c212207f74 refactor: split scraper service tests 2026-06-24 15:18:57 +08:00
ShukeBta 40cb8b07a2 refactor: split local metadata tests 2026-06-24 15:02:48 +08:00
ShukeBta 6be3409e3e fix: improve discover and series special handling 2026-06-24 14:49:26 +08:00
ShukeBta 3957349dc8 refactor: split bot feature tests 2026-06-24 14:18:30 +08:00
ShukeBta 7483b10853 refactor: split cloud provider tests 2026-06-24 14:11:21 +08:00
ShukeBta 34c9cb4244 fix: improve discover and manual scrape flows 2026-06-24 13:58:04 +08:00
ShukeBta 34396a3a8b refactor: split cloud mount modules 2026-06-24 13:23:28 +08:00
ShukeBta 7d7f2662dc refactor: split subscription candidate tests 2026-06-24 13:11:40 +08:00
ShukeBta 1b2cbabc71 refactor: split subscription planner modules 2026-06-24 13:06:39 +08:00
ShukeBta 0e79763462 refactor: split organizer scrape tests 2026-06-24 12:58:16 +08:00
ShukeBta a7fea2a0f5 refactor: split emby playback route tests 2026-06-24 12:54:18 +08:00
ShukeBta 47cd20f2ea refactor: isolate download organize tests 2026-06-24 12:45:23 +08:00
ShukeBta a8ee96211e refactor: split cloud browser state hook 2026-06-24 12:41:23 +08:00
ShukeBta ea34d7578e refactor: split strm page hooks 2026-06-24 12:31:51 +08:00
ShukeBta aec5914846 refactor: isolate cloud auto category tests 2026-06-24 12:21:39 +08:00
ShukeBta f7e6ae7b87 refactor: split oversized service tests 2026-06-24 12:18:39 +08:00
ShukeBta fb6a6b0c2f chore: ignore local tool binaries 2026-06-24 12:05:25 +08:00
ShukeBta 192f35d9fa refactor: split modules and harden scraping workflows 2026-06-24 11:59:18 +08:00
Shuke b164a46dd2 Update README.md
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-06-22 00:44:08 +08:00
ShukeBta c90c134d3a fix media playback and library workflows 2026-06-21 12:58:21 +08:00
ShukeBta efca3cbe69 fix media playback and library workflows 2026-06-21 12:53:52 +08:00
ShukeBta ada41e7078 docs: add security policy 2026-06-21 10:43:11 +08:00
ShukeBta 03741aff79 docs: update README contribution section 2026-06-21 10:43:11 +08:00
ShukeBta 7c579575b5 docs: add contribution guidelines 2026-06-21 10:33:27 +08:00
ShukeBta 7b881d031c fix organizer diagnostics and site integrations 2026-06-21 09:44:29 +08:00
ShukeBta 6e61c8b545 feat(telegram): 订阅/下载补齐 year/rating/genres/原名/语言 富通知字段
- 模型: Subscription/DownloadTask 新增 OriginalName/OriginalLanguage/Year/
  Rating/Genres 列(GORM AutoMigrate 自动建列, 无需手写迁移)
- enrich 链路: displayMetadata 与 DownloadTaskMeta 携带新字段, 从
  SearchExternalMedia(TMDb/豆瓣/Bangumi)结果回填并持久化; 放宽提前返回
  门槛, 已有海报但缺新字段时仍补齐
- 通知: 订阅/下载完成事件 data 注入 year/rating/genres/original_title/
  original_language, 喂给作者富模板 formatTelegramMediaNotification
- 测试: 新增端到端断言, 验证补齐字段透传进 Telegram caption
2026-06-20 01:45:40 +08:00
ShukeBta 9ef7721527 fix(telegram): 订阅/下载通知补充媒体模板所需字段
合并 #39 媒体通知模板后, formatTelegramMediaNotification 需要事件携带
片名/简介/外链/资源标题等字段才能渲染丰富内容。补充数据源中现成可用的字段:
- 订阅命中: title(订阅名)/overview/imdb_url(由 IMDBID 拼)/resource_title(首个命中资源)
- 下载完成: title/overview/resource_title(torrent 原始名, 供模板提取季集与版本)
缺失项(年份/评分/tmdb 链接等无现成来源)模板会自动略过, 不影响回退。
2026-06-20 01:16:48 +08:00
yebuwudong 8e43f66513 fix(telegram): improve media notification template 2026-06-20 01:08:55 +08:00
ShukeBta dff19801b6 fix(series): 彻底修复剧集被按单集显示且无法修复
根因(用真实数据确诊, 两个互相独立):
- Web 端拆集: episode NFO 的 <uniqueid type=tmdb> 是【单集 episode id】、
  <title> 是【单集名】, mergeEpisodeMetadata 却无条件覆盖了整剧 tm_db_id /
  original_name → 同剧每集 id/原名各不相同, 被 getSeriesKey 按 tmdb_id 拆成
  N 张单集卡(实测「遮天」90 集 = 89 个不同 tmdb_id)。
- Emby 散装单集: 电影库混入按 Season/SxxE 整理的剧集结构内容, 被判成 Episode
  却因 movie 库不聚 Series 而漏成散装单集。
- 无法修复: EnrichLibrary 只处理 pending/no_match, 跳过 matched 脏行。

修复:
- local_metadata.mergeEpisodeMetadata: 单集 NFO 的外部 id 不再写入整剧字段,
  单集名不再污染 original_name; 整剧 id/原名只认 tvshow.nfo。
- groupSeries.ts: 剧集分组改「库+路径剧名」优先(整剧目录对全剧一致), 外部 id
  仅作回退, 对存量脏数据也能合并。
- emby_compat: 电影库常规浏览新增 movieLibraryItems, 把剧集结构内容聚成 Series
  卡片与真电影并列(方案 B), 不再漏散装单集。
- cloud_path_repair: 「修复+重刮」先把脏的 matched 剧集行重置为 pending 再重刮,
  使其能被重新刮削; 结果新增 reset 计数。
- 新增 NormalizePollutedEpisodeMetadata 启动迁移(幂等): 按整剧目录聚合, 清洗
  tmdb/原名散乱的剧组并重置 pending。

测试: local_metadata/emby_compat/cloud_path_repair/episode_metadata_cleanup
新增或更新断言; 修正 scanner_cloud_test 中依赖旧错误行为的用例。
2026-06-20 01:04:19 +08:00
ShukeBta b40e2c7280 fix: 订阅恢复补缺集/单媒体手动整理入口/媒体库排序/网盘媒体分类迁移
- 订阅(问题1): selectPreparedSubscriptionCandidates 在本地已有部分集时不再
  无条件丢弃整季/全集包,单集候选不足时用全集包兜底;Restore 重置 total_episodes
  让下次 run 从权威元数据重算,避免被 feed 低估值锁死误判'无缺集'
- 单媒体整理(问题2): 后端已具备,新增 OrganizeMediaDialog 组件并在媒体详情页
  接入'整理入库'按钮(可选类型/二级分类/目标路径/转移方式/预览)
- 媒体库排序(问题3): ListByLibrariesFiltered 改多级排序 year/updated_at/
  created_at/id,id 作稳定 tie-breaker 消除云盘批量扫描同批时间戳的随机观感
- 网盘媒体消失(问题4): 云盘媒体 upsert 时允许 library_id 迁移到重新挂载的
  更精确分类库(本地媒体物理位置固定不迁移),修复一键挂载子目录后媒体消失
2026-06-19 21:29:02 +08:00
ShukeBta 9b4884359b fix(scrape): 单集名不再覆盖整剧 original_name 修复合集被拆集 + 媒体库页新增单库修复重刮
- 根因: 单集刮削把 TMDB episode.Name 写入 original_name(应为整剧原名),
  导致同剧每集 original_name 不同, 合集分组键回退到标题时被拆成多集无法合并。
  移除该覆盖, 保留单集 overview/剧照/评分/时长回填(不影响合集键)。
- 新增 RepairAndRescrapeLibrary 服务方法 + POST /admin/libraries/:id/repair-rescrape
  路由 + 媒体库页「修复+重刮本库」按钮, 可按库单独触发修复(回填占位符外部 ID)并重刮,
  既修空 ID 又借重刮覆盖此前被单集名污染的 original_name。
2026-06-19 21:29:02 +08:00
Shuke f873997d27 Update README.md
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-06-19 16:07:09 +08:00
ShukeBta 87bb94d4df feat(repair): 新增全库修复+重刮入口,剧集类路径正确归类
- 服务层 RepairAndRescrapeAllLibraries:先从路径占位符回填缺失/错误外部ID,再逐库重刮(含 no_match 重试)
- 新增 handler 与路由 POST /api/admin/media/repair-rescrape,异步执行经 WS 推送进度
- 前端媒体库页新增「全库修复+重刮」按钮调用该接口
- groupSeries:路径落在剧集类目录(电视剧/动漫/特别篇等)时按剧集卡处理,跳转分类视图
2026-06-19 12:23:56 +08:00
ShukeBta ce38fd257f chore: 移除源码注释与测试名中的第三方项目引用
将 cloud/external_search/filemanager/subscription_planner 注释及若干测试
函数名中的外部项目名替换为中性表述,不改变任何逻辑与行为。
2026-06-19 12:23:56 +08:00
ShukeBta b44c7f860b fix(media): 修复emby电影库混入剧集/特别篇识别/剧集合集 + 新增手动编辑元数据
emby 兼容层(hills/yamby 等客户端):
- 电影库不再混入剧集:filterLikelyEpisodicPathsFromMovieQuery 按路径
  特征(Season/S0x/Specials/电视剧/日番/国漫等)剔除误入电影库的剧集
- itemPayload 改用 mediaShouldBeEpisode:有季集号且(属剧集库或路径像剧集)
  才标记为 Episode,修正电影库里剧集被当成 Movie

特别篇识别:
- episode_parser 新增 patSpecialSeason,识别 S0/Season 0/Specials/SP/OVA/
  OAD/番外/特别篇/特典 文件夹 → 第0季;seasonFromParents 返回 found 标志,
  区分"明确第0季"与"未识别默认第1季"
- emby seasonDir 正则同步支持 Specials/SP/OVA/特别篇 等

剧集合集:
- groupSeries 抽出 isSeriesCard,卡片链接按是否剧集判定,修复剧集卡片
  跳转;每集分离的根因(季集号/分组key)随入库识别修复一并改善

手动编辑元数据(自采集视频无TMDB信息时可自定义封面/标题/简介/分类):
- 新增 MediaService.UpdateMetadata + PATCH /media/:id/metadata(需admin)
- 前端 MetadataEditDialog 接入 LibraryPage 与 MediaDetailPage

其他:scanner/scraper/nfo/local+cloud_metadata/adult 元数据识别配套改进;
.gitignore 忽略本地 .dev-cache/.dev-data 运行产物。
2026-06-19 12:23:56 +08:00
ShukeBta cce88ce3b0 fix(classify): anime 无元数据时纯中文译名不再误判国漫
日本动画的中文译名几乎都是纯汉字(如「葬送的芙莉莲」),原先靠 containsHan
判中文会把日本动画误判成国漫。新增 isChineseAnime:仅在有 origin_country/
语言元数据,或无元数据但有显式中文标记(国漫/国产/国创/华语)时才判国漫,
否则默认日番。有元数据时只认元数据,不受错误源目录名(如「国产剧」)误导。
未刮削的国漫刮出 origin_country=CN 后仍正确归类。补 4 个分类器测试用例。
2026-06-18 19:57:59 +00:00
ShukeBta b16f7d6663 fix(organize): 入库二级分类对齐MoviePilot结构 + 手动整理不再作为一级目录
- anime 顶层目录独立为「动漫」(动漫/{国漫,日番,儿童}),不再混入电视剧
- categoryPhysicalRootDir 把二级分类名映射到正确顶层:
  动画电影/华语电影/外语电影→电影; 国漫/日番/儿童→动漫;
  国产剧/日韩剧/欧美剧/纪录片/综艺/未分类→电视剧; 成人→成人
- 新增 staging 重定向:目标根落在「手动整理」等暂存目录时,上提到
  父级媒体根,媒体真正归入分类目录(如 媒体/电影/华语电影/片名),
  而非停留在手动整理下或与分类目录并列
- organizeLibraryRootForLayout 跳过 staging 名的库,避免回流暂存目录
- 同步更新陈旧测试期望(电视剧→动漫)+新增 staging 重定向测试
2026-06-18 19:32:59 +00:00
ShukeBta 0cec6606c1 fix(telegram): 区分'不在群'与'查不了',修复在群却被拒注册/绑定
getChatMember 失败(Bot不在群/频道非管理员/群ID失效/代理超时)此前被
吞成 false,导致已在群用户收到'不在绑定群组'误导提示。

- telegramChatMembership 返回三态(是/否/查不了)
- telegramUserBindDecision 据此返回 allowed/denied/unverifiable
- 四个拦截点(start绑定/register/redeem注册/hideadult)在'查不了'
  时改提示管理员检查Bot权限与群ID,不再误导用户
- syncgroupm 仅在'查实非成员'时判定可清理,查不了保守跳过防误删
- getChatMember 超时 8s->15s,缓解代理回退预算耗尽
2026-06-18 17:50:54 +00:00
ShukeBta 0a3748e564 Fix subscription restore and media library entry links 2026-06-18 10:54:10 +08:00
ShukeBta 6c50697a1a Fix cloud media metadata repair and merging 2026-06-18 02:58:30 +08:00
ShukeBta b492d9f867 Fix license sync and cloud scan scheduling 2026-06-18 01:39:39 +08:00
yebuwudong 692b7fb9df Improve Telegram notifications 2026-06-18 00:35:53 +08:00
ShukeBta e4b1488e11 Fix manual scrape apply for cloud media 2026-06-18 00:27:26 +08:00
ShukeBta 0291ab8194 Fix media scraping rename and compose pulls 2026-06-17 23:59:13 +08:00
ShukeBta 29ac7765d2 Fix logo assets and license heartbeat 2026-06-17 23:30:31 +08:00
ShukeBta fb362e1fec Backfill license usage and restore bot proxy fallbacks 2026-06-17 22:22:46 +08:00
ShukeBta a0aeb07f03 Fix notification event toggles and license heartbeat 2026-06-17 20:35:11 +08:00
ShukeBta b128b4d11e Improve Docker build caching 2026-06-17 20:06:09 +08:00
ShukeBta 850d3db811 Fix scraping workflows subscriptions bot and recycle bin 2026-06-17 19:48:17 +08:00
ShukeBta 91afbe54a2 Polish login copy and restore README community sections 2026-06-17 16:22:06 +08:00
ShukeBta 227407993c Restore legacy feature PRD document 2026-06-17 16:06:09 +08:00
ShukeBta ab4637beed Update MediaStationGo branding and deployment docs 2026-06-17 16:01:16 +08:00
ShukeBta 3368ac2947 Fix bot binding and terminal device limits 2026-06-17 01:06:20 +08:00
ShukeBta 8e5bf73edc Fix Emby handler tests on clean CI 2026-06-17 00:23:59 +08:00
ShukeBta 801af37462 Harden cleanup and client compatibility 2026-06-17 00:06:51 +08:00
ShukeBta 7766588f67 Rename Telegram compatibility commands to Mgo 2026-06-16 23:08:14 +08:00
ShukeBta 6e7854e8f6 Harden Telegram bot cleanup commands 2026-06-16 18:52:04 +08:00
ShukeBta 829b88036a Parallelize cloud library listing 2026-06-16 15:21:19 +08:00
ShukeBta 18ae6df115 Deduplicate missing cloud config warnings 2026-06-16 13:44:11 +08:00
ShukeBta 866b75f644 fix: refresh license status from server edits 2026-06-16 13:19:42 +08:00
ShukeBta 3f40b18909 Limit and split production logs 2026-06-16 12:58:27 +08:00
ShukeBta 877912fe4b Import local media before track probing 2026-06-16 12:31:07 +08:00
ShukeBta 5fc907e51b Fix subscription dedupe and scan throughput 2026-06-16 11:32:48 +08:00
ShukeBta 833966afb2 Clarify cleanup rule bot commands 2026-06-16 02:47:00 +08:00
ShukeBta a9b85c0f3f Allow virtual media reference ids 2026-06-16 02:20:27 +08:00
ShukeBta 284ec873bc Use compose deployment smoke in CI 2026-06-16 01:09:36 +08:00
ShukeBta 9a601a66b4 Wait longer for smoke startup 2026-06-16 01:03:12 +08:00
ShukeBta 852fe6ead0 Stabilize smoke startup port 2026-06-16 00:58:07 +08:00
ShukeBta 9af9217903 Skip HLS gate unless requested 2026-06-16 00:49:44 +08:00
ShukeBta 4979470469 Make smoke HLS check non-blocking 2026-06-16 00:44:12 +08:00
ShukeBta 6d872f45aa Fix smoke DLNA assertion 2026-06-16 00:28:21 +08:00
ShukeBta 74fef3e52c Harden smoke media fixture generation 2026-06-15 23:49:35 +08:00
ShukeBta db45b46f7b Fix SQLite migration fallback source 2026-06-15 23:29:35 +08:00
ShukeBta 61949c1e1c Fix SQLite to PostgreSQL media migration 2026-06-15 22:11:58 +08:00
ShukeBta 713fc6ec1e Harden Docker deployment tiers 2026-06-15 21:00:52 +08:00
ShukeBta fea1b1dda1 Fix PostgreSQL migration resume and home crash guard 2026-06-15 20:13:30 +08:00
ShukeBta 0a628a76f1 Document PostgreSQL deployment and SQLite migration 2026-06-15 18:48:47 +08:00
ShukeBta 90bfeb11f2 Hide Telegram compatibility aliases from command menus 2026-06-15 18:28:04 +08:00
ShukeBta eec2ee7fe3 Merge remote-tracking branch 'origin/main' into fix/organize-pipeline-cleanup 2026-06-15 18:19:24 +08:00
ShukeBta a5bf4bfdd4 Improve large library storage backends and scans 2026-06-15 18:16:34 +08:00
ShukeBta c9e6adf041 Support Chinese retention rule edits 2026-06-15 02:21:25 +08:00
ShukeBta ea44f21fcf Unify Telegram bot retention rules 2026-06-15 02:11:46 +08:00
ShukeBta 03be4df6e2 Hide duplicate Telegram bot command aliases 2026-06-15 01:43:24 +08:00
ShukeBta 9b9f8eae02 Adapt Sakura-style bot management commands 2026-06-15 01:21:45 +08:00
Shuke 93afa1aebb Delete .github/workflows/codeql.yml
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-06-15 01:05:44 +08:00
ShukeBta 406b311a8e Fix login stalls and bot redemption routing 2026-06-15 01:03:07 +08:00
ShukeBta 904701f29f Fix login stalls and bot redemption routing 2026-06-15 01:00:05 +08:00
ShukeBta 479a06f008 Unify organize ingest pipeline 2026-06-14 23:17:52 +08:00
ShukeBta a623702276 Deduplicate organize by multi-source metadata 2026-06-14 01:25:44 +08:00
ShukeBta 4a3ed77395 Fix organize metadata matching and Telegram group privacy 2026-06-14 01:12:55 +08:00
ShukeBta c76c72966a Fix organize targets and cloud transfer controls 2026-06-14 00:27:39 +08:00
ShukeBta 22b76c5e1d Make GHCR the default image source 2026-06-13 23:44:39 +08:00
ShukeBta a15ef68387 Document Docker image source options 2026-06-13 23:42:42 +08:00
ShukeBta 52b772ea45 Fix cloud playback and organize ingest pipeline 2026-06-13 23:21:56 +08:00
ShukeBta 4b2839d753 prefer openlist api credentials 2026-06-13 20:28:23 +08:00
ShukeBta b2b43e58da enforce pure cloud 302 playback 2026-06-13 17:45:10 +08:00
ShukeBta b6047af9fb add cloud playback diagnostics 2026-06-13 17:26:49 +08:00
ShukeBta 5f42836bbf fix organize trigger task flow 2026-06-13 17:08:56 +08:00
ShukeBta 4e0929bbca fix organize path mapping and task flow 2026-06-13 16:32:38 +08:00
Shuke 9070b4827c Delete PR_DESCRIPTION.md
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-06-13 14:46:15 +08:00
Shuke f9fb8630f9 Delete CONTRIBUTING.md
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-06-13 14:45:58 +08:00
Shuke 45e45d1043 Delete CLOUD_STATUS.md
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-06-13 14:45:46 +08:00
ShukeBta be8d001747 docs: highlight multi-user and Emby-compatible playback 2026-06-13 14:34:50 +08:00
Shuke 8a6f76f039 Update README.md
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-06-13 14:28:42 +08:00
ShukeBta ce1b234b17 docs: simplify deployment docs and restrict telegram group commands 2026-06-13 14:24:50 +08:00
ShukeBta a177dc61dd fix emby clients and cloud playback controls 2026-06-13 12:08:57 +08:00
ShukeBta ba43117a57 fix: honor STRM playback mode for cloud clients 2026-06-13 00:17:39 +08:00
ShukeBta 5d942f2c42 fix: stabilize STRM and cloud playback 2026-06-12 22:57:50 +08:00
ShukeBta d1a8824842 chore: consolidate contributor identities 2026-06-12 20:20:19 +08:00
ShukeBta 4b1f720107 chore: normalize git author identity 2026-06-12 19:50:38 +08:00
ShukeBta 4ee8d15cb8 Merge fix/resource-login-organize-playback 2026-06-12 19:21:27 +08:00
ShukeBta d270e9034f fix: 防止启动整理扫描阻塞登录 2026-06-12 19:06:47 +08:00
ShukeBta 1568ae127a perf: 重构 FTS 索引为 rowid 寻址+触发器维护,根治重启 CPU 占满与扫描卡死
- FTS5 普通列(含 UNINDEXED)不支持索引查找,旧版按 media_id 做
  NOT EXISTS/DELETE 全是整表扫描:启动回填 O(N^2) 烧 CPU 数小时,
  扫描时每个 upsert 一次全表扫描,均隔着全局写锁拖死登录(125s 超时)
- v2 布局:FTS rowid 与 media.rowid 对齐,由 INSERT/UPDATE/DELETE
  触发器实时维护;回填只剩 rowid 点查兜底,且去掉 ORDER BY
- 顺带修复刮削直写 Updates() 后新标题搜不到的问题(触发器覆盖)
- 写门改为 context 感知:长写语句不再让登录请求无限期排队
- warmMediaSearchIndex 延迟 30s 错峰启动
- library_scan 周期任务跳过云盘库(由 cloud_sync 夜间窗口负责),
  首轮延迟从 15s 改为等满一个周期,重启不再立即全量扫描风暴
- prune 改为只取 id/path 并按 500 条批量删除,缩短写锁占用
2026-06-12 06:41:14 +00:00
ShukeBta a7557918ce chore: 限制容器日志体积并补全 .gitignore
- docker-compose 增加 json-file 日志上限(10m x 3),防止日志无限增长
  持续消耗宿主机磁盘与 IO
- .gitignore 补充 .tmp-* 运行产物、downloads/、media/、*.pid
- 清理仓库目录中遗留的临时日志、诊断脚本与旧二进制(约 60MB,均未被
  git 跟踪)
2026-06-12 13:19:42 +08:00
ShukeBta 585edeb984 fix: 资源占用/登录稳定性/QB整理入库/第三方播放404 综合修复
资源占用(Docker 部署 CPU/内存长期居高):
- 云盘探测预算改为按尝试扣减,杜绝队列满时对每个文件反复入队
  并刷出数万条 WARN(实测日志 41165 条)
- 探测队列满时给文件挂 30 分钟退避 + 告警限速为每分钟一条
- 扫描时每个文件的海报/背景图由同步下载(单张最长 20s)改为
  后台预取队列,云盘大库扫描不再串行拉图数小时
- PlaybackInfo 的云盘 ffprobe 探测改异步(原同步最长 8s,
  既拖慢起播又放大云盘流量),带单飞去重
- 访问日志跳过 /api/health 与静态资源;logging.level/format
  配置真正生效(此前是死配置)

登录稳定性(经常登录报错):
- refresh token 未及时落库期间,刷新请求可识别「待落库令牌」,
  不再把用户踢回登录页;轮换/登出后取消后台补写,防止旧令牌复活

QB 下载整理入库:
- 新增 download.path_mappings 设置:自定义下载器→本程序路径映射
  (每行 客户端路径=本地路径),并复用 compose 环境变量映射规则
- 应用重启后补整理最近 24h 内完成的种子(此前重启即永久漏掉)
- 下载客户端初始化失败仍注册并惰性重连(容器启动顺序免疫)
- 硬链接跨文件系统(EXDEV)自动降级为复制,保种语义不变

第三方播放器 404:
- 播放处理器不再把所有错误吞成 404:媒体不存在→404,
  云盘解析失败/STRM 关闭→502+原因
- 存库的云盘播放 URL 规范化为相对路径,免疫扫描时固化的旧 host
- 云盘媒体 SupportsDirectPlay=false,强制走带鉴权的 DirectStream
2026-06-12 13:19:42 +08:00
ShukeBta 8d1d1f18a8 chore: 限制容器日志体积并补全 .gitignore
- docker-compose 增加 json-file 日志上限(10m x 3),防止日志无限增长
  持续消耗宿主机磁盘与 IO
- .gitignore 补充 .tmp-* 运行产物、downloads/、media/、*.pid
- 清理仓库目录中遗留的临时日志、诊断脚本与旧二进制(约 60MB,均未被
  git 跟踪)
2026-06-12 04:32:47 +00:00
ShukeBta 91df7521ce fix: 资源占用/登录稳定性/QB整理入库/第三方播放404 综合修复
资源占用(Docker 部署 CPU/内存长期居高):
- 云盘探测预算改为按尝试扣减,杜绝队列满时对每个文件反复入队
  并刷出数万条 WARN(实测日志 41165 条)
- 探测队列满时给文件挂 30 分钟退避 + 告警限速为每分钟一条
- 扫描时每个文件的海报/背景图由同步下载(单张最长 20s)改为
  后台预取队列,云盘大库扫描不再串行拉图数小时
- PlaybackInfo 的云盘 ffprobe 探测改异步(原同步最长 8s,
  既拖慢起播又放大云盘流量),带单飞去重
- 访问日志跳过 /api/health 与静态资源;logging.level/format
  配置真正生效(此前是死配置)

登录稳定性(经常登录报错):
- refresh token 未及时落库期间,刷新请求可识别「待落库令牌」,
  不再把用户踢回登录页;轮换/登出后取消后台补写,防止旧令牌复活

QB 下载整理入库:
- 新增 download.path_mappings 设置:自定义下载器→本程序路径映射
  (每行 客户端路径=本地路径),并复用 compose 环境变量映射规则
- 应用重启后补整理最近 24h 内完成的种子(此前重启即永久漏掉)
- 下载客户端初始化失败仍注册并惰性重连(容器启动顺序免疫)
- 硬链接跨文件系统(EXDEV)自动降级为复制,保种语义不变

第三方播放器 404:
- 播放处理器不再把所有错误吞成 404:媒体不存在→404,
  云盘解析失败/STRM 关闭→502+原因
- 存库的云盘播放 URL 规范化为相对路径,免疫扫描时固化的旧 host
- 云盘媒体 SupportsDirectPlay=false,强制走带鉴权的 DirectStream
2026-06-12 04:31:34 +00:00
ShukeBta a13aba1d9f fix: validate downloader RPC endpoints 2026-06-11 22:58:06 +08:00
ShukeBta 7d0afd0fcb fix: reduce sqlite write pressure during scans 2026-06-11 21:45:53 +08:00
ShukeBta 0e28d07122 fix: restore storage endpoint for smoke test 2026-06-11 21:11:05 +08:00
ShukeBta 52d75171b2 fix: allow login during sqlite write pressure 2026-06-11 20:06:55 +08:00
ShukeBta 7274ea19b7 fix: cap CPU and avoid heavy startup work 2026-06-11 19:48:00 +08:00
ShukeBta d9f87c61b6 fix: keep login responsive during background organize 2026-06-11 19:28:49 +08:00
ShukeBta 8b32d99b5c fix spa fallback for library routes 2026-06-11 18:57:02 +08:00
ShukeBta 2ad9606ad5 fix full library display and cloud search visibility 2026-06-11 18:50:05 +08:00
Shuke 42d04afbb2 Delete diagnose-cloud.ps1
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-06-11 18:39:52 +08:00
Shuke bb425ee9d4 Delete diagnose-cloud.sh
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-06-11 18:39:42 +08:00
Shuke f05ee25ff8 Delete deploy-update.sh
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-06-11 18:39:29 +08:00
ShukeBta 23d1fbfddb fix cloud library scale and playback compatibility
Root cause: display-only cloud library filtering was reused by scan jobs, merged cloud mounts could be skipped, and OpenList listing relied on WebDAV/first-page behavior that could cap huge directories around 100 items. Cloud scans also let new items consume probe budget before existing rows with missing track/artwork metadata.\n\nChanges:\n- split display filtering from scannable cloud filtering so merged cloud mounts still scan\n- add OpenList API pagination with WebDAV fallback\n- prioritize existing cloud media missing metadata before new imports\n- restrict automatic cloud sync to one successful 19:00-21:00 daily window and keep manual scan immediate\n- disable startup cloud full-scan by default, with an explicit opt-in setting\n- improve Emby/cloud playback compatibility and cache/search/test coverage from the continued work
2026-06-11 18:31:01 +08:00
ShukeBta c22d8271ae fix: use Get instead of FindByID to lookup storage config by type
The scanner was incorrectly using FindByID(mount.Provider) where mount.Provider
is a type string like 'openlist', not a database ID. This caused all cloud
library scans to fail with 'storage config not found'.

Changed to use Get(ctx, mount.Provider) which correctly looks up by type field.

Fixes cloud library scanning for all providers (openlist/quark/115/clouddrive2)
2026-06-11 13:21:37 +08:00
ShukeBta 66a75e61b4 fix: update diagnostic script port and add deployment script
- Fix API health check to use correct port 18080
- Add deploy-update.sh for easy deployment on NAS
- Include log viewing after deployment
2026-06-11 13:03:59 +08:00
ShukeBta 197d8bd463 fix: add bash diagnostic script for Debian/飞牛OS
- Replace PowerShell script with bash version
- Compatible with Debian-based systems
- Same diagnostic functionality for cloud storage
2026-06-11 13:00:34 +08:00
ShukeBta 53135b444f docs: add cloud storage diagnostics and status report
- Add PowerShell diagnostic script for cloud storage issues
- Document all cloud storage features and capabilities
- Provide troubleshooting guide for common problems
- Include configuration examples and best practices
2026-06-11 12:56:15 +08:00
ShukeBta 7ef4af6287 feat: add cloud storage health check on startup
- Validate all enabled cloud storage configs at boot
- Ping each cloud provider to ensure connectivity
- Log health status for better diagnostics
- Help identify cloud storage configuration issues early
2026-06-11 12:54:26 +08:00
ShukeBta 95794f7bbc fix: add storage deletion with library cleanup and scan validation
- Delete storage config now properly removes related libraries and media
- Scanner validates storage config exists before starting cloud scan
- Prevents orphaned scans when storage is removed
- Added repository methods: DeleteByType, FindByID for storage config
- Remove unused storageHandler route to fix compilation
2026-06-11 12:48:32 +08:00
ShukeBta e99d70b017 feat: improve cloud media playback and Emby compatibility
Based on nowen-video and MoviePilot implementations:
- Mark STRM media as IsRemote=true in Emby MediaSource
- Improve cloud media identification using STRMURL field
- Clean up unused cloud play service scaffolding
- Ensure third-party players (Infuse/Emby) recognize cloud media correctly
2026-06-11 12:17:14 +08:00
ShukeBta 39144d70ba fix: translate qBittorrent container paths for auto-organize
- Add path translation for cross-container download paths
- Map common qBittorrent container paths to /downloads
- Fixes 'payload path is not accessible' warnings in logs
- Enables auto-organize to work when qBittorrent runs in separate container
2026-06-11 11:38:08 +08:00
ShukeBta fab2b98096 fix: enable CORS for all Emby protocol paths
- Allow CORS for /emby/*, /Users/*, /Items/* routes
- Supports all Emby-compatible players (Infuse, Emby apps, Jellyfin, etc)
2026-06-11 11:31:16 +08:00
ShukeBta 62b204367c fix: auto-scan cloud libraries on boot + allow CORS for media playback
- Add BootCloudLibraries() to auto-scan all cloud libraries on startup
- Delay 3s to avoid conflict with system init, scan without auto-scrape
- Enable CORS for /api/cloud/play/* and /api/img to support 3rd-party players
- Fixes issue where each user triggers separate cloud library scans
- Fixes issue where Infuse/Emby apps cannot play cloud resources
2026-06-11 11:25:43 +08:00
ShukeBta d90b58ba22 fix: cache cloud artwork before library import 2026-06-11 00:54:29 +08:00
ShukeBta 542e85a067 fix: reduce emby load and stabilize strm scan 2026-06-11 00:18:51 +08:00
ShukeBta 859d6e9d24 implement automatic strm generation 2026-06-10 21:36:55 +08:00
ShukeBta fd0428ee7d fix cloud library mounts and artwork caching 2026-06-10 20:43:42 +08:00
ShukeBta 0bf983cec8 fix: improve cloud mount scanning feedback 2026-06-10 18:46:30 +08:00
ShukeBta 6a892d6549 fix: handle OpenList unicode mount paths 2026-06-10 12:47:39 +08:00
ShukeBta 0f82cb3084 feat: improve OpenList cloud storage support 2026-06-10 00:08:35 +08:00
ShukeBta 9f2d7a507b chore: allow docker build registry proxies 2026-06-09 22:02:12 +08:00
ShukeBta 8c8ff131f5 chore: reduce docker runtime vulnerability surface 2026-06-09 21:45:41 +08:00
ShukeBta eab8c3c709 feat: add clouddrive2 storage bridge 2026-06-09 20:22:00 +08:00
ShukeBta eda5d9a978 docs: simplify docker compose path guide 2026-06-09 19:29:26 +08:00
ShukeBta bfb0243712 fix: limit ffprobe concurrency 2026-06-09 19:18:21 +08:00
ShukeBta 7d7f3cc758 feat: add cloud transfer and cache optimizations 2026-06-09 19:03:28 +08:00
ShukeBta a55fc502ca feat: add configurable scraper throttling 2026-06-09 17:56:25 +08:00
ShukeBta 76fe0dbde9 feat: scrape metadata after organize 2026-06-09 17:00:24 +08:00
ShukeBta 81f0cabe91 docs: simplify docker compose deployment 2026-06-09 14:05:26 +08:00
ShukeBta fe0e7a09ce fix: improve media file organization workflow 2026-06-09 13:57:34 +08:00
ShukeBta 76d6e0c8ff fix: dedupe RSS subscription variants 2026-06-08 21:50:15 +08:00
Shuke 691b1cee17 Update README.md
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-06-08 19:57:48 +08:00
Shuke 7e77af5a7d Update NodeSeek link format in README.md
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-06-08 19:57:09 +08:00
Shuke 63a834b8ee Update README.md
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-06-08 19:56:25 +08:00
ShukeBta 2e26b71478 fix: add organize ingest action 2026-06-08 10:39:02 +08:00
ShukeBta ab9e5d6a8f fix: classify download organize output 2026-06-08 10:06:55 +08:00
ShukeBta cf6c58d26e fix: improve cleanup rule display 2026-06-08 09:52:17 +08:00
ShukeBta dea45c5423 fix: load full poster wall media 2026-06-07 22:46:57 +08:00
ShukeBta 22556f65d3 feat: add telegram bulk unbind commands 2026-06-07 22:27:11 +08:00
ShukeBta 8c8d472524 fix: add smart download classification 2026-06-07 21:34:21 +08:00
ShukeBta 681fc33dd5 fix(server): prevent stale SPA white screen after updates 2026-06-07 19:46:58 +08:00
ShukeBta 88ee6d6bb7 fix(downloads): prevent subscription readding deleted torrents 2026-06-07 19:20:10 +08:00
ShukeBta a5d061afa6 refactor: split site adapters and site page modules 2026-06-07 18:55:51 +08:00
ShukeBta df02fd1166 fix: harden bot accounts and download handling 2026-06-07 18:30:27 +08:00
ShukeBta 7e37126f7c fix(downloads): prevent readding existing media 2026-06-07 17:10:10 +08:00
ShukeBta 132096a596 fix(emby): support lowercase client routes 2026-06-07 16:07:54 +08:00
ShukeBta eb5ceba366 fix(bot): keep private replies out of group chats 2026-06-07 15:43:47 +08:00
ShukeBta afd42a5985 fix(bot): allow deleting all cleanup rules 2026-06-07 15:26:09 +08:00
ShukeBta 33698daa3f refactor: split oversized route and subscription modules 2026-06-07 14:59:32 +08:00
ShukeBta b35b36738e fix(telegram): honor proxy fallbacks for bot api 2026-06-07 13:58:24 +08:00
ShukeBta 1d88a09568 fix(subscription): only enqueue missing media 2026-06-07 13:36:49 +08:00
ShukeBta d77e890f51 fix(app): close feature association gaps 2026-06-07 12:55:27 +08:00
ShukeBta a3b4dbf1c1 fix(bot): complete command flow coverage 2026-06-07 12:42:21 +08:00
ShukeBta b38d47a27d feat(bot): manage device policy via telegram commands 2026-06-07 12:24:21 +08:00
ShukeBta cf2f77f35d chore(repo): add lint config and normalize scripts 2026-06-07 11:54:45 +08:00
ShukeBta d94330b30f fix(subscription): prevent duplicate qb downloads 2026-06-07 10:05:58 +08:00
shuk shuk 22b64d3d47 fix(organize): strip release tags/roman numerals/season markers; de-hardcode paths
feat(bot): button menu, capacity/open-reg quota, redemption codes, user mgmt,
account expiry + signin streak, device anti-sharing + inactivity cleanup,
one-click kick, self-service username/password

- Consolidate organize/rename defaults into Tools panel

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-07 09:54:14 +08:00
soldosluka857 f1d87638f3 fix(subscription): apply rules on RSS path, dedup movies via library, MoviePilot-style default excludes
- runOne (RSS) now applies matchesSubscriptionRules so ExcludeWords/Resolution/Quality/Effects/ReleaseGroups take effect (previously only the Filter regex ran, so editing 排除 had no effect on RSS subscriptions)
- movie candidate selection now consults library availability: non-wash subscriptions skip titles already in the library, stopping repeated downloads of 10bit/Dolby releases (aligns with MoviePilot 'download once = satisfied')
- add MoviePilot-style default exclude list (cam/ts/枪版/trailer/sample...) merged with user excludes; latin tokens matched on word boundaries to avoid substring false positives
- frontend: send rule fields as raw strings on edit so 排除词 can be cleared/changed (was dropped by '|| undefined')

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 23:14:22 +08:00
ShukeBta de3f1f3bb4 fix: honor licensed user limits 2026-05-30 21:48:09 +08:00
soldosluka857 e08b827861 fix(115/302): propagate auth token across stream→cloud/play redirect so browser <video> stays authenticated
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 21:04:23 +08:00
soldosluka857 3fa6932c99 fix(115): resolve direct link via app/chrome/downurl + bind CDN link to client UA
115 deprecated the plain web /files/download endpoint (ordinary cookies no
longer get file_url), breaking 302 playback with 'no file_url'. Switch
Resolve() to the current proapi.115.com/app/chrome/downurl endpoint, which
uses 115's m115 (RSA+XOR) request/response encryption (vendored from the
MIT-licensed SheltonZhu/115driver).

115 CDN links are bound to the User-Agent used to request them, so resolve
with the playback client's own UA (plumbed from the play handler) — the host
can then issue a pure 302 the client fetches directly, preserving true offload.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 21:04:23 +08:00
soldosluka857 74271081f4 feat(cloud): 115/夸克网盘 providers with cookie + QR login and 302 playback
Add a pluggable cloud-disk subsystem (internal/service/cloud) exposing remote
files as playable media via HTTP 302 redirects, so the host never transcodes
nor (by default) streams the bytes:

- quark (夸克网盘): cookie auth, directory listing + download_url resolution.
- cloud115 (115 网盘): cookie auth + QR-code login flow (token/poll/exchange);
  pickcode → CDN URL resolved for 302 offload.
- StorageConfigService gains cloud types, Ping() probes, List/Resolve/Import.
- New endpoints: admin /cloud/:type/{list,import,qr/start,qr/poll} and authed
  /cloud/play/:type (302 redirect, or reverse-proxy when the link needs auth
  headers). Imported files become Media rows with STRMURL → the play endpoint.
- Frontend: 115网盘/夸克网盘 tabs with cookie input, 115 QR-code login, a cloud
  file browser and one-click 302 import.

Providers are exercised against httptest mock servers (list/resolve/QR state
machine). Live login + playback require a real cloud account.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 21:04:23 +08:00
soldosluka857 e50282178d feat(playback): client direct-play decode mode (release host transcoding)
Add an admin toggle playback.direct_only that offloads all decoding to the
client. When enabled:
- Emby PlaybackInfo no longer advertises SupportsTranscoding nor a
  TranscodingUrl, forcing Emby/Infuse/Yamby clients to direct play.
- HLS endpoints refuse (ErrTranscodeDisabled) so the host never spawns ffmpeg.
- Web player forces direct play, hides the HLS toggle, and surfaces a hint.
Exposed via /system/info (direct_play_only) and the Settings page.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 21:04:23 +08:00
Shuke 48aa08fc94 Revert "feat(playback): client direct-play decode mode (release host transcoding)"
This reverts commit 8fd2ab4b51.
2026-05-30 21:04:03 +08:00
Shuke 6a9096d3aa Revert "feat(cloud): 115/夸克网盘 providers with cookie + QR login and 302 playback"
This reverts commit c6455103e6.
2026-05-30 21:04:03 +08:00
soldosluka857 c6455103e6 feat(cloud): 115/夸克网盘 providers with cookie + QR login and 302 playback
Add a pluggable cloud-disk subsystem (internal/service/cloud) exposing remote
files as playable media via HTTP 302 redirects, so the host never transcodes
nor (by default) streams the bytes:

- quark (夸克网盘): cookie auth, directory listing + download_url resolution.
- cloud115 (115 网盘): cookie auth + QR-code login flow (token/poll/exchange);
  pickcode → CDN URL resolved for 302 offload.
- StorageConfigService gains cloud types, Ping() probes, List/Resolve/Import.
- New endpoints: admin /cloud/:type/{list,import,qr/start,qr/poll} and authed
  /cloud/play/:type (302 redirect, or reverse-proxy when the link needs auth
  headers). Imported files become Media rows with STRMURL → the play endpoint.
- Frontend: 115网盘/夸克网盘 tabs with cookie input, 115 QR-code login, a cloud
  file browser and one-click 302 import.

Providers are exercised against httptest mock servers (list/resolve/QR state
machine). Live login + playback require a real cloud account.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 20:23:55 +08:00
soldosluka857 8fd2ab4b51 feat(playback): client direct-play decode mode (release host transcoding)
Add an admin toggle playback.direct_only that offloads all decoding to the
client. When enabled:
- Emby PlaybackInfo no longer advertises SupportsTranscoding nor a
  TranscodingUrl, forcing Emby/Infuse/Yamby clients to direct play.
- HLS endpoints refuse (ErrTranscodeDisabled) so the host never spawns ffmpeg.
- Web player forces direct play, hides the HLS toggle, and surfaces a hint.
Exposed via /system/info (direct_play_only) and the Settings page.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 20:23:55 +08:00
soldosluka857 f0bc066134 feat(telegram): admin-toggleable Bot registration + remove duplicate 最近入库 on 运行状态
- Add telegram.registration_enabled setting (default off); admins toggle via
  Settings page or /registration on|off bot command.
- Add /register (/reg /signup) bot command: when enabled, regular users can
  create a MediaStation account and auto-bind their Telegram. Reuses AuthService
  (username-taken / user-limit handling) and keeps new accounts as regular users.
- Regular users still limited to bind / adult-toggle / start / help; all other
  commands remain admin-only.
- Update /start and /help text to surface registration when enabled.
- Remove the duplicate 最近入库 module from the 运行状态 (stats) page; the homepage
  already shows recently added.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 18:34:00 +08:00
soldosluka857 d03f84e78d feat(organize): organize arbitrary source dir (e.g. downloads) with dedup + 洗版
- Add OrganizeDirectory service: walk an arbitrary source directory (download
  dir / NAS direct-read path) and organize video files into the destination,
  without requiring the source to be a registered library.
- Dedup: skip media already present in the destination (matched by scanned DB
  identity title+year[/season+episode], robust to dir case/layout, plus a
  filesystem folder fallback).
- 洗版 (resolution replacement): when the source resolution is higher than the
  existing version, replace the lower-res file (+NFO sidecar +DB row). Prefers
  scanned dimensions, then ffprobe, then filename token; never replaces on
  unknown resolution.
- New endpoints: GET /admin/organize/sources (download/media dir candidates)
  and POST /admin/organize/source.
- UI: ToolsPage adds a '整理来源目录(去重+洗版)' form so operators can pick the
  download dir as the organize source.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 16:29:15 +08:00
soldosluka857 7cc59f095c fix(auth/images): long-lived Emby token, unthrottle refresh, serve library posters
Three regressions reported on third-party clients and the web UI:

- Third-party clients (Emby/Jellyfin) dropped login / could not play /
  could not refresh the library, roughly hourly. The Emby
  AuthenticateByName response returned the 60-minute access token, but
  Emby clients have no refresh mechanism and reuse the AccessToken until
  logout. Issue a long-lived (30d) token for the Emby compat layer via
  AuthService.IssueEmbyToken so device sessions persist.

- Web could be thrown back to login under load: /auth/refresh was inside
  the IP rate-limited /auth group, so multiple users/tabs behind one
  reverse-proxy/NAT IP exhausted the budget and refresh failed -> logout.
  Only login/register are rate-limited now (raised to 30/min for shared
  IPs); refresh is excluded (already protected by a one-time refresh token).

- Posters/images stopped displaying on the web home and other pages
  (refresh did not help). The SSRF/path hardening (a) blocked the image
  proxy whenever a hostname *resolved* to a private IP, which happens
  under GFW DNS poisoning of public CDNs like image.tmdb.org, and (b)
  restricted local image reads to data/cache/movies/tv/anime dirs only,
  dropping sidecar posters stored under arbitrary per-library roots to a
  placeholder. isPrivateHost now only blocks literal private/loopback IPs
  (real SSRF vectors) and ImageProxy also allows reads under configured
  library roots.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 15:16:04 +08:00
ShukeBta 93c9cb7dfb ci: automate release publishing 2026-05-30 13:09:51 +08:00
soldosluka857 8f0c6d2324 feat(organize): separate source dir from destination dir (从源目录整理到目的地目录)
Previously organize only exposed a single 'target dir' that was actually
the destination, conflating 源目录 (where files to organize live) with
目的地目录 (where organized files go). Add an explicit source directory:

- OrganizeOptions gains SourcePath; DestPath replaces the old TargetPath
  (destination) for clarity. New organize.source_dir setting + source_path
  request override; resolveSourceRoot falls back to library path.
- OrganizeLibraryWithOptions only organizes media located under the source
  root, so operators can point at a specific download/staging folder and
  organize into a distinct destination.
- Handler accepts source_path/dest_path (target_path kept as a deprecated
  alias for the destination, backward compatible).
- Settings page splits into 整理源目录(待整理) + 整理目的地目录; Tools organize
  panel exposes 源目录 + 目的地目录 inputs with clear copy.

Defaults are unchanged (source = destination = library path) so existing
setups behave identically. Adds a regression test that organize is scoped
to the source directory.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 13:02:36 +08:00
soldosluka857 b2ccd04844 fix(dlna): always return non-nil device slice so API serializes [] not null
The DLNA device list endpoint could return {"devices":null} in two cases:
the SSDP-failure path returned a nil slice, and the cached path copied an
empty cache via append([]DLNADevice(nil), cache...) which also yields nil.
The web UI reads res.devices.length, so a null devices field crashed the
DLNA page to a white screen. Return non-nil slices in both paths and guard
the frontend with a null coalesce. Adds a regression test.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 13:02:16 +08:00
ShukeBta 7e83047fdb chore: release MediaStationGo v0.0.31 2026-05-30 12:07:35 +08:00
soldosluka857 b9b7b7052a feat(organize/scan): transfer modes, seeding-safe relocation, inode dedup, incremental scanning
- Organizer: add move/copy/hardlink/symlink transfer modes (default move),
  per-request target_path/transfer_mode overrides, and honor organize.target_dir
  / organize.transfer_mode settings.
- keep_seeding (default on): escalate move->hardlink (cross-device->copy) so the
  qBittorrent source stays in place and continues seeding after organize.
- qBittorrent SetLocation + POST /downloads/relocate to migrate whole torrents
  while keeping them seeding.
- Scanner: FileID (device:inode) hardlink dedup to avoid duplicate recognition
  and double-counted storage; extract single-file ingest.
- Watcher: recursive watch + incremental per-file ingest/remove instead of full
  re-scan; periodic full library scan now gated behind scan.periodic_enabled
  (default off) to reduce disk wear.
- Telegram bot: handle callback_query in polling, declare allowed_updates in
  webhook, answer callbacks.
- Frontend: settings for transfer mode / keep_seeding / periodic scan; organize
  panel target dir + transfer mode overrides.
- Tests for transfer modes, organizer resolution, SetLocation, inode dedup,
  incremental ingest/remove.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 12:01:46 +08:00
ShukeBta 518e7aa685 chore: release MediaStationGo v0.0.30 2026-05-30 11:09:40 +08:00
soldosluka857 4e5797f1f4 fix: correct HTTP status codes and error handling in remaining handlers
- createPlaylistHandler: 200 → 201 Created, service errors → 500
- createSiteHandler: service/DB errors → 500 (was 400)
- saveStorageConfigHandler: service errors → 500 (was 400)
- SchedulerHandler.RunTask: task errors → 500 (was 400 with ErrInternal)
- aria2StatsHandler: service errors → 500 (was 400)
- organizeMediaHandler: service errors → 500 (was 400)
- testDownloadClientHandler: test failure → 200 with ok:false (was 400)
- testStorageConfigHandler: test failure → 200 with ok:false (was 400)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 10:52:28 +08:00
soldosluka857 260cc164a0 fix: additional HTTP status code and error handling corrections
- createBackupHandler: 200 → 201 Created
- addDownloadHandler: 200 → 201 Created, service errors → 500
- createAssistantSessionHandler: 200 → 201 Created
- writeUserMutationError: default error → 500 (was 400)
- resetUserPasswordHandler: service errors → 500, success → 204 NoContent
- createLibraryHandler: service errors → 500 (was 400)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 10:45:29 +08:00
soldosluka857 e97891175a fix: security hardening and HTTP status code corrections
- importSTRMHandler: add URL scheme validation (blocks file://, ftp://, etc.)
- backup Delete/Restore: harden path traversal check (block backslash, require .db extension)
- HTTP 201 for create endpoints: register, subscription, download client, notify channel, library, STRM import
- Error handling: return 500 for service/infra errors in download client and notify channel handlers

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 10:44:47 +08:00
soldosluka857 0572612833 fix: add field whitelist to site update, fix HTTP status codes in site handlers
- SiteService.Update: whitelist updatable fields to prevent injection of
  id, created_at, deleted_at, login_status, upload_bytes, download_bytes
- createSiteHandler: return 201 Created (was 200 OK)
- siteSearchHandler: return 500 for infra errors (was 400)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 10:31:54 +08:00
soldosluka857 2c45fa596a fix: improve play profile input validation, error responses, and HTTP status codes
- createPlayProfileHandler: return 201 Created (not 200) on success
- create/update handlers: return 500 for infra errors, 400 only for
  validation errors (using new ErrPlayProfileValidation sentinel)
- deletePlayProfileHandler: validate JSON body (was silently ignored)
- verifyPlayProfilePINHandler: validate JSON body (was silently ignored)
- verify handler catch-all: return 500 (not 400) for unexpected errors
- Service layer: wrap validation errors with ErrPlayProfileValidation
  so handlers can distinguish client vs server errors

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 10:19:57 +08:00
soldosluka857 5bbc9fadfe security: fix SSRF, restrict CORS, add rate limiting on auth endpoints
- Add isPrivateHost() to block image proxy requests to loopback/private/
  link-local IPs (SSRF mitigation)
- Add isAllowedLocalPath() to restrict local file reads to configured
  data/cache/media directories only
- CORS middleware now takes debug flag; wildcard only when debug=true,
  production omits headers (same-origin enforced)
- Add per-IP sliding-window rate limiter (10 req/min) on login/register
  and Emby AuthenticateByName endpoints

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-30 10:03:43 +08:00
Shuke d22b48a801 Update README.md
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-05-30 09:18:15 +08:00
ShukeBta d35086f49d fix: hide adult libraries across dashboard views 2026-05-30 03:50:52 +08:00
ShukeBta e8c2cf1ea4 fix: require password only for adult visibility changes 2026-05-30 03:45:01 +08:00
ShukeBta ce9abf6306 fix: repair user password reset and recreate flow 2026-05-30 03:33:36 +08:00
ShukeBta 99ecae0c44 fix: add global adult library visibility controls 2026-05-30 03:24:08 +08:00
ShukeBta ce8ffabba7 fix: ignore non-command telegram group messages 2026-05-30 03:04:30 +08:00
ShukeBta f8ef35c8e1 fix: stabilize telegram bot proxy and legacy permissions 2026-05-30 02:54:25 +08:00
ShukeBta 67ba1bdcce fix: clarify telegram member binding permissions 2026-05-30 02:19:22 +08:00
ShukeBta 3c946559f5 fix: simplify telegram channel access rules 2026-05-30 02:16:18 +08:00
ShukeBta 7e01c42857 fix: improve telegram channel connectivity 2026-05-30 02:07:09 +08:00
ShukeBta be6b8bf27f fix: tighten telegram channel binding 2026-05-30 01:54:26 +08:00
ShukeBta b5e11b6938 fix: secure adult visibility and telegram bot access 2026-05-30 01:39:11 +08:00
ShukeBta db65e54c45 fix: avoid duplicate subscription downloads 2026-05-29 17:39:57 +08:00
ShukeBta 99fe7329f7 fix: isolate play profiles per user 2026-05-29 15:56:51 +08:00
ShukeBta 633a8cf715 fix: enforce adult profile pin visibility 2026-05-29 15:16:20 +08:00
ShukeBta 931db7a242 fix: handle wrapped permission responses 2026-05-29 14:11:51 +08:00
Shuke 7500cf07cb Update README.md
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-05-29 14:01:00 +08:00
ShukeBta a8395c9de7 fix: enforce transcoding resource controls 2026-05-29 13:54:19 +08:00
ShukeBta 27df93fa3d feat: add licensing and access controls 2026-05-29 12:47:54 +08:00
ShukeBta 2f7ec3ab17 fix: group local search series results 2026-05-29 10:55:01 +08:00
ShukeBta 4361ba73ba fix: show latest media per library directory 2026-05-29 10:36:47 +08:00
ShukeBta 00b9c1bb79 fix: refresh library artwork previews 2026-05-29 10:13:34 +08:00
ShukeBta bbf8f41507 chore: mark docker update helper executable 2026-05-29 10:03:23 +08:00
ShukeBta b430b5e638 docs: add docker image cleanup update helper 2026-05-29 10:03:05 +08:00
ShukeBta dfe2b3f017 fix: prioritize local poster artwork 2026-05-29 09:52:16 +08:00
ShukeBta 12e12bfa28 docs: remove default proxy environment 2026-05-29 09:40:45 +08:00
ShukeBta 7b2241ffeb docs: add docker proxy environment 2026-05-29 09:22:12 +08:00
ShukeBta b7ea68a67c fix: make qbittorrent login compatible 2026-05-29 04:15:44 +08:00
ShukeBta 2d90d9cba5 fix: improve qbittorrent host connectivity 2026-05-29 03:35:14 +08:00
ShukeBta ab64101141 docs: document NAS direct path compose setup 2026-05-29 03:12:22 +08:00
ShukeBta 48d33275cb fix: keep existing library files in place 2026-05-29 03:04:27 +08:00
ShukeBta fc0dc70bad fix: avoid repeated category organization 2026-05-29 02:55:46 +08:00
ShukeBta 1b86de7c26 docs: bump release examples to v0.0.6 2026-05-29 02:47:09 +08:00
ShukeBta 3031893156 fix: map docker host library paths 2026-05-29 02:44:52 +08:00
ShukeBta 5a7d90289b docs: clarify NAS absolute volume paths 2026-05-29 02:38:22 +08:00
ShukeBta 00b5418fb0 feat: align smart media classification paths 2026-05-29 02:08:38 +08:00
ShukeBta 4f8831c8fd docs: add nas paths and organization templates 2026-05-29 01:52:34 +08:00
ShukeBta 773343d744 docs: add ghcr pull troubleshooting 2026-05-29 01:44:38 +08:00
ShukeBta 895444b90f ci: restore ghcr docker image publishing 2026-05-29 01:21:17 +08:00
ShukeBta b0387ed40e ci: publish docker image to docker hub 2026-05-29 01:13:47 +08:00
ShukeBta af4bb04fa4 docs: remove release publishing guide from readme 2026-05-29 01:00:19 +08:00
ShukeBta 890d8e49c7 docs: expand docker deployment guide 2026-05-29 00:50:45 +08:00
Shuke 02ab0b0c9d Update README_EN.md
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-05-29 00:34:58 +08:00
ShukeBta 32d7fb0f45 ci: publish artifacts only on version tags 2026-05-29 00:33:39 +08:00
Shuke fef90aea5d Update README.md
Signed-off-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-05-29 00:26:25 +08:00
ShukeBta 5924204e01 docs: rewrite project readmes 2026-05-29 00:20:06 +08:00
ShukeBta 1051fb1825 chore: add favicon and harden release workflow 2026-05-28 23:40:54 +08:00
ShukeBta c1b5dac784 feat: improve subscription and download cards 2026-05-28 23:27:34 +08:00
ShukeBta f457d0050e Fix Docker build script builder setup 2026-05-28 22:30:03 +08:00
ShukeBta 9ad853168c Normalize release package names 2026-05-28 22:25:00 +08:00
ShukeBta 05d49ca470 Add advanced discovery subscriptions 2026-05-28 22:21:48 +08:00
ShukeBta a6a1226ede Add release package publishing workflow 2026-05-28 21:36:54 +08:00
ShukeBta a666245c44 Fix subscription search to queue full series 2026-05-28 20:10:19 +08:00
Shuke 5e2af11b40 Update README.md 2026-05-28 19:58:06 +08:00
Shuke 91293dbe92 Update README.md 2026-05-28 19:57:42 +08:00
Shuke 30a6a99023 Update README.md 2026-05-28 19:57:18 +08:00
Shuke 1e6213d558 Update README.md 2026-05-28 19:18:44 +08:00
Shuke 9750f40b15 Update README.md 2026-05-28 19:18:03 +08:00
Shuke 0084897fb5 Update README.md 2026-05-28 19:09:08 +08:00
ShukeBta d7b99aef6f Add privacy-redacted UI screenshots to README 2026-05-28 19:02:57 +08:00
ShukeBta e146177b59 Configure default Docker Compose install 2026-05-28 18:08:40 +08:00
ShukeBta caab131398 Ignore local release artifacts 2026-05-28 17:54:46 +08:00
ShukeBta 2dfccfd779 Publish Docker latest package from CI 2026-05-28 17:48:34 +08:00
ShukeBta 6afbe44480 Fix multi-arch Docker package build 2026-05-28 17:45:33 +08:00
ShukeBta 5b3798a283 Harden Docker package context 2026-05-28 17:43:42 +08:00
ShukeBta b5f1f1954e Update deployment docs and media library UI 2026-05-28 17:32:55 +08:00
ShukeBta 68a5a1e3c0 Fix Emby playback routes and stream compatibility 2026-05-28 16:00:09 +08:00
ShukeBta e93eb40f4e Add commit message generation 2026-05-28 14:55:01 +08:00
ShukeBta f553674b71 Update commit message generation 2026-05-28 14:52:52 +08:00
ShukeBta 32ccb33fed Fix local adult metadata and watch history removal 2026-05-28 14:52:20 +08:00
ShukeBta 352edd86ed Fix series selection playback and query cleaning 2026-05-28 08:47:49 +08:00
ShukeBta 5baf9515ea TG Bot 命令交互 + UI 圆角色差深度优化 + 通知配置修复
## TG Bot 交互命令系统 (新增)
- telegram_bot.go: 命令路由 + /start /help /status /search /downloads /stats
- telegram_webhook.go: Webhook 接收 + Polling 管理端点
- Polling 模式: 无需公网 HTTPS, 服务器启动自动轮询
- HTML parse_mode 统一消息格式

## 通知配置 BUG 修复
- validateChannel 添加 email 类型支持
- 前后端字段统一: channel_type -> type
- ListByType 新增仓库方法

## UI 深度优化
- glass-panel: 添加 rounded-2xl + 可见边框 + padding
- card: 添加 p-4 sm:p-6
- 全局 rounded-md -> rounded-xl, 裸 rounded -> rounded-lg
- 500+ 深色硬编码类名清零
- text-gray-400 -> gray-500 色差提升
- 按钮组 flex-wrap 防溢出
- data-table 表格溢出截断
2026-05-28 08:35:59 +08:00
ShukeBta c094818594 UI 全面重设计: 明亮画廊风格 + 剧集合并显示
## 色彩体系重构
- tailwind.config.js: sand/ink 全新色阶 + shadow 阴影预设
- index.css: 全组件系统重写 (card/input/btn/badge/table)

## 核心页面重写
- LoginPage: 纯白卡片 + 品牌光晕 + Framer Motion 入场动画
- HomePage: Billboard 推荐位 + 剧集卡片联动媒体库
- Layout: 白色侧边栏 + 路由过渡动画
- MediaCard: 新增 linkTo prop, 悬停浮层优化

## 剧集合并显示
- LibraryPage: 使用 groupSeries 合并剧集, 分季选集详情
- 首页剧集卡片自动链接到媒体库详情

## 全量类名迁移 (43 文件)
- text-slate-* -> text-ink-*/text-sand-*
- text-primary-* -> text-brand-*
- text-white -> text-ink-600
- bg/border-slate-* -> bg/border-sand-*
- 500+ 深色硬编码类名清零
2026-05-26 22:02:00 +08:00
ShukeBta 3a2db6bdd9 feat: Emby 兼容层完整实现 + 多模块功能增强
## Emby/Jellyfin 兼容层 (emby_compat.go / emby.go)
- 新增 SystemInfoPublic、FindUser、Items、Item、LatestItems、ResumeItems
- 新增 SetFavorite、MarkPlayed、RecordProgress 用户播放状态同步
- 新增 itemPayload、mediaSource、mediaStreams 媒体信息组装
- 双前缀路由 /emby/* 和 / 根路径,兼容 Infuse/Yamby/Senplayer/Kodi
- 新增 Ping、SystemEndpoint、AuthByName 端点
- emby.go 扩展对应 handler 函数

## 站点适配器 (site_adapter.go / site.go)
- SiteConfig 扩展 UserAgent/Timeout/Extra/FlareSolverrURL 字段
- doRequest() GET 请求支持 FlareSolverr 代理绕过 Cloudflare/WAF
- MTeam api_key 认证改为 Authorization: Bearer 格式
- Search() 重构为 sync.WaitGroup 并发执行,提升多站搜索性能
- siteModelToConfig() 改为 SiteService 方法,按 BrowserEmulation 填充 FlareSolverrURL

## 图片代理 (image_proxy.go)
- 重构图片代理服务,支持更多来源和缓存策略

## 下载管理 (downloads.go / download_clients.go / qbittorrent.go)
- 下载任务增强:状态管理、进度追踪优化
- qBittorrent 客户端连接稳定性改进

## 刮削与数据库 (scraper.go / tmdb.go / repository.go)
- 刮削器增强 TMDB 集成,补全元数据字段
- repository 扩展查询方法

## 前端 (web/src/)
- HomePage: 首页布局重构,按媒体库分组展示,系列聚合优化
- DiscoverPage: 发现页增强,错误处理改进(API key 缺失/网络错误分离)
- PosterWallPage: 海报墙优化,系列聚合展示
- MediaCard: 媒体卡片优化
- PlayerPage: 播放器改进
- 新增 utils/groupSeries.ts: 系列聚合工具函数
- .gitignore: 添加 .tmp_* 临时文件排除规则
2026-05-26 16:09:13 +08:00
ShukeBta 5d1ae91419 feat: 站点适配器 FlareSolverr 集成 + 并发搜索优化
- site_adapter.go: SiteConfig 扩展 UserAgent/Timeout/Extra/FlareSolverrURL 字段
- site_adapter.go: doRequest() GET 请求支持 FlareSolverr 代理绕过 Cloudflare/WAF
- site_adapter.go: buildRequest() 使用 cfg.UserAgent(覆盖默认 UA)
- site_adapter.go: MTeam api_key 认证改为 Authorization: Bearer 格式
- site_adapter.go: 修复 2 个 go vet 冗余 StatusFound 警告
- site.go: Search() 重构为 sync.WaitGroup 并发执行,提升多站搜索性能
- site.go: siteModelToConfig() 改为 SiteService 方法,按 BrowserEmulation 填充 FlareSolverrURL
- site.go: Search() 空关键词返回 [] 而非 error,nil slice 防护
- 新增 system_handler.go: 系统工具探测接口
- 新增 ffmpeg_auto_install.go: FFmpeg 自动安装服务
- 新增 scripts/install-ffmpeg.ps1: Windows FFmpeg 安装脚本
2026-05-24 17:14:47 +08:00
ShukeBta 68083a2840 feat: 实现下载完成后自动触发智能分类整理
- 添加 AutoAfterDownload 配置字段到 OrganizerConfig
- 设置默认值为 false
- 修改 DownloadService 结构,添加 organizer 和 prevStates 字段
- 修改 NewDownloadService 构造函数,接受 organizer 参数
- 修改 service.go 初始化顺序,将 organizer 创建移到 downloads 之前
- 修改 poll() 方法,检测下载完成并触发整理
- 实现 onTorrentComplete() 方法,根据 savePath 查找 Media 并整理
2026-05-18 03:10:29 +08:00
ShukeBta ff14251371 feat: 前端显示元数据信息并添加智能分类状态提示
- Media 类型添加 languages/countries/genres 字段
- MediaDetailPage 显示元数据 badges(Languages/Countries/Genres)
- ToolsPage OrganizePanel 显示智能分类启用状态
- Badge 组件支持 className prop
- 添加 parseCSV 辅助函数处理逗号分隔字符串
2026-05-18 02:57:45 +08:00
ShukeBta a98429c02a feat: 实现智能分类功能
- 配置模型扩展(OrganizerConfig:smart_classify + categories)
- 数据模型扩展(Media:languages/countries/genres 字段)
- TMDbProvider 增强(GetDetails 方法获取扩展元数据)
- Scraper 服务增强(保存 languages/countries/genres 到数据库)
- Organizer 智能分类逻辑(根据元数据自动分类到子目录)
- 前端 SettingsPage 添加智能分类开关
- 后端支持从数据库读取 organizer.smart_classify 设置

Task #90-94 完成,Task #95 部分完成
2026-05-18 02:49:28 +08:00
ShukeBta 7fa99a5669 fix: 使 TMDbProvider 支持从数据库读取 API Key
- 修改 TMDbProvider 添加 apiConfig *APIConfigService 字段
- NewTMDbProvider 接受 apiConfig 参数(可为 nil)
- 添加 resolveAPIKey(ctx) 方法:优先从 config 读取,回退到数据库
- 添加 resolveBaseURL(ctx) 方法:解析基础 URL
- 修改 SearchMovie 使用 resolveAPIKey / resolveBaseURL
- 修改 DiscoverService.Fetch 使用 resolveAPIKey / resolveBaseURL
- 调整 service.go 初始化顺序,确保 tmdb 在 scraper 之前初始化
- 构建验证通过
2026-05-18 01:57:48 +08:00
ShukeBta cdd28b8b88 feat: 添加 FlareSolverr 配置支持以绕过 Cloudflare/WAF
- 在 config.example.yaml 中添加 flaresolverr 配置段
- 在 internal/config/config.go 中添加 FlareSolverrConfig 结构体
- 修改 SiteService 接收 flaresolverr URL
- 更新 TestConnection 使用配置的 FlareSolverr URL
- 构建验证通过
2026-05-18 01:46:48 +08:00
ShukeBta a8e825ec13 fix: 增强站点连接测试HTTP请求头,添加浏览器仿真与FlareSolverr支持
- 新增 internal/helper/http.go:带浏览器仿真请求头和Cloudflare/WAF绕过支持
- TestSiteConnectivity 支持 FlareSolverr 代理(需配置 flareSolverrUrl)
- 检测 Cloudflare challenge 页面并返回友好错误信息
- 重构 service/site.go TestConnection 使用新的 helper 方法
2026-05-18 01:41:00 +08:00
ShukeBta 630f925725 fix: 修复站点管理API Key保存问题,移动RSS字段到主表单,修复Discover页面提示 2026-05-18 01:22:11 +08:00
ShukeBta 9827b46c49 feat(site): 补全 Site 模型 11 个缺失字段并同步前端类型
- model/site.go: 新增 user_agent/rss_url/timeout/priority/use_proxy/rate_limit/
  browser_emulation/login_status/upload_bytes/download_bytes/downloader 11个字段
- 字段对齐 Python 原版 MediaStation Site 模型(28字段 → 23字段)
- GORM AutoMigrate 自动补列,旧数据填充默认值(timeout=15, priority=50)
- types/index.ts: Site 接口同步所有新字段(带注释)
- 后端编译 ✅  前端构建 ✅  API 验证 ✅
2026-05-18 00:52:11 +08:00
ShukeBta 828ca36d7f @
feat: Warm Industrial UI redesign + social footer + code deduplication

Design System (Phase A):
- New palette: warm charcoal #111110 / amber gold #c9954a / sage green #7a9a8a
- Fonts: Cabinet Grotesk (heading) + Geist (body) + JetBrains Mono (data)
- SVG grain texture overlay, collapsed sidebar with hover expand
- Bento grid dashboard with WideContinueCard + PosterCard + QuickLink
- Backward-compatible CSS aliases for glass-panel/neon-button/input-base

Social Footer (Phase B):
- AppFooter component with GitHub repo / author homepage / TG group links
- Embedded in LoginPage, Layout (global), and SettingsPage
- TG group badge added to README.md

Code Cleanup:
- Removed deprecated DownloadClientCard / NotifyChannelCard components
- Merged downloadClient/notify API clients into unified modules
- Route deduplication: removed authed-group duplicates for admin endpoints
- Removed redundant AdminPage tabs (sites/downloads/notify/scheduler)
- UI fixes: blank pages (discover/download-clients/notify-channels) nil slice defense
- Email channel support in NotifyChannelsPage
@
2026-05-17 23:13:26 +08:00
ShukeBta 9be81222cc fix: 修复三个空白页面 + 许可证页面重构
- 修复发现页空白:后端 discover.go nil→[] Match,前端 discover.ts 加 ?? []
- 修复下载客户端页空白:后端 download_clients.go nil→[]model.DownloadClient,前端加 ?? []
- 修复通知渠道页空白:后端 notify_channels.go nil→[]struct{},前端加 ?? []
- 许可证页面重构:移除生成密钥功能,对接 LicenseServer 绑定/状态模式
- 所有变更通过 Go build + npm run build 零错误验证
2026-05-17 00:54:40 +08:00
ShukeBta 158fffaba0 feat: add public IP detection for VPS/NAS deployment
- Add getPublicIP() via api.ipify.org with configurable timeout
- Startup log now shows both local and public access URLs
- Graceful fallback: skip public IP if network unreachable
- Update README with dual-IP log example for both CN/EN
2026-05-17 00:19:38 +08:00
ShukeBta dd8f2a466c feat: auto-detect local IP on startup, update README access URL
- Add getLocalIP() to detect first non-loopback IPv4 address
- Startup log now shows accessible URL (e.g. http://192.168.1.4:8080)
- README: replace hardcoded localhost with auto-detected IP description
- Fallback to localhost if no network interface found
2026-05-17 00:17:00 +08:00
ShukeBta d10bc19e8e docs: premium README redesign with visual hierarchy
- Add centered header badge with light/dark theme support
- Add table of contents with emoji-anchored quick links
- Rewrite feature section as collapsible detail blocks
- Add Python vs Go comparison table with metrics
- Add prerequisite requirements table
- Enhance project structure with arrow annotations
- Add contribution section and footer
- Mirror all changes to README_EN.md
- 341 lines each, bilingual parity
2026-05-17 00:10:49 +08:00
ShukeBta 8e06b7e149 chore: remove license code, fix compilation, update README bilingual
- Remove all license-related code (handler/service/repository/model)
  License authorization is managed by separate server:
  https://github.com/ShukeBta/MediaStationLicenseServer
- Fix compilation errors: model field alignment, method name fixes,
  route conflicts, struct literal corrections (7 files)
- Add Chinese README.md as primary, English README_EN.md
- Update .gitignore: exclude .workbuddy/, editor backups
- Add new repository files: assistant, play_profile, storage_config
2026-05-17 00:02:41 +08:00
ShukeBta 37dd83c56d Resolve merge conflicts: merge all Go backend and React frontend changes 2026-05-16 22:48:30 +08:00
ShukeBta cbb4b806be feat: merge conflict resolution, site management, UI fixes 2026-05-16 17:57:34 +08:00
Shuke 4a319b4378 Merge pull request #8 from ShukeBta/feat/port-vue-ui-features
feat(web): port AI Assistant, STRM management, and admin tools pages from Vue UI
2026-05-16 17:55:42 +08:00
Kiro Agent 1dbd73b30b feat: complete Vue UI parity (full backend + frontend)
== New domain models (7) ==
- UserPermission: per-user feature toggles (13 booleans)
- StorageConfig: encrypted Alist/S3/WebDAV adapters
- LicenseKey + LicenseActivation: offline license issuance
- DownloadClient: multi-client downloader configs
- AssistantSession + AssistantMessage: multi-turn AI chat persistence

== New services (5) ==
- PermissionService: admin grants always-true; user defaults seeded
- StorageConfigService: AES-GCM encryption + per-type connection probe
- LicenseService: 24-char hyphenated key generation, activation/heartbeat
- DownloadClientService: qB/Aria2/Transmission CRUD + WebUI test
- AssistantService: chat history + execute/undo stubs (op_id tracking)

== Extended AIService.Chat ==
- Multi-turn LLM call with chat history; offline fallback reply

== New endpoints (60+) ==
Auth:
  POST /auth/refresh, /auth/logout, /auth/change-password
  PATCH /auth/profile
  GET /auth/permissions, /auth/me

Permissions admin:
  GET/PUT /admin/users/:id/permissions
  POST /admin/users/:id/permissions/reset

Search:
  GET /search, /search/advanced, /search/tmdb, /search/sites

System:
  GET /system/config, /settings/schema, /system/events/ticket
  POST /admin/system/scheduler/:name/trigger

Stats:
  GET /stats/user/:id, /stats/top-users
  POST /stats/play

Sites:
  GET /sites/:id/resource, /sites/:id/userdata

Subscriptions:
  PUT /subscriptions/:id, POST /subscriptions/:id/search

Playlists:
  POST /playlists/:id/reorder
  DELETE /playlists/:id/items/by-id/:item_id

DLNA per-renderer:
  POST /dlna/:uuid/{play,pause,stop}, GET /dlna/:uuid/status

Media:
  POST/DELETE /media/:id/favorite, GET /media/:id/favorite/status
  POST /media/:id/ai-scrape, /media/scrape/test, /media/organize
  GET /favorites (alias)

Playback:
  GET /playback/:id/info, /playback/:id/external-players, /playback/:id/external-url
  POST /playback/:id/progress
  GET /playback/transcode/:job_id/status

Downloads:
  POST /download/:id/{pause,resume,organize}
  POST /download/{organize,sync,start-auto-sync}
  GET /download/tasks
  Admin: full CRUD on /admin/download/clients + /admin/download/aria2/stats

License:
  POST /license/{activate,heartbeat}
  GET /license/{status,heartbeat-status}
  Admin: /admin/license/{generate,list,:id/activations,:id/revoke,activation/:id/unbind}

Storage:
  GET /admin/storage/{status,:type}
  PUT /admin/storage/:type, POST /admin/storage/:type/test

Assistant (multi-turn AI):
  GET/POST /admin/assistant/sessions
  GET/DELETE /admin/assistant/session/:id
  POST /admin/assistant/{chat,execute}
  POST /admin/assistant/undo/:op_id
  GET /admin/assistant/history

== New React pages (4) ==
- AssistantChatPage (/assistant): full multi-turn chat UI with sessions
  sidebar, optimistic user-turn append, live AI response.
- DownloadClientsPage (/download-clients): typed CRUD form for
  qBittorrent / Aria2 / Transmission + per-row Test action.
- LicensePage (/license): generate keys, list activations, revoke,
  unbind individual devices.
- StorageConfigPage (/storage-config): tabbed Alist/WebDAV/S3 form
  with secret-aware redaction + connection probe.

== New API helpers (5) ==
- assistant, download_clients, license, permissions, storage_config

== Layout ==
- Sidebar gains 4 new admin links (AI 对话, 下载器, 外部存储, 许可证).
2026-05-16 09:49:35 +00:00
Kiro Agent 7095d9ebec feat: port remaining Vue UI surfaces with full backend support
Backend additions:
- New GORM models: NotifyChannel, PlayProfile (with PIN hashing).
- NotifyChannelService: multi-channel CRUD + unified dispatcher
  supporting Telegram / Bark / WeChat / Webhook with optional
  per-channel event filtering.
- PlayProfileService: per-user 'viewing personas' with
  content-rating gates, library scoping, PIN protection, and
  player defaults (autoplay, skip-intro, audio/subtitle prefs).
- New endpoints:
    GET    /admin/notify/channels           list
    POST   /admin/notify/channels           create
    PUT    /admin/notify/channels/:id       update
    DELETE /admin/notify/channels/:id       delete
    POST   /admin/notify/channels/:id/test  send test
    GET    /play-profiles                   list (admin: ?all=true)
    POST   /play-profiles                   create
    PUT    /play-profiles/:id               update
    DELETE /play-profiles/:id               delete
    GET    /media/recent                    home page rail
    GET    /media/stats                     library composition
    GET    /watch-history                   list
    GET    /watch-history/stats             aggregate
    GET    /watch-history/continue          continue-watching rail
    DELETE /watch-history[?media_id=]       clear
    DELETE /watch-history/:id               remove one
    GET    /discover/sections               available rails
    GET    /discover/feed?sections=a,b      multi-section TMDb fetch
    GET    /system/info                     name/version/runtime
    GET    /system/status                   uptime/cpu/mem/disk
    GET    /system/scheduler                read-only scheduler view
    GET    /stats/overview                  counts only
    GET    /stats/trend                     daily plays
    GET    /stats/top-content               most played
    GET    /stats/libraries                 per-library size
    GET    /stats/monitor                   live hardware

Frontend additions:
- New API helpers: notify_channels, play_profiles, history,
  stats_extra, media_extra, discover_extra, system.
- ProfileManagementPage (/play-profiles): full CRUD form with
  PIN management, library scoping, language preferences.
- NotifyChannelsPage (/notify-channels, admin): typed config
  forms per channel type + per-channel test action.
- SettingsPage (/settings, admin): grouped key/value editor
  covering General, Organize/Scrape, Adult, qBittorrent.
- Layout sidebar links and App.tsx routes wired for all three.
2026-05-16 09:01:09 +00:00
Kiro Agent 261d342fa1 feat(web): port AI Assistant, STRM management, and admin tools pages from Vue UI
- AIAssistantPage (/ai): smart natural-language search showing parsed intent
  + matching media grid, plus history-based recommendations.
- StrmPage (/strm, admin): import streaming-only entries from URLs and
  attach/detach STRM URL on existing media.
- ToolsPage (/tools, admin): organize an entire library or single media
  item, send test notifications through configured channels.
- Wire new routes in App.tsx and add sidebar links in Layout.tsx.

Co-authored-by: Shuke <272197458+ShukeBta@users.noreply.github.com>
2026-05-16 08:41:43 +00:00
Shuke 108074feb0 Merge pull request #7 from ShukeBta/kiro/iter7-complete-port
feat: site management + cross-site torrent search
2026-05-16 16:34:01 +08:00
Kiro 7a18eb670a feat: site management + cross-site torrent search
Port the complete site management system from the original MediaStation:

Model:
  - model/model.go: Added Site table with fields for name, base_url,
    site_type (nexusphp/gazelle/unit3d/mteam/custom_rss), auth_type
    (cookie/api_key/authorization), cookie, api_key, auth_header,
    user_agent, rss_url, timeout, priority, use_proxy, enabled,
    login_status, downloader. Registered in AllModels().

Backend services:
  - service/site.go: SiteService with CRUD (Create/List/FindByID/Update/
    Delete), TestConnection (validates credentials via HTTP, updates
    login_status in DB), and Search (cross-site fan-out that queries
    every enabled site adapter and returns merged results sorted by
    seeders descending).
  - service/site_adapter.go: SiteAdapter interface + NewSiteAdapter
    factory + 5 implementations:
    * nexusPhpAdapter — regex HTML scraping of torrents.php
    * gazelleAdapter — JSON API /ajax.php?action=browse
    * unit3dAdapter — REST API /api/torrents/filter
    * mteamAdapter — M-Team v3 POST /api/torrent/search with x-api-key
    * rssAdapter — XML RSS/Atom feed parsing with keyword filter

Handler + routes:
  - handler/sites.go: listSites, getSite, createSite, updateSite,
    deleteSite, testSite, siteSearch handlers.
  - handler/handler.go: 7 new routes under authed group:
    GET/POST /sites, GET/PUT/DELETE /sites/:id,
    POST /sites/:id/test, GET /sites/search.

Frontend:
  - api/sites.ts: typed interfaces (Site, SiteSearchResult,
    CreateSiteInput) and sitesAPI helper.
  - pages/SitesPage.tsx: full CRUD UI with add-site form (site_type +
    auth_type selectors, cookie/api_key/rss inputs), connection test
    button with live status indicator, delete action.
  - pages/SiteSearchPage.tsx: cross-site search with keyword input,
    merged result table (site/title/size/seeders/leechers/free),
    one-click download-to-qBittorrent button.
  - App.tsx: lazy-loaded routes /sites and /site-search.
  - Layout.tsx: sidebar links 站点管理 + 站点搜索 under 自动化 group.

Verified: go build + go vet + go test pass; tsc -b + vite build passes
(32 lazy chunks, main bundle 255 KB / 85 KB gzipped).
2026-05-16 08:29:00 +00:00
Shuke 0be8c7952e Merge pull request #6 from ShukeBta/kiro/iter7-complete-port
feat: backup, notifier, organizer, douban, watch-history, poster-wall
2026-05-15 22:29:11 +08:00
Shuke 3a4689e950 Merge pull request #5 from ShukeBta/kiro/iter6-fix-and-harden
fix: context leak in goroutine handlers + harden smoke test
2026-05-15 22:27:36 +08:00
Kiro 778bf77437 feat: backup, notifier, organizer, douban, watch-history, poster-wall
Complete the port of the remaining MediaStation features:

Backend services:
  - service/backup.go: SQLite hot backup via VACUUM INTO, list, delete,
    restore. Backup files stored under {data_dir}/backups/ with timestamps.
  - service/notifier.go: Multi-channel push notifications (Telegram /
    Bark / WeChat Server酱 / Webhook). Configuration via settings table
    keys notify.telegram.*, notify.bark.*, etc.
  - service/organizer.go: Auto-rename + move media files into library
    directory structure. Movie: {Title} ({Year})/{Title} ({Year}).ext,
    TV: {Title}/Season XX/{Title} - SxxExx.ext. Both per-media and
    per-library batch endpoints.
  - service/douban.go: Douban (豆瓣) metadata provider using the
    unofficial subject_suggest API. Returns Chinese titles + posters.
    Requires douban_cookie in secrets config.

Handlers:
  - handler/backup.go: GET/POST/DELETE /admin/backups, POST restore.
  - handler/organizer.go: POST /admin/media/:id/organize,
    POST /admin/libraries/:id/organize.
  - handler/notify.go: POST /admin/notify/test.

Frontend pages:
  - WatchHistoryPage.tsx: full paginated history with progress bars,
    resume buttons, poster thumbnails and timestamps.
  - PosterWallPage.tsx: dense 8-column poster grid of all media
    (matches the original MediaStation PosterWallView.vue).

Sidebar additions: 观看历史 + 海报墙 links in the main nav group.

Verified: go build, go vet, go test all pass; tsc -b && vite build
emits 30 lazy chunks; main bundle 254 KB / 85 KB gzipped.
2026-05-15 14:17:26 +00:00
Kiro 4b747c74ca feat: port missing MediaStation features (DLNA, STRM, files, dup, sched, api-configs, emby, storage)
Audit-driven port from the original Python MediaStation. Eight major
subsystems that were absent from the Go rewrite are now in place,
each with its own service, handler, frontend page and smoke-test
assertions.

Backend services
  - service/crypto.go: AES-256-GCM encrypt/decrypt for at-rest secrets
    keyed off the JWT secret. Legacy plaintext rows pass through
    unchanged for smooth upgrades. Unit-tested.
  - service/api_config.go: third-party provider config (TMDb, Bangumi,
    TheTVDB, Fanart, Douban, OpenAI). Seeds defaults on first run.
    Encrypts api_key on write, returns masked 'abc1****wxyz' projection.
  - service/duplicate.go: sparse-sample MD5 (head + middle + tail, 1MiB
    each, plus file-size suffix) duplicate finder. Picks 'best' primary
    (matched > size > id) and marks others is_duplicate=true.
  - service/filemanager.go: server-side allow-listed file browser used
    by the library-path picker. Strict path-traversal protection.
  - service/dlna.go: real SSDP M-SEARCH discovery + AVTransport
    SetAVTransportURI/Play SOAP cast. 30 s discovery cache.
  - service/scheduler.go: 3 recurring background jobs (library_scan
    60min, transcode_cleanup 24h, recycle_purge 24h with 30-day
    cutoff). Status + run-now endpoints.
  - service/cache_cleanup.go: walkAndPrune helper used by scheduler.
  - service/storage.go: DB-only disk-usage breakdown by library and by
    container format.
  - service/emby_compat.go: read-only Emby/Jellyfin shim
    (System/Info, Users, Users/x/Views, Items, PlaybackInfo) so Infuse
    / VidHub / Kodi can browse MediaStationGo libraries.

Model updates
  - Media: new strm_url (302 redirect target), file_hash, is_duplicate,
    duplicate_of fields.
  - APIConfig: new table for encrypted provider secrets.
  - AutoMigrate registers APIConfig.

Stream layer
  - StreamService.ServeFile now redirects 302 to strm_url when set so
    WebDAV / Alist / S3 / HTTP direct links work transparently.

Handlers + routes
  - Authed: GET /files, GET /storage, GET /dlna/devices, POST /dlna/cast,
    PUT/DELETE /media/:id/strm, POST /strm/import,
    POST /duplicates/{scan,unmark}.
  - Admin: GET/PUT/DELETE /admin/api-configs/:provider,
    GET /admin/scheduler, POST /admin/scheduler/:name/run.
  - New /emby/* group: System/Info, Users, Users/:userId/Views,
    Users/:userId/Items, Items/:id/PlaybackInfo (auth-required).

Frontend pages (lazy-loaded, 7 new chunks)
  - DlnaPage: device list + media picker + cast button.
  - FileManagerPage: root selector + breadcrumb + sortable listing.
  - APIConfigsPage: per-provider card with masked-key editor.
  - StoragePage: usage tiles + per-library bars + per-container grid.
  - DuplicatesPage: scan form + grouped report with primary highlight.
  - SchedulerPage: live job table with run-now button (5s refresh).
  - Sidebar reorganised: 自动化 group adds DLNA, 管理 group adds
    存储 / 文件浏览 / 重复文件 / 定时任务 / API 配置.

Smoke test additions (all admin-only)
  - api-configs seeded with 6 providers
  - api-config encrypted in db (sqlite3 enc:v1: prefix check)
  - storage breakdown
  - file browser lists library root + rejects /etc (path traversal)
  - dlna devices endpoint
  - scheduler exposes 3 jobs + run library_scan
  - emby /System/Info + /Users/{x}/Views
  - strm set + stream 302 + strm clear
  - duplicate scan

Verified: go build, go vet, go test (incl. new TestCrypto* suite + the
existing TestParseEpisode/TestCleanQuery/TestSrtToVTT/TestStripASSTags/
TestBuildFFmpegArgs); tsc -b && vite build emits 28 route chunks plus
the deferred hls chunk; main bundle 253 KB / 85 KB gzipped; smoke test
PASS=42 / FAIL=0.
2026-05-15 10:58:32 +00:00
Shuke 2b03995e8a Merge pull request #4 from ShukeBta/kiro/iter5-e2e-test
test: end-to-end deployment smoke test (26 assertions, all passing)
2026-05-15 18:49:13 +08:00
Kiro da1a264d98 fix: context leak in goroutine handlers + harden smoke test
Bugs fixed:
  - handler/media.go: createLibraryHandler and deleteLibraryHandler
    spawned goroutines that called svc.Watcher.Refresh(c.Request.Context()).
    Since the HTTP request returns immediately, the context is cancelled
    before the watcher finishes. Use context.Background() so the refresh
    always completes.
  - handler/streaming.go: scrapeLibraryHandler had the same issue with
    c.Copy().Request.Context(). Fixed to context.Background().

Smoke test improvements:
  - Always create test media files (dummy or ffmpeg), always run scan and
    search — only ffprobe-specific assertions (width=320) and HLS are
    gated behind HAVE_FFMPEG.
  - ID is now always set (from library media list), so history/favourites
    /playlists tests never hit an unbound variable.
  - NFO + recycle-bin assertions no longer gated behind HAVE_FFMPEG since
    they work on any media row (even dummy files).

Verified: go build + go vet + go test all pass; 55/55 endpoint assertions
pass with 0 server 5xx; smoke test 26/26 PASS with and without ffmpeg.
2026-05-15 10:40:42 +00:00
Shuke a0ee132687 Update project references in README 2026-05-15 18:38:26 +08:00
Shuke 63da2f34c0 Merge pull request #3 from ShukeBta/kiro/iter4-providers-hwaccel-ai
feat: hwaccel + Fanart/TheTVDB + AI + Discover + NFO + Tasks + Recycle bin
2026-05-15 18:34:24 +08:00
Kiro 77e3fdd1f6 test: add E2E smoke test script + CI gate
After deploying the binary against a fresh data dir and exercising every
major REST endpoint by hand, codify the verification as a re-runnable
script.

scripts/smoke-test.sh:
  - Spins up bin/mediastation-go against a tempdir.
  - Generates 3 sample clips (movie, tv, anime) + an external SRT.
  - Walks 26 assertions: health → auth → libraries → scan → ffprobe →
    season parser → search → range stream → HLS playlist → external
    subtitle → history → favourites → playlists → RBAC (alice 403) →
    NFO export → recycle bin → SPA fallback → graceful shutdown.
  - Tears the server down via SIGTERM and reports pass/fail counts.
  - Exits non-zero on any failure so CI can use it as a deployment gate.

Local verification on this branch: PASS=26  FAIL=0.

Wiring:
  - Makefile: new 'smoke' target, depends on 'build'.
  - .github/workflows/ci.yml: third job 'smoke' that depends on backend
    + frontend, installs ffmpeg, builds both halves, then runs the
    script. The CI pipeline now blocks merges that break end-to-end
    deployability.
2026-05-14 16:36:34 +00:00
Shuke 15655a531a Merge pull request #2 from ShukeBta/kiro/iter3-comprehensive
feat: downloads, RSS, subtitles, Bangumi, watcher, stats, profile, audit
2026-05-15 00:25:34 +08:00
Shuke 9fe623cb9a Merge pull request #1 from ShukeBta/kiro/iter2-metadata-playback
feat(iter2): ffprobe metadata, TMDb scrape, HLS transcode, history / favourites / playlists
2026-05-15 00:24:29 +08:00
Kiro 8824638efb feat: hwaccel + Fanart/TheTVDB + AI + Discover + NFO + Tasks + Recycle bin
Backend
  - service/transcoder.go: encoder selector (software / nvenc / qsv /
    vaapi) with proper hwaccel + scale_* filters per encoder; configurable
    bitrate/preset/height/segment-seconds via transcoder.* config; new
    Active() snapshot + ActiveJob struct for the Tasks panel; unit-tested
    via buildFFmpegArgs(...).
  - service/thetvdb.go: TheTVDB v4 provider — login() caches the JWT for
    24h, SearchSeries() returns Match struct.
  - service/fanart.go: Fanart.tv provider — high-res movie artwork keyed
    by TMDb id; used to upgrade poster/backdrop after a successful match.
  - service/scraper.go: provider chain reworked — anime → Bangumi, tv →
    TheTVDB, default → TMDb, with optional Fanart upgrade post-match.
  - service/discover.go: TMDb /trending/movie/day + /movie/popular for
    the Discover rail.
  - service/ai.go: OpenAI-compatible client. SmartSearch() turns a free-
    form query into a structured SearchIntent JSON; Recommend() emits a
    short list of titles given the user's history. Disabled when
    ai.api_key is empty.
  - service/nfo.go: per-movie .nfo writer (Kodi/Jellyfin schema) +
    library-scope batch exporter.
  - service/media.go: SoftDelete / Restore / Purge / ListRecycleBin
    helpers backed by gorm Unscoped().
  - service/service.go: container wires Fanart, TheTVDB, Discover, AI,
    NFO; everything still tears down cleanly via Close().
  - config: new transcoder.* section (encoder, preset, video_bitrate,
    max_rate, buf_size, max_height, segment_seconds) with sensible
    defaults.

Handlers / routes
  - new files: discover.go, recycle.go, nfo.go, ai.go, tasks.go.
  - registers GET /tasks, /discover/{trending,popular},
    /ai/{status,search,recommend}; admin-only GET /recycle and
    DELETE/POST /media/:id (soft delete / restore / purge); admin-only
    POST /media/:id/nfo and /libraries/:id/nfo.

Frontend
  - new pages: DiscoverPage, TasksPage, RecycleBinPage; SearchPage gains
    an optional AI smart-search toggle that calls /api/ai/search.
  - MediaDetailPage gains admin actions for 导出 NFO and 移至回收站.
  - new api/ helpers: ai.ts, discover.ts, recycle.ts, tasks.ts.
  - App.tsx routes /discover, /tasks, /recycle (last two admin-only).
  - Layout sidebar groups now include Discover + Tasks + Recycle Bin.

Docker / config
  - Dockerfile: adds intel-media-driver / libva-utils / mesa-va-gallium
    so QSV + VAAPI work out of the box on Intel iGPUs.
  - docker-compose.yml: documents devices + group_add + nvidia runtime
    overrides for hardware transcoding.

Verified: go build, go vet, go test (incl. new TestBuildFFmpegArgs across
software / nvenc / qsv / vaapi encoder profiles); tsc -b && vite build
emits 22 route chunks plus the deferred hls chunk; main bundle 248 KB /
83 KB gzipped.
2026-05-14 16:22:08 +00:00
Kiro 7bd6bcfb1b feat: downloads, RSS, subtitles, Bangumi, watcher, stats, profile, audit
Backend
  - service/bangumi.go: Bangumi (bgm.tv) scraper for anime libraries.
  - service/episode_parser.go: SxxExx / NxE / EPxx / 第NN集 parser with
    unit tests; consumed by the scanner for tv/anime libraries.
  - service/scraper.go: provider chain orchestrator picks Bangumi for
    anime libraries (TMDb fallback), TMDb for everything else; emits
    'no_match' rows so we don't retry forever; AnyEnabled() for the
    scanner kick.
  - service/scanner.go: writes season/episode numbers for tv/anime libs
    and reports a 'probed' counter alongside 'added'.
  - service/transcoder.go (existing): unchanged, stays per-media.
  - service/subtitle.go: discovers external subtitles next to the source
    file (.srt/.vtt/.ass/.ssa) and converts SRT/ASS to WebVTT on the fly.
  - service/qbittorrent.go: thread-safe qBittorrent v2 Web UI client
    (login / add / list / delete) with cookie-jar reuse.
  - service/downloads.go: persists download tasks, reads runtime
    qbittorrent.* settings, polls /torrents/info every 5 s and pushes
    the result to WS subscribers ('download' topic).
  - service/subscription.go: 10-minute RSS poller with regex filter,
    GUID dedup persisted in the settings table, and 'subscription' WS
    events on enqueue.
  - service/watcher.go: fsnotify watcher with 5 s coalescing debouncer
    that triggers per-library rescans on create/rename/remove.
  - service/stats.go: dashboard snapshot — totals, recently added,
    gopsutil-driven CPU/mem/disk readings.
  - service/profile.go: non-credential profile patch + admin role mutator.
  - service/audit.go: best-effort writer for the access_logs table.
  - service/service.go: container wires every new service; Boot() spins
    up watcher / downloads poller / subscription scheduler; Close()
    tears them down on graceful shutdown.

Handlers
  - new files: downloads.go, subscriptions.go, subtitles.go, series.go,
    stats.go, profile.go, util.go.
  - handler.go: registers PATCH /me, /libraries/:id/seasons,
    /media/:id/subtitles, /subtitles/:id, /downloads*, /subscriptions*,
    /stats, /admin/users/:id/role.
  - auth.go / media.go: write audit rows for login + library CRUD and
    refresh the watcher when libraries change.

Frontend
  - api: new helpers for downloads, subscriptions, profile, series, stats,
    subtitles; library helper gained scrape().
  - hooks/useWebSocket.ts: shared connection with 3 s reconnect.
  - components/GlobalEvents.tsx: surfaces scan / scrape / subscription
    completion as toasts (mounted at app root).
  - pages: Library now switches to a season-grouped layout for tv/anime
    libraries; Player attaches WebVTT <track> elements; new pages for
    Downloads (live torrent table), Subscriptions, Profile, Stats.
  - components/Layout.tsx + App.tsx: sidebar groups (媒体库 / 自动化 /
    账号 / 管理) and routes for the new pages; /stats and /admin remain
    admin-only.
  - types/index.ts: new types — Subscription, DownloadTask, QBitTorrent,
    Hardware, StatsSnapshot.

Verified: go build, go vet, go test (incl. ParseEpisode + srtToVTT +
stripASSTags) all pass; tsc -b && vite build emits 17 route chunks plus
the deferred hls chunk; main bundle 247 KB / 83 KB gzipped.
2026-05-14 16:07:49 +00:00
Kiro 0f30c34463 feat: ffprobe + TMDb scrape + HLS transcode + history/favourites/playlists
Backend
  - service/ffprobe.go: thin ffprobe wrapper, parses duration / resolution /
    codecs into a typed ProbeResult. 30s per-file timeout.
  - service/tmdb.go: minimal TMDb provider (search/movie). Disabled when no
    api key; supports tmdb_api_proxy / tmdb_image_proxy overrides for users
    behind a firewall.
  - service/scraper.go: filename cleaner (handles bracketed tags, scene
    noise tokens, year extraction), per-row + per-library enrichment with a
    4 RPS throttle and WS hub progress events. Unit-tested.
  - service/scanner.go: now invokes ffprobe per file and kicks the TMDb
    scraper in the background once a library scan finishes.
  - service/transcoder.go: per-media ffmpeg HLS job manager; outputs
    index.m3u8 + seg_NNNNN.ts under cache/hls/<id>; cancels jobs on
    shutdown; publishes 'transcode' WS events.
  - service/stream.go: serves HLS playlist (with 30s wait-for-ready) and
    .ts segments with path-traversal protection. Adds Probe() helper used
    by the admin 'reprobe' button.
  - service/image_proxy.go: cached, host-allow-listed reverse proxy for
    TMDb / Bangumi / Douban / Fanart / TheTVDB images so the SPA never
    hits a CORS or GFW issue.
  - service/playback.go: history upsert, favourites toggle, playlist CRUD
    + ordered items. RecentHistory joins with model.Media in one extra
    query so the home page can render a 'Continue Watching' row.
  - handler/streaming.go + handler/playback.go: REST endpoints for HLS,
    image proxy, scrape (one + library), reprobe, history, favourites,
    playlists.
  - handler/handler.go: registers /api/hls/:id/{index.m3u8,:seg}, /api/img,
    /api/history, /api/favourites/:id, /api/playlists/* with proper
    auth/admin guards.

Frontend
  - api/client.ts: imageURL() helper; hlsURL() endpoint; reuses the JWT in
    a query parameter for <video src> and <img src>.
  - api/playback.ts: typed helpers for history, favourites, playlists.
  - components/MediaCard.tsx: optional 'progress' prop renders a thin
    bottom progress bar, used by the new Continue Watching row.
  - pages/HomePage.tsx: two rows (Continue Watching + Recently Added);
    falls back to the empty-state hint when both are empty.
  - pages/PlayerPage.tsx: hls.js (lazy-imported) with auto-fallback to
    direct play; ?mode=hls|direct query toggle; resume position written
    every 10s while playing.
  - pages/MediaDetailPage.tsx: heart toggle + admin 'rescrape' / 'reprobe'
    buttons + dedicated 'HLS 转码播放' CTA.
  - pages/FavouritesPage.tsx, PlaylistsPage.tsx, PlaylistDetailPage.tsx:
    new screens.
  - components/Layout.tsx + App.tsx: sidebar links for Favourites and
    Playlists; routes are now lazily code-split via React.lazy + Suspense
    so the initial bundle stays at ~243 KB / 82 KB gzipped (hls.js is
    fetched only on first HLS playback).

Verified: go build, go vet, go test (incl. CleanQuery cases) all pass;
frontend tsc -b && vite build emits 9 route chunks plus a deferred hls
chunk.
2026-05-14 15:44:31 +00:00
Kiro d5cf5fb4b2 feat: bootstrap MediaStationGo (Go + React rewrite of MediaStation)
Adopt the cropflre/nowen-video tech stack and rebuild the project from
scratch:

  - Backend: Go 1.25 + Gin + GORM + SQLite (WAL) + JWT + WebSocket hub.
    Layered packages config / database / model / repository / service /
    middleware / handler. Default admin (admin/admin123) seeded on first
    run; /api routes for auth, libraries, media, stream and admin
    panels. WebSocket scan-progress events at /api/ws.
  - Frontend: React 18 + Vite 5 + Tailwind 3.4 + Zustand + axios +
    react-router 6 + lucide-react + framer-motion + hls.js. Pages for
    Login / Home / Library / Search / MediaDetail / Player / Admin
    (Library, Users, Settings tabs).
  - Distribution: multi-arch Dockerfile (frontend -> backend -> Alpine
    runtime), docker-compose.yml, GitHub Actions for CI and GHCR
    publish, Makefile, env-prefixed config (MEDIASTATION_*).
  - Docs: README, CONTRIBUTING, .env.example, config.example.yaml.

Backend builds, vets and tests pass. Frontend builds via tsc -b && vite
build (250 kB JS / 16 kB CSS, gzipped 84 / 4 kB).
2026-05-14 15:26:29 +00:00
804 changed files with 196921 additions and 1 deletions
+63
View File
@@ -0,0 +1,63 @@
.git
.github
.gitignore
.dockerignore
README.md
CONTRIBUTING.md
LICENSE
docs/
data/
cache/
logs/
verify-data/
verify-cache/
verify-media/
verify-downloads/
.codex-*
.tmp/
.tmp_*
.tmp-deploy-*
.tmp-deploy-data/
.mmtl.pid
*.db
*.db-journal
*.db-shm
*.db-wal
*.log
.env
.env.*
config.yaml
config/*.yaml
!config.example.yaml
*.pem
*.key
*.crt
*.p12
*.pfx
.jwt_secret
*.secret
secrets.*
secret.*
api_keys.*
apikey.*
tokens.*
token.*
password.*
.idea/
.vscode/
.workbuddy/
.dev-cache/
.dev-data/
node_modules/
**/node_modules/
web/dist/
**/dist/
web/.vite/
**/.vite/
web/coverage/
bin/
*.exe
*.dll
*.so
*.dylib
*~
+20
View File
@@ -0,0 +1,20 @@
* text=auto
.gitattributes text eol=lf
*.sh text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
Dockerfile text eol=lf
*.ps1 text eol=crlf
# GitHub Linguist: keep repository language stats focused on product code
# (Go backend + React/TypeScript frontend + Docker packaging). Deployment
# helpers, generated lock files, and static brand assets are still tracked but
# should not appear as primary project languages.
scripts/** linguist-vendored
docker-entrypoint.sh linguist-vendored
web/package-lock.json linguist-generated
web/*.config.js linguist-vendored
web/public/** linguist-vendored
web/src/**/*.css linguist-vendored
+52
View File
@@ -0,0 +1,52 @@
---
name: Bug 反馈
about: 报告可复现的问题、报错、功能异常或回归
title: "[Bug] "
labels: bug
assignees: ""
---
## 问题现象
请描述实际发生了什么。
## 期望行为
请描述你认为正确结果应该是什么。
## 复现步骤
1.
2.
3.
## 部署方式
- 部署方式:Docker 第一档 / 第二档 / 第三档 / 裸机 / 其他
- 镜像或版本:
- NAS / 系统:
- Docker 版本:
- Docker Compose 版本:
- 浏览器:
## 关键配置
请贴出相关配置片段,例如路径映射、媒体库路径、下载器保存路径、站点类型等。
请务必隐藏 Cookie、API Key、Passkey、密码、Token 和私有下载链接。
```yaml
# docker-compose.yml 相关片段
```
## 日志 / 任务详情
请附上应用日志、任务队列详情、浏览器控制台错误或网络请求错误。
```text
```
## 补充信息
截图、录屏、相关 Issue、你已经尝试过的排查步骤。
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Telegram MMTL 交流群
url: https://t.me/MMTL
about: 适合快速交流部署经验、使用问题和排查线索。
+29
View File
@@ -0,0 +1,29 @@
---
name: 功能建议
about: 提出新功能、体验改进或兼容性需求
title: "[Feature] "
labels: enhancement
assignees: ""
---
## 需求背景
这个功能解决什么问题?当前使用流程哪里不方便?
## 期望方案
请描述你希望 MMTL 如何工作。
## 使用场景
- 部署环境:
- 相关页面或模块:
- 受影响用户:
## 可接受的替代方案
如果有其他实现方式或临时解决办法,也请写出来。
## 补充信息
截图、竞品参考、API 文档、相关讨论链接等。
+4
View File
@@ -0,0 +1,4 @@
name: MMTL CodeQL
paths-ignore:
- internal/service/fileid_other.go
+40
View File
@@ -0,0 +1,40 @@
## 背景
说明这个 PR 解决的问题、关联 Issue 或用户场景。
> 请确认本 PR 基于本仓库最新 `main` 的独立分支或 fork 分支提交,未直接向 `main` 推送,也未夹带个人部署魔改配置。
## 改动摘要
-
## 验证
- [ ] `go test ./...`
- [ ] `npm --prefix web run build`
- [ ] `git diff --check`
- [ ] 其他:
## 风险与兼容性
- 是否影响 Docker / NAS 路径映射:
- 是否影响数据库迁移或数据结构:
- 是否影响下载器、订阅、站点 API 或限流:
- 是否包含敏感信息脱敏:
## 截图 / 日志
涉及 UI、任务队列、错误提示、设置页时请附截图或日志片段。
```text
```
## 提交前检查
- [ ] 分支已同步最新 `main`,不是直接在 `main` 上提交。
- [ ] PR 范围聚焦,没有夹带无关重构。
- [ ] 未提交个人部署配置、私有路径、API Key、Cookie、Token 或私有镜像标签。
- [ ] 用户可见错误有清晰提示或日志。
- [ ] 新行为有测试覆盖,或已说明无法覆盖的原因。
- [ ] 文档、示例配置、README 已按需同步。
+268
View File
@@ -0,0 +1,268 @@
name: AuTo Docker Image
on:
push:
branches: [main]
pull_request:
branches: [main]
# 保留手动触发作为备选
workflow_dispatch:
inputs:
version_type:
description: '版本递增类型'
required: true
default: 'patch'
type: choice
options:
- patch # 0.0.x
- minor # 0.x.0
- major # x.0.0
permissions:
contents: write # 需要写入权限来更新版本文件
packages: write
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:
fetch-depth: 0 # 获取完整历史以便版本计算
token: ${{ secrets.GITHUB_TOKEN }}
# 1. 获取或初始化版本号
- name: Get current version
id: get_version
run: |
# 从文件读取版本号,或使用默认值
if [ -f VERSION ]; then
CURRENT_VERSION=$(cat VERSION)
else
CURRENT_VERSION="0.0.0"
echo $CURRENT_VERSION > VERSION
fi
echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
# 分离版本组成部分
MAJOR=$(echo $CURRENT_VERSION | cut -d. -f1)
MINOR=$(echo $CURRENT_VERSION | cut -d. -f2)
PATCH=$(echo $CURRENT_VERSION | cut -d. -f3)
echo "major=$MAJOR" >> $GITHUB_OUTPUT
echo "minor=$MINOR" >> $GITHUB_OUTPUT
echo "patch=$PATCH" >> $GITHUB_OUTPUT
# 2. 计算新版本号
- name: Bump version
id: bump_version
run: |
MAJOR=${{ steps.get_version.outputs.major }}
MINOR=${{ steps.get_version.outputs.minor }}
PATCH=${{ steps.get_version.outputs.patch }}
# 手动触发时根据选择递增
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TYPE="${{ github.event.inputs.version_type }}"
if [ "$TYPE" = "major" ]; then
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
elif [ "$TYPE" = "minor" ]; then
MINOR=$((MINOR + 1))
PATCH=0
else # patch
PATCH=$((PATCH + 1))
fi
else
# 自动触发时默认 patch 递增
PATCH=$((PATCH + 1))
fi
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "tag=MMTL-v${NEW_VERSION}" >> $GITHUB_OUTPUT
echo "tag=mmtl-v${NEW_VERSION}" >> $GITHUB_OUTPUT
# 3. 更新 VERSION 文件
- name: Update version file
run: |
echo "${{ steps.bump_version.outputs.new_version }}" > VERSION
# 如果存在 go.mod,也更新其中的版本(可选)
# if [ -f go.mod ]; then
# sed -i "s/^version .*/version ${{ steps.bump_version.outputs.new_version }}/" go.mod
# fi
# 4. 提交版本变更
- name: Commit version bump
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add VERSION
git commit -m "chore: bump version to ${{ steps.bump_version.outputs.new_version }} [skip ci]"
git push
# 5. 创建 Git Tag
- name: Create and push tag
run: |
TAG="${{ steps.bump_version.outputs.tag }}"
git tag $TAG
git push origin $TAG
# 6. 设置 Docker QEMU 和 Buildx
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
# 7. 登录 GHCR
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# 8. 提取镜像元数据
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/mmtl
tags: |
type=raw,value=latest
type=raw,value=${{ steps.bump_version.outputs.tag }}
type=raw,value=${{ steps.bump_version.outputs.new_version }}
# 9. 构建并推送
- name: Build & push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
provenance: false
sbom: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
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
+84
View File
@@ -0,0 +1,84 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend:
name: Backend (Go)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
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 ./...
- name: go build
run: go build ./...
- name: go test
run: go test ./...
frontend:
name: Frontend (Node)
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
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
run: npm ci
- name: Type-check & build
run: npm run build
smoke:
name: Deployment smoke test
runs-on: ubuntu-latest
needs: [backend, frontend]
steps:
- uses: actions/checkout@v4
- name: Validate single-image compose
run: docker compose -f docker-compose.simple.yml config --quiet
- name: Validate tier 1 compose
run: docker compose -f docker-compose.yml config --quiet
- name: Validate tier 2 compose
run: docker compose -f docker-compose.standard.yml config --quiet
- name: Validate tier 3 compose
run: docker compose -f docker-compose.search.yml config --quiet
ci-success:
name: CI Success
runs-on: ubuntu-latest
needs: [backend, frontend, smoke]
if: success()
steps:
- run: echo "CI passed, ready for release"
+63
View File
@@ -0,0 +1,63 @@
name: Publish Docker image
on:
workflow_dispatch:
inputs:
version:
description: 'Image tag to publish, for example MMTL-v0.0.32'
required: true
type: string
ref:
description: 'Git ref to build from'
required: false
default: main
type: string
permissions:
contents: read
packages: write
jobs:
docker:
runs-on: ubuntu-latest
env:
RELEASE_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref }}
- 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: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
# 自动使用当前仓库所有者
images: ghcr.io/${{ github.repository_owner }}/mmtl
tags: |
type=raw,value=latest
type=raw,value=${{ env.RELEASE_VERSION }}
- name: Build & push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
provenance: false
sbom: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ env.RELEASE_VERSION }}
cache-from: type=gha
cache-to: type=gha,mode=max
+82
View File
@@ -0,0 +1,82 @@
# Binaries
bin/
*.exe
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
*.out
# Go workspace
go.work
# Dependency directories
node_modules/
# Build artifacts
web/dist/
web/.vite/
web/coverage/
web/tsconfig.tsbuildinfo
dist-release/
# Data / runtime
data/
cache/
logs/
.tmp-deploy-data/
.tmp-deploy-smoke-data/
.tmp-deploy-smoke-cache/
.tmp-deploy-cache/
.tmp-deploy-server.*
.tmp-live-server.*
.mmtl.pid
*.log
*.db
*.db-journal
*.db-shm
*.db-wal
# Editor / OS
.idea/
.vscode/
.DS_Store
Thumbs.db
# Env files
.env
.env.local
.env.*.local
# Local configs (keep examples)
config/secrets.yaml
config.yaml
# WorkBuddy workspace (local AI assistant memory)
.workbuddy/
# Editor backups
*~
.tmp_*
# Runtime / local-only artifacts (清理补充)
.tmp/
.tmp-live-backups/
.tmp-*
.codex-*
downloads/
media/
*.pid
# 本地开发运行产物
.agents/
.claude/
.dev-cache/
.dev-data/
.dev-logs/
tools/
verify-cache/
verify-data/
.zcode/
+114
View File
@@ -0,0 +1,114 @@
# 贡献规范
感谢你愿意帮助 MMTL 变得更稳定。这个项目主要面向 NAS、Docker 部署、媒体库整理、订阅下载和多端播放场景;提交 Issue 或 Pull Request 时,请尽量提供可复现、可验证的信息。
## Issue 提交规范
提交 Issue 前,请先确认:
- 已搜索现有 Issues,避免重复提交同一个问题。
- 使用的是最新镜像、最新主分支,或已说明当前版本号 / 镜像摘要。
- 如果是部署或运行问题,已附上部署方式和关键配置。
### Bug Report 必填信息
- 问题现象:实际发生了什么,是否稳定复现。
- 期望行为:你认为正确结果应该是什么。
- 复现步骤:从哪个页面、点击什么、填写什么、触发什么任务。
- 部署方式:Docker 第一档 / 第二档 / 第三档、裸机运行、反代方式等。
- 环境信息:NAS 型号或系统、Docker / Compose 版本、浏览器、MMTL 镜像版本。
- 相关配置:路径映射、下载器保存路径、媒体库路径、站点类型等。请隐藏 Cookie、API Key、密码和 Token。
- 日志和任务信息:优先提供应用日志、任务队列详情、浏览器控制台错误、网络请求错误。
### 日志建议
排查订阅、站点搜索、下载器、整理入库、网盘扫描时,建议临时把日志级别调整为 `info` 或 `debug`,复现后再恢复。
Docker 部署常用命令:
```bash
docker compose ps
docker compose logs --tail=300 mmtl
docker compose exec mmtl sh -lc 'ls -la /data/logs || true'
```
PostgreSQL 部署查询示例:
```bash
docker compose exec postgres psql -U mmtl -d mmtl -c "select key,value,updated_at from settings order by updated_at desc limit 30;"
```
请勿公开粘贴以下敏感信息:
- 站点 Cookie、Passkey、API Key、YemaPT Auth Key、M-Team API Key。
- qBittorrent / Transmission / Aria2 密码。
- Telegram Bot Token。
- JWT、数据库密码、私有下载链接。
## Pull Request 提交规范
所有非紧急变更都应通过 Pull Request 合入 `main`,不要直接向主分支推送。贡献者可以从 fork 或本仓库的独立分支发起 PR;维护者只有在紧急安全修复、发布流水线修复等特殊场景下,才可以短暂绕过 PR 流程,并需要在提交说明或后续 Issue 中补充原因。
PR 应该尽量小而清晰。一次 PR 聚焦一个问题或一组强相关改动,避免把无关重构、格式化和功能混在一起。
### 分支要求
- 分支从最新 `main` 创建,提交前先同步远端主分支。
- 分支名建议使用 `fix/...`、`feat/...`、`docs/...` 或 `test/...`。
- 不要在 `main` 上直接开发和提交 PR 内容。
- 不要把个人部署配置、NAS 本地路径、私有镜像标签或测试数据提交进 PR。
- 如果基于魔改版、私有部署版或临时补丁开发,请先确认改动能在本仓库最新 `main` 上复现和应用,再提交 PR。
### PR 描述应包含
- 背景:修复哪个 Issue / 哪个用户场景 / 哪个回归。
- 改动摘要:后端、前端、配置、文档分别改了什么。
- 验证结果:运行过哪些命令,是否有无法运行的测试。
- 风险说明:数据迁移、Docker 配置、路径映射、下载器行为、站点 API 限流等是否受影响。
- 截图或录屏:涉及 UI、任务队列、错误提示、设置页时请附上。
### 推荐验证命令
根据改动范围选择运行:
```bash
go test ./...
npm --prefix web run build
git diff --check
```
如果只改动某个模块,可以先跑定向测试,例如:
```bash
go test ./internal/service -run "TestOrganize|TestSubscription|TestMTeam" -count=1
go test ./cmd/server -count=1
```
### 代码要求
- Go 代码使用 `gofmt`。
- 前端 TypeScript 需要通过 `npm --prefix web run build`。
- 用户可见错误需要可操作:说明失败原因、下一步怎么查或怎么修。
- 后台任务失败不要静默吞掉,应进入任务队列、日志或 API 响应。
- Docker/NAS 路径相关改动必须考虑宿主机路径与容器路径映射。
- 站点 API、订阅和下载器改动需要注意去重、限流、敏感信息脱敏。
## Commit Message 建议
优先使用简短清晰的动词开头:
```text
fix organizer hardlink diagnostics
feat(yemapt): add auth-only site adapter
docs: add issue and pull request guidelines
test: cover subscription restore matching
```
## 维护者合并检查
合并前建议确认:
- PR 范围清楚,没有夹带无关改动。
- 自动化检查通过,或失败原因已说明。
- 修改涉及的用户路径已有日志、错误提示或回归测试。
- 文档、示例配置和 README 是否需要同步更新。
+95
View File
@@ -0,0 +1,95 @@
# syntax=docker/dockerfile:1.6
# =============================================================================
# Multi-architecture build for MMTL.
#
# Stage 1 (frontend) : Node 20 -> static SPA bundle
# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0)
# Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user
#
# Build:
# docker buildx build --platform linux/amd64,linux/arm64 \
# --build-arg VERSION=MMTL-v0.1.16 -t mmtl:latest --push .
#
# Optional Intel VAAPI/QSV runtime packages:
# docker buildx build --build-arg WITH_VAAPI=true ...
# =============================================================================
# ---- Stage 1: frontend (always build on the host architecture) -------------
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend
ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/
WORKDIR /app/web
COPY web/package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --registry="${NPM_CONFIG_REGISTRY}"
COPY web/ .
RUN npm run build
# ---- Stage 2: backend (cross-compiled to TARGETPLATFORM) -------------------
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS backend
ARG TARGETOS
ARG TARGETARCH
ARG GOPROXY=https://proxy.golang.org,direct
ARG VERSION=dev
ENV GOPROXY=${GOPROXY}
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
COPY --from=frontend /app/web/dist ./web/dist
RUN --mount=type=cache,target=/go/pkg/mod \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o mmtl ./cmd/server
# ---- Stage 3: runtime ------------------------------------------------------
FROM alpine:3.23
ARG WITH_VAAPI=false
# Default runtime keeps only the packages needed by normal deployments.
# VAAPI/mesa drivers pull a large graphics dependency tree, so they are opt-in
# for users who explicitly build an Intel hardware-acceleration image.
# NVENC requires the proprietary NVIDIA Container Toolkit on the host only.
RUN apk add --no-cache \
ffmpeg \
docker-cli \
tzdata \
ca-certificates \
su-exec \
&& if [ "$WITH_VAAPI" = "true" ]; then \
if [ "$(apk --print-arch)" = "x86_64" ]; then \
apk add --no-cache intel-media-driver libva-utils mesa-va-gallium; \
else \
apk add --no-cache libva-utils mesa-va-gallium || true; \
fi; \
fi \
&& rm -rf /var/cache/apk/*
# Non-root user for the long-running process.
RUN addgroup -S mmtl && adduser -S mmtl -G mmtl
WORKDIR /app
COPY --from=backend /app/mmtl /usr/local/bin/mmtl
COPY --from=frontend /app/web/dist /app/web/dist
RUN mkdir -p /data /cache /media \
&& chown -R mmtl:mmtl /data /cache /media
# Default environment (overridable via docker-compose / `docker run -e`).
ENV MMTL_APP_PORT=8080 \
MMTL_APP_DATA_DIR=/data \
MMTL_APP_WEB_DIR=/app/web/dist \
MMTL_DATABASE_DB_PATH=/data/mmtl.db \
MMTL_CACHE_CACHE_DIR=/cache \
MMTL_LOGGING_LEVEL=info \
TZ=Asia/Shanghai
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD busybox wget -q --spider http://127.0.0.1:8080/api/health || exit 1
# Tiny entrypoint that lets us run as a NAS host UID/GID via PUID/PGID without
# rewriting /etc/passwd or /etc/group on every container start.
COPY docker-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
CMD ["/entrypoint.sh"]
+9
View File
@@ -0,0 +1,9 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This project is licensed under the GNU GPL v3.0.
For the full license text, see https://www.gnu.org/licenses/gpl-3.0.txt
+430
View File
@@ -0,0 +1,430 @@
# MMTL (My Movie and TV Library)
<p align="center">
<img src="web/public/brand/logo-192.png" width="96" height="96" alt="MMTL Logo" />
</p>
<h3 align="center">适合 NAS、家庭共享和多端播放的私人媒体中心</h3>
<p align="center">
<strong>Docker 一键部署 · PostgreSQL 主库 · Redis 热缓存 · OpenSearch 搜索增强 · Emby 协议兼容 · Bot 通知</strong>
</p>
<p align="center">
<a href="#快速开始">快速开始</a> ·
<a href="#三挡部署">三挡部署</a> ·
<a href="#路径映射">路径映射</a> ·
<a href="#旧-sqlite-迁移">旧 SQLite 迁移</a> ·
<a href="#开发构建">开发构建</a> ·
<a href="CONTRIBUTING.md">贡献规范</a> ·
<a href="https://mgo.3jzs.com">在线演示</a>
</p>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go&logoColor=white" />
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111827" />
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
<img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" />
</p>
---
## 项目简介
MMTL 是一个自托管媒体管理系统,面向 NAS、小主机、家庭影音和多用户共享场景。它把媒体库、刮削、下载整理、订阅、网盘播放、Emby 协议兼容、用户权限和 Bot 通知放在一个后台里,目标是让用户只维护一套服务,就能给网页端、手机端、电视端和第三方播放器使用。
核心能力:
- **媒体库管理**:电影、电视剧、动漫、综艺、音乐和自定义媒体库统一管理。
- **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 档位。
```bash
mkdir -p MMTL
cd MMTL
# 最省心:单镜像 + SQLite,只启动一个容器
curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.simple.yml -o docker-compose.yml
# 或第一档:PostgreSQL(多用户/高并发更稳)
# curl -fsSL https://raw.githubusercontent.com/truewhile/MMTL/main/docker-compose.yml -o docker-compose.yml
docker compose up -d
```
启动后访问:
```text
http://服务器IP:18080
```
默认账号:
```text
admin / admin123
```
首次登录后请立刻修改管理员密码。
镜像地址:
```text
GHCR:ghcr.io/truewhile/mmtl:latest
```
## 部署档位
MMTL 推荐按机器资源和用户规模选择部署档位。每份 Compose 文件都是完整文件,不需要再叠加多个 `-f`。想一个镜像跑起来就选单镜像档(SQLite);需要多用户 / 高并发时再用 PostgreSQL 三档。Redis 和 OpenSearch 是增强组件,不替代 PostgreSQL。
| 档位 | 完整配置文件 | 组件 | 适合场景 |
| --- | --- | --- | --- |
| 单镜像档 | `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 | 超大媒体库、复杂全文搜索、后续需要独立搜索索引 |
### 单镜像档: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` 里的这几处:
```yaml
ports:
- "18080:8080" # 改左边 18080 即可
volumes:
- ./data:/data # 必须备份
- ./media:/media # 改左边为你的媒体目录,例如 /vol1/1000/Media:/media
# - /dev/dri:/dev/dri # Intel 核显硬解需要时取消注释
```
网页后台添加媒体库时填写容器内路径:
```text
/media
/media/电影
/media/电视剧
```
关键数据目录:
```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 上调整:
```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
```
## 路径映射
路径映射是 Docker 部署里最容易填错的地方。原则是:`volumes` 左边是宿主机真实路径,右边是容器内路径;环境变量里也要保持对应关系。
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`,再手动修改镜像版本并单独拉取。
如果第二档或第三档保留了原始文件名,更新时指定对应完整文件:
```bash
# 第二档
docker compose -f docker-compose.standard.yml pull mmtl
docker compose -f docker-compose.standard.yml up -d --no-deps mmtl
# 第三档
docker compose -f docker-compose.search.yml pull mmtl
docker compose -f docker-compose.search.yml up -d --no-deps mmtl
```
必须备份:
```text
./postgres PostgreSQL 主数据库
./data JWT 密钥、运行配置、旧 SQLite 迁移源
```
可重建:
```text
./cache 图片缓存、临时文件、转码缓存
./redis Redis 热缓存
./opensearch 搜索索引
```
## 常见问题
**启动后还是反复迁移 SQLite?**
确认旧数据已经迁移成功后,把 `MMTL_DATABASE_DB_PATH` 改成不存在的路径,例如 `/data/no-sqlite-migration.db`,然后重启容器。
**扫库或入库速度很慢?**
先确认数据库档位和路径映射正确。第一档已经足够大多数场景;第二档 Redis 能缓解频繁刷新造成的数据库压力;第三档主要增强搜索,不会替代媒体扫描本身。网盘扫描还会受网盘接口响应、目录数量和网络质量影响。
**qBittorrent 下载完成后无法整理?**
确认 qBittorrent 保存路径已经通过 `volumes` 挂载进 MMTL 容器,并且 `MMTL_DOWNLOAD_DIR` 与 `MMTL_DOWNLOAD_CONTAINER_DIR` 对应正确。
**硬链接目录在 Docker / NAS 上看不到内容?**
硬链接不能直接链接“目录”本身,只能链接目录里的文件。文件管理器执行目录硬链接时会递归创建目标目录结构,并为每个文件创建硬链接。硬链接还要求源文件和目标文件在容器内属于同一个文件系统/子卷;如果下载目录和媒体目录是两个独立 bind mount、不同硬盘、不同 btrfs 子卷或网盘挂载,系统会返回 `invalid cross-device link`,此时请选择“复制”或“软链接”。
**第三方播放器无法连接?**
确认播放器填写的是 `http://服务器IP:18080`,账号密码使用 MMTL 用户账号。反代部署时需要正确设置外部访问地址和 HTTPS 头。
## 开发构建
本地开发需要 Go、Node.js 和 npm。
后端会将 `web/dist` 通过 `go:embed` 编进二进制,因此**在编译 / 运行后端之前要先构建前端**,否则 `web` 包会因为缺少嵌入资源而编译失败。
```bash
# 前端依赖与构建(必须先做,产物被 go:embed 打进二进制)
npm --prefix web ci
npm --prefix web run build
# 后端测试
go test ./...
# 本地运行后端(二进制自带前端界面,无需额外 web 目录)
go run ./cmd/server
# 本地运行前端开发服务器
npm --prefix web run dev
```
前端开发服务器默认访问:
```text
http://127.0.0.1:3000
```
后端健康检查:
```text
http://127.0.0.1:8080/api/health
```
### 交叉编译单文件发布物
CI(`.github/workflows/Auto-docker-publish.yml`)每次发布会自动为 Windows / Linux(含 Debian) / macOS 交叉编译 amd64 + arm64 的单文件可执行程序,并上传到对应的 GitHub Release。你可以在 Releases 页面下载 `.zip`(Windows)或 `.tar.gz`(Linux / macOS)附件,解压后直接运行其中的 `mmtl`(Windows 为 `mmtl.exe`),无需额外携带前端目录。
本地手动交叉编译某个平台:
```bash
# 先构建前端
npm --prefix web ci && npm --prefix web run build
# 例如:构建 Linux amd64 单文件
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o mmtl-linux-amd64 ./cmd/server
```
## 贡献与反馈
提交 Bug、功能建议或 Pull Request 前,请先阅读 [贡献规范](CONTRIBUTING.md)。
- Bug 反馈请使用 Issue 模板,并提供部署方式、复现步骤、日志和关键配置。
- 功能建议请说明使用场景、期望行为和可接受的替代方案。
- 安全漏洞请不要公开发 Issue,按 [安全策略](SECURITY.md) 使用私密渠道报告。
- Pull Request 请从独立分支或 fork 分支发起,不要直接向 `main` 推送。
- 分支名建议使用 `fix/...`、`feat/...`、`docs/...` 或 `test/...`,例如 `docs/contribution-guidelines`。
- 提交前按改动范围运行 `go test ./...`、`npm --prefix web run build` 或定向测试,并在 PR 中说明验证结果。
## Star History
<a href="https://www.star-history.com/?repos=ShukeBta%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" />
</picture>
</a>
## 许可证
本项目使用 GPL-3.0 License。详见 [LICENSE](LICENSE)。
+585
View File
@@ -0,0 +1,585 @@
# MMTL (My Movie and TV Library)
<p align="center">
<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>
<p align="center">
<strong>Docker-first setup · Multi-user management · Media library · Metadata · Downloads · Emby-protocol clients · Cloud playback</strong>
</p>
<p align="center">
<a href="README.md">中文</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>
</p>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go&logoColor=white" />
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111827" />
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
<img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" />
</p>
---
## What is it?
MMTL is a self-hosted media center for personal libraries, home NAS, and home-theater users.
It helps 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.
> The project is moving fast. With the default PostgreSQL deployment, back up both `data/` and `postgres/`.
---
## Key Highlights
- **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.
---
## 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.
```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
```
Edit `docker-compose.yml`:
```bash
vi docker-compose.yml
```
Start:
```bash
docker compose up -d
```
Open:
```text
http://SERVER_IP:18080
```
Default login:
```text
Username: admin
Password: admin123
```
---
## Docker Compose Recommended
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.
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.
### Deployment modes
| 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 |
Each compose file is standalone. Do not stack multiple `-f` files together.
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.
### Database Choice And Disabling SQLite
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
### Update
```bash
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
```
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`.
### 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
```
---
## FAQ
### 1. The web page does not open
Check the container:
```bash
docker ps
docker compose logs --tail=100 mmtl
```
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.
---
## 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:
```bash
go run ./cmd/server
```
Frontend:
```bash
cd web
npm install
npm run dev
```
Tests:
```bash
go test ./...
cd web && npm run build
```
---
## 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/)
---
## Donation
If MMTL saves you time, feel free to buy the author a bowl of noodles.
<img width="200" height="200" alt="WeChat Donation QR" src="https://github.com/user-attachments/assets/d6077de5-8305-400d-8b82-470ef05d926e" />
---
## Star History
<a href="https://www.star-history.com/?repos=ShukeBta%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" />
</picture>
</a>
---
## License and Non-Commercial Statement
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>
+83
View File
@@ -0,0 +1,83 @@
# 安全策略
MMTL 是自托管媒体系统,常部署在 NAS、家庭网络、Docker、反向代理和第三方下载器环境中。安全问题通常会同时涉及应用代码、容器配置、路径映射、站点 Cookie / API Key、下载器凭据和外部访问入口。请按本策略报告和处理安全问题。
## 支持范围
我们优先支持以下版本和部署方式的安全修复:
- 当前 `main` 分支。
- 最新发布镜像:`ghcr.io/shukebta/mmtl:latest`。
- README 中推荐的 Docker Compose 第一档、第二档、第三档部署方式。
历史版本、私有魔改镜像、未公开补丁分支和非标准部署仍可报告,但维护者可能要求先在最新 `main` 或最新镜像中复现。
## 如何报告安全漏洞
请不要在公开 Issue、PR、讨论区或群聊中披露可利用细节。优先使用 GitHub Security Advisory 私密报告:
<https://github.com/ShukeBta/MMTL/security/advisories/new>
如果无法使用 GitHub 私密报告,可以先通过项目 README 中的社区入口联系维护者,说明“需要私下报告安全问题”,不要直接贴出利用细节、密钥或完整日志。
报告时请尽量提供:
- 影响范围:认证绕过、权限提升、敏感信息泄露、任意文件读写、命令执行、SSRF、路径穿越、下载器凭据泄露等。
- 复现环境:部署方式、镜像版本或 commit、NAS / 系统、Docker / Compose 版本、是否有反向代理。
- 复现步骤:最小可复现路径、请求、页面操作或配置条件。
- 影响证明:截图、脱敏日志、请求响应、数据库字段名等。
- 缓解建议:如果你已经验证过可行修复或临时规避方式,请一并说明。
请务必脱敏:
- 站点 Cookie、Passkey、API Key、YemaPT Auth Key、M-Team API Key。
- qBittorrent / Transmission / Aria2 用户名密码。
- Telegram Bot Token、JWT、数据库密码、反代访问 Token。
- 私有下载链接、媒体库真实敏感路径、用户个人信息。
## 响应流程
维护者会尽力按以下节奏处理:
- 3 个工作日内确认收到报告。
- 7 个工作日内给出初步影响判断、复现状态或需要补充的信息。
- 高危问题优先修复,并在可行时提供临时缓解建议。
- 修复发布后,再公开披露必要信息;公开内容会避免包含可直接滥用的细节。
如果问题需要更长时间修复,例如涉及数据迁移、权限模型、第三方站点 API 或下载器协议,我们会在私密报告中同步进展。
## 安全问题范围
欢迎报告:
- 未授权访问管理接口、媒体库、下载任务、站点配置或用户数据。
- 普通用户越权执行管理员操作。
- 读取或写入容器可访问范围外的文件。
- 通过路径映射、整理入库、STRM、图片代理、字幕、备份恢复等功能触发路径穿越。
- 泄露 Cookie、API Key、下载器密码、Telegram Token、JWT 或数据库凭据。
- SSRF、任意重定向、反代信任边界错误。
- Docker Compose 示例中可能导致默认暴露敏感服务的问题。
- 日志中输出敏感信息或无法脱敏的问题。
通常不按安全漏洞处理:
- 需要管理员主动填写恶意配置才能触发、且不会突破管理员已有权限的问题。
- 只影响个人私有魔改版、无法在最新主分支复现的问题。
- 已经失效的依赖告警,且没有可达利用路径。
- 没有安全影响的 UI 显示问题、普通功能 Bug 或性能问题。
## 自托管安全基线
部署 MMTL 时建议:
- 首次登录后立即修改默认 `admin / admin123`。
- 不要把 PostgreSQL、Redis、OpenSearch、qBittorrent WebUI 暴露到公网。
- 反向代理公网访问时启用 HTTPS,并限制管理后台访问来源。
- 使用强随机的 JWT / 加密密钥,妥善备份 `./data` 和数据库。
- 不要在 Issue、PR、截图或日志中公开站点 Cookie、API Key、Passkey、下载器密码。
- Docker `volumes` 只挂载 MMTL 需要访问的目录,媒体库目录需要写入时再授予写权限。
- 定期更新镜像,并在升级前备份 `./postgres` 和 `./data`。
## 安全修复 PR
安全修复 PR 请遵循 [贡献规范](CONTRIBUTING.md),但不要在公开 PR 中暴露可利用细节。必要时先通过私密安全报告确认修复方案,再提交脱敏后的补丁。
+1 -1
View File
@@ -1 +1 @@
0.1.154
0.0.62
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/ShukeBta/MMTL/internal/config"
)
const defaultLogMaxSizeMB = 20
type rotatingFileWriter struct {
mu sync.Mutex
path string
maxSize int64
maxBackups int
maxAge time.Duration
file *os.File
size int64
}
func newRotatingFileWriter(path string, cfg config.LoggingConfig) (*rotatingFileWriter, error) {
if path == "" {
return nil, fmt.Errorf("log path required")
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return nil, fmt.Errorf("create log dir: %w", err)
}
maxSizeMB := cfg.MaxSizeMB
if maxSizeMB <= 0 {
maxSizeMB = defaultLogMaxSizeMB
}
w := &rotatingFileWriter{
path: path,
maxBackups: cfg.MaxBackups,
}
if cfg.EnableRotation {
w.maxSize = int64(maxSizeMB) * 1024 * 1024
}
if w.maxBackups < 0 {
w.maxBackups = 0
}
if cfg.MaxAgeDays > 0 {
w.maxAge = time.Duration(cfg.MaxAgeDays) * 24 * time.Hour
}
if err := w.open(); err != nil {
return nil, err
}
return w, nil
}
func (w *rotatingFileWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
if err := w.open(); err != nil {
return 0, err
}
}
if w.maxSize > 0 && w.size > 0 && w.size+int64(len(p)) > w.maxSize {
if err := w.rotate(); err != nil {
return 0, err
}
}
n, err := w.file.Write(p)
w.size += int64(n)
return n, err
}
func (w *rotatingFileWriter) Sync() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return nil
}
return w.file.Sync()
}
func (w *rotatingFileWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return nil
}
err := w.file.Close()
w.file = nil
w.size = 0
return err
}
func (w *rotatingFileWriter) open() error {
file, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o640)
if err != nil {
return fmt.Errorf("open log file %s: %w", w.path, err)
}
w.file = file
if stat, err := file.Stat(); err == nil {
w.size = stat.Size()
}
return nil
}
func (w *rotatingFileWriter) rotate() error {
if w.file != nil {
_ = w.file.Close()
w.file = nil
}
if w.maxBackups == 0 {
_ = os.Remove(w.path)
return w.open()
}
for i := w.maxBackups - 1; i >= 1; i-- {
oldPath := fmt.Sprintf("%s.%d", w.path, i)
newPath := fmt.Sprintf("%s.%d", w.path, i+1)
if _, err := os.Stat(oldPath); err == nil {
_ = os.Rename(oldPath, newPath)
}
}
if _, err := os.Stat(w.path); err == nil {
_ = os.Rename(w.path, fmt.Sprintf("%s.1", w.path))
}
w.pruneByAge()
return w.open()
}
func (w *rotatingFileWriter) pruneByAge() {
if w.maxAge <= 0 {
return
}
cutoff := time.Now().Add(-w.maxAge)
for i := 1; i <= w.maxBackups; i++ {
path := fmt.Sprintf("%s.%d", w.path, i)
if stat, err := os.Stat(path); err == nil && stat.ModTime().Before(cutoff) {
_ = os.Remove(path)
}
}
}
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"os"
"path/filepath"
"strings"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"github.com/ShukeBta/MMTL/internal/config"
)
// newLogger 根据 cfg.Logging 构建 Zap。
func newLogger(cfg *config.Config) (*zap.Logger, error) {
log, _, err := newLoggerWithCloser(cfg)
return log, err
}
func newLoggerWithCloser(cfg *config.Config) (*zap.Logger, func(), error) {
if cfg.App.Debug {
log, err := zap.NewDevelopment()
return log, func() {}, err
}
level := configuredLogLevel(cfg.Logging.Level)
encoderCfg := zap.NewProductionEncoderConfig()
encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder
var encoder zapcore.Encoder
if strings.EqualFold(strings.TrimSpace(cfg.Logging.Format), "console") {
encoder = zapcore.NewConsoleEncoder(encoderCfg)
} else {
encoder = zapcore.NewJSONEncoder(encoderCfg)
}
cores := []zapcore.Core{
zapcore.NewCore(encoder, zapcore.Lock(os.Stdout), level),
}
var closers []func() error
appPath, warnPath, errorPath := logFilePaths(cfg)
if appPath != "" {
appWriter, err := newRotatingFileWriter(appPath, cfg.Logging)
if err != nil {
return nil, nil, err
}
cores = append(cores, zapcore.NewCore(encoder, appWriter, level))
closers = append(closers, appWriter.Close)
}
if warnPath != "" {
warnWriter, err := newRotatingFileWriter(warnPath, cfg.Logging)
if err != nil {
return nil, nil, err
}
cores = append(cores, zapcore.NewCore(encoder, warnWriter, zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
return lvl == zapcore.WarnLevel && level.Enabled(lvl)
})))
closers = append(closers, warnWriter.Close)
}
if errorPath != "" {
errorWriter, err := newRotatingFileWriter(errorPath, cfg.Logging)
if err != nil {
return nil, nil, err
}
cores = append(cores, zapcore.NewCore(encoder, errorWriter, zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
return lvl >= zapcore.ErrorLevel && level.Enabled(lvl)
})))
closers = append(closers, errorWriter.Close)
}
closeFn := func() {
for _, c := range closers {
_ = c()
}
}
return zap.New(zapcore.NewTee(cores...), zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel), zap.ErrorOutput(zapcore.Lock(os.Stderr))), closeFn, nil
}
func configuredLogLevel(raw string) zapcore.Level {
level := zapcore.WarnLevel
raw = strings.TrimSpace(raw)
if raw != "" {
var parsed zapcore.Level
if err := parsed.UnmarshalText([]byte(raw)); err == nil {
level = parsed
}
}
return level
}
func logFilePaths(cfg *config.Config) (string, string, string) {
out := strings.TrimSpace(cfg.Logging.OutputPath)
if strings.EqualFold(out, "stdout") || strings.EqualFold(out, "stderr") {
return "", "", ""
}
if out == "" {
out = filepath.Join(cfg.App.DataDir, "logs")
}
if ext := filepath.Ext(out); ext != "" {
base := strings.TrimSuffix(out, ext)
return out, base + ".warn" + ext, base + ".error" + ext
}
return filepath.Join(out, "app.log"), filepath.Join(out, "warn.log"), filepath.Join(out, "error.log")
}
+118
View File
@@ -0,0 +1,118 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/config"
)
func TestProductionLoggerWritesConfiguredInfoToAppLogAndSplitsWarnError(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{}
cfg.App.DataDir = dir
cfg.Logging.Level = "info"
cfg.Logging.Format = "json"
cfg.Logging.OutputPath = filepath.Join(dir, "logs")
cfg.Logging.EnableRotation = true
cfg.Logging.MaxSizeMB = 1
cfg.Logging.MaxBackups = 2
log, closeFn, err := newLoggerWithCloser(cfg)
if err != nil {
t.Fatal(err)
}
defer closeFn()
log.Info("info should be stored")
log.Warn("warning only", zap.String("kind", "warn"))
log.Error("error only", zap.String("kind", "error"))
_ = log.Sync()
appBytes, err := os.ReadFile(filepath.Join(dir, "logs", "app.log"))
if err != nil {
t.Fatal(err)
}
warnBytes, err := os.ReadFile(filepath.Join(dir, "logs", "warn.log"))
if err != nil {
t.Fatal(err)
}
errorBytes, err := os.ReadFile(filepath.Join(dir, "logs", "error.log"))
if err != nil {
t.Fatal(err)
}
appLog := string(appBytes)
warnLog := string(warnBytes)
errorLog := string(errorBytes)
if !strings.Contains(appLog, "info should be stored") ||
!strings.Contains(appLog, "warning only") ||
!strings.Contains(appLog, "error only") {
t.Fatalf("app log should contain all enabled levels: %s", appLog)
}
if strings.Contains(warnLog, "info should be stored") || strings.Contains(errorLog, "info should be stored") {
t.Fatal("split warn/error logs should not contain info")
}
if !strings.Contains(warnLog, "warning only") || strings.Contains(warnLog, "error only") {
t.Fatalf("warn log not isolated: %s", warnLog)
}
if !strings.Contains(errorLog, "error only") || strings.Contains(errorLog, "warning only") {
t.Fatalf("error log not isolated: %s", errorLog)
}
}
func TestProductionLoggerDefaultsToWarnInAppLog(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{}
cfg.App.DataDir = dir
cfg.Logging.Format = "json"
cfg.Logging.OutputPath = filepath.Join(dir, "logs")
cfg.Logging.EnableRotation = true
log, closeFn, err := newLoggerWithCloser(cfg)
if err != nil {
t.Fatal(err)
}
defer closeFn()
log.Info("info should stay quiet by default")
log.Warn("warning should be stored")
_ = log.Sync()
appBytes, err := os.ReadFile(filepath.Join(dir, "logs", "app.log"))
if err != nil {
t.Fatal(err)
}
appLog := string(appBytes)
if strings.Contains(appLog, "info should stay quiet by default") {
t.Fatalf("default logger should not store info: %s", appLog)
}
if !strings.Contains(appLog, "warning should be stored") {
t.Fatalf("default logger should store warn: %s", appLog)
}
}
func TestRotatingFileWriterCapsFileSize(t *testing.T) {
path := filepath.Join(t.TempDir(), "app.log")
writer, err := newRotatingFileWriter(path, config.LoggingConfig{
EnableRotation: true,
MaxSizeMB: 1,
MaxBackups: 2,
})
if err != nil {
t.Fatal(err)
}
defer writer.Close()
chunk := strings.Repeat("x", 700*1024)
if _, err := writer.Write([]byte(chunk)); err != nil {
t.Fatal(err)
}
if _, err := writer.Write([]byte(chunk)); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path + ".1"); err != nil {
t.Fatalf("expected rotated backup: %v", err)
}
_ = writer.Sync()
}
+139
View File
@@ -0,0 +1,139 @@
// Package main is the MMTL HTTP server entry point.
//
// MMTL is a Go rewrite of the legacy Python implementation,
// adopting the same tech stack as cropflre/nowen-video:
//
// Backend: Go 1.25 + Gin + GORM + PostgreSQL/SQLite + Viper + Zap + JWT
// Frontend: React 18 + Vite + Tailwind + Zustand + HLS.js
//
// The binary embeds the SPA build artifacts at /app/web/dist and serves them
// alongside the JSON REST API at /api/* and the WebSocket hub at /api/ws.
package main
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/database"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
)
// version is overwritten at build time via -ldflags="-X main.version=...".
var version = "dev"
func effectiveVersion(buildVersion string) string {
buildVersion = strings.TrimSpace(buildVersion)
if buildVersion != "" && buildVersion != "dev" {
return buildVersion
}
if envVersion := strings.TrimSpace(os.Getenv("MMTL_VERSION")); envVersion != "" {
return envVersion
}
if buildVersion == "" {
return "dev"
}
return buildVersion
}
func main() {
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "config load failed: %v\n", err)
os.Exit(1)
}
logger, err := newLogger(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "logger init failed: %v\n", err)
os.Exit(1)
}
defer func() { _ = logger.Sync() }()
appVersion := effectiveVersion(version)
logger.Info("starting MMTL",
zap.String("version", appVersion),
zap.Int("port", cfg.App.Port),
zap.String("data_dir", cfg.App.DataDir),
)
// Ensure data / cache / web dirs exist.
for _, d := range []string{cfg.App.DataDir, cfg.Cache.CacheDir} {
if err := os.MkdirAll(d, 0o750); err != nil {
logger.Fatal("create dir failed", zap.String("dir", d), zap.Error(err))
}
}
db, err := database.Open(cfg, logger)
if err != nil {
logger.Fatal("database open failed", zap.Error(err))
}
if err := waitForDatabase(db, logger); err != nil {
logger.Fatal("database not ready", zap.Error(err))
}
if err := database.AutoMigrate(db); err != nil {
logger.Fatal("auto-migrate failed", zap.Error(err))
}
if err := database.MigrateSQLiteToCurrentIfNeeded(cfg, db, logger); err != nil {
logger.Fatal("sqlite to postgres migration failed", zap.Error(err))
}
repos := repository.New(db)
service.ApplyRuntimeSettings(context.Background(), cfg, repos, logger)
applyCPUThreadLimit(cfg, logger)
services := service.NewWithVersion(cfg, logger, repos, appVersion)
// 一次性清洗历史脏数据: 老版本把单集 episode id / 单集名写进整剧字段, 导致
// 同一部剧被拆成多张单集卡。清空被污染的字段并重置为 pending(借后续重刮修正)。
if cleaned, err := services.NormalizePollutedEpisodeMetadata(context.Background()); err != nil {
logger.Warn("polluted episode metadata cleanup failed", zap.Error(err))
} else if cleaned > 0 {
logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned))
}
if err := services.Auth.SeedAdmin(context.Background()); err != nil {
logger.Warn("seed admin failed", zap.Error(err))
}
router := buildRouter(cfg, logger, services)
serverMgr := newServerManager(cfg, logger, router)
services.ReloadHTTPServer = serverMgr.Reload
if err := serverMgr.Start(); err != nil {
logger.Fatal("listen failed", zap.Error(err))
}
go func() {
scheme := "http"
if cfg.App.HTTPSEnabled {
scheme = "https"
}
if publicIP := getPublicIP(3 * time.Second); publicIP != "" {
logger.Info("server public endpoint",
zap.String("public", fmt.Sprintf("%s://%s:%d", scheme, publicIP, cfg.App.Port)),
)
}
}()
go services.Boot()
// Graceful shutdown.
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
logger.Info("shutdown requested")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := serverMgr.Shutdown(ctx); err != nil {
logger.Error("graceful shutdown failed", zap.Error(err))
}
services.Close()
logger.Info("MMTL stopped")
}
+169
View File
@@ -0,0 +1,169 @@
package main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestEffectiveVersionPrefersBuildVersion(t *testing.T) {
t.Setenv("MMTL_VERSION", "MMTL-v0.1.15")
if got := effectiveVersion("MMTL-v0.1.16"); got != "MMTL-v0.1.16" {
t.Fatalf("effectiveVersion = %q, want MMTL-v0.1.16", got)
}
}
func TestEffectiveVersionUsesEnvWhenBuildVersionIsDev(t *testing.T) {
t.Setenv("MMTL_VERSION", " MMTL-v0.1.16 ")
if got := effectiveVersion("dev"); got != "MMTL-v0.1.16" {
t.Fatalf("effectiveVersion = %q, want MMTL-v0.1.16", got)
}
}
func TestEffectiveVersionDefaultsToDev(t *testing.T) {
t.Setenv("MMTL_VERSION", "")
if got := effectiveVersion(""); got != "dev" {
t.Fatalf("effectiveVersion = %q, want dev", got)
}
}
func TestServeSPANoCachesIndexAndServesRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
webDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(webDir, "assets"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(webDir, "index.html"), []byte("<html><div id=\"root\"></div></html>"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(webDir, "assets", "app.js"), []byte("console.log('ok')"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(webDir, "favicon.svg"), []byte("<svg></svg>"), 0o644); err != nil {
t.Fatal(err)
}
router := gin.New()
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)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("%s status = %d, want 200", path, w.Code)
}
if got := w.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
t.Fatalf("%s Cache-Control = %q, want no-store", path, got)
}
if !strings.Contains(w.Body.String(), "root") {
t.Fatalf("%s did not serve index.html: %q", path, w.Body.String())
}
}
}
func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
webDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(webDir, "assets"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(webDir, "brand"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(webDir, "index.html"), []byte("index"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(webDir, "assets", "app.js"), []byte("console.log('ok')"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(webDir, "brand", "mmtl-logo.svg"), []byte("<svg></svg>"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(webDir, "artwork-cache-sw.js"), []byte("self.addEventListener('fetch', () => {})"), 0o644); err != nil {
t.Fatal(err)
}
router := gin.New()
serveSPA(router, os.DirFS(webDir))
assetReq := httptest.NewRequest(http.MethodGet, "/assets/app.js", nil)
assetResp := httptest.NewRecorder()
router.ServeHTTP(assetResp, assetReq)
if assetResp.Code != http.StatusOK {
t.Fatalf("asset status = %d, want 200", assetResp.Code)
}
if got := assetResp.Header().Get("Cache-Control"); !strings.Contains(got, "immutable") {
t.Fatalf("asset Cache-Control = %q, want immutable", got)
}
brandReq := httptest.NewRequest(http.MethodGet, "/brand/mmtl-logo.svg", nil)
brandResp := httptest.NewRecorder()
router.ServeHTTP(brandResp, brandReq)
if brandResp.Code != http.StatusOK {
t.Fatalf("brand asset status = %d, want 200", brandResp.Code)
}
if got := brandResp.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
t.Fatalf("brand asset Cache-Control = %q, want no-store", got)
}
if strings.Contains(brandResp.Body.String(), "index") {
t.Fatalf("brand asset should not serve SPA index: %q", brandResp.Body.String())
}
swReq := httptest.NewRequest(http.MethodGet, "/artwork-cache-sw.js", nil)
swResp := httptest.NewRecorder()
router.ServeHTTP(swResp, swReq)
if swResp.Code != http.StatusOK {
t.Fatalf("service worker status = %d, want 200", swResp.Code)
}
if got := swResp.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
t.Fatalf("service worker Cache-Control = %q, want no-store", got)
}
if strings.Contains(swResp.Body.String(), "index") {
t.Fatalf("service worker should not serve SPA index: %q", swResp.Body.String())
}
for _, path := range []string{
"/api/missing",
"/emby",
"/emby/missing",
"/Library/VirtualFolders",
"/Startup/Configuration",
"/QuickConnect/Enabled",
"/embywebsocket",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusNotFound {
t.Fatalf("%s fallback status = %d, want 404", path, resp.Code)
}
if strings.Contains(resp.Body.String(), "index") {
t.Fatalf("%s should not serve SPA index: %q", path, resp.Body.String())
}
}
}
func TestServeSPAMissingIndexReportsExplicit404(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
serveSPA(router, os.DirFS(t.TempDir()))
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
if !strings.Contains(w.Body.String(), "web UI not found") {
t.Fatalf("body = %q, want explicit missing UI message", w.Body.String())
}
}
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"io"
"net"
"net/http"
"strings"
"time"
)
// getLocalIP returns the first non-loopback IPv4 address of the machine.
// Falls back to "localhost" if no suitable interface is found.
func getLocalIP() string {
interfaces, err := net.Interfaces()
if err != nil {
return "localhost"
}
for _, iface := range interfaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
switch v := addr.(type) {
case *net.IPNet:
if ip := v.IP.To4(); ip != nil {
return ip.String()
}
}
}
}
return "localhost"
}
// getPublicIP tries to detect the public-facing IP by querying ipify.org.
// Returns empty string if detection fails (e.g. no internet, timeout).
func getPublicIP(timeout time.Duration) string {
client := &http.Client{Timeout: timeout}
resp, err := client.Get("https://api.ipify.org")
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 64))
if err != nil || len(data) == 0 {
return ""
}
return strings.TrimSpace(string(data))
}
+197
View File
@@ -0,0 +1,197 @@
package main
import (
"io/fs"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/handler"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/service"
"github.com/ShukeBta/MMTL/web"
)
func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) *gin.Engine {
if !cfg.App.Debug {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
r.Use(gin.Recovery())
r.Use(middleware.RequestLogger(logger))
if !cfg.App.Debug && len(cfg.App.CORSOrigins) == 0 {
logger.Warn("CORS: no origins configured in production — CORS headers will be omitted (same-origin enforced). Set app.cors_origins for cross-origin access.")
}
r.Use(middleware.CORS(cfg.App.CORSOrigins, cfg.App.Debug))
handler.Register(r, cfg, logger, svc)
// 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. 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.GET("/*filepath", serveFSDir(root, "assets"))
brand := r.Group("/brand")
brand.Use(func(c *gin.Context) {
setNoCacheHeaders(c)
c.Next()
})
brand.GET("/*filepath", serveFSDir(root, "brand"))
for _, rootFile := range []string{"/favicon.ico", "/favicon.svg", "/artwork-cache-sw.js"} {
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
if shouldBypassSPAFallback(path) {
c.Status(http.StatusNotFound)
return
}
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)
})
}
// 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) {
handler.ServeHTTP(c.Writer, c.Request)
}
}
// 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))
}
}
func setNoCacheHeaders(c *gin.Context) {
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
}
func shouldBypassSPAFallback(path string) bool {
if isFrontendLibraryRoute(path) {
return false
}
lower := strings.ToLower(path)
for _, exact := range []string{
"/emby",
} {
if lower == exact {
return true
}
}
for _, prefix := range []string{
"/api/",
"/emby/",
"/system/",
"/users/",
"/items/",
"/shows/",
"/library/",
"/videos/",
"/sessions/",
"/displaypreferences/",
"/branding/",
"/localization/",
"/startup/",
"/quickconnect/",
"/socket",
"/embywebsocket",
} {
if strings.HasPrefix(lower, prefix) {
return true
}
}
return false
}
func isFrontendLibraryRoute(path string) bool {
const prefix = "/library/"
if !strings.HasPrefix(path, prefix) {
return false
}
id := strings.TrimPrefix(path, prefix)
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
}
for i, ch := range id {
switch i {
case 8, 13, 18, 23:
if ch != '-' {
return false
}
default:
if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')) {
return false
}
}
}
return true
}
+267
View File
@@ -0,0 +1,267 @@
package main
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/service"
)
// tlsPair 记录当前正在服务的证书,用于判断是否需要重新绑定监听。
type tlsPair struct {
cert tls.Certificate
certPEM string
keyPEM string
// version 是解析后的证书/私钥指纹;内容或磁盘文件变化都会导致其改变,
// 据此决定是否需要重新绑定监听。
version string
}
// serverManager 负责 MMTL 的 HTTP/HTTPS 监听。HTTPS 设置保存后调用 Reload,
// 在同一个端口上把明文 HTTP 与 TLS 监听热切换,无需重启进程:
//
// - 关闭旧监听释放端口(同一进程内 Windows 不允许重复绑定同一端口);
// - 按最新配置重新绑定并立即对外服务;
// - 旧服务器随后优雅退出,正在进行的播放/请求不会被立刻掐断。
//
// 任何校验失败都会中止切换并保留旧监听,保证用户不会被锁在服务外面。
type serverManager struct {
cfg *config.Config
log *zap.Logger
handler http.Handler
addr string
mu sync.Mutex
srv *http.Server
ln net.Listener
pair *tlsPair
stopCh chan struct{}
autoReloadStarted bool
}
func newServerManager(cfg *config.Config, log *zap.Logger, handler http.Handler) *serverManager {
return &serverManager{
cfg: cfg,
log: log,
handler: handler,
addr: fmt.Sprintf(":%d", cfg.App.Port),
stopCh: make(chan struct{}),
}
}
// Start 启动监听。即使 HTTPS 配置损坏也退回明文 HTTP 继续启动,避免服务冷启动失败。
func (m *serverManager) Start() error {
m.mu.Lock()
defer m.mu.Unlock()
pair, err := m.desiredPair()
if err != nil {
m.log.Error("invalid HTTPS config at startup, serving plain HTTP instead", zap.Error(err))
pair = nil
}
if err := m.bind(pair); err != nil {
return err
}
m.logServerReady()
m.maybeStartAutoReloadLocked()
return nil
}
// Reload 依据最新配置热切换监听。返回的错误会带给调用它的设置接口;若新监听
// 绑定失败会自动回滚到旧配置继续服务。
func (m *serverManager) Reload() error {
m.mu.Lock()
defer m.mu.Unlock()
pair, err := m.desiredPair()
if err != nil {
m.log.Error("server reload aborted", zap.Error(err))
return err
}
if m.pairEquals(pair) {
return nil
}
oldSrv, oldLn, oldPair := m.srv, m.ln, m.pair
if oldLn != nil {
_ = oldLn.Close() // 释放端口后再绑定新监听
}
m.srv, m.ln, m.pair = nil, nil, nil
firstErr := m.bind(pair)
if firstErr != nil {
m.log.Error("bind new listener failed, rolling back to previous", zap.Error(firstErr))
if rbErr := m.bind(oldPair); rbErr != nil {
return fmt.Errorf("reload failed: %v; rollback failed: %v", firstErr, rbErr)
}
}
// 新监听已就绪,让旧服务器在新连接切换到新监听后优雅退出。
m.drain(oldSrv)
m.logServerReady()
m.maybeStartAutoReloadLocked()
return firstErr
}
// Shutdown 优雅停止当前服务器(用于进程退出)。
func (m *serverManager) Shutdown(ctx context.Context) error {
select {
case <-m.stopCh:
default:
close(m.stopCh)
}
m.mu.Lock()
defer m.mu.Unlock()
if m.srv == nil {
return nil
}
return m.srv.Shutdown(ctx)
}
// desiredPair 根据当前配置计算目标监听形态:nil 表示明文 HTTP,非 nil 表示 TLS。
// 证书/私钥按"路径优先、内容兜底"解析,并校验是否匹配。
func (m *serverManager) desiredPair() (*tlsPair, error) {
if m.cfg == nil || !m.cfg.App.HTTPSEnabled {
return nil, nil
}
certPEM, err := service.ResolveSSLMaterial(m.cfg.App.SSLCert, m.cfg.App.SSLCertPath, "证书")
if err != nil {
return nil, err
}
keyPEM, err := service.ResolveSSLMaterial(m.cfg.App.SSLKey, m.cfg.App.SSLKeyPath, "私钥")
if err != nil {
return nil, err
}
if err := service.ValidateSSLKeyPair(certPEM, keyPEM); err != nil {
return nil, err
}
cert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
if err != nil {
return nil, fmt.Errorf("SSL 证书/私钥无效:%v", err)
}
return &tlsPair{
cert: cert,
certPEM: certPEM,
keyPEM: keyPEM,
version: certPEM + "\x00" + keyPEM,
}, nil
}
// maybeStartAutoReloadLocked 在证书/私钥通过文件路径配置时,幂等地启动后台轮询,
// 便于运行中切换到路径方式(或换证)后无需重启也能热更新。调用方需持有 m.mu。
func (m *serverManager) maybeStartAutoReloadLocked() {
if m.autoReloadStarted {
return
}
if !m.pathBased() {
return
}
m.autoReloadStarted = true
m.startAutoReload()
}
// pathBased 是否至少有一侧证书/私钥通过文件路径配置。
func (m *serverManager) pathBased() bool {
return strings.TrimSpace(m.cfg.App.SSLCertPath) != "" || strings.TrimSpace(m.cfg.App.SSLKeyPath) != ""
}
// startAutoReload 后台轮询文件变更并自动热更新,方便换证。
func (m *serverManager) startAutoReload() {
ticker := time.NewTicker(30 * time.Second)
go func() {
defer ticker.Stop()
for {
select {
case <-m.stopCh:
return
case <-ticker.C:
if !m.pathBased() {
continue // 路径已清空(改回内容配置),不再轮询
}
if err := m.Reload(); err != nil {
m.log.Warn("periodic https reload failed", zap.Error(err))
}
}
}
}()
}
// pairEquals 判断目标配置与当前监听是否一致,一致则无需重新绑定。
func (m *serverManager) pairEquals(pair *tlsPair) bool {
if pair == nil && m.pair == nil {
return true
}
if pair == nil || m.pair == nil {
return false
}
return pair.version == m.pair.version
}
// bind 创建并按需启用 TLS 的监听,异步开始服务。
func (m *serverManager) bind(pair *tlsPair) error {
ln, err := net.Listen("tcp", m.addr)
if err != nil {
return fmt.Errorf("listen %s: %w", m.addr, err)
}
srv := &http.Server{
Handler: m.handler,
ReadHeaderTimeout: 15 * time.Second,
}
if pair != nil {
ln = tls.NewListener(ln, &tls.Config{
Certificates: []tls.Certificate{pair.cert},
MinVersion: tls.VersionTLS12,
})
}
m.srv, m.ln, m.pair = srv, ln, pair
go m.serve(srv, ln)
return nil
}
func (m *serverManager) serve(s *http.Server, ln net.Listener) {
if err := s.Serve(ln); err != nil &&
!errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
m.log.Fatal("listen failed", zap.Error(err))
}
}
// drain 让旧服务器在后台优雅退出(等待进行中的连接完成或在超时后强制关闭)。
func (m *serverManager) drain(s *http.Server) {
if s == nil {
return
}
go func(s *http.Server) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := s.Shutdown(ctx); err != nil && !errors.Is(err, context.DeadlineExceeded) {
m.log.Warn("drain old server failed", zap.Error(err))
}
}(s)
}
func (m *serverManager) logServerReady() {
scheme := "http"
if m.pair != nil {
scheme = "https"
}
localIP := getLocalIP()
m.log.Info("server is ready",
zap.String("scheme", scheme),
zap.String("local", fmt.Sprintf("%s://%s:%d", scheme, localIP, m.cfg.App.Port)),
zap.String("listen", m.addr),
)
if m.pair != nil {
m.log.Info("HTTPS is enabled; plain HTTP is no longer served on this port",
zap.String("addr", m.addr),
)
}
}
+107
View File
@@ -0,0 +1,107 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/config"
)
func makeTestPairPEM(t *testing.T) (certPEM, keyPEM string) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "localhost"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
DNSNames: []string{"localhost"},
}
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
if err != nil {
t.Fatal(err)
}
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
t.Fatal(err)
}
certPEM = strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})))
keyPEM = strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})))
return certPEM, keyPEM
}
func newTestServerManager(t *testing.T) *serverManager {
t.Helper()
cfg := &config.Config{}
cfg.App.Port = 18081
return newServerManager(cfg, zap.NewNop(), http.NewServeMux())
}
func TestDesiredPairModes(t *testing.T) {
m := newTestServerManager(t)
if p, err := m.desiredPair(); err != nil || p != nil {
t.Fatalf("disabled should be nil pair, got p=%v err=%v", p, err)
}
certPEM, keyPEM := makeTestPairPEM(t)
m.cfg.App.HTTPSEnabled = true
m.cfg.App.SSLCert, m.cfg.App.SSLKey = certPEM, keyPEM
p, err := m.desiredPair()
if err != nil || p == nil || p.version == "" {
t.Fatalf("content pair failed: p=%v err=%v", p, err)
}
dir := t.TempDir()
certPath, keyPath := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
if err := os.WriteFile(certPath, []byte(certPEM), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(keyPath, []byte(keyPEM), 0o600); err != nil {
t.Fatal(err)
}
m.cfg.App.SSLCert, m.cfg.App.SSLKey = "", ""
m.cfg.App.SSLCertPath, m.cfg.App.SSLKeyPath = certPath, keyPath
p2, err := m.desiredPair()
if err != nil || p2 == nil {
t.Fatalf("path pair failed: %v", err)
}
m.cfg.App.SSLKeyPath = filepath.Join(dir, "nope.pem")
if _, err := m.desiredPair(); err == nil {
t.Fatal("expected error when key file missing")
}
m.cfg.App.SSLKeyPath = keyPath
// 替换文件(换一套新的有效证书)后版本号应变化,触发热更新。
newCert, newKey := makeTestPairPEM(t)
if err := os.WriteFile(certPath, []byte(newCert), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(keyPath, []byte(newKey), 0o600); err != nil {
t.Fatal(err)
}
p3, err := m.desiredPair()
if err != nil {
t.Fatalf("replace: %v", err)
}
if p3.version == p2.version {
t.Fatal("version should change after files replaced")
}
}
+46
View File
@@ -0,0 +1,46 @@
package main
import (
"context"
"database/sql"
"runtime"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/config"
)
func applyCPUThreadLimit(cfg *config.Config, logger *zap.Logger) {
if cfg == nil || cfg.App.MaxCPUThreads < 1 {
return
}
prev := runtime.GOMAXPROCS(cfg.App.MaxCPUThreads)
if logger != nil {
logger.Info("runtime CPU thread limit applied",
zap.Int("max_cpu_threads", cfg.App.MaxCPUThreads),
zap.Int("previous", prev))
}
}
func waitForDatabase(db interface{ DB() (*sql.DB, error) }, logger *zap.Logger) error {
sqlDB, err := db.DB()
if err != nil {
return err
}
var lastErr error
for attempt := 1; attempt <= 30; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
err = sqlDB.PingContext(ctx)
cancel()
if err == nil {
return nil
}
lastErr = err
if logger != nil {
logger.Warn("database not ready; retrying", zap.Int("attempt", attempt), zap.Error(err))
}
time.Sleep(time.Duration(attempt) * 500 * time.Millisecond)
}
return lastErr
}
+204
View File
@@ -0,0 +1,204 @@
# MMTL 第三档完整 Docker Compose 部署文件
#
# 组件:
# MMTL + PostgreSQL + Redis + OpenSearch
#
# 使用方式二选一:
# 1. 保存为 docker-compose.yml 后执行:
# docker compose up -d
# 2. 保留本文件名时执行:
# docker compose -f docker-compose.search.yml up -d
#
# 适合:
# 超大媒体库、复杂全文搜索、后续需要独立搜索索引的部署。
#
# 注意:
# OpenSearch 常驻内存明显高于 Redis/PostgreSQL。低配 NAS 不建议开启。
#
# 默认账号:
# admin / admin123
services:
mmtl:
# 镜像二选一:
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
image: ghcr.io/shukebta/mmtl:latest
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
# image: shukbet/mmtl:latest
restart: unless-stopped
init: true
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
opensearch:
condition: service_healthy
ports:
- "18080:8080"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
# 程序运行数据:JWT 密钥、运行配置、旧 SQLite 迁移源。
- ./data:/data
# 缓存目录:海报缓存、临时文件等。通常不用备份。
- ./cache:/cache
# 媒体库目录。自动整理/重命名/入库需要读写权限。
# NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- type: bind
source: ./media
target: /media
bind:
create_host_path: false
# 下载目录。需要和 qBittorrent 保存路径保持一致。
# NAS 示例:source: /vol1/1000/Downloads
# Windows Docker Desktop 示例:source: D:/Downloads
- type: bind
source: ./downloads
target: /downloads
bind:
create_host_path: false
# 管理面板「系统更新」需要访问 Docker 引擎。
# 需要一键更新 Docker 镜像时取消下一行注释。
# - /var/run/docker.sock:/var/run/docker.sock
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
MMTL_LOGGING_LEVEL: info
MMTL_LOGGING_FORMAT: console
MMTL_LOGGING_OUTPUT_PATH: /data/logs
MMTL_LOGGING_MAX_SIZE_MB: "50"
MMTL_LOGGING_MAX_BACKUPS: "20"
MMTL_LOGGING_MAX_AGE_DAYS: "30"
MMTL_DATABASE_TYPE: postgres
MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable
MMTL_DATABASE_DB_PATH: /data/mmtl.db
MMTL_CACHE_REDIS_URL: redis://redis:6379/0
MMTL_CACHE_CACHE_DIR: /cache
# OpenSearch 只做搜索索引,主数据仍以 PostgreSQL 为准。
MMTL_SEARCH_BACKEND: opensearch
MMTL_SEARCH_OPENSEARCH_URL: http://opensearch:9200
MMTL_SEARCH_INDEX: mmtl_media
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
# 默认推荐在网页里使用容器路径 /media。
# 如果旧媒体库已经保存了宿主机路径 /vol1/1000/Media,
# 再把这里改成同一个宿主机真实路径用于旧路径换算。
MMTL_MEDIA_DIR: /media
MMTL_MEDIA_CONTAINER_DIR: /media
MMTL_DOWNLOAD_DIR: /downloads
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
MMTL_TRANSCODER_ENABLED: "true"
MMTL_TRANSCODER_HARDWARE_ACCEL: "false"
MMTL_TRANSCODER_REALTIME: "true"
MMTL_TRANSCODER_THREADS: "2"
MMTL_TRANSCODER_MAX_CONCURRENT: "1"
MMTL_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
postgres:
image: postgres:16-alpine
# 首次部署允许拉取;日常更新请只 pull mmtl。
pull_policy: missing
restart: unless-stopped
environment:
POSTGRES_DB: mmtl
POSTGRES_USER: mmtl
POSTGRES_PASSWORD: mmtl
TZ: Asia/Shanghai
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
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
redis:
image: redis:7-alpine
pull_policy: missing
restart: unless-stopped
command:
- redis-server
- --appendonly
- "yes"
- --maxmemory
- 256mb
- --maxmemory-policy
- allkeys-lru
volumes:
- ./redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
opensearch:
image: opensearchproject/opensearch:2
pull_policy: missing
restart: unless-stopped
environment:
discovery.type: single-node
plugins.security.disabled: "true"
OPENSEARCH_JAVA_OPTS: "-Xms512m -Xmx512m"
DISABLE_INSTALL_DEMO_CONFIG: "true"
bootstrap.memory_lock: "false"
volumes:
- ./opensearch:/usr/share/opensearch/data
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:9200 >/dev/null || exit 1"]
interval: 20s
timeout: 10s
retries: 15
start_period: 60s
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
+106
View File
@@ -0,0 +1,106 @@
# MMTL 单镜像部署模板(SQLite)
#
# 适合:新手、单人使用、低配 NAS / 小主机。
# 特点:只有一个镜像,不需要 PostgreSQL / Redis / .env。
#
# 使用:
# 1. 按需修改 ports 和 volumes 左侧的宿主机目录。
# 2. docker compose -f docker-compose.simple.yml up -d
# 3. 浏览器打开 http://服务器IP:18080
#
# 默认账号:admin / admin123
# 首次登录后请立刻修改密码。
services:
mmtl:
image: ghcr.io/shukebta/mmtl:latest
# Docker Hub 备用:
# image: shukbet/mmtl:latest
container_name: mmtl
restart: unless-stopped
init: true
ports:
# 左边是宿主机访问端口,右边固定为容器内 8080。
- "18080:8080"
volumes:
# 必须备份:SQLite 数据库、系统配置、JWT 密钥、日志。
- ./data:/data
# 可重建:海报缓存、临时文件、转码缓存。
- ./cache:/cache
# 媒体库。网页里添加媒体库时填写 /media 或 /media/子目录。
# NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- type: bind
source: ./media
target: /media
bind:
create_host_path: false
# 下载目录。qBittorrent / Transmission 的保存目录建议也对齐到 /downloads。
# NAS 示例:source: /vol1/1000/Downloads
# Windows Docker Desktop 示例:source: D:/Downloads
- type: bind
source: ./downloads
target: /downloads
bind:
create_host_path: false
# 可选:Intel 核显硬解/转码。需要时取消注释,并在后台开启硬件加速。
# - /dev/dri:/dev/dri
# 可选:管理面板一键更新需要访问 Docker 引擎。需要时取消注释。
# - /var/run/docker.sock:/var/run/docker.sock
environment:
TZ: Asia/Shanghai
# Linux/NAS 文件权限。写入文件权限异常时,改成宿主机实际 uid/gid。
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
# 单镜像档固定使用 SQLite。主数据库文件:./data/mmtl.db。
MMTL_DATABASE_TYPE: sqlite
MMTL_DATABASE_DB_PATH: /data/mmtl.db
MMTL_CACHE_CACHE_DIR: /cache
# 路径映射保持容器内统一,网页和下载器里优先使用 /media、/downloads。
MMTL_MEDIA_DIR: /media
MMTL_MEDIA_CONTAINER_DIR: /media
MMTL_DOWNLOAD_DIR: /downloads
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
# 完整应用日志默认写入 ./data/logs/app.log;排查复杂问题时可临时改成 debug。
MMTL_LOGGING_LEVEL: info
MMTL_LOGGING_FORMAT: console
MMTL_LOGGING_OUTPUT_PATH: /data/logs
MMTL_LOGGING_MAX_SIZE_MB: "50"
MMTL_LOGGING_MAX_BACKUPS: "20"
MMTL_LOGGING_MAX_AGE_DAYS: "30"
extra_hosts:
# 容器访问宿主机服务用,例如 qBittorrent: http://host.docker.internal:8085
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
+169
View File
@@ -0,0 +1,169 @@
# MMTL 第二档完整 Docker Compose 部署文件
#
# 组件:
# MMTL + PostgreSQL + Redis
#
# 使用方式二选一:
# 1. 保存为 docker-compose.yml 后执行:
# docker compose up -d
# 2. 保留本文件名时执行:
# docker compose -f docker-compose.standard.yml up -d
#
# 适合:
# 多用户、第三方 Emby 客户端频繁刷新、媒体列表/首页访问较多的 NAS。
#
# 默认账号:
# admin / admin123
services:
mmtl:
# 镜像二选一:
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
image: ghcr.io/shukebta/mmtl:latest
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
# image: shukbet/mmtl:latest
restart: unless-stopped
init: true
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "18080:8080"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
# 程序运行数据:JWT 密钥、运行配置、旧 SQLite 迁移源。
- ./data:/data
# 缓存目录:海报缓存、临时文件等。通常不用备份。
- ./cache:/cache
# 媒体库目录。自动整理/重命名/入库需要读写权限。
# NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- type: bind
source: ./media
target: /media
bind:
create_host_path: false
# 下载目录。需要和 qBittorrent 保存路径保持一致。
# NAS 示例:source: /vol1/1000/Downloads
# Windows Docker Desktop 示例:source: D:/Downloads
- type: bind
source: ./downloads
target: /downloads
bind:
create_host_path: false
# 管理面板「系统更新」需要访问 Docker 引擎。
# 需要一键更新 Docker 镜像时取消下一行注释。
# - /var/run/docker.sock:/var/run/docker.sock
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
MMTL_LOGGING_LEVEL: info
MMTL_LOGGING_FORMAT: console
MMTL_LOGGING_OUTPUT_PATH: /data/logs
MMTL_LOGGING_MAX_SIZE_MB: "50"
MMTL_LOGGING_MAX_BACKUPS: "20"
MMTL_LOGGING_MAX_AGE_DAYS: "30"
MMTL_DATABASE_TYPE: postgres
MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable
MMTL_DATABASE_DB_PATH: /data/mmtl.db
# Redis 只做热缓存,源数据仍在 PostgreSQL;Redis 丢失可自动重建。
MMTL_CACHE_REDIS_URL: redis://redis:6379/0
MMTL_CACHE_CACHE_DIR: /cache
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
# 路径换算配置。左边宿主机真实路径要和 volumes 左边保持一致。
MMTL_MEDIA_DIR: /media
MMTL_MEDIA_CONTAINER_DIR: /media
MMTL_DOWNLOAD_DIR: /downloads
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
MMTL_TRANSCODER_ENABLED: "true"
MMTL_TRANSCODER_HARDWARE_ACCEL: "false"
MMTL_TRANSCODER_REALTIME: "true"
MMTL_TRANSCODER_THREADS: "2"
MMTL_TRANSCODER_MAX_CONCURRENT: "1"
MMTL_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
postgres:
image: postgres:16-alpine
# 首次部署允许拉取;日常更新请只 pull mmtl。
pull_policy: missing
restart: unless-stopped
environment:
POSTGRES_DB: mmtl
POSTGRES_USER: mmtl
POSTGRES_PASSWORD: mmtl
TZ: Asia/Shanghai
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
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
redis:
image: redis:7-alpine
pull_policy: missing
restart: unless-stopped
command:
- redis-server
- --appendonly
- "yes"
- --maxmemory
- 256mb
- --maxmemory-policy
- allkeys-lru
volumes:
- ./redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
+174
View File
@@ -0,0 +1,174 @@
# MMTL 最简单 Docker Compose 部署文件
#
# 新手建议:
# 1. 不用 .env。
# 2. 直接改本文件。
# 3. 第一次可以不改路径,先用当前目录下的 ./media 和 ./downloads 体验。
#
# 启动:
# docker compose up -d
#
# 访问:
# http://服务器IP:18080
#
# 默认账号:
# admin / admin123
services:
mmtl:
# 镜像二选一:
# 方式一:GitHub 仓库镜像 GHCR(默认,推荐)
image: ghcr.io/shukebta/mmtl:latest
# 方式二:Docker Hub 备用(GHCR 拉取慢或不可用时使用)
# image: shukbet/mmtl:latest
restart: unless-stopped
init: true
depends_on:
postgres:
condition: service_healthy
# 浏览器访问端口。
# 如果 18080 被占用,可以改成 "19011:8080" 之类。
ports:
- "18080:8080"
# 让容器可以访问宿主机上的 qBittorrent。
# qB 地址通常可填:http://host.docker.internal:8085
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
# 程序运行数据:JWT 密钥、运行配置、旧 SQLite 迁移源。
# 主数据库在 ./postgres,升级/备份时 ./data 和 ./postgres 都要保留。
- ./data:/data
# 缓存目录:海报缓存、临时文件等。通常不用备份。
- ./cache:/cache
# 媒体库目录。
# 如果要使用自动整理/重命名/入库,这里必须保持读写,不能加 :ro。
# 只有完全不整理、只扫描/播放已有媒体时,才建议手动改成只读。
# 新手可先创建当前目录的 ./media 并把影片放进去。
# NAS 用户把左边改成真实路径,例如:
# source: /vol1/1000/Media
# Windows Docker Desktop 示例:
# source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- type: bind
source: ./media
target: /media
bind:
create_host_path: false
# 下载目录。
# qB 下载目录、手动整理、自动整理会经常用到。
# NAS 示例:
# source: /vol1/1000/Downloads
# Windows Docker Desktop 示例:
# source: D:/Downloads
- type: bind
source: ./downloads
target: /downloads
bind:
create_host_path: false
# 管理面板「系统更新」需要访问 Docker 引擎。
# 需要一键更新 Docker 镜像时取消下一行注释;如果提示权限不足,
# 请确认 PUID/PGID 对 /var/run/docker.sock 有读写权限。
# - /var/run/docker.sock:/var/run/docker.sock
environment:
TZ: Asia/Shanghai
# Linux/NAS 用户权限。一般 1000 就可以。
# 如果写入文件权限不对,再改成宿主机实际用户的 uid/gid。
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
# 详细应用日志会保存在 ./data/logs/app.log,warn/error 也会拆分保存。
# 排查复杂问题时可临时改成 debug。
MMTL_LOGGING_LEVEL: info
MMTL_LOGGING_FORMAT: console
MMTL_LOGGING_OUTPUT_PATH: /data/logs
MMTL_LOGGING_MAX_SIZE_MB: "50"
MMTL_LOGGING_MAX_BACKUPS: "20"
MMTL_LOGGING_MAX_AGE_DAYS: "30"
# 轻量模式默认只使用 PostgreSQL,适合大多数 NAS。
# 旧版 ./data/mmtl.db 存在时,首次启动会自动迁移到 PostgreSQL。
MMTL_DATABASE_TYPE: postgres
MMTL_DATABASE_DSN: postgres://mmtl:mmtl@postgres:5432/mmtl?sslmode=disable
# SQLite 旧库迁移源:
# - 首次从旧版 ./data/mmtl.db 导入时保持此路径。
# - 确认迁移完成后,建议改成 /data/no-sqlite-migration.db 这类不存在的路径。
MMTL_DATABASE_DB_PATH: /data/mmtl.db
MMTL_CACHE_CACHE_DIR: /cache
# 管理面板热更新默认拉取此镜像,并用 Watchtower 一次性重建当前容器。
MMTL_UPDATE_IMAGE: ghcr.io/shukebta/mmtl:latest
# 路径换算配置。
# 默认推荐在网页里使用容器路径 /media。
# 如果旧媒体库已经保存了宿主机路径 /vol1/1000/Media,
# 再把这里改成同一个宿主机真实路径用于旧路径换算。
MMTL_MEDIA_DIR: /media
MMTL_MEDIA_CONTAINER_DIR: /media
# 默认推荐下载器保存路径使用 /downloads。
# 如果下载器只能返回宿主机路径,再改成同一个宿主机真实路径。
MMTL_DOWNLOAD_DIR: /downloads
MMTL_DOWNLOAD_CONTAINER_DIR: /downloads
# NAS 友好的低负载默认值。
MMTL_TRANSCODER_ENABLED: "true"
MMTL_TRANSCODER_HARDWARE_ACCEL: "false"
MMTL_TRANSCODER_REALTIME: "true"
MMTL_TRANSCODER_THREADS: "2"
MMTL_TRANSCODER_MAX_CONCURRENT: "1"
MMTL_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
# 限制 Docker 日志大小,避免长期运行把磁盘写满。
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
postgres:
image: postgres:16-alpine
# 首次部署允许拉取;日常更新请只 pull mmtl。
# 如需升级 PostgreSQL,请先备份 ./postgres 后再手动调整镜像版本并拉取。
pull_policy: missing
restart: unless-stopped
environment:
POSTGRES_DB: mmtl
POSTGRES_USER: mmtl
POSTGRES_PASSWORD: mmtl
TZ: Asia/Shanghai
volumes:
# PostgreSQL 主数据目录。升级/重建容器时必须保留。
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mmtl -d mmtl"]
interval: 10s
timeout: 5s
retries: 10
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
+28
View File
@@ -0,0 +1,28 @@
#!/bin/sh
set -eu
run_uid="${PUID:-$(id -u mmtl 2>/dev/null || echo 1000)}"
run_gid="${PGID:-$(id -g mmtl 2>/dev/null || echo 1000)}"
case "$run_uid" in
''|*[!0-9]*)
echo "PUID must be a numeric uid, got: $run_uid" >&2
exit 1
;;
esac
case "$run_gid" in
''|*[!0-9]*)
echo "PGID must be a numeric gid, got: $run_gid" >&2
exit 1
;;
esac
if [ "$run_uid" = "0" ]; then
exec mmtl
fi
chown -R "$run_uid:$run_gid" /data /cache 2>/dev/null || true
chown "$run_uid:$run_gid" /media 2>/dev/null || true
exec su-exec "$run_uid:$run_gid" mmtl
+68506
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
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
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/redis/go-redis/v9 v9.7.0
github.com/shirou/gopsutil/v3 v3.24.5
github.com/spf13/viper v1.18.2
github.com/stretchr/testify v1.9.0
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
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.4.3 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
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
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+210
View File
@@ -0,0 +1,210 @@
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=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
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=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM=
gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+78
View File
@@ -0,0 +1,78 @@
// Package config 加载分层配置:默认值、配置文件和环境变量。
//
// 优先级(低 -> 高):
// 1. 内置默认值
// 2. 工作目录中的 config.yaml(嵌套格式)
// 3. config/*.yaml 分片文件(按模块)
// 4. 以 MMTL_ 为前缀的环境变量
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/viper"
)
// EnvPrefix 是所有环境变量驱动的覆盖使用的前缀。
const EnvPrefix = "MMTL"
// Load 从默认值 / 文件 / 环境读取配置。
//
// 即使没有文件也始终返回可用的 Config。
func Load() (*Config, error) {
v := viper.New()
setDefaults(v)
v.SetConfigName("config")
v.SetConfigType("yaml")
v.AddConfigPath(".")
v.AddConfigPath("./config")
if err := v.ReadInConfig(); err != nil {
var notFound viper.ConfigFileNotFoundError
if !asConfigFileNotFound(err, &notFound) {
return nil, fmt.Errorf("read main config: %w", err)
}
}
// 合并 ./config/*.yaml 下的分片文件。
if entries, err := os.ReadDir("config"); err == nil {
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
continue
}
s := viper.New()
s.SetConfigFile(filepath.Join("config", e.Name()))
if err := s.ReadInConfig(); err == nil {
_ = v.MergeConfigMap(s.AllSettings())
}
}
}
v.SetEnvPrefix(EnvPrefix)
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
cfg := &Config{}
if err := v.Unmarshal(cfg); err != nil {
return nil, fmt.Errorf("decode config: %w", err)
}
if err := cfg.normalize(); err != nil {
return nil, err
}
return cfg, nil
}
// asConfigFileNotFound 是 errors.As 的小辅助函数,避免在这个短文件中导入 errors。
func asConfigFileNotFound(err error, target *viper.ConfigFileNotFoundError) bool {
if err == nil {
return false
}
if v, ok := err.(viper.ConfigFileNotFoundError); ok {
*target = v
return true
}
return false
}
+138
View File
@@ -0,0 +1,138 @@
package config
import (
"os"
"path/filepath"
"testing"
)
// TestLoadDefaults asserts that a Load on a clean working directory yields
// usable, normalized defaults.
func TestLoadDefaults(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
wd, _ := os.Getwd()
defer func() { _ = os.Chdir(wd) }()
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.App.Port != 8080 {
t.Fatalf("expected default port 8080, got %d", cfg.App.Port)
}
if cfg.App.MaxCPUThreads != 2 {
t.Fatalf("expected default MaxCPUThreads 2, got %d", cfg.App.MaxCPUThreads)
}
if cfg.Database.DBPath == "" {
t.Fatalf("expected non-empty DBPath")
}
if cfg.Database.Type != "auto" {
t.Fatalf("expected default database type auto, got %q", cfg.Database.Type)
}
if cfg.Logging.Level != "warn" || !cfg.Logging.EnableRotation || cfg.Logging.MaxSizeMB != 20 {
t.Fatalf("expected warn rotating logs by default, got level=%q rotation=%v max=%d", cfg.Logging.Level, cfg.Logging.EnableRotation, cfg.Logging.MaxSizeMB)
}
if cfg.Database.MaxOpenConns != defaultDatabaseMaxOpenConns {
t.Fatalf("expected default MaxOpenConns %d, got %d", defaultDatabaseMaxOpenConns, cfg.Database.MaxOpenConns)
}
if cfg.Cache.RedisPrefix != "mmtl" {
t.Fatalf("expected default redis prefix, got %q", cfg.Cache.RedisPrefix)
}
if cfg.Cache.MediaTTLSeconds != 15 {
t.Fatalf("expected default media cache ttl 15, got %d", cfg.Cache.MediaTTLSeconds)
}
if cfg.Search.Index != "mmtl_media" {
t.Fatalf("expected default search index, got %q", cfg.Search.Index)
}
if cfg.Database.MaxIdleConns != defaultDatabaseMaxIdleConns {
t.Fatalf("expected default MaxIdleConns %d, got %d", defaultDatabaseMaxIdleConns, cfg.Database.MaxIdleConns)
}
if cfg.Secrets.JWTSecret == "" {
t.Fatalf("expected auto-generated JWT secret")
}
if !cfg.Organizer.SmartClassify {
t.Fatalf("expected organizer smart classify enabled by default")
}
if cfg.License.ServerURL != defaultLicenseServerURL || cfg.License.PublicKey != defaultLicensePublicKey || cfg.License.HMACSecret != "" {
t.Fatalf("expected bundled license bridge defaults, got url=%q public_key=%q hmac=%q", cfg.License.ServerURL, cfg.License.PublicKey, cfg.License.HMACSecret)
}
// Re-loading must reuse the persisted secret on disk.
cfg2, err := Load()
if err != nil {
t.Fatalf("second Load() error: %v", err)
}
if cfg.Secrets.JWTSecret != cfg2.Secrets.JWTSecret {
t.Fatalf("expected JWT secret to persist across Load() calls")
}
if _, err := os.Stat(filepath.Join(cfg.App.DataDir, ".jwt_secret")); err != nil {
t.Fatalf("expected jwt secret file: %v", err)
}
}
// TestEnvOverride checks that MMTL_* env vars override the defaults.
func TestEnvOverride(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)
}
t.Setenv("MMTL_APP_PORT", "9090")
t.Setenv("MMTL_DATABASE_TYPE", "postgres")
t.Setenv("MMTL_DATABASE_DSN", "postgres://mmtl:secret@postgres:5432/mmtl?sslmode=disable")
t.Setenv("MMTL_CACHE_REDIS_URL", "redis://redis:6379/0")
t.Setenv("MMTL_CACHE_MEDIA_TTL_SECONDS", "30")
t.Setenv("MMTL_SEARCH_BACKEND", "opensearch")
t.Setenv("MMTL_SEARCH_OPENSEARCH_URL", "http://opensearch:9200")
t.Setenv("MMTL_LICENSE_SERVER_URL", "https://license.example.com")
t.Setenv("MMTL_LICENSE_HMAC_SECRET", "override-secret")
t.Setenv("MMTL_LICENSE_PUBLIC_KEY", "override-public-key")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.App.Port != 9090 {
t.Fatalf("expected port 9090 from env, got %d", cfg.App.Port)
}
if cfg.Database.Type != "postgres" || cfg.Database.DSN == "" {
t.Fatalf("expected postgres database config from env, got type=%q dsn=%q", cfg.Database.Type, cfg.Database.DSN)
}
if cfg.Cache.RedisURL != "redis://redis:6379/0" || cfg.Cache.MediaTTLSeconds != 30 {
t.Fatalf("expected redis cache config from env, got url=%q ttl=%d", cfg.Cache.RedisURL, cfg.Cache.MediaTTLSeconds)
}
if cfg.Search.Backend != "opensearch" || cfg.Search.OpenSearchURL != "http://opensearch:9200" {
t.Fatalf("expected opensearch config from env, got backend=%q url=%q", cfg.Search.Backend, cfg.Search.OpenSearchURL)
}
if cfg.License.ServerURL != "https://license.example.com" || cfg.License.HMACSecret != "override-secret" || cfg.License.PublicKey != "override-public-key" {
t.Fatalf("expected license config from env, got url=%q secret=%q public_key=%q", cfg.License.ServerURL, cfg.License.HMACSecret, cfg.License.PublicKey)
}
}
func TestLoadAllowsExplicitSingleConnectionDatabaseConfig(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)
}
if err := os.WriteFile("config.yaml", []byte("database:\n max_open_conns: 1\n max_idle_conns: 1\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Database.MaxOpenConns != 1 {
t.Fatalf("expected explicit MaxOpenConns=1 to be preserved, got %d", cfg.Database.MaxOpenConns)
}
if cfg.Database.MaxIdleConns != 1 {
t.Fatalf("expected explicit MaxIdleConns=1 to be preserved, got %d", cfg.Database.MaxIdleConns)
}
}
+123
View File
@@ -0,0 +1,123 @@
package config
import "github.com/spf13/viper"
const (
defaultDatabaseMaxOpenConns = 16
defaultDatabaseMaxIdleConns = 4
defaultLicenseServerURL = "https://mgosever.3jzs.com"
defaultLicensePublicKey = "MCowBQYDK2VwAyEABRXnXy+urjrbKit6Yu/HiezWgP0NdsZW3tsegJWRrtI="
)
func setDefaults(v *viper.Viper) {
v.SetDefault("app.port", 8080)
v.SetDefault("app.debug", false)
v.SetDefault("app.env", "production")
v.SetDefault("app.data_dir", "./data")
v.SetDefault("app.web_dir", "./web/dist")
v.SetDefault("app.ffmpeg_path", "ffmpeg")
v.SetDefault("app.ffprobe_path", "ffprobe")
v.SetDefault("app.ffprobe_max_concurrent", 2)
v.SetDefault("app.cloud_scan_max_concurrent", 8)
v.SetDefault("app.max_cpu_threads", 2)
v.SetDefault("app.vaapi_device", "/dev/dri/renderD128")
v.SetDefault("app.cors_origins", []string{})
v.SetDefault("app.server_url", "")
v.SetDefault("database.type", "auto")
v.SetDefault("database.db_path", "./data/mmtl.db")
v.SetDefault("database.dsn", "")
v.SetDefault("database.wal_mode", true)
v.SetDefault("database.busy_timeout", 5000)
v.SetDefault("database.cache_size", -20000)
v.SetDefault("database.max_open_conns", defaultDatabaseMaxOpenConns)
v.SetDefault("database.max_idle_conns", defaultDatabaseMaxIdleConns)
v.SetDefault("secrets.jwt_secret", "")
v.SetDefault("logging.level", "warn")
v.SetDefault("logging.format", "console")
v.SetDefault("logging.enable_rotation", true)
v.SetDefault("logging.max_size_mb", 20)
v.SetDefault("logging.max_age_days", 30)
v.SetDefault("logging.max_backups", 10)
v.SetDefault("cache.cache_dir", "./cache")
v.SetDefault("cache.images_max_size_mb", 500)
v.SetDefault("cache.cleanup_interval_min", 60)
v.SetDefault("cache.redis_url", "")
v.SetDefault("cache.redis_prefix", "mmtl")
v.SetDefault("cache.media_ttl_seconds", 15)
v.SetDefault("search.backend", "")
v.SetDefault("search.opensearch_url", "")
v.SetDefault("search.index", "mmtl_media")
v.SetDefault("search.username", "")
v.SetDefault("search.password", "")
v.SetDefault("ai.enabled", false)
v.SetDefault("ai.provider", "openai")
v.SetDefault("ai.api_base", "https://api.openai.com/v1")
v.SetDefault("ai.model", "gpt-4o-mini")
v.SetDefault("ai.timeout", 30)
v.SetDefault("ai.max_concurrent", 3)
v.SetDefault("flaresolverr.enabled", false)
v.SetDefault("flaresolverr.url", "http://localhost:8191")
v.SetDefault("flaresolverr.session", "mmtl")
v.SetDefault("flaresolverr.timeout", 60)
v.SetDefault("downloads.smart_classify", true)
v.SetDefault("organizer.smart_classify", true)
v.SetDefault("organizer.auto_after_download", false)
v.SetDefault("organize.scrape_after", true)
v.SetDefault("scrape.delay_min_ms", 250)
v.SetDefault("scrape.delay_max_ms", 500)
v.SetDefault("organizer.categories.concert_movie", "演唱会")
v.SetDefault("organizer.categories.documentary_movie", "纪录片")
v.SetDefault("organizer.categories.chinese_movie", "华语电影")
v.SetDefault("organizer.categories.animation_movie", "动画电影")
v.SetDefault("organizer.categories.euus_movie", "欧美电影")
v.SetDefault("organizer.categories.jk_movie", "日韩电影")
v.SetDefault("organizer.categories.domestic_tv", "国产剧")
v.SetDefault("organizer.categories.euus_tv", "欧美剧")
v.SetDefault("organizer.categories.jk_tv", "日韩剧")
v.SetDefault("organizer.categories.unclassified_tv", "未分类")
v.SetDefault("organizer.categories.jp_anime", "日番")
v.SetDefault("organizer.categories.cn_anime", "国漫")
v.SetDefault("organizer.categories.kr_anime", "韩漫")
v.SetDefault("organizer.categories.us_anime", "美漫")
v.SetDefault("organizer.categories.other_anime", "其他")
v.SetDefault("organizer.categories.variety", "综艺")
v.SetDefault("organizer.categories.documentary", "纪录片")
v.SetDefault("organizer.categories.children", "儿童")
v.SetDefault("organizer.categories.adult", "成人")
v.SetDefault("recognition_words.enabled", true)
v.SetDefault("recognition_words.shared_urls", []string{
"https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/general.txt",
"https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/TV.txt",
"https://raw.githubusercontent.com/Putarku/MoviePilot-Help/main/Words/anime.txt",
})
v.SetDefault("transcoder.encoder", "")
v.SetDefault("transcoder.enabled", true)
v.SetDefault("transcoder.hardware_accel", false)
v.SetDefault("transcoder.preset", "veryfast")
v.SetDefault("transcoder.video_bitrate", "1500k")
v.SetDefault("transcoder.max_rate", "1800k")
v.SetDefault("transcoder.buf_size", "3000k")
v.SetDefault("transcoder.max_height", 720)
v.SetDefault("transcoder.segment_seconds", 4)
v.SetDefault("transcoder.realtime", true)
v.SetDefault("transcoder.threads", 2)
v.SetDefault("transcoder.max_concurrent", 1)
v.SetDefault("transcoder.idle_timeout_seconds", 120)
// API Config 默认设置
v.SetDefault("api_config.auto_encrypt", true)
v.SetDefault("api_config.default_timeout", 30)
v.SetDefault("license.server_url", defaultLicenseServerURL)
v.SetDefault("license.hmac_secret", "")
v.SetDefault("license.public_key", defaultLicensePublicKey)
}
+76
View File
@@ -0,0 +1,76 @@
package config
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
)
// normalize 填充派生默认值并自愈空的关键字段。
func (c *Config) normalize() error {
if c.App.DataDir == "" {
c.App.DataDir = "./data"
}
if c.Database.DBPath == "" {
c.Database.DBPath = filepath.Join(c.App.DataDir, "mmtl.db")
}
if c.Database.Type == "" {
c.Database.Type = "auto"
}
if c.App.MaxCPUThreads < 1 {
c.App.MaxCPUThreads = 1
}
if c.App.MaxCPUThreads > 8 {
c.App.MaxCPUThreads = 8
}
if c.App.CloudScanMaxConcurrent < 1 {
c.App.CloudScanMaxConcurrent = 1
}
if c.App.CloudScanMaxConcurrent > 16 {
c.App.CloudScanMaxConcurrent = 16
}
if c.Database.MaxOpenConns <= 0 {
c.Database.MaxOpenConns = defaultDatabaseMaxOpenConns
}
if c.Database.MaxIdleConns <= 0 || c.Database.MaxIdleConns > c.Database.MaxOpenConns {
c.Database.MaxIdleConns = defaultDatabaseMaxIdleConns
if c.Database.MaxIdleConns > c.Database.MaxOpenConns {
c.Database.MaxIdleConns = c.Database.MaxOpenConns
}
}
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"
}
if c.Cache.MediaTTLSeconds < 1 {
c.Cache.MediaTTLSeconds = 15
}
c.Search.Backend = strings.ToLower(strings.TrimSpace(c.Search.Backend))
if c.Search.Index == "" {
c.Search.Index = "mmtl_media"
}
if c.Secrets.JWTSecret == "" {
// 持久化自动生成的密钥以在操作员忘记配置时保持会话稳定。
path := filepath.Join(c.App.DataDir, ".jwt_secret")
if data, err := os.ReadFile(path); err == nil && len(data) > 0 { // #nosec G304 -- path is fixed to .jwt_secret under configured DataDir.
c.Secrets.JWTSecret = strings.TrimSpace(string(data))
} else {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return fmt.Errorf("generate jwt secret: %w", err)
}
c.Secrets.JWTSecret = hex.EncodeToString(buf)
_ = os.MkdirAll(c.App.DataDir, 0o750)
_ = os.WriteFile(path, []byte(c.Secrets.JWTSecret), 0o600)
}
}
return nil
}
+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)
}
}
+175
View File
@@ -0,0 +1,175 @@
package config
// Config 是根配置聚合。
type Config struct {
App AppConfig `mapstructure:"app"`
Database DatabaseConfig `mapstructure:"database"`
Secrets SecretsConfig `mapstructure:"secrets"`
Logging LoggingConfig `mapstructure:"logging"`
Cache CacheConfig `mapstructure:"cache"`
Search SearchConfig `mapstructure:"search"`
Media MediaConfig `mapstructure:"media"`
Transcoder TranscoderConfig `mapstructure:"transcoder"`
AI AIConfig `mapstructure:"ai"`
FlareSolverr FlareSolverrConfig `mapstructure:"flaresolverr"`
ApiConfig ApiConfigConfig `mapstructure:"api_config"`
Organizer OrganizerConfig `mapstructure:"organizer"`
License LicenseConfig `mapstructure:"license"`
}
// ApiConfigConfig API 配置相关设置。
type ApiConfigConfig struct {
// AutoEncrypt 是否自动加密敏感字段
AutoEncrypt bool `mapstructure:"auto_encrypt"`
// DefaultTimeout 默认请求超时(秒)
DefaultTimeout int `mapstructure:"default_timeout"`
}
// TranscoderConfig 控制 HLS / ffmpeg 后端。
type TranscoderConfig struct {
Encoder string `mapstructure:"encoder"` // "" / nvenc / qsv / vaapi
Enabled bool `mapstructure:"enabled"`
HardwareAccel bool `mapstructure:"hardware_accel"`
Preset string `mapstructure:"preset"`
VideoBitrate string `mapstructure:"video_bitrate"`
MaxRate string `mapstructure:"max_rate"`
BufSize string `mapstructure:"buf_size"`
MaxHeight int `mapstructure:"max_height"`
SegmentSeconds int `mapstructure:"segment_seconds"`
Realtime bool `mapstructure:"realtime"`
Threads int `mapstructure:"threads"`
MaxConcurrent int `mapstructure:"max_concurrent"`
IdleTimeoutSeconds int `mapstructure:"idle_timeout_seconds"`
}
// 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"`
// HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。
HTTPSEnabled bool `mapstructure:"https_enabled"`
// SSLCert 是 PEM 编码的 SSL 证书内容。
SSLCert string `mapstructure:"ssl_cert"`
// SSLKey 是 PEM 编码的 SSL 私钥内容。
SSLKey string `mapstructure:"ssl_key"`
// SSLCertPath 是 SSL 证书文件路径;非空时优先于 SSLCert 从文件读取。
SSLCertPath string `mapstructure:"ssl_cert_path"`
// SSLKeyPath 是 SSL 私钥文件路径;非空时优先于 SSLKey 从文件读取。
SSLKeyPath string `mapstructure:"ssl_key_path"`
FFmpegPath string `mapstructure:"ffmpeg_path"`
FFprobePath string `mapstructure:"ffprobe_path"`
// FFprobeMaxConcurrent limits concurrent ffprobe/ffmpeg metadata probes.
// NAS devices can become unresponsive when a scan starts many probe
// processes at once, so the default is deliberately conservative.
FFprobeMaxConcurrent int `mapstructure:"ffprobe_max_concurrent"`
// CloudScanMaxConcurrent limits concurrent cloud directory list requests
// inside one mounted cloud library scan.
CloudScanMaxConcurrent int `mapstructure:"cloud_scan_max_concurrent"`
MaxCPUThreads int `mapstructure:"max_cpu_threads"`
VAAPIDevice string `mapstructure:"vaapi_device"`
CORSOrigins []string `mapstructure:"cors_origins"`
ServerURL string `mapstructure:"server_url"`
}
// DatabaseConfig 配置 GORM 数据库。默认 auto:
// Docker Compose 主线会注入 PostgreSQL DSN;裸机/旧部署没有 DSN 时回退 SQLite。
type DatabaseConfig struct {
Type string `mapstructure:"type"`
DBPath string `mapstructure:"db_path"`
DSN string `mapstructure:"dsn"`
WALMode bool `mapstructure:"wal_mode"`
BusyTimeout int `mapstructure:"busy_timeout"`
CacheSize int `mapstructure:"cache_size"`
MaxOpenConns int `mapstructure:"max_open_conns"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
}
// SecretsConfig 保存 JWT / 第三方 API 密钥(不要提交值)。
type SecretsConfig struct {
JWTSecret string `mapstructure:"jwt_secret"`
TMDbAPIKey string `mapstructure:"tmdb_api_key"`
TMDbAPIProxy string `mapstructure:"tmdb_api_proxy"`
TMDbImageProxy string `mapstructure:"tmdb_image_proxy"`
BangumiToken string `mapstructure:"bangumi_access_token"`
TheTVDBAPIKey string `mapstructure:"thetvdb_api_key"`
FanartAPIKey string `mapstructure:"fanart_tv_api_key"`
DoubanCookie string `mapstructure:"douban_cookie"`
// 用于加密的密钥,如果为空则使用 JWTSecret
EncryptionKey string `mapstructure:"encryption_key"`
}
// LoggingConfig 配置 Zap。
type LoggingConfig struct {
Level string `mapstructure:"level"`
Format string `mapstructure:"format"`
OutputPath string `mapstructure:"output_path"`
EnableRotation bool `mapstructure:"enable_rotation"`
MaxSizeMB int `mapstructure:"max_size_mb"`
MaxAgeDays int `mapstructure:"max_age_days"`
MaxBackups int `mapstructure:"max_backups"`
}
// 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"`
CleanupIntervalMin int `mapstructure:"cleanup_interval_min"`
RedisURL string `mapstructure:"redis_url"`
RedisPrefix string `mapstructure:"redis_prefix"`
MediaTTLSeconds int `mapstructure:"media_ttl_seconds"`
}
type SearchConfig struct {
Backend string `mapstructure:"backend"`
OpenSearchURL string `mapstructure:"opensearch_url"`
Index string `mapstructure:"index"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
}
// MediaConfig 保存默认库位置(用于引导库)。
type MediaConfig struct {
MoviesDir string `mapstructure:"movies_dir"`
TVDir string `mapstructure:"tv_dir"`
AnimeDir string `mapstructure:"anime_dir"`
}
// AIConfig 配置可选的 LLM 提供者。
type AIConfig struct {
Enabled bool `mapstructure:"enabled"`
Provider string `mapstructure:"provider"`
APIBase string `mapstructure:"api_base"`
APIKey string `mapstructure:"api_key"`
Model string `mapstructure:"model"`
Timeout int `mapstructure:"timeout"`
MaxConcurrent int `mapstructure:"max_concurrent"`
}
// LicenseConfig configures the optional MMTL license server bridge.
type LicenseConfig struct {
ServerURL string `mapstructure:"server_url"`
HMACSecret string `mapstructure:"hmac_secret"`
PublicKey string `mapstructure:"public_key"`
}
// OrganizerConfig 配置媒体文件智能分类整理。
type OrganizerConfig struct {
SmartClassify bool `mapstructure:"smart_classify"`
AutoAfterDownload bool `mapstructure:"auto_after_download"`
Categories map[string]string `mapstructure:"categories"`
}
// FlareSolverrConfig 配置 FlareSolverr 服务(用于绕过 Cloudflare/WAF)。
type FlareSolverrConfig struct {
Enabled bool `mapstructure:"enabled"`
URL string `mapstructure:"url"`
Session string `mapstructure:"session"`
Timeout int `mapstructure:"timeout"`
}
+122
View File
@@ -0,0 +1,122 @@
// Package database wires up GORM against the configured database and exposes
// startup migration helpers.
package database
import (
"errors"
"fmt"
"strings"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/ShukeBta/MMTL/internal/config"
)
// Open initialises the configured GORM database. database.type=auto chooses
// PostgreSQL when database.dsn is present and otherwise falls back to SQLite.
func Open(cfg *config.Config, log *zap.Logger) (*gorm.DB, error) {
if cfg == nil {
return nil, errors.New("database config is required")
}
dialect := normalizeDatabaseType(cfg.Database.Type)
if dialect == "auto" {
dialect = effectiveAutoDatabaseType(cfg)
}
dialector, err := databaseDialector(cfg, dialect)
if err != nil {
return nil, err
}
db, err := gorm.Open(dialector, &gorm.Config{
Logger: newGormLogger(log),
PrepareStmt: true,
DisableForeignKeyConstraintWhenMigrating: false,
})
if err != nil {
return nil, fmt.Errorf("gorm open: %w", err)
}
if dialect == "sqlite" {
installSQLiteWriteGate(db)
}
if err := configureConnectionPool(db, cfg); err != nil {
return nil, err
}
return db, nil
}
func newGormLogger(log *zap.Logger) logger.Interface {
if log == nil {
log = zap.NewNop()
}
return logger.New(
zapStdLogger{log: log},
logger.Config{
SlowThreshold: 0,
LogLevel: logger.Warn,
IgnoreRecordNotFoundError: true,
Colorful: false,
},
)
}
func configureConnectionPool(db *gorm.DB, cfg *config.Config) error {
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("gorm sqldb: %w", err)
}
if cfg.Database.MaxOpenConns > 0 {
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns)
}
if cfg.Database.MaxIdleConns > 0 {
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
}
return nil
}
func normalizeDatabaseType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "auto":
return "auto"
case "sqlite", "sqlite3":
return "sqlite"
case "postgres", "postgresql", "pg":
return "postgres"
default:
return strings.ToLower(strings.TrimSpace(value))
}
}
func effectiveAutoDatabaseType(cfg *config.Config) string {
if cfg != nil && strings.TrimSpace(cfg.Database.DSN) != "" {
return "postgres"
}
return "sqlite"
}
func databaseDialector(cfg *config.Config, dialect string) (gorm.Dialector, error) {
switch dialect {
case "sqlite":
return sqlite.Open(buildSQLiteDSN(cfg)), nil
case "postgres":
dsn := strings.TrimSpace(cfg.Database.DSN)
if dsn == "" {
return nil, fmt.Errorf("database.dsn is required when database.type=postgres")
}
return postgres.Open(dsn), nil
default:
return nil, fmt.Errorf("unsupported database.type %q (supported: sqlite, postgres)", cfg.Database.Type)
}
}
// zapStdLogger adapts a *zap.Logger to GORM's tiny logger interface.
type zapStdLogger struct{ log *zap.Logger }
func (z zapStdLogger) Printf(format string, args ...interface{}) {
if z.log == nil {
return
}
z.log.Sugar().Infof(format, args...)
}
+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"])
}
}
+425
View File
@@ -0,0 +1,425 @@
package database
import (
"path/filepath"
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
)
func TestOpenRequiresConfig(t *testing.T) {
db, err := Open(nil, nil)
if err == nil {
t.Fatal("expected nil config to return an error")
}
if db != nil {
t.Fatal("db should be nil when config is missing")
}
if !strings.Contains(err.Error(), "database config") {
t.Fatalf("error = %v, want database config message", err)
}
}
func TestOpenSQLiteWithNilLoggerConfiguresPool(t *testing.T) {
cfg := &config.Config{}
cfg.Database.Type = "sqlite"
cfg.Database.DBPath = filepath.Join(t.TempDir(), "mmtl.db")
cfg.Database.WALMode = true
cfg.Database.BusyTimeout = 5000
cfg.Database.CacheSize = -2000
cfg.Database.MaxOpenConns = 3
cfg.Database.MaxIdleConns = 2
db, err := Open(cfg, nil)
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
defer sqlDB.Close()
if err := db.Exec("SELECT 1").Error; err != nil {
t.Fatal(err)
}
stats := sqlDB.Stats()
if stats.MaxOpenConnections != 3 {
t.Fatalf("MaxOpenConnections = %d, want 3", stats.MaxOpenConnections)
}
}
func TestEnsurePerformanceIndexesCreatesHotPathIndexes(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Media{}, &model.Favorite{}, &model.PlaybackHistory{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
if err := ensurePerformanceIndexes(db); err != nil {
t.Fatal(err)
}
for _, name := range []string{
"idx_media_library_created_active",
"idx_media_library_episode_active",
"idx_favorites_user_media_active",
"idx_playback_histories_user_media_active",
"idx_play_profiles_user_created_active",
} {
var count int
if err := db.Raw(`SELECT COUNT(1) FROM sqlite_master WHERE type = 'index' AND name = ?`, name).Scan(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("index %s count = %d, want 1", name, count)
}
}
}
func TestEnsureMediaSearchIndexCreatesVersionedTriggers(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Media{}); err != nil {
t.Fatal(err)
}
if err := ensureMediaSearchIndex(db); err != nil {
t.Fatal(err)
}
if !sqliteFTSTableExists(t, db, "media_search_fts") {
t.Skip("SQLite FTS5 is unavailable in this build")
}
var version int
if err := db.Raw(`SELECT version FROM media_search_meta WHERE id = 1`).Scan(&version).Error; err != nil {
t.Fatal(err)
}
if version != mediaSearchIndexSchemaVersion {
t.Fatalf("media search schema version = %d, want %d", version, mediaSearchIndexSchemaVersion)
}
for _, trigger := range []string{"media_search_fts_ai", "media_search_fts_au", "media_search_fts_ad"} {
var count int
if err := db.Raw(`SELECT COUNT(1) FROM sqlite_master WHERE type = 'trigger' AND name = ?`, trigger).Scan(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("trigger %s count = %d, want 1", trigger, count)
}
}
media := model.Media{LibraryID: "lib-1", Title: "中文搜索电影", Path: "/media/movie.mkv", Genres: "动画,冒险"}
if err := db.Create(&media).Error; err != nil {
t.Fatal(err)
}
var indexed int
if err := db.Raw(`SELECT COUNT(1) FROM media_search_fts WHERE media_id = ?`, media.ID).Scan(&indexed).Error; err != nil {
t.Fatal(err)
}
if indexed != 1 {
t.Fatalf("indexed rows = %d, want inserted media indexed", indexed)
}
}
func sqliteFTSTableExists(t *testing.T, db *gorm.DB, table string) bool {
t.Helper()
var count int
if err := db.Raw(`SELECT COUNT(1) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&count).Error; err != nil {
t.Fatal(err)
}
return count == 1
}
func TestCopyModelTablesMigratesExistingSQLiteRows(t *testing.T) {
src, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
dst, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
for _, db := range []*gorm.DB{src, dst} {
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
}
user := model.User{Username: "admin", PasswordHash: "hash", Role: "admin", IsActive: true}
if err := src.Create(&user).Error; err != nil {
t.Fatal(err)
}
lib := model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
if err := src.Create(&lib).Error; err != nil {
t.Fatal(err)
}
if err := src.Create(&model.Setting{Key: "organize.auto", Value: "false"}).Error; err != nil {
t.Fatal(err)
}
_, copied, err := copyModelTables(src, dst, 2)
if err != nil {
t.Fatal(err)
}
if copied != 3 {
t.Fatalf("copied rows = %d, want 3", copied)
}
var got model.User
if err := dst.First(&got, "username = ?", "admin").Error; err != nil {
t.Fatal(err)
}
if got.ID != user.ID || got.Role != "admin" {
t.Fatalf("user not preserved: %#v", got)
}
}
func TestCopyModelTablesResumesPartialSQLiteMigration(t *testing.T) {
src, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
dst, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
for _, db := range []*gorm.DB{src, dst} {
if err := db.AutoMigrate(&model.User{}, &model.Media{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
}
user := model.User{Username: "admin", PasswordHash: "hash", Role: "admin", IsActive: true}
if err := src.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := dst.Create(&user).Error; err != nil {
t.Fatal(err)
}
media := model.Media{
LibraryID: "library-1",
Title: "Resume Migration",
Path: "/media/resume.mp4",
Container: "mov,mp4,m4a,3gp,3g2,mj2",
ScrapeStatus: "matched",
OriginalName: "Resume Migration",
PosterURL: "/media/poster.jpg",
BackdropURL: "/media/backdrop.jpg",
VideoCodec: "hevc",
AudioCodec: "eac3",
DurationSec: 120,
Genres: "家庭,动画,冒险,喜剧,奇幻,Peter Del Vecho,Jeff Draheim,詹妮弗·李,克里斯·巴克,伊迪娜·门泽尔,克里斯汀·贝尔,乔什·盖德,乔纳森·格罗夫,埃文·蕾切尔·伍德,斯特林·K·布朗",
SizeBytes: 1024,
Width: 3840,
Height: 2160,
SeasonNum: 1,
EpisodeNum: 1,
}
if err := src.Create(&media).Error; err != nil {
t.Fatal(err)
}
if err := src.Create(&model.Setting{Key: "organize.auto", Value: "false"}).Error; err != nil {
t.Fatal(err)
}
_, copied, err := copyModelTables(src, dst, 2)
if err != nil {
t.Fatal(err)
}
if copied != 2 {
t.Fatalf("copied rows = %d, want 2", copied)
}
var got model.Media
if err := dst.First(&got, "path = ?", media.Path).Error; err != nil {
t.Fatal(err)
}
if got.Container != media.Container {
t.Fatalf("container = %q, want %q", got.Container, media.Container)
}
if got.Genres != media.Genres {
t.Fatalf("genres = %q, want %q", got.Genres, media.Genres)
}
_, copied, err = copyModelTables(src, dst, 2)
if err != nil {
t.Fatal(err)
}
if copied != 0 {
t.Fatalf("second copy rows = %d, want 0", copied)
}
}
func TestSQLiteMigrationCompleteMarker(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Setting{}); err != nil {
t.Fatal(err)
}
complete, err := sqliteMigrationMarkedComplete(db)
if err != nil {
t.Fatal(err)
}
if complete {
t.Fatal("fresh database should not be marked migrated")
}
if err := markSQLiteMigrationComplete(db); err != nil {
t.Fatal(err)
}
complete, err = sqliteMigrationMarkedComplete(db)
if err != nil {
t.Fatal(err)
}
if !complete {
t.Fatal("database should be marked migrated")
}
}
func TestSQLiteMigrationFallsBackToDataDirDefaultPath(t *testing.T) {
dir := t.TempDir()
sqlitePath := filepath.Join(dir, "mmtl.db")
src, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := src.AutoMigrate(&model.User{}, &model.Library{}); err != nil {
t.Fatal(err)
}
user := model.User{Username: "real-admin", PasswordHash: "hash", Role: "admin", IsActive: true}
if err := src.Create(&user).Error; err != nil {
t.Fatal(err)
}
lib := model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
if err := src.Create(&lib).Error; err != nil {
t.Fatal(err)
}
sqlDB, _ := src.DB()
_ = sqlDB.Close()
dst, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := dst.AutoMigrate(&model.User{}, &model.Library{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
if err := dst.Create(&model.User{Username: "admin", PasswordHash: "bootstrap", Role: "admin", IsActive: true}).Error; err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.App.DataDir = dir
cfg.Database.DBPath = filepath.Join(dir, "disabled-sqlite-migration.db")
sourcePath, err := sqliteMigrationSourcePath(cfg, nil)
if err != nil {
t.Fatal(err)
}
if sourcePath != sqlitePath {
t.Fatalf("source path = %q, want fallback %q", sourcePath, sqlitePath)
}
src2, err := gorm.Open(sqlite.Open(sourcePath), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB2, _ := src2.DB()
defer func() {
if sqlDB2 != nil {
_ = sqlDB2.Close()
}
}()
if err := resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src2, dst, nil); err != nil {
t.Fatal(err)
}
_, copied, err := copyModelTables(src2, dst, 2)
if err != nil {
t.Fatal(err)
}
if copied != 2 {
t.Fatalf("copied rows = %d, want 2", copied)
}
var userCount int64
if err := dst.Model(&model.User{}).Count(&userCount).Error; err != nil {
t.Fatal(err)
}
if userCount != 1 {
t.Fatalf("user count = %d, want migrated source only", userCount)
}
var got model.User
if err := dst.First(&got, "username = ?", "real-admin").Error; err != nil {
t.Fatal(err)
}
var libCount int64
if err := dst.Model(&model.Library{}).Where("path = ?", "/media/movies").Count(&libCount).Error; err != nil {
t.Fatal(err)
}
if libCount != 1 {
t.Fatalf("library count = %d, want 1", libCount)
}
}
func TestOpenSQLiteMigrationSourceUsesFallbackSourcePath(t *testing.T) {
dir := t.TempDir()
sqlitePath := filepath.Join(dir, "mmtl.db")
src, err := gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := src.AutoMigrate(&model.User{}, &model.Library{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
if err := src.Create(&model.User{Username: "real-admin", PasswordHash: "hash", Role: "admin", IsActive: true}).Error; err != nil {
t.Fatal(err)
}
if err := src.Create(&model.Library{Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}).Error; err != nil {
t.Fatal(err)
}
sqlDB, _ := src.DB()
_ = sqlDB.Close()
dst, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := dst.AutoMigrate(&model.User{}, &model.Library{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.App.DataDir = dir
cfg.Database.DBPath = filepath.Join(dir, "disabled-sqlite-migration.db")
sourcePath, err := sqliteMigrationSourcePath(cfg, nil)
if err != nil {
t.Fatal(err)
}
if sourcePath != sqlitePath {
t.Fatalf("source path = %q, want %q", sourcePath, sqlitePath)
}
src2, err := openSQLiteMigrationSource(cfg, sourcePath)
if err != nil {
t.Fatal(err)
}
sqlDB2, _ := src2.DB()
defer func() {
if sqlDB2 != nil {
_ = sqlDB2.Close()
}
}()
_, copied, err := copyModelTables(src2, dst, 2)
if err != nil {
t.Fatal(err)
}
if copied != 2 {
t.Fatalf("copied rows = %d, want 2", copied)
}
var userCount int64
if err := dst.Model(&model.User{}).Where("username = ?", "real-admin").Count(&userCount).Error; err != nil {
t.Fatal(err)
}
if userCount != 1 {
t.Fatalf("migrated user count = %d, want 1", userCount)
}
}
+111
View File
@@ -0,0 +1,111 @@
package database
import (
"path/filepath"
"strings"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/model"
)
func ensureLibraryRootsCompatibility(db *gorm.DB) error {
var libraries []model.Library
if err := db.Find(&libraries).Error; err != nil {
return err
}
for _, lib := range libraries {
if strings.TrimSpace(lib.Path) == "" {
continue
}
var count int64
if err := db.Model(&model.LibraryRoot{}).Where("library_id = ?", lib.ID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
continue
}
root := model.LibraryRoot{
LibraryID: lib.ID,
Name: firstLibraryRootLabel(lib.Path),
Path: lib.Path,
Enabled: lib.Enabled,
SortOrder: 0,
}
if err := db.Create(&root).Error; err != nil {
return err
}
if err := backfillLibraryRootMedia(db, lib, root); err != nil {
return err
}
}
return nil
}
func backfillLibraryRootMedia(db *gorm.DB, lib model.Library, root model.LibraryRoot) error {
var rows []model.Media
if err := db.Unscoped().
Model(&model.Media{}).
Select("id", "path").
Where("library_id = ? AND (library_root_id = '' OR library_root_id IS NULL)", lib.ID).
Find(&rows).Error; err != nil {
return err
}
rootPath := strings.TrimSpace(root.Path)
for _, row := range rows {
rel, ok := relativePathWithinRoot(row.Path, rootPath)
if !ok {
continue
}
if err := db.Unscoped().Model(&model.Media{}).Where("id = ?", row.ID).Updates(map[string]any{
"library_root_id": root.ID,
"relative_path": rel,
}).Error; err != nil {
return err
}
}
return nil
}
func relativePathWithinRoot(pathValue, root string) (string, bool) {
pathValue = strings.TrimSpace(pathValue)
root = strings.TrimSpace(root)
if pathValue == "" || root == "" {
return "", false
}
if strings.HasPrefix(strings.ToLower(root), "cloud://") || strings.HasPrefix(strings.ToLower(pathValue), "cloud://") {
prefix := strings.TrimRight(root, "/") + "/"
if strings.EqualFold(pathValue, root) {
return "", true
}
if strings.HasPrefix(strings.ToLower(pathValue), strings.ToLower(prefix)) {
return strings.TrimPrefix(pathValue, prefix), true
}
return "", false
}
cleanPath := filepath.Clean(pathValue)
cleanRoot := filepath.Clean(root)
rel, err := filepath.Rel(cleanRoot, cleanPath)
if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
return "", false
}
return rel, true
}
func firstLibraryRootLabel(pathValue string) string {
pathValue = strings.TrimSpace(pathValue)
if pathValue == "" {
return ""
}
if strings.HasPrefix(strings.ToLower(pathValue), "cloud://") {
parts := strings.Split(strings.Trim(pathValue, "/"), "/")
if len(parts) > 0 {
return parts[len(parts)-1]
}
}
base := filepath.Base(filepath.Clean(pathValue))
if base == "." || base == string(filepath.Separator) {
return pathValue
}
return base
}
+91
View File
@@ -0,0 +1,91 @@
package database
import "gorm.io/gorm"
// mediaSearchIndexSchemaVersion identifies the physical FTS index layout.
// v2 aligns FTS rowids with media rowids and keeps the index current with
// triggers.
const mediaSearchIndexSchemaVersion = 2
func ensureMediaSearchIndex(db *gorm.DB) error {
if err := ensureMediaSearchMetaTable(db); err != nil {
return nil
}
version := currentMediaSearchIndexVersion(db)
if version != mediaSearchIndexSchemaVersion {
resetMediaSearchIndex(db)
}
if !createMediaSearchFTSTable(db) {
return nil
}
if err := createMediaSearchTriggers(db); err != nil {
return err
}
if version != mediaSearchIndexSchemaVersion {
return markMediaSearchIndexVersion(db)
}
return nil
}
func ensureMediaSearchMetaTable(db *gorm.DB) error {
return db.Exec(`CREATE TABLE IF NOT EXISTS media_search_meta (id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL)`).Error
}
func currentMediaSearchIndexVersion(db *gorm.DB) int {
var version int
_ = db.Raw(`SELECT version FROM media_search_meta WHERE id = 1`).Scan(&version).Error
return version
}
func resetMediaSearchIndex(db *gorm.DB) {
for _, stmt := range []string{
`DROP TRIGGER IF EXISTS media_search_fts_ai`,
`DROP TRIGGER IF EXISTS media_search_fts_au`,
`DROP TRIGGER IF EXISTS media_search_fts_ad`,
`DROP TABLE IF EXISTS media_search_fts`,
} {
_ = db.Exec(stmt).Error
}
}
func createMediaSearchFTSTable(db *gorm.DB) bool {
if err := db.Exec(`CREATE VIRTUAL TABLE IF NOT EXISTS media_search_fts USING fts5(media_id UNINDEXED, title, original_name, path, genres, tokenize='trigram')`).Error; err == nil {
return true
}
if err := db.Exec(`CREATE VIRTUAL TABLE IF NOT EXISTS media_search_fts USING fts5(media_id UNINDEXED, title, original_name, path, genres, tokenize='unicode61')`).Error; err == nil {
return true
}
// FTS is an acceleration path. Some embedded SQLite builds may omit FTS5;
// keep startup working and let repository queries fall back to LIKE search.
return false
}
func createMediaSearchTriggers(db *gorm.DB) error {
for _, stmt := range mediaSearchTriggerStatements {
if err := db.Exec(stmt).Error; err != nil {
return err
}
}
return nil
}
var mediaSearchTriggerStatements = []string{
`CREATE TRIGGER IF NOT EXISTS media_search_fts_ai AFTER INSERT ON media WHEN new.deleted_at IS NULL BEGIN
DELETE FROM media_search_fts WHERE rowid = new.rowid;
INSERT INTO media_search_fts(rowid, media_id, title, original_name, path, genres)
VALUES (new.rowid, new.id, COALESCE(new.title, ''), COALESCE(new.original_name, ''), COALESCE(new.path, ''), COALESCE(new.genres, ''));
END`,
`CREATE TRIGGER IF NOT EXISTS media_search_fts_au AFTER UPDATE OF title, original_name, path, genres, deleted_at ON media BEGIN
DELETE FROM media_search_fts WHERE rowid = old.rowid;
INSERT INTO media_search_fts(rowid, media_id, title, original_name, path, genres)
SELECT new.rowid, new.id, COALESCE(new.title, ''), COALESCE(new.original_name, ''), COALESCE(new.path, ''), COALESCE(new.genres, '')
WHERE new.deleted_at IS NULL;
END`,
`CREATE TRIGGER IF NOT EXISTS media_search_fts_ad AFTER DELETE ON media BEGIN
DELETE FROM media_search_fts WHERE rowid = old.rowid;
END`,
}
func markMediaSearchIndexVersion(db *gorm.DB) error {
return db.Exec(`INSERT INTO media_search_meta(id, version) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET version = excluded.version`, mediaSearchIndexSchemaVersion).Error
}
+104
View File
@@ -0,0 +1,104 @@
package database
import (
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/model"
)
// AutoMigrate creates tables for every model registered in the model package.
func AutoMigrate(db *gorm.DB) error {
if err := db.AutoMigrate(model.AllModels()...); err != nil {
return err
}
if err := ensurePostgresColumnCompatibility(db); err != nil {
return err
}
if err := ensurePerformanceIndexes(db); err != nil {
return err
}
if err := ensureLibraryRootsCompatibility(db); err != nil {
return err
}
if err := ensureEmbyMountsCompatibility(db); err != nil {
return err
}
if isSQLite(db) {
return ensureMediaSearchIndex(db)
}
return nil
}
func ensurePostgresColumnCompatibility(db *gorm.DB) error {
if !isPostgres(db) {
return nil
}
statements := []string{
`ALTER TABLE media ALTER COLUMN container TYPE varchar(128)`,
`ALTER TABLE media ALTER COLUMN genres TYPE text`,
`ALTER TABLE media ALTER COLUMN series_id TYPE varchar(128)`,
`ALTER TABLE media ALTER COLUMN duplicate_of TYPE varchar(128)`,
`ALTER TABLE playback_histories ALTER COLUMN media_id TYPE varchar(128)`,
`ALTER TABLE favorites ALTER COLUMN media_id TYPE varchar(128)`,
`ALTER TABLE playlist_items ALTER COLUMN media_id TYPE varchar(128)`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
return err
}
}
return nil
}
func ensurePerformanceIndexes(db *gorm.DB) error {
statements := []string{
`CREATE INDEX IF NOT EXISTS idx_media_library_created_active ON media(library_id, created_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_release_active ON media(library_id, release_date DESC, year DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_episode_active ON media(library_id, season_num, episode_num, created_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_root_active ON media(library_id, library_root_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_series_active ON media(series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_favorites_user_media_active ON favorites(user_id, media_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_playback_histories_user_media_active ON playback_histories(user_id, media_id, watched_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_playback_histories_resume_active ON playback_histories(user_id, completed, watched_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_play_profiles_user_created_active ON play_profiles(user_id, created_at DESC) WHERE deleted_at IS NULL`,
}
if isSQLite(db) {
statements = append(statements,
`CREATE INDEX IF NOT EXISTS idx_media_title_active ON media(title COLLATE NOCASE) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_original_name_active ON media(original_name COLLATE NOCASE) WHERE deleted_at IS NULL`,
)
} else {
statements = append(statements,
`CREATE INDEX IF NOT EXISTS idx_media_title_active ON media(title) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_original_name_active ON media(original_name) WHERE deleted_at IS NULL`,
)
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
return err
}
}
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)
}
}
+65
View File
@@ -0,0 +1,65 @@
package database
import (
"fmt"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
)
// MigrateSQLiteToCurrentIfNeeded copies an existing SQLite database into
// PostgreSQL. Redis is not migrated because it is a rebuildable cache, not a
// source of truth.
const sqliteMigrationCompleteSettingKey = "database.sqlite_migration_complete"
func MigrateSQLiteToCurrentIfNeeded(cfg *config.Config, target *gorm.DB, log *zap.Logger) error {
if cfg == nil || target == nil || target.Dialector == nil || target.Dialector.Name() != "postgres" {
return nil
}
sqlitePath, err := sqliteMigrationSourcePath(cfg, log)
if err != nil {
return err
}
if sqlitePath == "" {
return nil
}
if complete, err := sqliteMigrationMarkedComplete(target); err != nil {
return err
} else if complete {
if log != nil {
log.Info("skip sqlite to postgres migration: already completed")
}
return nil
}
src, err := openSQLiteMigrationSource(cfg, sqlitePath)
if err != nil {
return fmt.Errorf("open sqlite migration source: %w", err)
}
sqlDB, err := src.DB()
if err == nil {
defer sqlDB.Close()
}
started := time.Now()
if err := resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src, target, log); err != nil {
return err
}
_, copied, err := copyModelTables(src, target, 500)
if err != nil {
return err
}
if err := markSQLiteMigrationComplete(target); err != nil {
return err
}
if log != nil {
log.Info("sqlite data migrated to postgres",
zap.String("source", sqlitePath),
zap.Int64("rows", copied),
zap.Duration("duration", time.Since(started)))
}
return nil
}
@@ -0,0 +1,126 @@
package database
import (
"fmt"
"strings"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/ShukeBta/MMTL/internal/model"
)
func resetBootstrapTargetBeforeSQLiteMigrationIfSafe(src, target *gorm.DB, log *zap.Logger) error {
hasRows, err := sqliteSourceHasMigratableRows(src)
if err != nil {
return err
}
if !hasRows {
return nil
}
bootstrapOnly, err := targetLooksLikeBootstrapOnly(target)
if err != nil || !bootstrapOnly {
return err
}
for i := len(model.AllModels()) - 1; i >= 0; i-- {
m := model.AllModels()[i]
if !target.Migrator().HasTable(m) {
continue
}
if err := target.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(m).Error; err != nil {
return fmt.Errorf("clear bootstrap target table %T: %w", m, err)
}
}
if log != nil {
log.Warn("cleared bootstrap postgres rows before sqlite migration")
}
return nil
}
func sqliteSourceHasMigratableRows(src *gorm.DB) (bool, error) {
for _, table := range []string{"users", "libraries", "media", "settings"} {
exists, err := sqliteTableExists(src, table)
if err != nil {
return false, err
}
if !exists {
continue
}
var count int64
if err := src.Raw("SELECT COUNT(1) FROM " + quoteIdent(table)).Scan(&count).Error; err != nil {
return false, fmt.Errorf("count sqlite table %s: %w", table, err)
}
if count > 0 {
return true, nil
}
}
return false, nil
}
func targetLooksLikeBootstrapOnly(target *gorm.DB) (bool, error) {
for _, m := range []any{
&model.Library{},
&model.Series{},
&model.Media{},
&model.PlaybackHistory{},
&model.Favorite{},
&model.Playlist{},
&model.PlaylistItem{},
} {
if !target.Migrator().HasTable(m) {
continue
}
var count int64
if err := target.Unscoped().Model(m).Count(&count).Error; err != nil {
return false, err
}
if count > 0 {
return false, nil
}
}
var userCount int64
if !target.Migrator().HasTable(&model.User{}) {
return true, nil
}
if err := target.Model(&model.User{}).Count(&userCount).Error; err != nil {
return false, err
}
if userCount == 0 {
return true, nil
}
if userCount != 1 {
return false, nil
}
var user model.User
if err := target.Unscoped().Where("username = ?", "admin").First(&user).Error; err != nil {
return false, nil
}
return user.Role == "admin", nil
}
func sqliteMigrationMarkedComplete(db *gorm.DB) (bool, error) {
var value string
err := db.Raw("SELECT value FROM "+quoteIdent("settings")+" WHERE "+quoteIdent("key")+" = ?", sqliteMigrationCompleteSettingKey).Scan(&value).Error
if err != nil {
return false, fmt.Errorf("check sqlite migration marker: %w", err)
}
return strings.EqualFold(strings.TrimSpace(value), "true"), nil
}
func markSQLiteMigrationComplete(db *gorm.DB) error {
now := time.Now()
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value", "updated_at"}),
}).Create(&model.Setting{
Key: sqliteMigrationCompleteSettingKey,
Value: "true",
UpdatedAt: now,
}).Error; err != nil {
return fmt.Errorf("mark sqlite migration complete: %w", err)
}
return nil
}
+228
View File
@@ -0,0 +1,228 @@
package database
import (
"fmt"
"reflect"
"sort"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
"github.com/ShukeBta/MMTL/internal/model"
)
func copyModelTables(src, target *gorm.DB, batchSize int) (map[string]int64, int64, error) {
if batchSize <= 0 {
batchSize = 500
}
tableCounts := make(map[string]int64)
var totalCopied int64
for _, m := range model.AllModels() {
table, err := modelTableName(src, m)
if err != nil {
return tableCounts, totalCopied, err
}
primaryColumns, err := modelPrimaryColumns(src, m)
if err != nil {
return tableCounts, totalCopied, fmt.Errorf("inspect model %T primary keys: %w", m, err)
}
exists, err := sqliteTableExists(src, table)
if err != nil {
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 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 tableCounts, totalCopied, fmt.Errorf("count target table %s: %w", table, err)
}
modelType := reflect.TypeOf(m)
if modelType.Kind() != reflect.Ptr {
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 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 tableCounts, totalCopied, err
}
filtered = filterRowsMissingInTarget(target, table, primaryColumns, filtered, primaryKeySet)
}
if filtered.Len() == 0 {
continue
}
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 tableCounts, totalCopied, fmt.Errorf("copy sqlite table %s: %w", table, err)
}
copiedForTable := int64(filtered.Len())
tableCounts[table] = copiedForTable
totalCopied += copiedForTable
}
return tableCounts, totalCopied, nil
}
func modelPrimaryColumns(db *gorm.DB, m any) ([]string, error) {
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(m); err != nil {
return nil, err
}
var cols []string
for _, field := range stmt.Schema.PrimaryFields {
cols = append(cols, field.DBName)
}
if len(cols) == 0 {
return nil, fmt.Errorf("no primary key columns")
}
return cols, nil
}
func targetPrimaryKeySet(target *gorm.DB, table string, primaryColumns []string) (map[string]struct{}, error) {
if len(primaryColumns) != 1 {
return nil, nil
}
var values []string
if err := target.Raw("SELECT " + quoteIdent(primaryColumns[0]) + " FROM " + quoteIdent(table)).Scan(&values).Error; err != nil {
return nil, fmt.Errorf("read target primary keys for table %s: %w", table, err)
}
set := make(map[string]struct{}, len(values))
for _, value := range values {
set[value] = struct{}{}
}
return set, nil
}
func filterRowsMissingInTarget(target *gorm.DB, table string, primaryColumns []string, rows reflect.Value, primaryKeySet map[string]struct{}) reflect.Value {
if rows.Kind() != reflect.Slice || rows.Len() == 0 || len(primaryColumns) == 0 {
return rows
}
out := reflect.MakeSlice(rows.Type(), 0, rows.Len())
for i := 0; i < rows.Len(); i++ {
row := rows.Index(i)
keys, ok := rowPrimaryKeys(row, primaryColumns)
if !ok {
out = reflect.Append(out, row)
continue
}
if primaryKeySet != nil {
if _, exists := primaryKeySet[fmt.Sprint(keys[primaryColumns[0]])]; !exists {
out = reflect.Append(out, row)
}
continue
}
if !targetHasPrimaryKey(target, table, keys) {
out = reflect.Append(out, row)
}
}
return out
}
func rowPrimaryKeys(row reflect.Value, primaryColumns []string) (map[string]any, bool) {
if row.Kind() == reflect.Pointer {
if row.IsNil() {
return nil, false
}
row = row.Elem()
}
if row.Kind() != reflect.Struct {
return nil, false
}
keys := make(map[string]any, len(primaryColumns))
for _, column := range primaryColumns {
value, ok := fieldByDBName(row, column)
if !ok || value.IsZero() {
return nil, false
}
keys[column] = value.Interface()
}
return keys, true
}
func fieldByDBName(row reflect.Value, column string) (reflect.Value, bool) {
rowType := row.Type()
for i := 0; i < row.NumField(); i++ {
fieldType := rowType.Field(i)
field := row.Field(i)
if fieldType.Anonymous {
if value, ok := fieldByDBName(field, column); ok {
return value, true
}
}
if columnNameForStructField(fieldType) == column {
if field.Kind() == reflect.Pointer && field.IsNil() {
return reflect.Value{}, false
}
return field, field.CanInterface()
}
}
return reflect.Value{}, false
}
func columnNameForStructField(field reflect.StructField) string {
if field.PkgPath != "" && !field.Anonymous {
return ""
}
tag := field.Tag.Get("gorm")
settings := schema.ParseTagSetting(tag, ";")
if column := settings["COLUMN"]; column != "" {
return column
}
return schema.NamingStrategy{}.ColumnName("", field.Name)
}
func targetHasPrimaryKey(target *gorm.DB, table string, keys map[string]any) bool {
where := make([]string, 0, len(keys))
args := make([]any, 0, len(keys))
for _, column := range sortedMapKeys(keys) {
where = append(where, quoteIdent(column)+" = ?")
args = append(args, keys[column])
}
var count int64
err := target.Raw("SELECT COUNT(1) FROM "+quoteIdent(table)+" WHERE "+strings.Join(where, " AND "), args...).Scan(&count).Error
return err == nil && count > 0
}
func sortedMapKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func sqliteTableExists(db *gorm.DB, table string) (bool, error) {
var count int64
if err := db.Raw(`SELECT COUNT(1) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&count).Error; err != nil {
return false, fmt.Errorf("inspect sqlite table %s: %w", table, err)
}
return count > 0, nil
}
func modelTableName(db *gorm.DB, m any) (string, error) {
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(m); err != nil {
return "", err
}
return stmt.Schema.Table, nil
}
func quoteIdent(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
}
@@ -0,0 +1,77 @@
package database
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/ShukeBta/MMTL/internal/config"
)
func openSQLiteMigrationSource(cfg *config.Config, sqlitePath string) (*gorm.DB, error) {
srcCfg := *cfg
srcCfg.Database.Type = "sqlite"
srcCfg.Database.DBPath = sqlitePath
return gorm.Open(sqlite.Open(buildSQLiteDSN(&srcCfg)), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
}
func sqliteMigrationSourcePath(cfg *config.Config, log *zap.Logger) (string, error) {
configured := strings.TrimSpace(cfg.Database.DBPath)
if configured != "" {
exists, err := regularFileExists(configured)
if err != nil {
return "", fmt.Errorf("stat sqlite migration source: %w", err)
}
if exists {
return configured, nil
}
}
fallback := filepath.Join(strings.TrimSpace(cfg.App.DataDir), "mmtl.db")
if fallback == "" || sameCleanPath(configured, fallback) {
return "", nil
}
exists, err := regularFileExists(fallback)
if err != nil {
return "", fmt.Errorf("stat default sqlite migration source: %w", err)
}
if !exists {
return "", nil
}
if log != nil && configured != "" {
log.Warn("configured sqlite migration source not found; using data-dir default",
zap.String("configured", configured),
zap.String("fallback", fallback))
}
return fallback, nil
}
func regularFileExists(path string) (bool, error) {
if strings.TrimSpace(path) == "" {
return false, nil
}
info, err := os.Stat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, err
}
return !info.IsDir(), nil
}
func sameCleanPath(a, b string) bool {
if a == "" || b == "" {
return false
}
return filepath.Clean(a) == filepath.Clean(b)
}
+127
View File
@@ -0,0 +1,127 @@
package database
import (
"context"
"fmt"
"path/filepath"
"strings"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
)
func installSQLiteWriteGate(db *gorm.DB) {
if db == nil {
return
}
const lockedKey = "mmtl:sqlite_write_locked"
gate := newSQLiteWriteGate()
lock := func(tx *gorm.DB) {
ctx := context.Background()
if tx.Statement != nil && tx.Statement.Context != nil {
ctx = tx.Statement.Context
}
if err := gate.Lock(ctx); err != nil {
_ = tx.AddError(err)
return
}
tx.InstanceSet(lockedKey, struct{}{})
}
unlock := func(tx *gorm.DB) {
if _, ok := tx.InstanceGet(lockedKey); ok {
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", 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 {
ch chan struct{}
}
func newSQLiteWriteGate() *sqliteWriteGate {
return &sqliteWriteGate{ch: make(chan struct{}, 1)}
}
func (g *sqliteWriteGate) Lock(ctx context.Context) error {
select {
case g.ch <- struct{}{}:
return nil
default:
}
if ctx == nil {
ctx = context.Background()
}
select {
case g.ch <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (g *sqliteWriteGate) Unlock() {
select {
case <-g.ch:
default:
}
}
func buildSQLiteDSN(cfg *config.Config) string {
dbPath := cfg.Database.DBPath
if !filepath.IsAbs(dbPath) {
// keep as-is to respect user-provided relative paths.
dbPath = filepath.Clean(dbPath)
}
dsn := dbPath + "?_pragma=foreign_keys(1)"
if cfg.Database.WALMode {
dsn += "&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)"
}
if cfg.Database.BusyTimeout > 0 {
dsn += fmt.Sprintf("&_pragma=busy_timeout(%d)", cfg.Database.BusyTimeout)
}
if cfg.Database.CacheSize != 0 {
dsn += fmt.Sprintf("&_pragma=cache_size(%d)", cfg.Database.CacheSize)
}
dsn += "&_pragma=temp_store(MEMORY)&_pragma=mmap_size(268435456)"
return dsn
}
func isSQLite(db *gorm.DB) bool {
return db != nil && db.Dialector != nil && db.Dialector.Name() == "sqlite"
}
func isPostgres(db *gorm.DB) bool {
return db != nil && db.Dialector != nil && db.Dialector.Name() == "postgres"
}
+80
View File
@@ -0,0 +1,80 @@
package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/service"
)
const embyCtxUserName = "emby_user_name"
func activeUserRequired(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
userID, _ := uid.(string)
if userID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40101, "message": "missing user"})
return
}
u, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil {
if service.IsTransientDatabaseLock(err) {
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40101, "message": "user not found"})
return
}
if u == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40101, "message": "user not found"})
return
}
if !u.IsActive {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 40302, "message": "user account is disabled"})
return
}
if u.ExpiredAt != nil && time.Now().After(*u.ExpiredAt) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 40303, "message": "user account has expired"})
return
}
c.Next()
}
}
func activeEmbyUserRequired(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
userID, _ := uid.(string)
u, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if userID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"Code": 40101, "Message": "User not found"})
return
}
if err != nil {
if service.IsTransientDatabaseLock(err) {
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"Code": 40101, "Message": "User not found"})
return
}
if u == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"Code": 40101, "Message": "User not found"})
return
}
if !u.IsActive {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"Code": 40302, "Message": "User account is disabled"})
return
}
if u.ExpiredAt != nil && time.Now().After(*u.ExpiredAt) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"Code": 40303, "Message": "User account has expired"})
return
}
c.Set(embyCtxUserName, u.Username)
c.Next()
}
}
+295
View File
@@ -0,0 +1,295 @@
// Package handler — admin endpoints (users / settings / logs).
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/service"
)
func listUsersHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
users, err := svc.Repo.User.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := annotateProtectedUsers(c.Request.Context(), svc, users); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if svc.Sessions != nil {
svc.Sessions.ApplyToUsers(c.Request.Context(), users)
}
for i := range users {
users[i].PopulateComputedFields()
}
c.JSON(http.StatusOK, users)
}
}
type adminCreateUserReq struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
func createUserHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req adminCreateUserReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
u, _, err := svc.Auth.Register(c.Request.Context(), req.Username, req.Password)
if err != nil {
writeUserMutationError(c, svc, err)
return
}
// Admin-created users are intentionally normal viewers by default.
// They can log in from Web/Emby-compatible clients and play media, but
// cannot scrape, scan, download, delete, export NFO, or manage files.
if u.Role != "user" {
u, err = svc.Profile.AdminUpdateRole(c.Request.Context(), u.ID, "user")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
c.JSON(http.StatusCreated, u)
}
}
type adminUpdateUserReq struct {
Username string `json:"username" binding:"required"`
}
type adminResetPasswordReq struct {
Password string `json:"password" binding:"required,min=6"`
}
type adminUpdateUserStatusReq struct {
IsActive bool `json:"is_active"`
}
func updateUserHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req adminUpdateUserReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
nextUsername := strings.TrimSpace(req.Username)
if nextUsername == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "username required"})
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
}
if existing, err := svc.Repo.User.FindByUsername(c.Request.Context(), nextUsername); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
} else if existing != nil && existing.ID != userID {
writeUserMutationError(c, svc, service.ErrUsernameTaken)
return
}
updates := map[string]any{"username": nextUsername}
if firstAdmin, err := svc.Repo.User.FirstAdmin(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
} else if firstAdmin != nil && firstAdmin.ID == userID {
updates["role"] = "admin"
updates["tier"] = "plus"
}
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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, updated)
}
}
func deleteUserHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
firstAdmin, err := svc.Repo.User.FirstAdmin(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if firstAdmin != nil && firstAdmin.ID == c.Param("id") {
c.JSON(http.StatusForbidden, gin.H{"error": "default admin cannot be deleted"})
return
}
if svc.Sessions != nil && svc.Sessions.UserRecentlyActive(c.Request.Context(), c.Param("id"), service.RealtimeDeletionGuardWindow()) {
c.JSON(http.StatusConflict, gin.H{"error": "user has a recent realtime session; confirm the user is offline before deletion"})
return
}
if err := svc.Repo.User.Delete(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func resetUserPasswordHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req adminResetPasswordReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Auth.ResetPassword(c.Request.Context(), c.Param("id"), req.Password); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func updateUserStatusHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req adminUpdateUserStatusReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.Param("id")
if !req.IsActive {
if firstAdmin, err := svc.Repo.User.FirstAdmin(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
} else if firstAdmin != nil && firstAdmin.ID == userID {
c.JSON(http.StatusForbidden, gin.H{"error": "default admin cannot be disabled"})
return
}
}
updates := map[string]any{"is_active": req.IsActive}
if req.IsActive {
updates["share_warnings"] = 0
updates["last_share_warn_at"] = nil
}
if err := svc.Repo.User.UpdateFields(c.Request.Context(), userID, updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if req.IsActive {
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, false)
} else {
_ = svc.Repo.UserDevice.SetKickedByUser(c.Request.Context(), userID, true)
}
updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
updated.PopulateComputedFields()
c.JSON(http.StatusOK, updated)
}
}
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)
}
}
func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error {
firstAdmin, err := svc.Repo.User.FirstAdmin(ctx)
if err != nil {
return err
}
for i := range users {
if service.UserIsProtectedAccount(ctx, svc.Repo, &users[i]) {
users[i].IsProtected = true
}
if firstAdmin != nil && users[i].ID == firstAdmin.ID {
users[i].IsDefaultAdmin = true
users[i].IsProtected = true
users[i].Role = "admin"
users[i].Tier = "plus"
}
}
return nil
}
func writeUserMutationError(c *gin.Context, svc *service.Container, err error) {
switch {
case errors.Is(err, service.ErrUsernameTaken):
c.JSON(http.StatusConflict, gin.H{"error": "username already taken"})
case errors.Is(err, service.ErrUserLimitReached):
c.JSON(http.StatusBadRequest, gin.H{"error": "user limit reached", "max_users": service.UserLimit})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
}
+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())
}
}
+299
View File
@@ -0,0 +1,299 @@
package handler
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/service"
)
type settingReq struct {
Key string `json:"key" binding:"required"`
Value string `json:"value"`
}
func listSettingsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
settings, err := svc.Repo.Setting.All(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, settings)
}
}
func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req settingReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
oldValue := ""
if req.Key == service.AdultLibraryIDsSettingKey {
oldValue, _ = svc.Repo.Setting.Get(c.Request.Context(), req.Key)
}
if err := svc.Repo.Setting.Set(c.Request.Context(), req.Key, req.Value); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
oldAdultLibraryIDs := service.DecodeAllowedLibraryIDs(oldValue)
newAdultLibraryIDs := service.DecodeAllowedLibraryIDs(req.Value)
if req.Key == service.AdultLibraryIDsSettingKey && len(oldAdultLibraryIDs) == 0 && len(newAdultLibraryIDs) > 0 {
_ = svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.User{}).Where("hide_adult = ?", false).Update("hide_adult", true).Error
}
service.ApplyRuntimeSetting(svc.Cfg, req.Key, req.Value)
if err := applyHTTPSetting(svc, req.Key, req.Value); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if svc.FFprobe != nil && (req.Key == "ffprobe.max_concurrent" || req.Key == "app.ffprobe_max_concurrent") {
svc.FFprobe.SetMaxConcurrent(svc.Cfg.App.FFprobeMaxConcurrent)
}
if req.Key == "transcode.enabled" && !svc.Cfg.Transcoder.Enabled {
svc.Transcoder.StopAll()
}
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)
}
}
// applyHTTPSetting 校验 HTTPS 相关设置,并在可行时热重载监听。
// 必须在 ApplyRuntimeSetting 之后调用,这样 svc.Cfg 已反映刚保存的值。
//
// 规则:
// - https.enabled=true 时强制要求证书与私钥都已配置(内容或路径均可)且匹配,
// 否则返回错误("如果启用就必须配置 SSL 证书和密钥");
// - 证书/私钥(内容或路径)单独保存时只校验格式;若 HTTPS 已开启且新的整体
// 配置可解析匹配才触发重载,避免"只存了新证书、私钥还没保存"时用旧私钥带
// 新证书对外提供服务。
func applyHTTPSetting(svc *service.Container, key, value string) error {
skipReload := func(reason string) {
if svc.Log != nil {
svc.Log.Warn("https setting saved but not applied yet", zap.String("key", key), zap.String("reason", reason))
}
}
switch key {
case "https.enabled":
if svc.Cfg.App.HTTPSEnabled {
if _, err := service.ResolveSSLKeyPair(svc.Cfg.App.SSLCert, svc.Cfg.App.SSLCertPath, svc.Cfg.App.SSLKey, svc.Cfg.App.SSLKeyPath); err != nil {
return fmt.Errorf("启用 HTTPS 失败:%v", err)
}
}
case "https.cert", "https.cert_path", "https.key", "https.key_path":
if err := validateSSLMaterialSource(key, value); err != nil {
return err
}
if !svc.Cfg.App.HTTPSEnabled {
return nil
}
if !httpsPairReady(svc) {
skipReload("证书与私钥尚未匹配,等待另一半保存后生效")
return nil
}
default:
return nil
}
if svc.ReloadHTTPServer != nil {
return svc.ReloadHTTPServer()
}
return nil
}
// validateSSLMaterialSource 校验刚保存的证书/私钥来源(内容或路径)本身格式合法。
func validateSSLMaterialSource(key, value string) error {
switch key {
case "https.cert":
return service.ValidateSSLCert(value)
case "https.cert_path":
if strings.TrimSpace(value) == "" {
return nil // 清空路径也允许,启用时由整体校验把关
}
pemStr, err := service.ResolveSSLMaterial("", value, "证书")
if err != nil {
return err
}
return service.ValidateSSLCert(pemStr)
case "https.key":
return service.ValidateSSLKey(value)
case "https.key_path":
if strings.TrimSpace(value) == "" {
return nil
}
pemStr, err := service.ResolveSSLMaterial("", value, "私钥")
if err != nil {
return err
}
return service.ValidateSSLKey(pemStr)
}
return nil
}
// httpsPairReady 判断基于当前配置解析出的证书/私钥是否完整且匹配。
func httpsPairReady(svc *service.Container) bool {
_, err := service.ResolveSSLKeyPair(svc.Cfg.App.SSLCert, svc.Cfg.App.SSLCertPath, svc.Cfg.App.SSLKey, svc.Cfg.App.SSLKeyPath)
return err == nil
}
type testAdultScraperReq struct {
Engine string `json:"engine"`
ServerURL string `json:"server_url"`
Token string `json:"token"`
JavDBURL string `json:"javdb_url"`
JavBusURL string `json:"javbus_url"`
Cookie string `json:"cookie"`
}
func testAdultScraperHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req testAdultScraperReq
_ = c.ShouldBindJSON(&req)
engine := strings.ToLower(strings.TrimSpace(req.Engine))
if engine == "" {
engine = "metatube"
}
if engine == "metatube" {
serverURL := strings.TrimSpace(req.ServerURL)
if serverURL == "" {
serverURL, _ = svc.Repo.Setting.Get(c.Request.Context(), "adult.scraper.metatube_server")
}
if serverURL == "" {
serverURL = "http://127.0.0.1:7700"
}
token := strings.TrimSpace(req.Token)
if token == "" {
token, _ = svc.Repo.Setting.Get(c.Request.Context(), "adult.scraper.metatube_token")
}
client := service.NewMetaTubeProvider(svc.Log)
res, err := client.TestConnection(c.Request.Context(), serverURL, token)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"latency_ms": res.LatencyMs,
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": res.Success,
"latency_ms": res.LatencyMs,
"providers": res.Providers,
"error": res.Error,
})
return
}
// Builtin scraper test
bases := []string{}
if req.JavDBURL != "" {
bases = append(bases, strings.TrimSpace(req.JavDBURL))
}
if req.JavBusURL != "" {
bases = append(bases, strings.Split(req.JavBusURL, ",")...)
}
if len(bases) == 0 {
if s, _ := svc.Repo.Setting.Get(c.Request.Context(), "adult.scraper.builtin_javdb_url"); s != "" {
bases = append(bases, s)
}
if s, _ := svc.Repo.Setting.Get(c.Request.Context(), "adult.scraper.builtin_javbus_url"); s != "" {
bases = append(bases, strings.Split(s, ",")...)
}
}
if len(bases) == 0 {
bases = []string{"https://javdb.com", "https://javbus.sbs", "https://www.javbus.com"}
}
cookie := strings.TrimSpace(req.Cookie)
if cookie == "" {
cookie, _ = svc.Repo.Setting.Get(c.Request.Context(), "adult.scraper.builtin_cookie")
}
if !strings.Contains(cookie, "age=") {
cookie = cookie + "; age=verified"
}
httpClient := service.NewExternalHTTPClient(8 * time.Second)
start := time.Now()
var lastErr error
success := false
for _, b := range bases {
b = strings.TrimRight(strings.TrimSpace(b), "/")
if b == "" {
continue
}
if !strings.HasPrefix(b, "http://") && !strings.HasPrefix(b, "https://") {
b = "https://" + b
}
httpReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, b, nil)
if err != nil {
lastErr = err
continue
}
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
httpReq.Header.Set("Cookie", cookie)
resp, err := httpClient.Do(httpReq)
if err != nil {
lastErr = err
continue
}
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 256<<10))
_ = resp.Body.Close()
if resp.StatusCode >= 400 {
lastErr = fmt.Errorf("%s returned HTTP %d", b, resp.StatusCode)
continue
}
text := string(bodyBytes)
if strings.Contains(text, "driver-verify") || strings.Contains(text, "Age Verification") {
lastErr = fmt.Errorf("%s intercepted by age verification", b)
continue
}
success = true
break
}
latency := time.Since(start).Milliseconds()
if success {
c.JSON(http.StatusOK, gin.H{
"success": true,
"latency_ms": latency,
"message": "内置刮削源连接正常",
})
return
}
errMsg := "内置源连接失败"
if lastErr != nil {
errMsg = lastErr.Error()
}
c.JSON(http.StatusOK, gin.H{
"success": false,
"latency_ms": latency,
"error": errMsg,
})
}
}
func recentLogsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rows, err := svc.Repo.Log.Recent(c.Request.Context(), 200)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rows)
}
}
+135
View File
@@ -0,0 +1,135 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
)
func TestDeleteUserRefusesRecentRealtimeSession(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.User{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
admin := model.User{Base: model.Base{ID: "admin"}, Username: "admin", PasswordHash: "x", Role: "admin", IsActive: true}
viewer := model.User{Base: model.Base{ID: "viewer"}, Username: "viewer", PasswordHash: "x", Role: "user", IsActive: true}
if err := repos.DB.Create(&[]model.User{admin, viewer}).Error; err != nil {
t.Fatal(err)
}
tracker := service.NewSessionTrackerService(zap.NewNop())
tracker.RecordLogin(t.Context(), viewer.ID, viewer.Username, "dev-1", "Apple TV", "Yamby", "10.0.0.8")
svc := &service.Container{Repo: repos, Sessions: tracker}
router := gin.New()
router.DELETE("/admin/users/:id", deleteUserHandler(svc))
req := httptest.NewRequest(http.MethodDelete, "/admin/users/viewer", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if found, _ := repos.User.FindByID(t.Context(), viewer.ID); found == nil {
t.Fatal("recent realtime user should not be deleted")
}
}
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")
}
}
+77
View File
@@ -0,0 +1,77 @@
// Package handler — third-party API config (TMDb / Bangumi / TheTVDB / …).
//
// All routes live under /api/admin/api-configs/* so only administrators
// can list / update / delete provider keys.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
)
func listAPIConfigsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
items, err := svc.APIConfig.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
}
func getAPIConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
view, err := svc.APIConfig.Get(c.Request.Context(), c.Param("provider"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if view == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, view)
}
}
func updateAPIConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var patch service.APIConfigPatch
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
view, err := svc.APIConfig.Update(c.Request.Context(), c.Param("provider"), patch)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, view)
}
}
func deleteAPIConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.APIConfig.Delete(c.Request.Context(), c.Param("provider")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func testAPIConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
provider := c.Param("provider")
result, err := svc.ApiConfig.TestConnection(c.Request.Context(), provider)
if err != nil {
c.JSON(http.StatusOK, gin.H{"result": result, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"result": result})
}
}
+186
View File
@@ -0,0 +1,186 @@
// Package handler — API 配置 HTTP Handler。
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/service"
)
// ApiConfigHandler API 配置 HTTP 处理。
type ApiConfigHandler struct {
svc *service.Container
log *zap.Logger
}
// NewApiConfigHandler 创建 API 配置处理器。
func NewApiConfigHandler(svc *service.Container, log *zap.Logger) *ApiConfigHandler {
return &ApiConfigHandler{svc: svc, log: log}
}
// ListApiConfigs 获取所有 API 配置。
// GET /api/api-config
func (h *ApiConfigHandler) ListApiConfigs(c *gin.Context) {
configs, err := h.svc.ApiConfig.List(c.Request.Context())
if err != nil {
h.log.Error("list api configs failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
return
}
// 遮蔽 API Key
for i := range configs {
if configs[i].APIKey != "" {
configs[i].APIKey = h.svc.ApiConfig.MaskAPIKey(configs[i].APIKey)
}
}
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": configs})
}
// ListProviders 获取预定义的提供者列表。
// GET /api/api-config/providers/list
func (h *ApiConfigHandler) ListProviders(c *gin.Context) {
providers := h.svc.ApiConfig.GetProviders()
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": providers})
}
// GetApiConfig 获取指定提供者的配置。
// GET /api/api-config/:provider
func (h *ApiConfigHandler) GetApiConfig(c *gin.Context) {
provider := c.Param("provider")
if provider == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "provider required", "data": nil})
return
}
cfg, err := h.svc.ApiConfig.GetByProvider(c.Request.Context(), provider)
if err != nil {
if err == service.ErrApiConfigNotFound {
c.JSON(http.StatusNotFound, gin.H{"code": 40401, "message": "api config not found", "data": nil})
return
}
h.log.Error("get api config failed", zap.Error(err), zap.String("provider", provider))
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
return
}
// 遮蔽 API Key
if cfg.APIKey != "" {
cfg.APIKey = h.svc.ApiConfig.MaskAPIKey(cfg.APIKey)
}
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": cfg})
}
// GetEffectiveConfig 获取生效的配置(数据库配置优先于配置文件)。
// GET /api/api-config/:provider/effective
func (h *ApiConfigHandler) GetEffectiveConfig(c *gin.Context) {
provider := c.Param("provider")
if provider == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "provider required", "data": nil})
return
}
cfg, err := h.svc.ApiConfig.GetEffectiveConfig(c.Request.Context(), provider)
if err != nil {
if err == service.ErrApiConfigNotFound {
c.JSON(http.StatusNotFound, gin.H{"code": 40401, "message": "api config not found", "data": nil})
return
}
h.log.Error("get effective config failed", zap.Error(err), zap.String("provider", provider))
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
return
}
// 遮蔽 API Key
if cfg.APIKey != "" {
cfg.APIKey = h.svc.ApiConfig.MaskAPIKey(cfg.APIKey)
}
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": cfg})
}
// UpsertApiConfig 创建或更新 API 配置。
// POST /api/api-config/:provider
func (h *ApiConfigHandler) UpsertApiConfig(c *gin.Context) {
provider := c.Param("provider")
if provider == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "provider required", "data": nil})
return
}
var req struct {
APIKey string `json:"api_key"`
BaseURL string `json:"base_url"`
Extra string `json:"extra"`
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "invalid request", "data": nil})
return
}
cfg, err := h.svc.ApiConfig.Upsert(c.Request.Context(), provider, req.APIKey, req.BaseURL, req.Extra, req.Enabled)
if err != nil {
if err == service.ErrInvalidProvider {
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "invalid provider", "data": nil})
return
}
h.log.Error("upsert api config failed", zap.Error(err), zap.String("provider", provider))
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
return
}
// 返回遮蔽后的配置
cfg.APIKey = h.svc.ApiConfig.MaskAPIKey(cfg.APIKey)
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": cfg})
}
// DeleteApiConfig 删除 API 配置。
// DELETE /api/api-config/:provider
func (h *ApiConfigHandler) DeleteApiConfig(c *gin.Context) {
provider := c.Param("provider")
if provider == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "provider required", "data": nil})
return
}
if err := h.svc.ApiConfig.Delete(c.Request.Context(), provider); err != nil {
h.log.Error("delete api config failed", zap.Error(err), zap.String("provider", provider))
c.JSON(http.StatusInternalServerError, gin.H{"code": 50001, "message": "internal error", "data": nil})
return
}
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": nil})
}
// TestApiConfig 测试 API 连接。
// POST /api/api-config/:provider/test
func (h *ApiConfigHandler) TestApiConfig(c *gin.Context) {
provider := c.Param("provider")
if provider == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": 40001, "message": "provider required", "data": nil})
return
}
result, err := h.svc.ApiConfig.TestConnection(c.Request.Context(), provider)
if err != nil {
h.log.Debug("test api config failed", zap.Error(err), zap.String("provider", provider))
// 不返回错误,只返回测试结果
}
// 更新测试结果
_ = h.svc.ApiConfig.UpdateTestResult(c.Request.Context(), provider, result)
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "ok",
"data": gin.H{
"result": result,
},
})
}
+116
View File
@@ -0,0 +1,116 @@
// Package handler — auth-related HTTP endpoints.
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/service"
)
type loginReq struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type registerReq struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required,min=6"`
}
func loginHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req loginReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
resp, err := svc.Auth.Login(c.Request.Context(), req.Username, req.Password)
if err != nil {
if errors.Is(err, service.ErrInvalidCredentials) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if errors.Is(err, service.ErrUserInactive) {
c.JSON(http.StatusForbidden, gin.H{"error": "user account is inactive"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if svc.Sessions != nil {
svc.Sessions.RecordLogin(c.Request.Context(), resp.User.ID, resp.User.Username, "", "Web", "Web", c.ClientIP())
}
if resp.Tokens != nil {
setAccessTokenCookie(c, resp.Tokens.AccessToken, int(resp.Tokens.ExpiresIn))
}
c.JSON(http.StatusOK, gin.H{
"user": resp.User,
"tokens": resp.Tokens,
})
svc.Audit.RecordBestEffort(resp.User.ID, "auth.login", resp.User.Username, c.ClientIP(), "")
}
}
func registerHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req registerReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
u, tokens, err := svc.Auth.Register(c.Request.Context(), req.Username, req.Password)
if err != nil {
if errors.Is(err, service.ErrUsernameTaken) {
c.JSON(http.StatusConflict, gin.H{"error": "username taken"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if tokens != nil {
setAccessTokenCookie(c, tokens.AccessToken, int(tokens.ExpiresIn))
}
c.JSON(http.StatusCreated, gin.H{
"user": u,
"tokens": tokens,
})
}
}
func meHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
u, err := svc.Repo.User.FindByID(c.Request.Context(), uid.(string))
if err != nil || u == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
u.PopulateComputedFields()
c.JSON(http.StatusOK, u)
}
}
type changePwdReq struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}
func changePasswordHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req changePwdReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get(middleware.CtxUserID)
if err := svc.Auth.ChangePassword(c.Request.Context(), uid.(string), req.OldPassword, req.NewPassword); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
+55
View File
@@ -0,0 +1,55 @@
package handler
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/service"
)
func setAccessTokenCookie(c *gin.Context, token string, maxAgeSeconds int) {
token = strings.TrimSpace(token)
if token == "" {
return
}
if maxAgeSeconds <= 0 {
maxAgeSeconds = int(service.AccessTokenDuration.Seconds())
}
writeAccessTokenCookie(c, token, maxAgeSeconds)
}
func clearAccessTokenCookie(c *gin.Context) {
writeAccessTokenCookie(c, "", -1)
}
func writeAccessTokenCookie(c *gin.Context, value string, maxAgeSeconds int) {
cookie := &http.Cookie{
Name: middleware.AccessTokenCookieName,
Value: value,
Path: middleware.AccessTokenCookiePath,
MaxAge: maxAgeSeconds,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: requestIsHTTPS(c),
}
if maxAgeSeconds > 0 {
cookie.Expires = time.Now().Add(time.Duration(maxAgeSeconds) * time.Second)
} else if maxAgeSeconds < 0 {
cookie.Expires = time.Unix(0, 0)
}
http.SetCookie(c.Writer, cookie)
}
func requestIsHTTPS(c *gin.Context) bool {
if c == nil || c.Request == nil {
return false
}
if c.Request.TLS != nil {
return true
}
return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
}
+71
View File
@@ -0,0 +1,71 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/middleware"
)
func TestSetAccessTokenCookie(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "https://media.local/api/auth/login", nil)
setAccessTokenCookie(c, "access-token", 3600)
cookie := findResponseCookie(t, w, middleware.AccessTokenCookieName)
if cookie.Value != "access-token" {
t.Fatalf("cookie value = %q", cookie.Value)
}
if cookie.Path != middleware.AccessTokenCookiePath {
t.Fatalf("cookie path = %q, want %q", cookie.Path, middleware.AccessTokenCookiePath)
}
if cookie.MaxAge != 3600 {
t.Fatalf("cookie max age = %d, want 3600", cookie.MaxAge)
}
if !cookie.HttpOnly {
t.Fatal("cookie should be HttpOnly")
}
if !cookie.Secure {
t.Fatal("https request should set Secure cookie")
}
if cookie.SameSite != http.SameSiteLaxMode {
t.Fatalf("cookie SameSite = %v, want Lax", cookie.SameSite)
}
}
func TestClearAccessTokenCookie(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "http://127.0.0.1:8080/api/me/logout", nil)
clearAccessTokenCookie(c)
cookie := findResponseCookie(t, w, middleware.AccessTokenCookieName)
if cookie.MaxAge >= 0 {
t.Fatalf("clear cookie max age = %d, want negative", cookie.MaxAge)
}
if cookie.Path != middleware.AccessTokenCookiePath {
t.Fatalf("cookie path = %q, want %q", cookie.Path, middleware.AccessTokenCookiePath)
}
if cookie.Secure {
t.Fatal("plain http request should not set Secure cookie")
}
}
func findResponseCookie(t *testing.T, w *httptest.ResponseRecorder, name string) *http.Cookie {
t.Helper()
for _, cookie := range w.Result().Cookies() {
if cookie.Name == name {
return cookie
}
}
t.Fatalf("missing response cookie %q", name)
return nil
}
+45
View File
@@ -0,0 +1,45 @@
// Package handler — auth surface beyond /login + /register:
//
// POST /auth/refresh — issue a fresh JWT for the current user
// POST /auth/logout — best-effort no-op (kept for parity)
// PATCH /auth/profile — alias for /me
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/service"
)
// refreshHandler returns a fresh token signed for the current user.
// Because we don't track refresh tokens server-side, the caller's
// existing access token is sufficient — it must already pass the
// AuthRequired middleware.
func refreshHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
u, err := svc.Repo.User.FindByID(c.Request.Context(), toString(uid))
if err != nil || u == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
return
}
token, err := svc.Auth.IssueToken(u)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"token": token, "user": u})
}
}
// logoutHandler is a deliberate no-op (we use stateless JWT). It exists
// so the Vue frontend's logout button gets a 200 instead of 404.
func logoutHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
clearAccessTokenCookie(c)
c.Status(http.StatusNoContent)
}
}
+52
View File
@@ -0,0 +1,52 @@
// Package handler — database backup/restore endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
)
func createBackupHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
info, err := svc.Backup.Create(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, info)
}
}
func listBackupsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
items, err := svc.Backup.List()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
}
func deleteBackupHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Backup.Delete(c.Query("filename")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func restoreBackupHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Backup.Restore(c.Request.Context(), c.Query("filename")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "restored — restart the server to apply"})
}
}
+33
View File
@@ -0,0 +1,33 @@
// Package handler — danmaku endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
)
// getDanmakuHandler returns danmaku for a media. ?kw= optionally overrides the
// search keyword (used by the player's manual search box); ?episodeId= forces a
// specific danmaku library chosen by the user after a disambiguation.
func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
res, err := svc.Danmaku.Fetch(c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
// getDanmakuConfigHandler exposes the danmaku renderer knobs (opacity, font
// size, area, enabled) so the player can initialize its control panel without
// admin privileges.
func getDanmakuConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, svc.Danmaku.Config(c.Request.Context()))
}
}
+42
View File
@@ -0,0 +1,42 @@
// Package handler — DLNA / UPnP discovery + cast endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
)
func dlnaListHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
force := c.Query("force") == "true"
devices, err := svc.DLNA.Discover(c.Request.Context(), force)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"devices": devices})
}
}
type dlnaCastReq struct {
ControlURL string `json:"control_url" binding:"required"`
MediaURL string `json:"media_url" binding:"required"`
}
func dlnaCastHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req dlnaCastReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.DLNA.Cast(c.Request.Context(), req.ControlURL, req.MediaURL); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
+129
View File
@@ -0,0 +1,129 @@
// Package handler — per-renderer DLNA control endpoints used by the
// Vue UI. These are best-effort SOAP calls; failures surface as 4xx.
package handler
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
)
// dlnaControlPath maps the action name to the AVTransport SOAP body.
// (kept for parity with the upstream Vue admin UI)
type dlnaAction string
const (
_dlnaPlay dlnaAction = "Play"
_dlnaPause dlnaAction = "Pause"
_dlnaStop dlnaAction = "Stop"
)
var _ = []dlnaAction{_dlnaPlay, _dlnaPause, _dlnaStop}
// findRendererControlURL returns the cached control URL for the given
// uuid (matched against the device UDN). We rely on DLNAService's
// existing Discover() cache.
func findRendererControlURL(ctx context.Context, svc *service.Container, uuid string) (string, error) {
devs, err := svc.DLNA.Discover(ctx, false)
if err != nil {
return "", err
}
for _, d := range devs {
if d.UDN == uuid || strings.HasSuffix(d.UDN, uuid) {
return d.ControlURL, nil
}
}
return "", errors.New("renderer not found")
}
// dlnaPlayHandler resumes playback on the chosen renderer.
func dlnaPlayHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
controlURL, err := findRendererControlURL(c.Request.Context(), svc, c.Param("uuid"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
envelope := buildSimpleAVTransport("Play", `<Speed>1</Speed>`)
if err := svc.DLNA.SOAP(c.Request.Context(), controlURL, "Play", envelope); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// dlnaPauseHandler pauses playback on the chosen renderer.
func dlnaPauseHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
controlURL, err := findRendererControlURL(c.Request.Context(), svc, c.Param("uuid"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
envelope := buildSimpleAVTransport("Pause", "")
if err := svc.DLNA.SOAP(c.Request.Context(), controlURL, "Pause", envelope); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// dlnaStopHandler stops playback.
func dlnaStopHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
controlURL, err := findRendererControlURL(c.Request.Context(), svc, c.Param("uuid"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
envelope := buildSimpleAVTransport("Stop", "")
if err := svc.DLNA.SOAP(c.Request.Context(), controlURL, "Stop", envelope); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// dlnaStatusHandler returns "playing" / "paused" / "stopped" via
// GetTransportInfo. We don't parse the response — the UI can read the
// raw body via the upstream proxy if it needs more detail.
func dlnaStatusHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
controlURL, err := findRendererControlURL(c.Request.Context(), svc, c.Param("uuid"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
envelope := buildSimpleAVTransport("GetTransportInfo", "")
if err := svc.DLNA.SOAP(c.Request.Context(), controlURL, "GetTransportInfo", envelope); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// buildSimpleAVTransport assembles a SOAP body for the given action +
// extra body fragment. InstanceID is hard-coded to 0 (single zone).
func buildSimpleAVTransport(action string, extra string) string {
return fmt.Sprintf(
`<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:%s xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>%s
</u:%s>
</s:Body>
</s:Envelope>`, action, extra, action,
)
}
+3
View File
@@ -0,0 +1,3 @@
// Package handler provides the HTTP API, including Emby/Jellyfin compatibility
// routes mounted both at /emby/* and at the root path for client discovery.
package handler
+351
View File
@@ -0,0 +1,351 @@
package handler
import (
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/service"
)
// embyError 返回 Emby 风格的错误(顶层 Code/Message)。
func embyError(c *gin.Context, status int, msg string) {
c.JSON(status, gin.H{"Code": status, "Message": msg})
}
// embyUserID 从中间件中获取 user id。Emby auth middleware 写入 CtxUserID。
func embyUserID(c *gin.Context) string {
if uid, ok := c.Get(middleware.CtxUserID); ok {
if s, ok := uid.(string); ok {
return s
}
}
return ""
}
const embyCompatSessionTTL = 30 * time.Minute
type embyCompatSession struct {
token string
expiresAt time.Time
}
var embyCompatSessions = struct {
sync.RWMutex
items map[string]embyCompatSession
}{items: map[string]embyCompatSession{}}
func embyAuthRequiredWithSessionFallback(secret string) gin.HandlerFunc {
required := middleware.EmbyAuthRequired(secret)
return func(c *gin.Context) {
// 兼容小幻影视等客户端的「UserId 直连」凭据格式:
// token 形如 X-Emby-Token=UserId="<uuid>",不是 JWT。解析出 uuid
// 注入 CtxUserID,交由后续 activeEmbyUserRequired 查库验证用户
// 存在且有效(未禁用/未过期),避免这类客户端每次 401 Invalid token。
if uid := userIdDirectToken(c); uid != "" {
c.Set(middleware.CtxUserID, uid)
c.Set(middleware.EmbyCtxUserID, uid)
c.Next()
return
}
if embyRequestToken(c) == "" {
if token := embyCompatSessionToken(c); token != "" {
c.Request.Header.Set("X-Emby-Token", token)
}
}
required(c)
}
}
// userIdDirectToken 从 Emby token 来源中识别形如 UserId="<uuid>" 的直连凭据,
// 返回其中的 uuid;不是该格式则返回空串。用于兼容小幻影视(RodelPlayer)等
// 客户端把用户 ID 当作 token 提交的行为。
// 识别三种来源:
// 1. URL query:X-Emby-Token=UserId="<uuid>"
// 2. Authorization / X-Emby-Authorization / X-MediaBrowser-Authorization 头:
// Emby UserId="<uuid>", Client="...", ...(无 Token=)
// 3. X-Emby-Token / X-MediaBrowser-Token 头直传 UserId="<uuid>"
func userIdDirectToken(c *gin.Context) string {
if c == nil || c.Request == nil {
return ""
}
const prefix = `UserId="`
// 从一段文本中提取 UserId="<uuid>" 中的 uuid;不存在则返回空。
extract := func(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
idx := strings.Index(raw, prefix)
if idx < 0 {
return ""
}
rest := raw[idx+len(prefix):]
end := strings.Index(rest, `"`)
if end <= 0 {
return ""
}
uid := strings.TrimSpace(rest[:end])
if len(uid) > 0 && len(uid) <= 64 {
return uid
}
return ""
}
// 1) URL query 参数直传 UserId="..."。
for _, key := range []string{"X-Emby-Token", "X-MediaBrowser-Token", "token", "api_key", "apiKey", "ApiKey"} {
if uid := extract(c.Query(key)); uid != "" {
return uid
}
}
// 2) 认证头中的 Emby/MediaBrowser UserId="..."(无 Token= 的直连凭据)。
for _, header := range []string{"Authorization", "X-Emby-Authorization", "X-MediaBrowser-Authorization"} {
if uid := extract(c.GetHeader(header)); uid != "" {
// 仅当该头不是标准 Token= 形式时才当作直连凭据,避免误拦截。
if !strings.Contains(c.GetHeader(header), "Token=") {
return uid
}
}
}
// 3) X-Emby-Token / X-MediaBrowser-Token 头直传 UserId="..."。
for _, header := range []string{"X-Emby-Token", "X-MediaBrowser-Token"} {
if uid := extract(c.GetHeader(header)); uid != "" {
return uid
}
}
return ""
}
func embyRealtimeSessionActivity(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
recordEmbySessionActivity(c, svc, embyUserID(c), embyContextUserName(c))
c.Next()
}
}
func recordEmbySessionActivity(c *gin.Context, svc *service.Container, userID, userName string) {
if c == nil || svc == nil || svc.Sessions == nil || strings.TrimSpace(userID) == "" {
return
}
clientInfo := embyClientInfoFromRequest(c)
svc.Sessions.RecordActivity(c.Request.Context(), userID, userName,
clientInfo.DeviceID,
clientInfo.DeviceName,
clientInfo.Client,
c.ClientIP())
}
func embyContextUserName(c *gin.Context) string {
if c == nil {
return ""
}
if value, ok := c.Get(embyCtxUserName); ok {
if username, ok := value.(string); ok {
return strings.TrimSpace(username)
}
}
return ""
}
func embyRememberCompatSession(c *gin.Context, token string) {
token = strings.TrimSpace(token)
if token == "" {
return
}
keys := embyCompatSessionKeys(c)
if len(keys) == 0 {
return
}
expiresAt := time.Now().Add(embyCompatSessionTTL)
embyCompatSessions.Lock()
defer embyCompatSessions.Unlock()
if len(embyCompatSessions.items) > 1000 {
now := time.Now()
for key, session := range embyCompatSessions.items {
if now.After(session.expiresAt) {
delete(embyCompatSessions.items, key)
}
}
if len(embyCompatSessions.items) > 1000 {
embyCompatSessions.items = map[string]embyCompatSession{}
}
}
for _, key := range keys {
embyCompatSessions.items[key] = embyCompatSession{token: token, expiresAt: expiresAt}
}
}
func embyCompatSessionToken(c *gin.Context) string {
keys := embyCompatSessionKeys(c)
if len(keys) == 0 {
return ""
}
now := time.Now()
embyCompatSessions.RLock()
defer embyCompatSessions.RUnlock()
for _, key := range keys {
session, ok := embyCompatSessions.items[key]
if ok && now.Before(session.expiresAt) {
return session.token
}
}
return ""
}
func embyCompatSessionKeys(c *gin.Context) []string {
if c == nil {
return nil
}
ip := strings.TrimSpace(c.ClientIP())
if ip == "" {
return nil
}
keys := []string{}
add := func(kind, value string) {
value = strings.TrimSpace(value)
if value != "" {
keys = append(keys, ip+"\x00"+kind+"\x00"+value)
}
}
add("device", firstHeaderValue(c, "X-Emby-Device-Id", "X-Emby-DeviceId", "X-MediaBrowser-Device-Id", "X-MediaBrowser-DeviceId"))
add("ua", c.GetHeader("User-Agent"))
return keys
}
func firstHeaderValue(c *gin.Context, names ...string) string {
for _, name := range names {
if value := strings.TrimSpace(c.GetHeader(name)); value != "" {
return value
}
}
return ""
}
type embyClientInfo struct {
DeviceID string
DeviceName string
Client string
}
func embyClientInfoFromRequest(c *gin.Context) embyClientInfo {
auth := parseMediaBrowserAuthorization(firstHeaderValue(c,
"X-Emby-Authorization",
"X-MediaBrowser-Authorization",
"Authorization",
))
info := embyClientInfo{
DeviceID: firstNonEmptyHeaderString(
firstHeaderValue(c, "X-Emby-Device-Id", "X-Emby-DeviceId", "X-MediaBrowser-Device-Id", "X-MediaBrowser-DeviceId"),
c.Query("DeviceId"),
c.Query("DeviceID"),
c.Query("deviceId"),
c.Query("deviceID"),
auth["DeviceId"],
auth["DeviceID"],
),
DeviceName: firstNonEmptyHeaderString(
firstHeaderValue(c, "X-Emby-Device-Name", "X-Emby-DeviceName", "X-MediaBrowser-Device-Name", "X-MediaBrowser-DeviceName"),
c.Query("Device"),
c.Query("DeviceName"),
c.Query("device"),
c.Query("deviceName"),
auth["Device"],
),
Client: firstNonEmptyHeaderString(
firstHeaderValue(c, "X-Emby-Client", "X-MediaBrowser-Client"),
c.Query("Client"),
c.Query("client"),
auth["Client"],
),
}
ua := strings.TrimSpace(c.GetHeader("User-Agent"))
if info.Client == "" {
info.Client = embyClientFromUserAgent(ua)
}
if info.DeviceName == "" {
info.DeviceName = embyDeviceFromUserAgent(ua)
}
return info
}
func parseMediaBrowserAuthorization(raw string) map[string]string {
out := map[string]string{}
raw = strings.TrimSpace(raw)
if raw == "" {
return out
}
for _, prefix := range []string{"MediaBrowser ", "Emby "} {
if strings.HasPrefix(raw, prefix) {
raw = strings.TrimSpace(strings.TrimPrefix(raw, prefix))
break
}
}
for _, part := range strings.Split(raw, ",") {
key, value, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.Trim(strings.TrimSpace(value), `"`)
if key != "" && value != "" {
out[key] = value
}
}
return out
}
func firstNonEmptyHeaderString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func embyClientFromUserAgent(ua string) string {
ua = strings.TrimSpace(ua)
lower := strings.ToLower(ua)
switch {
case strings.Contains(lower, "infuse"):
return "Infuse"
case strings.Contains(lower, "emby"):
return "Emby"
case strings.Contains(lower, "jellyfin"):
return "Jellyfin"
case strings.Contains(lower, "yamby"):
return "Yamby"
case strings.Contains(lower, "vidhub"):
return "VidHub"
case strings.Contains(lower, "hills"):
return "Hills"
default:
return ua
}
}
func embyDeviceFromUserAgent(ua string) string {
lower := strings.ToLower(strings.TrimSpace(ua))
switch {
case strings.Contains(lower, "android"):
return "Android"
case strings.Contains(lower, "iphone"):
return "iPhone"
case strings.Contains(lower, "ipad"):
return "iPad"
case strings.Contains(lower, "ios"):
return "iOS"
case strings.Contains(lower, "windows"):
return "Windows PC"
case strings.Contains(lower, "macintosh") || strings.Contains(lower, "mac os"):
return "Mac"
case strings.Contains(lower, "linux"):
return "Linux PC"
case strings.Contains(lower, "appletv") || strings.Contains(lower, "apple tv"):
return "Apple TV"
default:
return ""
}
}
+174
View File
@@ -0,0 +1,174 @@
package handler
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/url"
"strings"
"github.com/gin-gonic/gin"
)
type embyAuthByNameReq struct {
Username string `json:"Username"`
Pw string `json:"Pw"`
Password string `json:"Password"`
PasswordMd5 string `json:"PasswordMd5"`
PasswordSha1 string `json:"PasswordSha1"`
}
func parseEmbyAuthByNameReq(c *gin.Context) (embyAuthByNameReq, error) {
req := embyAuthByNameReq{}
if strings.Contains(strings.ToLower(c.GetHeader("Content-Type")), "json") {
var body map[string]any
if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) {
return req, err
}
fillEmbyAuthFromMap(&req, body)
}
if req.Username == "" || (req.Pw == "" && req.Password == "" && req.PasswordMd5 == "" && req.PasswordSha1 == "") {
_ = c.Request.ParseForm()
if req.Username == "" {
req.Username = firstFormValue(c, "Username", "username", "Name", "name")
}
if req.Pw == "" {
req.Pw = firstFormValue(c, "Pw", "pw")
}
if req.Password == "" {
req.Password = firstFormValue(c, "Password", "password")
}
if req.PasswordMd5 == "" {
req.PasswordMd5 = firstFormValue(c, "PasswordMd5", "passwordMd5", "password_md5")
}
if req.PasswordSha1 == "" {
req.PasswordSha1 = firstFormValue(c, "PasswordSha1", "passwordSha1", "password_sha1")
}
}
if req.Username == "" {
req.Username = firstQueryValue(c, "Username", "username", "Name", "name")
}
if req.Pw == "" {
req.Pw = firstQueryValue(c, "Pw", "pw")
}
if req.Password == "" {
req.Password = firstQueryValue(c, "Password", "password")
}
if req.PasswordMd5 == "" {
req.PasswordMd5 = firstQueryValue(c, "PasswordMd5", "passwordMd5", "password_md5")
}
if req.PasswordSha1 == "" {
req.PasswordSha1 = firstQueryValue(c, "PasswordSha1", "passwordSha1", "password_sha1")
}
if req.Username == "" || (req.Pw == "" && req.Password == "" && req.PasswordMd5 == "" && req.PasswordSha1 == "") {
fillEmbyAuthFromRawBody(c, &req)
}
return req, nil
}
func fillEmbyAuthFromMap(req *embyAuthByNameReq, body map[string]any) {
if req.Username == "" {
req.Username = firstStringFromMap(body, "Username", "username", "UserName", "userName", "Name", "name", "LoginName", "loginName")
}
if req.Pw == "" {
req.Pw = firstStringFromMap(body, "Pw", "pw", "PW")
}
if req.Password == "" {
req.Password = firstStringFromMap(body, "Password", "password", "Pass", "pass", "Pwd", "pwd")
}
if req.PasswordMd5 == "" {
req.PasswordMd5 = firstStringFromMap(body, "PasswordMd5", "passwordMd5", "password_md5")
}
if req.PasswordSha1 == "" {
req.PasswordSha1 = firstStringFromMap(body, "PasswordSha1", "passwordSha1", "password_sha1")
}
}
func fillEmbyAuthFromRawBody(c *gin.Context, req *embyAuthByNameReq) {
if c.Request == nil || c.Request.Body == nil {
return
}
raw, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
if err != nil {
return
}
c.Request.Body = io.NopCloser(bytes.NewReader(raw))
raw = bytes.TrimSpace(raw)
if len(raw) == 0 {
return
}
if bytes.HasPrefix(raw, []byte("{")) {
var body map[string]any
if err := json.Unmarshal(raw, &body); err == nil {
fillEmbyAuthFromMap(req, body)
}
return
}
if values, err := url.ParseQuery(string(raw)); err == nil {
fillEmbyAuthFromValues(req, values)
}
}
func fillEmbyAuthFromValues(req *embyAuthByNameReq, values url.Values) {
if req.Username == "" {
req.Username = firstValue(values, "Username", "username", "UserName", "userName", "Name", "name", "LoginName", "loginName")
}
if req.Pw == "" {
req.Pw = firstValue(values, "Pw", "pw", "PW")
}
if req.Password == "" {
req.Password = firstValue(values, "Password", "password", "Pass", "pass", "Pwd", "pwd")
}
if req.PasswordMd5 == "" {
req.PasswordMd5 = firstValue(values, "PasswordMd5", "passwordMd5", "password_md5")
}
if req.PasswordSha1 == "" {
req.PasswordSha1 = firstValue(values, "PasswordSha1", "passwordSha1", "password_sha1")
}
}
func firstValue(values url.Values, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(values.Get(key)); value != "" {
return value
}
}
return ""
}
func firstStringFromMap(body map[string]any, keys ...string) string {
if len(body) == 0 {
return ""
}
for _, key := range keys {
if value, ok := body[key]; ok {
if s, ok := value.(string); ok {
return strings.TrimSpace(s)
}
}
}
return ""
}
func firstFormValue(c *gin.Context, keys ...string) string {
for _, key := range keys {
if values, ok := c.Request.PostForm[key]; ok && len(values) > 0 {
if value := strings.TrimSpace(values[0]); value != "" {
return value
}
}
}
return ""
}
func firstQueryValue(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(c.Query(key)); value != "" {
return value
}
}
return ""
}
+242
View File
@@ -0,0 +1,242 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
)
func TestParseEmbyAuthByNameReqAcceptsLowercaseJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/Users/AuthenticateByName", strings.NewReader(`{"username":"alice","password":"secret"}`))
c.Request.Header.Set("Content-Type", "application/json")
req, err := parseEmbyAuthByNameReq(c)
if err != nil {
t.Fatalf("parseEmbyAuthByNameReq returned error: %v", err)
}
if req.Username != "alice" || req.Password != "secret" {
t.Fatalf("unexpected request: %#v", req)
}
}
func TestParseEmbyAuthByNameReqAcceptsFormBody(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/Users/AuthenticateByName", strings.NewReader("Username=bob&Pw=secret"))
c.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req, err := parseEmbyAuthByNameReq(c)
if err != nil {
t.Fatalf("parseEmbyAuthByNameReq returned error: %v", err)
}
if req.Username != "bob" || req.Pw != "secret" {
t.Fatalf("unexpected request: %#v", req)
}
}
func TestParseEmbyAuthByNameReqAcceptsJSONWithoutContentType(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/emby/users/authenticatebyname", strings.NewReader(`{"UserName":"carol","PW":"secret"}`))
req, err := parseEmbyAuthByNameReq(c)
if err != nil {
t.Fatalf("parseEmbyAuthByNameReq returned error: %v", err)
}
if req.Username != "carol" || req.Pw != "secret" {
t.Fatalf("unexpected request: %#v", req)
}
}
func TestEmbyAuthenticateByNameAcceptsCaseVariantUsernameAndPath(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.UserPermission{}, &model.RefreshToken{}, &model.Setting{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
log := zap.NewNop()
permissions := service.NewPermissionService(log, repos)
auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions)
if _, _, err := auth.Register(context.Background(), "viewer", "secret-pass"); err != nil {
t.Fatalf("register: %v", err)
}
router := gin.New()
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
Repo: repos,
Auth: auth,
Emby: service.NewEmbyService(cfg, log, repos),
Audit: service.NewAuditService(log, repos),
})
req := httptest.NewRequest(http.MethodPost, "/emby/users/authenticatebyname", strings.NewReader(`{"Username":"Viewer","Pw":"secret-pass"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["AccessToken"] == "" {
t.Fatalf("missing AccessToken: %#v", payload)
}
}
func TestEmbyAuthenticateRecordsMediaBrowserClientInfo(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if sqlDB, err := db.DB(); err == nil {
sqlDB.SetMaxOpenConns(1)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
log := zap.NewNop()
permissions := service.NewPermissionService(log, repos)
auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions)
if _, _, err := auth.Register(context.Background(), "viewer", "secret-pass"); err != nil {
t.Fatalf("register: %v", err)
}
router := gin.New()
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
Repo: repos,
Auth: auth,
Emby: service.NewEmbyService(cfg, log, repos),
Device: service.NewDeviceService(log, repos),
Audit: service.NewAuditService(log, repos),
Permissions: permissions,
})
req := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(`{"Username":"viewer","Pw":"secret-pass"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="PC", DeviceId="device-42"`)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
user, err := repos.User.FindByUsername(context.Background(), "viewer")
if err != nil {
t.Fatalf("find user: %v", err)
}
devices, err := repos.UserDevice.ListByUser(context.Background(), user.ID)
if err != nil {
t.Fatalf("list devices: %v", err)
}
if len(devices) != 1 {
t.Fatalf("devices = %#v, want one recorded device", devices)
}
if devices[0].DeviceID != "device-42" || devices[0].DeviceName != "PC" || devices[0].Client != "Infuse" {
t.Fatalf("device info not parsed from MediaBrowser header: %#v", devices[0])
}
}
// TestEmbyUserIdDirectTokenCompatibility covers clients (e.g. 小幻影视 / Hills)
// that send `X-Emby-Token=UserId="<uuid>"` as the auth credential instead of a
// JWT. The server must accept a valid, non-disabled user id in that format and
// let the request through (user validity is enforced by activeEmbyUserRequired).
func TestEmbyUserIdDirectTokenCompatibility(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if sqlDB, err := db.DB(); err == nil {
// :memory: SQLite 每个连接是独立库,必须锁单连接。
sqlDB.SetMaxOpenConns(1)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
log := zap.NewNop()
permissions := service.NewPermissionService(log, repos)
auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions)
user, _, err := auth.Register(context.Background(), "viewer", "secret-pass")
if err != nil {
t.Fatalf("register: %v", err)
}
router := gin.New()
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
Repo: repos,
Auth: auth,
Emby: service.NewEmbyService(cfg, log, repos),
Device: service.NewDeviceService(log, repos),
Audit: service.NewAuditService(log, repos),
Permissions: permissions,
})
// 1) UserId="<uuid>" direct credential must pass for a valid user.
req := httptest.NewRequest(http.MethodGet, "/emby/Users/Me?X-Emby-Token="+url.QueryEscape(`UserId="`+user.ID+`"`), nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("userId direct token (query) = %d body=%s", w.Code, w.Body.String())
}
// 1b) Emby UserId="<uuid>" in X-Emby-Authorization header (RodelPlayer / 小幻影视 style).
req = httptest.NewRequest(http.MethodGet, "/emby/Users/Me", nil)
req.Header.Set("X-Emby-Authorization", `Emby UserId="`+user.ID+`", Client="RodelPlayer", Device="WHILETRUE", DeviceId="dev-1", Version="2.2607.7.0"`)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("userId direct token (X-Emby-Authorization header) = %d body=%s", w.Code, w.Body.String())
}
// 2) A random / non-existent user id in the same format must be rejected.
req = httptest.NewRequest(http.MethodGet, "/emby/Users/Me?X-Emby-Token="+url.QueryEscape(`UserId="does-not-exist"`), nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("bogus userId direct token = %d, want 401", w.Code)
}
// 2b) Non-existent user in X-Emby-Authorization header must be rejected too.
req = httptest.NewRequest(http.MethodGet, "/emby/Users/Me", nil)
req.Header.Set("X-Emby-Authorization", `Emby UserId="does-not-exist", Client="RodelPlayer"`)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("bogus userId direct token (header) = %d, want 401", w.Code)
}
}
+147
View File
@@ -0,0 +1,147 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
)
// TestEmbyLoginThenAuthedRequest simulates the exact flow an Emby-compatible
// client performs: POST /Users/AuthenticateByName to obtain an AccessToken,
// then immediately reuses that token to fetch /Users/Me and /Items.
//
// This guards against regressions where login succeeds but the returned token
// fails downstream auth (the "每次登录后立刻 401 Invalid token" report).
func TestEmbyLoginThenAuthedRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if sqlDB, err := db.DB(); err == nil {
sqlDB.SetMaxOpenConns(1)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
log := zap.NewNop()
permissions := service.NewPermissionService(log, repos)
tokenSvc := service.NewTokenService(cfg, log, repos)
auth := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
if _, _, err := auth.Register(context.Background(), "viewer", "secret-pass"); err != nil {
t.Fatalf("register: %v", err)
}
router := gin.New()
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
Repo: repos,
Auth: auth,
Emby: service.NewEmbyService(cfg, log, repos),
Device: service.NewDeviceService(log, repos),
Audit: service.NewAuditService(log, repos),
Permissions: permissions,
})
loginBody := `{"Username":"viewer","Pw":"secret-pass"}`
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
}
var login struct {
AccessToken string `json:"AccessToken"`
}
if err := json.Unmarshal(w.Body.Bytes(), &login); err != nil {
t.Fatalf("decode login: %v", err)
}
if login.AccessToken == "" {
t.Fatalf("empty AccessToken from login")
}
// Reuse the returned token against authenticated endpoints.
for _, path := range []string{"/emby/Users/Me", "/emby/Items", "/emby/Library/VirtualFolders"} {
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-Emby-Token", login.AccessToken)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET %s with returned token = %d body=%s", path, w.Code, w.Body.String())
}
}
}
// TestEmbyLoginWithAuthorizationHeaderToken covers clients that place the
// bearer token in Authorization instead of X-Emby-Token.
func TestEmbyLoginWithAuthorizationHeaderToken(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if sqlDB, err := db.DB(); err == nil {
// :memory: SQLite 每个连接是独立库,必须锁单连接否则表在不同连接上互相不可见。
sqlDB.SetMaxOpenConns(1)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
log := zap.NewNop()
permissions := service.NewPermissionService(log, repos)
auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions)
if _, _, err := auth.Register(context.Background(), "viewer", "secret-pass"); err != nil {
t.Fatalf("register: %v", err)
}
router := gin.New()
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
Repo: repos,
Auth: auth,
Emby: service.NewEmbyService(cfg, log, repos),
Device: service.NewDeviceService(log, repos),
Audit: service.NewAuditService(log, repos),
Permissions: permissions,
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(`{"Username":"viewer","Pw":"secret-pass"}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
}
var login struct {
AccessToken string `json:"AccessToken"`
}
if err := json.Unmarshal(w.Body.Bytes(), &login); err != nil {
t.Fatalf("decode login: %v", err)
}
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/emby/Users/Me", nil)
req.Header.Set("Authorization", "MediaBrowser Token=\""+login.AccessToken+"\"")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Authorization-token request = %d body=%s", w.Code, w.Body.String())
}
}
@@ -0,0 +1,256 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
)
func TestEmbyWithRequestAddressUsesHost(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "http://192.168.1.4:18080/System/Info/Public", nil)
payload := embyWithRequestAddress(c, map[string]any{"Id": "mmtl-001"})
if payload["LocalAddress"] != "http://192.168.1.4:18080" {
t.Fatalf("unexpected LocalAddress: %#v", payload["LocalAddress"])
}
if payload["WanAddress"] != "http://192.168.1.4:18080" {
t.Fatalf("unexpected WanAddress: %#v", payload["WanAddress"])
}
}
func TestEmbyWithRequestAddressHonorsForwardedHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "http://127.0.0.1/System/Info/Public", nil)
c.Request.Header.Set("X-Forwarded-Proto", "https")
c.Request.Header.Set("X-Forwarded-Host", "media.example.test")
payload := embyWithRequestAddress(c, map[string]any{"Id": "mmtl-001"})
if payload["LocalAddress"] != "https://media.example.test" {
t.Fatalf("unexpected LocalAddress: %#v", payload["LocalAddress"])
}
}
func TestEmbyPublicSystemInfoLooksLikeModernEmbyServer(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
repos := repository.New(db)
router := gin.New()
registerEmbyRoutes(router, "test-secret", &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
})
req := httptest.NewRequest(http.MethodGet, "/System/Info/Public", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode system info: %v", err)
}
if payload["ProductName"] != "Emby Server" {
t.Fatalf("ProductName = %#v, want Emby Server", payload["ProductName"])
}
version, _ := payload["Version"].(string)
if !strings.HasPrefix(version, "4.") {
t.Fatalf("Version = %q, want Emby-compatible 4.x", version)
}
}
func TestEmbyMobileCompatibilityRoutesAvoidPlaybackBlocking404s(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := db.Create(&model.User{
Base: model.Base{ID: "user-1"},
Username: "viewer",
PasswordHash: "hash",
Role: "admin",
IsActive: true,
}).Error; err != nil {
t.Fatalf("create user: %v", err)
}
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
repos := repository.New(db)
router := gin.New()
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(cfg, zap.NewNop(), repos),
})
token := signedTestToken(t, cfg.Secrets.JWTSecret)
tests := []struct {
path string
auth bool
wantCode int
}{
{path: "/emby/System/Ext/ServerDomains", wantCode: http.StatusOK},
{path: "/emby/Items/msgo-series-demo/Similar", auth: true, wantCode: http.StatusOK},
{path: "/emby/api/danmu/media-demo/raw", auth: true, wantCode: http.StatusOK},
}
for _, tc := range tests {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
if tc.auth {
req.Header.Set("X-Emby-Token", token)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != tc.wantCode {
t.Fatalf("%s status = %d body=%s", tc.path, w.Code, w.Body.String())
}
}
}
func TestEmbyOfficialClientProbeRoutesAvoidHomepageBlocking404s(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
})
token := signedTestToken(t, secret)
tests := []struct {
method string
path string
auth bool
}{
{method: http.MethodGet, path: "/emby/CustomCssJS/Scripts"},
{method: http.MethodGet, path: "/emby/Localization/cultures"},
{method: http.MethodPost, path: "/emby/Sessions/Logout"},
{method: http.MethodGet, path: "/emby/System/WakeOnLanInfo", auth: true},
{method: http.MethodGet, path: "/emby/ScheduledTasks", auth: true},
{method: http.MethodGet, path: "/emby/LiveTv/Recordings", auth: true},
{method: http.MethodGet, path: "/emby/System/ActivityLog/Entries", auth: true},
{method: http.MethodGet, path: "/emby/web/configurationpages", auth: true},
{method: http.MethodPost, path: "/emby/Users/user-1/Configuration", auth: true},
{method: http.MethodGet, path: "/emby/Items/Latest?UserId=user-1", auth: true},
{method: http.MethodGet, path: "/emby/Items/Resume?UserId=user-1", auth: true},
{method: http.MethodGet, path: "/emby/Genres", auth: true},
{method: http.MethodGet, path: "/emby/Shows/Upcoming", auth: true},
{method: http.MethodGet, path: "/emby/Items/item-1/ThumbnailSet", auth: true},
{method: http.MethodGet, path: "/emby/Items/item-1/ThemeMedia", auth: true},
{method: http.MethodGet, path: "/emby/Users/user-1/Items/item-1/SpecialFeatures", auth: true},
{method: http.MethodGet, path: "/emby/Users/user-1/Items/item-1/Intros", auth: true},
}
for _, tt := range tests {
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, nil)
if tt.auth {
req.Header.Set("X-Emby-Token", token)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code == http.StatusNotFound {
t.Fatalf("route returned 404 body=%s", w.Body.String())
}
if w.Code >= 500 {
t.Fatalf("route returned %d body=%s", w.Code, w.Body.String())
}
})
}
}
func TestEmbySenPlayerDiscoveryRoutesReturnProtocolResponses(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
cfg := &config.Config{}
cfg.App.Port = 9011
repos := repository.New(db)
router := gin.New()
registerEmbyRoutes(router, "test-secret", &service.Container{
Repo: repos,
Emby: service.NewEmbyService(cfg, zap.NewNop(), repos),
})
tests := []struct {
path string
contentType string
contains string
}{
{path: "/emby", contentType: "application/json", contains: "Emby Server"},
{path: "/emby/", contentType: "application/json", contains: "Emby Server"},
{path: "/Startup/Configuration", contentType: "application/json", contains: "StartupWizardCompleted"},
{path: "/emby/Startup/Configuration", contentType: "application/json", contains: "StartupWizardCompleted"},
{path: "/System/Configuration/Public", contentType: "application/json", contains: "IsStartupWizardCompleted"},
{path: "/emby/System/Configuration/Public", contentType: "application/json", contains: "IsStartupWizardCompleted"},
{path: "/QuickConnect/Enabled", contentType: "application/json", contains: "false"},
{path: "/emby/QuickConnect/Enabled", contentType: "application/json", contains: "false"},
{path: "/Branding/Css", contentType: "text/css", contains: ""},
{path: "/emby/Branding/Css", contentType: "text/css", contains: ""},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if contentType := w.Header().Get("Content-Type"); !strings.Contains(contentType, tt.contentType) {
t.Fatalf("Content-Type = %q, want %q", contentType, tt.contentType)
}
if tt.contains != "" && !strings.Contains(w.Body.String(), tt.contains) {
t.Fatalf("body = %q, want contains %q", w.Body.String(), tt.contains)
}
if strings.Contains(w.Body.String(), "<html") {
t.Fatalf("protocol discovery route served SPA HTML: %q", w.Body.String())
}
})
}
}
+67
View File
@@ -0,0 +1,67 @@
package handler
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
)
var embyPlaceholderPNG = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41,
0x54, 0x78, 0x9c, 0x63, 0x50, 0xd1, 0x30, 0xf8,
0x0f, 0x00, 0x02, 0x6c, 0x01, 0x7c, 0x30, 0xed,
0x6e, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
}
// embyItemImageHandler 把 /Items/{id}/Images/Primary 等请求直接输出为图片。
// Emby 客户端缓存图片 URL 时经常不会继续携带 token;如果重定向到受保护的
// /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。
func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
clearEmbyImageNoStoreHeaders(c)
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
defer cancel()
req := c.Request.WithContext(ctx)
id := c.Param("id")
imgType := strings.ToLower(c.Param("type"))
raw, err := svc.Emby.ImageURL(ctx, id, imgType)
if err != nil || raw == "" {
embyServePlaceholderImage(c)
return
}
if svc.ImageProxy == nil {
embyServePlaceholderImage(c)
return
}
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
embyServePlaceholderImage(c)
}
}
}
func clearEmbyImageNoStoreHeaders(c *gin.Context) {
c.Writer.Header().Del("Pragma")
c.Writer.Header().Del("Expires")
}
func embyServePlaceholderImage(c *gin.Context) {
c.Header("Content-Type", "image/png")
c.Header("Cache-Control", "public, max-age=3600")
c.Header("Content-Length", strconv.Itoa(len(embyPlaceholderPNG)))
if c.Request.Method == http.MethodHead {
c.Status(http.StatusOK)
return
}
c.Data(http.StatusOK, "image/png", embyPlaceholderPNG)
}
+247
View File
@@ -0,0 +1,247 @@
package handler
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
)
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50"))
offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
uid := c.Param("userId")
if uid == "" {
uid = firstQueryValue(c, "UserId", "userId", "userid")
}
if uid == "" {
uid = embyUserID(c)
}
splitOpt := func(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
return service.ItemsParams{
UserID: uid,
ParentID: firstQueryValue(c, "ParentId", "parentId", "parentid"),
IDs: splitOpt(firstQueryValue(c, "Ids", "ids")),
SearchTerm: firstQueryValue(c, "SearchTerm", "searchTerm", "searchterm"),
IncludeItemTypes: splitOpt(firstQueryValue(c, "IncludeItemTypes", "includeItemTypes", "includeitemtypes")),
Filters: splitOpt(firstQueryValue(c, "Filters", "filters")),
Recursive: strings.EqualFold(firstQueryValue(c, "Recursive", "recursive"), "true"),
SortBy: firstQueryValue(c, "SortBy", "sortBy", "sortby"),
SortOrder: firstQueryValue(c, "SortOrder", "sortOrder", "sortorder"),
Limit: limit,
StartIndex: offset,
}
}
func embyFirstNonEmptyString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
out, err := svc.Emby.Items(c.Request.Context(), parseEmbyItemsParams(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
embyAttachRequestTokenToMediaSources(c, out)
c.JSON(http.StatusOK, out)
}
}
func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
out, err := svc.Emby.Item(c.Request.Context(), id, uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if out == nil {
embyError(c, http.StatusNotFound, "item not found")
return
}
embyAttachRequestTokenToMediaSources(c, out)
c.JSON(http.StatusOK, out)
}
}
func embyUserItemByIDHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
switch strings.ToLower(c.Param("id")) {
case "latest":
embyLatestItemsHandler(svc)(c)
case "resume":
embyResumeItemsHandler(svc)(c)
default:
embyItemByIDHandler(svc)(c)
}
}
}
func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = firstQueryValue(c, "UserId", "userId", "userid")
}
if uid == "" {
uid = embyUserID(c)
}
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
embyAttachRequestTokenToMediaSources(c, out)
c.JSON(http.StatusOK, out)
}
}
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = firstQueryValue(c, "UserId", "userId", "userid")
}
if uid == "" {
uid = embyUserID(c)
}
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
embyAttachRequestTokenToMediaSources(c, out)
c.JSON(http.StatusOK, out)
}
}
func embyItemsCountsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc != nil && svc.Emby != nil {
uid := firstQueryValue(c, "UserId", "userId")
if uid == "" {
uid = c.Param("userId")
}
if uid == "" {
uid = embyUserID(c)
}
out, err := svc.Emby.ItemCounts(c.Request.Context(), uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, out)
return
}
c.JSON(http.StatusOK, gin.H{
"MovieCount": 0,
"SeriesCount": 0,
"EpisodeCount": 0,
"ItemCount": 0,
})
}
}
func embyDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"Id": c.Param("id"),
"ViewType": "Poster",
"SortBy": "SortName",
"SortOrder": "Ascending",
"IndexBy": "SortName",
"RememberIndexing": false,
"PrimaryImageHeight": 250,
"PrimaryImageWidth": 250,
"ScrollDirection": "Vertical",
"ShowSidebar": true,
"CustomPrefs": gin.H{
"homeexploresection": "1",
"homesection0": "smalllibrarytiles",
"homesection1": "resume",
"homesection2": "none",
"homesection3": "nextup",
"homesection4": "none",
"homesection5": "none",
"homesection6": "none",
"latestItems": "false",
"landing-livetv": "false",
},
})
}
}
func embySaveDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.Status(http.StatusNoContent)
}
}
func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
params := service.ItemsParams{
UserID: firstQueryValue(c, "UserId", "userId"),
ParentID: c.Param("id"),
Limit: 500,
}
out, err := svc.Emby.Items(c.Request.Context(), params)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
embyAttachRequestTokenToMediaSources(c, out)
c.JSON(http.StatusOK, out)
}
}
func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
parentID := firstQueryValue(c, "SeasonId", "seasonId")
if parentID == "" {
parentID = c.Param("id")
}
params := service.ItemsParams{
UserID: firstQueryValue(c, "UserId", "userId"),
ParentID: parentID,
IncludeItemTypes: []string{"Episode"},
Recursive: true,
Limit: 500,
}
out, err := svc.Emby.Items(c.Request.Context(), params)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
embyAttachRequestTokenToMediaSources(c, out)
c.JSON(http.StatusOK, out)
}
}
+243
View File
@@ -0,0 +1,243 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
)
func TestEmbyItemImageServesWithoutAPIAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.Media{}); err != nil {
t.Fatalf("migrate: %v", err)
}
posterPath := filepath.Join(t.TempDir(), "poster.png")
if err := os.WriteFile(posterPath, []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41,
0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
}, 0o644); err != nil {
t.Fatalf("write poster: %v", err)
}
repos := repository.New(db)
cfg := &config.Config{
App: config.AppConfig{DataDir: filepath.Dir(posterPath)},
Cache: config.CacheConfig{CacheDir: t.TempDir()},
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "media-1"},
Title: "Poster Test",
Path: "D:\\media\\poster-test.mp4",
PosterURL: posterPath,
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
router := gin.New()
registerEmbyRoutes(router, "test-secret", &service.Container{
Repo: repos,
Emby: service.NewEmbyService(cfg, zap.NewNop(), repos),
ImageProxy: service.NewImageProxy(cfg, zap.NewNop()),
})
req := httptest.NewRequest(http.MethodGet, "/Items/media-1/Images/Primary", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
if location := w.Header().Get("Location"); location != "" {
t.Fatalf("expected direct image response, got redirect to %q", location)
}
if contentType := w.Header().Get("Content-Type"); !strings.Contains(contentType, "image/png") {
t.Fatalf("expected png content type, got %q", contentType)
}
if got := w.Header().Get("Cache-Control"); !strings.Contains(got, "max-age=2592000") {
t.Fatalf("image Cache-Control = %q, want long browser cache", got)
}
if got := w.Header().Get("Pragma"); got != "" {
t.Fatalf("image Pragma = %q, want empty", got)
}
if got := w.Header().Get("Expires"); got != "" {
t.Fatalf("image Expires = %q, want empty", got)
}
}
func TestEmbyMissingItemImageReturnsTransparentPlaceholder(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
cfg := &config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}
router := gin.New()
registerEmbyRoutes(router, "test-secret", &service.Container{
Repo: repos,
Emby: service.NewEmbyService(cfg, zap.NewNop(), repos),
ImageProxy: service.NewImageProxy(cfg, zap.NewNop()),
})
req := httptest.NewRequest(http.MethodHead, "/Items/missing/Images/Primary", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected placeholder status 200, got %d body=%s", w.Code, w.Body.String())
}
if contentType := w.Header().Get("Content-Type"); !strings.Contains(contentType, "image/png") {
t.Fatalf("expected png content type, got %q", contentType)
}
if length := w.Header().Get("Content-Length"); length == "" || length == "0" {
t.Fatalf("expected placeholder content length, got %q", length)
}
if got := w.Header().Get("Pragma"); got != "" {
t.Fatalf("placeholder Pragma = %q, want empty", got)
}
if got := w.Header().Get("Expires"); got != "" {
t.Fatalf("placeholder Expires = %q, want empty", got)
}
}
func TestEmbyUserItemByIDRouteReturnsJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Favorite{}, &model.PlaybackHistory{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
lib := model.Library{Name: "剧集", Path: "D:\\media\\tv", Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "episode-1"},
LibraryID: lib.ID,
Title: "Test Show",
Path: "D:\\media\\tv\\Test Show\\Season 01\\Test Show - S01E01.mkv",
SeasonNum: 1,
EpisodeNum: 1,
Container: "mkv",
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
})
req := httptest.NewRequest(http.MethodGet, "/Users/user-1/Items/episode-1", nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
req.Header.Set("If-None-Match", `"stale-client-cache"`)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
if contentType := w.Header().Get("Content-Type"); !strings.Contains(contentType, "application/json") {
t.Fatalf("expected JSON content type, got %q body=%s", contentType, w.Body.String())
}
var item map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &item); err != nil {
t.Fatalf("decode item: %v", err)
}
if item["Id"] != "episode-1" || item["Type"] != "Episode" {
t.Fatalf("unexpected item payload: %#v", item)
}
}
func TestEmbyUserItemByIDRouteReturnsLibraryView(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
lib := model.Library{Base: model.Base{ID: "lib-tv"}, Name: "剧集", Path: "D:\\media\\tv", Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
})
req := httptest.NewRequest(http.MethodGet, "/Users/user-1/Items/lib-tv", nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
var item map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &item); err != nil {
t.Fatalf("decode item: %v", err)
}
if item["Id"] != "lib-tv" || item["Type"] != "CollectionFolder" || item["CollectionType"] != "tvshows" {
t.Fatalf("unexpected library payload: %#v", item)
}
}
+236
View File
@@ -0,0 +1,236 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/gorilla/websocket"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
)
func TestEmbyVirtualFoldersRouteReturnsJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.Library{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
for _, lib := range []model.Library{
{Name: "电影", Path: "D:\\media\\movies", Type: "movie", Enabled: true},
{Name: "剧集", Path: "D:\\media\\tv", Type: "tv", Enabled: true},
{Name: "综艺", Path: "D:\\media\\variety", Type: "variety", Enabled: true},
} {
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
}
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{Repo: repos})
req := httptest.NewRequest(http.MethodGet, "/Library/VirtualFolders", nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
if contentType := w.Header().Get("Content-Type"); !strings.Contains(contentType, "application/json") {
t.Fatalf("expected JSON content type, got %q body=%s", contentType, w.Body.String())
}
if strings.HasPrefix(strings.TrimSpace(w.Body.String()), "<!doctype html>") {
t.Fatalf("route returned frontend HTML instead of JSON")
}
var folders []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &folders); err != nil {
t.Fatalf("decode folders: %v", err)
}
if len(folders) != 3 {
t.Fatalf("expected 3 folders, got %d: %#v", len(folders), folders)
}
if folders[1]["CollectionType"] != "tvshows" || folders[2]["CollectionType"] != "tvshows" {
t.Fatalf("episodic libraries should expose tvshows collection type: %#v", folders)
}
}
func TestEmbyItemsCountsRouteReturnsJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{Repo: repos})
for _, path := range []string{"/Items/Counts", "/Users/user-1/Items/Counts", "/items/counts"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("%s status=%d body=%s", path, w.Code, w.Body.String())
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("%s decode response: %v", path, err)
}
if _, ok := body["MovieCount"]; !ok {
t.Fatalf("%s missing MovieCount: %#v", path, body)
}
}
}
func TestEmbyDisplayPreferencesAllowsAnonymousCompatibility(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
registerEmbyRoutes(router, "secret", &service.Container{})
req := httptest.NewRequest(http.MethodGet, "/emby/DisplayPreferences/usersettings", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected GET status: %d body=%s", w.Code, w.Body.String())
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode display preferences: %v", err)
}
if body["Id"] != "usersettings" {
t.Fatalf("unexpected preferences payload: %#v", body)
}
customPrefs, ok := body["CustomPrefs"].(map[string]any)
if !ok {
t.Fatalf("missing CustomPrefs: %#v", body)
}
if customPrefs["homesection0"] != "smalllibrarytiles" || customPrefs["homesection2"] != "none" || customPrefs["latestItems"] != "false" {
t.Fatalf("homepage sections should expose library tiles without duplicate latest rails: %#v", customPrefs)
}
if body["ScrollDirection"] != "Vertical" {
t.Fatalf("homepage should prefer vertical library browsing, got %#v", body)
}
req = httptest.NewRequest(http.MethodPost, "/emby/displaypreferences/usersettings", strings.NewReader(`{}`))
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("unexpected POST status: %d body=%s", w.Code, w.Body.String())
}
}
func TestEmbyWebSocketRouteUpgradesForOfficialClients(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
registerEmbyRoutes(router, "secret", &service.Container{})
server := httptest.NewServer(router)
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/embywebsocket?api_key=test-token&deviceId=device-1"
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
status := 0
if resp != nil {
status = resp.StatusCode
}
t.Fatalf("websocket dial failed status=%d err=%v", status, err)
}
defer conn.Close()
if resp == nil || resp.StatusCode != http.StatusSwitchingProtocols {
t.Fatalf("expected websocket upgrade, got resp=%#v", resp)
}
}
func TestEmbyWebSocketRefreshesRealtimeActivity(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "viewer",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
const secret = "test-secret"
tracker := service.NewSessionTrackerService(zap.NewNop())
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Sessions: tracker,
})
server := httptest.NewServer(router)
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/embywebsocket?deviceId=device-1&device=Windows&client=Emby"
header := http.Header{"X-Emby-Token": []string{signedTestToken(t, secret)}}
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, header)
if err != nil {
status := 0
if resp != nil {
status = resp.StatusCode
}
t.Fatalf("websocket dial failed status=%d err=%v", status, err)
}
defer conn.Close()
sessions := tracker.List(t.Context())
if len(sessions) != 1 {
t.Fatalf("sessions = %#v, want websocket heartbeat session", sessions)
}
if sessions[0].DeviceID != "device-1" || sessions[0].DeviceName != "Windows" || sessions[0].Client != "Emby" {
t.Fatalf("websocket did not refresh client session: %#v", sessions[0])
}
}
+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)
}
}
+364
View File
@@ -0,0 +1,364 @@
package handler
import (
"errors"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
)
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"), uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if out == nil {
embyError(c, http.StatusNotFound, "not found")
return
}
embyAttachRequestTokenToMediaSources(c, out)
c.JSON(http.StatusOK, out)
}
}
// 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 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)
}
ctx := c.Request.Context()
// The official-format route carries a :format suffix (Stream.ass /
// Stream.vtt); prefer it for the Content-Type when present, otherwise
// fall back to the discovered source codec. :mediaSourceId is ignored —
// the item is located by :id and subtitles by :index (1:1 in this shim).
format := strings.TrimSpace(c.Param("format"))
codec := svc.Emby.SubtitleStreamCodec(ctx, c.Param("id"), c.Param("index"), uid)
if codec != "" {
if format != "" {
codec = service.SubtitleCodecFromFormat(format)
}
c.Header("Content-Type", service.SubtitleContentType(codec))
}
c.Header("Cache-Control", "public, max-age=3600")
if err := svc.Emby.ServeSubtitleStream(ctx, c.Writer, c.Param("id"), c.Param("index"), uid); err != nil {
embyError(c, http.StatusNotFound, "subtitle not found")
return
}
}
}
func embyAttachRequestTokenToMediaSources(c *gin.Context, out any) {
token := embyRequestToken(c)
if token == "" || out == nil {
return
}
embyAttachTokenToMediaSourcesValue(out, token)
}
func embyAttachTokenToMediaSourcesValue(value any, token string) {
switch typed := value.(type) {
case map[string]any:
embyAttachTokenToMediaSourcesMap(typed, token)
case gin.H:
embyAttachTokenToMediaSourcesMap(map[string]any(typed), token)
case []map[string]any:
for _, item := range typed {
embyAttachTokenToMediaSourcesMap(item, token)
}
case []any:
for _, item := range typed {
embyAttachTokenToMediaSourcesValue(item, token)
}
}
}
func embyAttachTokenToMediaSourcesMap(out map[string]any, token string) {
if out == nil {
return
}
if sources, ok := out["MediaSources"].([]map[string]any); ok {
embyAttachTokenToMediaSources(sources, token)
} else if sources, ok := out["MediaSources"].([]any); ok {
for _, source := range sources {
if sourceMap, ok := source.(map[string]any); ok {
embyAttachTokenToMediaSources([]map[string]any{sourceMap}, token)
}
}
}
if items, ok := out["Items"]; ok {
embyAttachTokenToMediaSourcesValue(items, token)
}
}
func embyAttachTokenToMediaSources(sources []map[string]any, token string) {
for _, source := range sources {
for _, key := range []string{"DirectStreamUrl", "TranscodingUrl"} {
raw, ok := source[key].(string)
if !ok {
continue
}
source[key] = embyAppendAPIKey(raw, token)
}
// Subtitle streams advertise a DeliveryUrl; the official Emby client
// fetches it directly, so it must carry the auth token too.
if streams, ok := source["MediaStreams"].([]map[string]any); ok {
for _, stream := range streams {
if stream["Type"] != "Subtitle" {
continue
}
raw, ok := stream["DeliveryUrl"].(string)
if !ok {
continue
}
stream["DeliveryUrl"] = embyAppendAPIKey(raw, token)
}
}
}
}
func embyRequestToken(c *gin.Context) string {
if c == nil {
return ""
}
for _, key := range []string{"api_key", "apiKey", "ApiKey", "token", "X-Emby-Token", "X-MediaBrowser-Token"} {
if value := strings.TrimSpace(c.Query(key)); value != "" {
return value
}
}
for _, header := range []string{"X-Emby-Token", "X-MediaBrowser-Token"} {
if value := strings.TrimSpace(c.GetHeader(header)); value != "" {
return value
}
}
for _, header := range []string{"Authorization", "X-Emby-Authorization", "X-MediaBrowser-Authorization"} {
if token := embyTokenFromAuthHeader(c.GetHeader(header)); token != "" {
return token
}
}
return ""
}
func embyTokenFromAuthHeader(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
// 优先提取 Token="..." 引号内的纯 token。RodelPlayer 等客户端会把
// UserId 和 Token 一起放进同一个 Emby/MediaBrowser 头里,例如
// `Emby UserId="..", Client="..", Token="<jwt>"`。此时必须取 Token 引号内的
// 纯 JWT,不能取整个头,否则 JWT 解析会因多余杂质失败。
if strings.Contains(value, "Token=") {
return embyTokenFromAuthHeaderTokenPart(value)
}
for _, prefix := range []string{"Bearer ", "Emby "} {
if strings.HasPrefix(value, prefix) {
return strings.TrimSpace(strings.TrimPrefix(value, prefix))
}
}
// 其它格式(例如只带 Client/Device 信息的 MediaBrowser 头)不是令牌,
// 不能整串返回,否则会被当作 JWT 解析导致 "Invalid token"。
if strings.HasPrefix(value, "MediaBrowser ") || strings.HasPrefix(value, "Emby ") {
return ""
}
return value
}
// embyTokenFromAuthHeaderTokenPart 从 "Token=..." 形如的字段中取出引号内的纯 token。
func embyTokenFromAuthHeaderTokenPart(value string) string {
for _, part := range strings.Split(value, ",") {
part = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(part), "MediaBrowser "))
if !strings.HasPrefix(part, "Token=") {
continue
}
token := strings.TrimSpace(strings.TrimPrefix(part, "Token="))
return strings.Trim(token, `"`)
}
return ""
}
func embyAppendAPIKey(raw, token string) string {
raw = strings.TrimSpace(raw)
token = strings.TrimSpace(token)
if raw == "" || token == "" {
return raw
}
if strings.HasPrefix(raw, "//") {
return raw
}
u, err := url.Parse(raw)
if err != nil || u.IsAbs() {
return raw
}
q := u.Query()
if q.Get("api_key") == "" && q.Get("apiKey") == "" && q.Get("token") == "" {
q.Set("api_key", token)
u.RawQuery = q.Encode()
}
return u.String()
}
// 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(), encodedID, uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if item == nil {
c.Status(http.StatusNotFound)
return
}
if embyShouldRedirectVideoStreamToSTRM(c, svc, c.Param("id"), cloudMode) {
target := "/api/stream/" + url.PathEscape(strings.TrimSpace(c.Param("id")))
if token := embyPlaybackRedirectToken(c, svc); token != "" {
target = embyAppendAPIKey(target, token)
}
setRedirectNoStoreHeaders(c)
c.Redirect(http.StatusFound, absoluteRequestURL(c, target))
return
}
// 直接调用 Stream service 写入 response。
// 此前这里把所有错误一律吞成 404:云盘 Cookie 过期、直链解析失败、
// STRM 播放被关闭……在第三方播放器上全部表现为「404 不存在」,
// 无法排查。现在区分:行不存在→404;云盘播放不可用/上游故障→502+原因。
err = svc.Stream.ServeFileWithCloudMode(c.Writer, c.Request, c.Param("id"), cloudMode)
switch {
case err == nil:
case errors.Is(err, service.ErrMediaNotFound):
c.Status(http.StatusNotFound)
case errors.Is(err, service.ErrCloudPlaybackDisabled):
if !c.Writer.Written() {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
}
default:
if !c.Writer.Written() {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
}
}
func embyPlaybackRedirectToken(c *gin.Context, svc *service.Container) string {
if token := embyRequestToken(c); token != "" {
return token
}
if c == nil || svc == nil || svc.Auth == nil || svc.Repo == nil || svc.Repo.User == nil {
return ""
}
uid := embyUserID(c)
if uid == "" {
return ""
}
u, err := svc.Repo.User.FindByID(c.Request.Context(), uid)
if err != nil || u == nil {
return ""
}
token, err := svc.Auth.IssueEmbyToken(u)
if err != nil {
return ""
}
return token
}
func embyShouldRedirectVideoStreamToSTRM(c *gin.Context, svc *service.Container, mediaID, cloudMode string) bool {
if c == nil || svc == nil || svc.Repo == nil || svc.Repo.Media == nil || cloudMode != service.CloudPlaybackModeRedirectProxy {
return false
}
settings := service.CloudPlaybackSettings(c.Request.Context(), svc.Repo)
if settings.PreferredMode != service.CloudPlaybackModeSTRM || !settings.STRMEnabled {
return false
}
m, err := svc.Repo.Media.FindByID(c.Request.Context(), mediaID)
if err != nil || m == nil {
return false
}
return strings.TrimSpace(m.STRMURL) != ""
}
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 {
c.Status(http.StatusNotFound)
return
}
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"))
if errors.Is(err, service.ErrTranscodeDisabled) {
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
return
}
if errors.Is(err, service.ErrTranscodeBusy) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "transcode busy"})
return
}
if err != nil {
c.Status(http.StatusNotFound)
}
}
}
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 {
c.Status(http.StatusNotFound)
return
}
if err := svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg")); err != nil {
c.Status(http.StatusNotFound)
}
}
}
@@ -0,0 +1,245 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
)
func TestEmbyLowercasePlaybackInfoRouteReturnsJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
lib := model.Library{Name: "电影", Path: t.TempDir(), Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "media-1"},
LibraryID: lib.ID,
Title: "Lowercase Playback",
Path: filepath.Join(lib.Path, "lowercase-playback.mp4"),
Container: "mp4",
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
})
req := httptest.NewRequest(http.MethodGet, "/users/user-1/items/media-1/playbackinfo", nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode playback info: %v", err)
}
if _, ok := body["MediaSources"]; !ok {
t.Fatalf("missing MediaSources: %#v", body)
}
sources, ok := body["MediaSources"].([]any)
if !ok || len(sources) == 0 {
t.Fatalf("unexpected MediaSources: %#v", body["MediaSources"])
}
source, ok := sources[0].(map[string]any)
if !ok {
t.Fatalf("unexpected MediaSource: %#v", sources[0])
}
directURL, _ := source["DirectStreamUrl"].(string)
if !strings.Contains(directURL, "api_key=") {
t.Fatalf("DirectStreamUrl should carry api_key for clients that do not repeat auth headers: %#v", source)
}
transcodeURL, _ := source["TranscodingUrl"].(string)
if transcodeURL != "" && !strings.Contains(transcodeURL, "api_key=") {
t.Fatalf("TranscodingUrl should carry api_key: %#v", source)
}
}
func TestEmbyPlaybackInfoDoesNotExposeTokenInCloudPath(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.Setting.Set(t.Context(), service.CloudPlaybackModeSettingKey, service.CloudPlaybackModeSTRM); err != nil {
t.Fatalf("set cloud playback mode: %v", err)
}
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "cloud-1"},
LibraryID: lib.ID,
Title: "Cloud Movie",
Path: "cloud://openlist/Movies/Movie.mkv",
STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv",
Container: "mkv",
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
})
req := httptest.NewRequest(http.MethodGet, "/users/user-1/items/cloud-1/playbackinfo", nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode playback info: %v", err)
}
source := body["MediaSources"].([]any)[0].(map[string]any)
pathURL, _ := source["Path"].(string)
if pathURL != "/Movies/Movie.mkv" {
t.Fatalf("cloud Path should expose the OpenList source path, got %#v", source)
}
if strings.Contains(pathURL, "api_key=") || strings.Contains(pathURL, "token=") {
t.Fatalf("cloud Path must not expose auth key/token: %#v", source)
}
if strings.Contains(pathURL, "/api/cloud/play/") || strings.Contains(pathURL, "/api/stream/") {
t.Fatalf("cloud Path should not expose a playback URL: %#v", source)
}
directURL, _ := source["DirectStreamUrl"].(string)
if !strings.HasPrefix(directURL, "/api/stream/cloud-1") || !strings.Contains(directURL, "api_key=") {
t.Fatalf("DirectStreamUrl should stay tokenized: %#v", source)
}
if source["SupportsDirectPlay"] != true {
t.Fatalf("cloud media should advertise DirectPlay when tokenized Path is playable: %#v", source)
}
if source["SupportsTranscoding"] != false {
t.Fatalf("cloud media should not advertise host transcoding: %#v", source)
}
}
func TestEmbyItemsDoNotExposeTokenInEmbeddedCloudPath(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.Setting.Set(t.Context(), service.CloudPlaybackModeSettingKey, service.CloudPlaybackModeSTRM); err != nil {
t.Fatalf("set cloud playback mode: %v", err)
}
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "cloud-1"},
LibraryID: lib.ID,
Title: "Cloud Movie",
Path: "cloud://openlist/Movies/Movie.mkv",
STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv",
Container: "mkv",
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
const secret = "test-secret"
token := signedTestToken(t, secret)
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
})
req := httptest.NewRequest(http.MethodGet, "/emby/Users/user-1/Items?IncludeItemTypes=Movie&Recursive=true&Limit=5&X-Emby-Token="+token, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode items: %v", err)
}
items := body["Items"].([]any)
if len(items) != 1 {
t.Fatalf("unexpected items: %#v", body["Items"])
}
source := items[0].(map[string]any)["MediaSources"].([]any)[0].(map[string]any)
pathURL, _ := source["Path"].(string)
if pathURL != "/Movies/Movie.mkv" {
t.Fatalf("embedded cloud Path should expose the OpenList source path, got %#v", source)
}
if strings.Contains(pathURL, "api_key=") || strings.Contains(pathURL, "token=") {
t.Fatalf("embedded cloud Path must not expose auth key/token: %#v", source)
}
}
@@ -0,0 +1,258 @@
package handler
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
)
func TestEmbyLowercaseVideoStreamRouteServesMedia(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
dir := t.TempDir()
mediaPath := filepath.Join(dir, "sample.mp4")
if err := os.WriteFile(mediaPath, []byte("fake-video-bytes"), 0o644); err != nil {
t.Fatalf("write media: %v", err)
}
lib := model.Library{Name: "电影", Path: dir, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "media-1"},
LibraryID: lib.ID,
Title: "Lowercase Stream",
Path: mediaPath,
Container: "mp4",
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
Stream: service.NewStreamService(&config.Config{}, zap.NewNop(), repos, nil),
})
req := httptest.NewRequest(http.MethodGet, "/videos/media-1/stream?api_key="+signedTestToken(t, secret), nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
if got := w.Body.String(); got != "fake-video-bytes" {
t.Fatalf("unexpected stream body: %q", got)
}
}
func TestEmbyPrefixedAPIStreamRouteServesMedia(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
dir := t.TempDir()
mediaPath := filepath.Join(dir, "sample.mp4")
if err := os.WriteFile(mediaPath, []byte("fake-video-bytes"), 0o644); err != nil {
t.Fatalf("write media: %v", err)
}
lib := model.Library{Name: "电影", Path: dir, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "media-1"},
LibraryID: lib.ID,
Title: "Prefixed API Stream",
Path: mediaPath,
Container: "mp4",
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
Stream: service.NewStreamService(&config.Config{}, zap.NewNop(), repos, nil),
})
req := httptest.NewRequest(http.MethodGet, "/emby/api/stream/media-1?api_key="+signedTestToken(t, secret), nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
if got := w.Body.String(); got != "fake-video-bytes" {
t.Fatalf("unexpected stream body: %q", got)
}
}
func TestEmbyLowercaseOriginalHeadRouteServesHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
dir := t.TempDir()
mediaPath := filepath.Join(dir, "sample.mp4")
if err := os.WriteFile(mediaPath, []byte("fake-video-bytes"), 0o644); err != nil {
t.Fatalf("write media: %v", err)
}
lib := model.Library{Name: "电影", Path: dir, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "media-1"},
LibraryID: lib.ID,
Title: "Lowercase Original",
Path: mediaPath,
Container: "mp4",
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
Stream: service.NewStreamService(&config.Config{}, zap.NewNop(), repos, nil),
})
req := httptest.NewRequest(http.MethodHead, "/videos/media-1/original.mp4?api_key="+signedTestToken(t, secret), nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
if w.Body.Len() != 0 {
t.Fatalf("HEAD response should not include body, got %q", w.Body.String())
}
}
func TestEmbyLowercaseVideoHLSRouteDoesNot404WhenDirectOnly(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
dir := t.TempDir()
mediaPath := filepath.Join(dir, "sample.mp4")
if err := os.WriteFile(mediaPath, []byte("fake-video-bytes"), 0o644); err != nil {
t.Fatalf("write media: %v", err)
}
lib := model.Library{Name: "电影", Path: dir, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "media-1"},
LibraryID: lib.ID,
Title: "Lowercase HLS",
Path: mediaPath,
Container: "mp4",
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
if err := repos.Setting.Set(t.Context(), service.PlaybackDirectOnlySettingKey, "true"); err != nil {
t.Fatalf("set direct-only: %v", err)
}
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
Stream: service.NewStreamService(&config.Config{}, zap.NewNop(), repos, nil),
})
req := httptest.NewRequest(http.MethodGet, "/videos/media-1/master.m3u8?api_key="+signedTestToken(t, secret), nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code == http.StatusNotFound {
t.Fatalf("lowercase HLS route should be registered, got 404")
}
if w.Code != http.StatusConflict {
t.Fatalf("direct-only HLS should return 409, got %d body=%s", w.Code, w.Body.String())
}
}
+119
View File
@@ -0,0 +1,119 @@
package handler
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
)
type embyPlayingReq struct {
ItemId string `json:"ItemId"`
PositionTicks int64 `json:"PositionTicks"`
RunTimeTicks int64 `json:"RunTimeTicks"`
}
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := embyUserID(c)
if uid == "" {
c.Status(http.StatusUnauthorized)
return
}
var req embyPlayingReq
_ = c.ShouldBindJSON(&req)
if req.ItemId == "" {
req.ItemId = c.Query("ItemId")
}
if req.PositionTicks == 0 {
req.PositionTicks, _ = strconv.ParseInt(c.Query("PositionTicks"), 10, 64)
}
if req.RunTimeTicks == 0 {
req.RunTimeTicks, _ = strconv.ParseInt(c.Query("RunTimeTicks"), 10, 64)
}
if req.ItemId == "" {
c.Status(http.StatusOK)
return
}
clientInfo := embyClientInfoFromRequest(c)
if svc.Device != nil && svc.Device.IsTerminalKicked(c.Request.Context(), uid, clientInfo.DeviceID, clientInfo.DeviceName, clientInfo.Client) {
c.Status(http.StatusUnauthorized)
return
}
_ = svc.Emby.RecordProgress(c.Request.Context(), uid, req.ItemId, req.PositionTicks, req.RunTimeTicks)
stopped := strings.Contains(strings.ToLower(c.FullPath()+" "+c.Request.URL.Path), "stopped")
if svc.Sessions != nil {
svc.Sessions.RecordPlayback(c.Request.Context(), uid, "",
clientInfo.DeviceID,
clientInfo.DeviceName,
clientInfo.Client,
c.ClientIP(),
req.ItemId,
req.PositionTicks,
req.RunTimeTicks,
stopped)
}
if svc.Device != nil && !stopped {
svc.Device.RecordPlayback(c.Request.Context(), uid,
clientInfo.DeviceID,
clientInfo.DeviceName,
clientInfo.Client)
}
c.Status(http.StatusNoContent)
}
}
func embyFavoriteHandler(svc *service.Container, fav bool) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
mid := c.Param("itemId")
if uid == "" || mid == "" {
c.Status(http.StatusBadRequest)
return
}
if err := svc.Emby.SetFavorite(c.Request.Context(), uid, mid, fav); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
out, _ := svc.Emby.Item(c.Request.Context(), mid, uid)
if out != nil {
c.JSON(http.StatusOK, out["UserData"])
return
}
c.JSON(http.StatusOK, gin.H{"IsFavorite": fav})
}
}
func embyMarkPlayedHandler(svc *service.Container, played bool) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
mid := c.Param("itemId")
if uid == "" || mid == "" {
c.Status(http.StatusBadRequest)
return
}
if err := svc.Emby.MarkPlayed(c.Request.Context(), uid, mid, played); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if played && svc.Device != nil {
clientInfo := embyClientInfoFromRequest(c)
svc.Device.RecordPlayback(c.Request.Context(), uid, clientInfo.DeviceID, clientInfo.DeviceName, clientInfo.Client)
}
out, _ := svc.Emby.Item(c.Request.Context(), mid, uid)
if out != nil {
c.JSON(http.StatusOK, out["UserData"])
return
}
c.JSON(http.StatusOK, gin.H{"Played": played})
}
}
+254
View File
@@ -0,0 +1,254 @@
package handler
import (
"time"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/service"
)
// registerEmbyRoutes 在 r 上挂双前缀("" + "/emby")的 Emby 兼容路由。
func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container) {
for _, prefix := range []string{"/emby", ""} {
grp := r.Group(prefix)
grp.Use(embyNoStoreHeaders())
registerEmbyRootRoutes(grp, prefix, svc)
registerEmbyPublicRoutes(grp, jwtSecret, svc)
registerEmbyPublicImageRoutes(grp, svc)
// 鉴权后端点
auth := grp.Group("", embyAuthRequiredWithSessionFallback(jwtSecret), activeEmbyUserRequired(svc), embyRealtimeSessionActivity(svc))
registerEmbyAuthenticatedRoutes(auth, prefix, svc)
}
}
type embyRouteHandlerFactory func(*service.Container) gin.HandlerFunc
func embyNoStoreHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Cache-Control", "no-store")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
c.Next()
}
}
func registerEmbyRootRoutes(grp *gin.RouterGroup, prefix string, svc *service.Container) {
if prefix != "/emby" {
return
}
grp.GET("", embyRootHandler(svc))
grp.HEAD("", embyRootHandler(svc))
grp.GET("/", embyRootHandler(svc))
grp.HEAD("/", embyRootHandler(svc))
}
func registerEmbyPublicRoutes(grp *gin.RouterGroup, jwtSecret string, svc *service.Container) {
registerEmbyPublicSystemRoutes(grp, svc)
registerEmbyPublicSessionRoutes(grp, jwtSecret, svc)
registerEmbyPublicClientRoutes(grp, jwtSecret, svc)
}
func registerEmbyPublicSystemRoutes(grp *gin.RouterGroup, svc *service.Container) {
registerEmbyGetHeadRoutes(grp, svc, []string{"/System/Info/Public", "/system/info/public"}, embySystemInfoPublicHandler)
registerEmbyGetHeadRoutes(grp, svc, []string{"/System/Info", "/system/info"}, embySystemInfoHandler)
registerEmbyGetRoutes(grp, svc, []string{"/System/Endpoint", "/system/endpoint"}, embySystemEndpointHandler)
registerEmbyGetHeadRoutes(grp, svc, []string{"/System/Ext/ServerDomains", "/system/ext/serverdomains"}, embyServerDomainsHandler)
registerEmbyGetHeadRoutes(grp, svc, []string{"/System/Configuration/Public", "/system/configuration/public"}, embyPublicServerConfigurationHandler)
registerEmbyGetHeadRoutes(grp, svc, []string{"/Startup/Configuration", "/startup/configuration"}, embyStartupConfigurationHandler)
registerEmbyPostRoutes(grp, svc, []string{"/Startup/Complete", "/startup/complete"}, embyNoContentHandler)
registerEmbyGetHeadRoutes(grp, svc, []string{"/QuickConnect/Enabled", "/quickconnect/enabled"}, embyQuickConnectEnabledHandler)
for _, path := range []string{"/System/Ping", "/system/ping"} {
grp.GET(path, embyPingHandler(svc))
grp.HEAD(path, embyPingHandler(svc))
grp.POST(path, embyPingHandler(svc))
}
}
func registerEmbyPublicSessionRoutes(grp *gin.RouterGroup, jwtSecret string, svc *service.Container) {
for _, path := range []string{
"/Sessions/Capabilities", "/Sessions/Capabilities/Full",
"/sessions/capabilities", "/sessions/capabilities/full",
} {
grp.POST(path, embySessionCapabilitiesHandler(svc, jwtSecret))
}
// 30/min per IP: many Emby clients sit behind a single NAT/reverse-proxy
// IP, so a low limit would throttle legitimate logins into 429s.
embyLoginLimiter := middleware.NewRateLimiter(30, 1*time.Minute)
for _, path := range []string{"/Users/AuthenticateByName", "/Users/authenticatebyname", "/users/AuthenticateByName", "/users/authenticatebyname"} {
grp.POST(path, middleware.RateLimit(embyLoginLimiter), embyAuthByNameHandler(svc))
}
registerEmbyGetRoutes(grp, svc, []string{"/Users/Public", "/users/public"}, embyPublicUsersHandler)
}
func registerEmbyPublicClientRoutes(grp *gin.RouterGroup, jwtSecret string, svc *service.Container) {
registerEmbyGetRoutes(grp, svc, []string{"/Branding/Configuration", "/branding/configuration"}, embyBrandingConfigHandler)
registerEmbyGetHeadRoutes(grp, svc, []string{"/Branding/Css", "/branding/css"}, embyBrandingCSSHandler)
registerEmbyGetRoutes(grp, svc, []string{"/Localization/Options", "/localization/options"}, embyLocalizationOptionsHandler)
registerEmbyGetRoutes(grp, svc, []string{"/Localization/Cultures", "/Localization/cultures", "/localization/cultures"}, embyLocalizationCulturesHandler)
registerEmbyGetHeadRoutes(grp, svc, []string{"/CustomCssJS/Scripts", "/customcssjs/scripts"}, embyCustomCSSJSScriptsHandler)
for _, path := range []string{"/embywebsocket", "/EmbyWebSocket"} {
grp.GET(path, embyWebSocketHandler(svc, jwtSecret))
grp.HEAD(path, embyNoContentHandler(svc))
}
for _, path := range []string{"/Sessions/Logout", "/sessions/logout"} {
grp.POST(path, embySessionLogoutHandler(svc, jwtSecret))
}
grp.GET("/DisplayPreferences/:id", embyDisplayPreferencesHandler(svc))
grp.POST("/DisplayPreferences/:id", embySaveDisplayPreferencesHandler(svc))
grp.GET("/displaypreferences/:id", embyDisplayPreferencesHandler(svc))
grp.POST("/displaypreferences/:id", embySaveDisplayPreferencesHandler(svc))
}
func registerEmbyPublicImageRoutes(grp *gin.RouterGroup, svc *service.Container) {
// 图片公开(Infuse 缓存 URL 时会丢 token)
grp.GET("/Items/:id/Images/:type", embyItemImageHandler(svc))
grp.GET("/Items/:id/Images/:type/:index", embyItemImageHandler(svc))
grp.HEAD("/Items/:id/Images/:type", embyItemImageHandler(svc))
grp.GET("/items/:id/images/:type", embyItemImageHandler(svc))
grp.GET("/items/:id/images/:type/:index", embyItemImageHandler(svc))
grp.HEAD("/items/:id/images/:type", embyItemImageHandler(svc))
}
func registerEmbyGetRoutes(grp *gin.RouterGroup, svc *service.Container, paths []string, factory embyRouteHandlerFactory) {
for _, path := range paths {
grp.GET(path, factory(svc))
}
}
func registerEmbyGetHeadRoutes(grp *gin.RouterGroup, svc *service.Container, paths []string, factory embyRouteHandlerFactory) {
for _, path := range paths {
grp.GET(path, factory(svc))
grp.HEAD(path, factory(svc))
}
}
func registerEmbyPostRoutes(grp *gin.RouterGroup, svc *service.Container, paths []string, factory embyRouteHandlerFactory) {
for _, path := range paths {
grp.POST(path, factory(svc))
}
}
func registerEmbyAuthenticatedRoutes(auth *gin.RouterGroup, prefix string, svc *service.Container) {
registerEmbyAuthenticatedUserRoutes(auth, svc)
registerEmbyAuthenticatedItemRoutes(auth, svc)
registerEmbyAuthenticatedPlaybackRoutes(auth, prefix, svc)
registerEmbyAuthenticatedProgressRoutes(auth, svc)
registerEmbyAuthenticatedUserDataRoutes(auth, svc)
registerEmbyAuthenticatedSystemRoutes(auth, svc)
registerLowercaseEmbyAuthRoutes(auth, svc)
}
func registerEmbyAuthenticatedUserRoutes(auth *gin.RouterGroup, svc *service.Container) {
auth.GET("/Users/Me", embyMeHandler(svc))
auth.GET("/Users", embyListUsersHandler(svc))
auth.GET("/Users/:userId", embyGetUserByIDHandler(svc))
auth.GET("/Users/:userId/Views", embyViewsHandler(svc))
auth.GET("/Library/MediaFolders", embyViewsHandler(svc))
auth.GET("/Library/VirtualFolders", embyVirtualFoldersHandler(svc))
auth.GET("/Library/SelectableMediaFolders", embyVirtualFoldersHandler(svc))
}
func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Container) {
auth.GET("/Items", embyItemsHandler(svc))
auth.GET("/Users/:userId/Items", embyItemsHandler(svc))
auth.GET("/Items/Counts", embyItemsCountsHandler(svc))
auth.GET("/Users/:userId/Items/Counts", embyItemsCountsHandler(svc))
auth.GET("/Items/Latest", embyLatestItemsHandler(svc))
auth.GET("/Items/Resume", embyResumeItemsHandler(svc))
auth.GET("/Items/:id", embyItemByIDHandler(svc))
auth.GET("/Users/:userId/Items/:id", embyUserItemByIDHandler(svc))
auth.GET("/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
auth.GET("/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
auth.GET("/Users/:userId/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
auth.GET("/Users/:userId/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
auth.GET("/Shows/NextUp", embyEmptyItemsHandler(svc))
auth.GET("/Users/:userId/Shows/NextUp", embyEmptyItemsHandler(svc))
auth.GET("/MediaSegments/:id", embyEmptyItemsHandler(svc))
auth.GET("/Artists", embyEmptyItemsHandler(svc))
auth.GET("/Persons", embyEmptyItemsHandler(svc))
auth.GET("/Genres", embyEmptyItemsHandler(svc))
auth.GET("/Shows/Upcoming", embyEmptyItemsHandler(svc))
auth.GET("/Users/:userId/Shows/Upcoming", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/Similar", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/ThumbnailSet", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/ThemeMedia", embyThemeMediaHandler(svc))
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
auth.GET("/Users/:userId/Items/:id/Intros", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/Intros", embyEmptyItemsHandler(svc))
auth.GET("/api/danmu/:id/raw", embyDanmuRawHandler(svc))
}
func registerEmbyAuthenticatedPlaybackRoutes(auth *gin.RouterGroup, prefix string, svc *service.Container) {
auth.GET("/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
auth.POST("/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
auth.GET("/Users/:userId/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
auth.POST("/Users/:userId/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
registerEmbyVideoStreamRoutes(auth, svc, "/Videos")
auth.GET("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
auth.HEAD("/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
auth.GET("/Users/:userId/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
auth.HEAD("/Users/:userId/Videos/:id/Subtitles/:index/Stream", embySubtitleStreamHandler(svc))
// Official Emby/Swagger shape:
// /Videos/{Id}/{MediaSourceId}/Subtitles/{Index}/Stream.{Format}
// The mediaSourceId segment is a bare path param. We name it :seg (matching
// the HLS /Videos/:id/:seg catch-all) so gin allows the two routes to
// coexist — gin permits the same :param name to be both terminal and parent
// of children, but rejects two different param names at the same position.
// The subtitle handler ignores this segment (lookup is by :id + :index).
auth.GET("/Videos/:id/:seg/Subtitles/:index/Stream.:format", embySubtitleStreamHandler(svc))
auth.HEAD("/Videos/:id/:seg/Subtitles/:index/Stream.:format", embySubtitleStreamHandler(svc))
auth.GET("/Users/:userId/Videos/:id/:seg/Subtitles/:index/Stream.:format", embySubtitleStreamHandler(svc))
auth.HEAD("/Users/:userId/Videos/:id/:seg/Subtitles/:index/Stream.:format", embySubtitleStreamHandler(svc))
if prefix == "/emby" {
auth.GET("/api/stream/:id", embyVideoStreamHandler(svc, service.CloudPlaybackModeSTRM))
auth.HEAD("/api/stream/:id", embyVideoStreamHandler(svc, service.CloudPlaybackModeSTRM))
}
auth.GET("/Videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc))
auth.HEAD("/Videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc))
auth.GET("/Videos/:id/main.m3u8", embyVideoHLSPlaylistHandler(svc))
auth.HEAD("/Videos/:id/main.m3u8", embyVideoHLSPlaylistHandler(svc))
auth.GET("/Videos/:id/:seg", embyVideoHLSSegmentHandler(svc))
}
func registerEmbyVideoStreamRoutes(auth *gin.RouterGroup, svc *service.Container, basePath string) {
streamHandler := func() gin.HandlerFunc {
return embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)
}
for _, path := range []string{"/:id/stream", "/:id/stream.:container", "/:id/original", "/:id/original.:container"} {
fullPath := basePath + path
auth.GET(fullPath, streamHandler())
auth.HEAD(fullPath, streamHandler())
}
}
func registerEmbyAuthenticatedProgressRoutes(auth *gin.RouterGroup, svc *service.Container) {
auth.POST("/Sessions/Playing", embyPlayingProgressHandler(svc))
auth.POST("/Sessions/Playing/Progress", embyPlayingProgressHandler(svc))
auth.POST("/Sessions/Playing/Stopped", embyPlayingProgressHandler(svc))
}
func registerEmbyAuthenticatedUserDataRoutes(auth *gin.RouterGroup, svc *service.Container) {
auth.POST("/Users/:userId/FavoriteItems/:itemId", embyFavoriteHandler(svc, true))
auth.DELETE("/Users/:userId/FavoriteItems/:itemId", embyFavoriteHandler(svc, false))
auth.POST("/Users/:userId/PlayedItems/:itemId", embyMarkPlayedHandler(svc, true))
auth.DELETE("/Users/:userId/PlayedItems/:itemId", embyMarkPlayedHandler(svc, false))
}
func registerEmbyAuthenticatedSystemRoutes(auth *gin.RouterGroup, svc *service.Container) {
auth.GET("/Sessions", embySessionsHandler(svc))
auth.GET("/System/Configuration", embyServerConfigurationHandler(svc))
auth.GET("/System/WakeOnLanInfo", embyEmptyArrayHandler(svc))
auth.GET("/ScheduledTasks", embyEmptyArrayHandler(svc))
auth.GET("/LiveTv/Recordings", embyEmptyItemsHandler(svc))
auth.GET("/System/ActivityLog/Entries", embyEmptyItemsHandler(svc))
auth.GET("/Web/ConfigurationPages", embyEmptyArrayHandler(svc))
auth.POST("/Users/:userId/Configuration", embyNoContentHandler(svc))
}
+106
View File
@@ -0,0 +1,106 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
)
func registerLowercaseEmbyAuthRoutes(auth *gin.RouterGroup, svc *service.Container) {
registerLowercaseEmbyUserRoutes(auth, svc)
registerLowercaseEmbyItemRoutes(auth, svc)
registerLowercaseEmbyPlaybackRoutes(auth, svc)
registerLowercaseEmbyProgressRoutes(auth, svc)
registerLowercaseEmbyUserDataRoutes(auth, svc)
registerLowercaseEmbySystemRoutes(auth, svc)
}
func registerLowercaseEmbyUserRoutes(auth *gin.RouterGroup, svc *service.Container) {
auth.GET("/users/me", embyMeHandler(svc))
auth.GET("/users", embyListUsersHandler(svc))
auth.GET("/users/:userId", embyGetUserByIDHandler(svc))
auth.GET("/users/:userId/views", embyViewsHandler(svc))
auth.GET("/library/mediafolders", embyViewsHandler(svc))
auth.GET("/library/virtualfolders", embyVirtualFoldersHandler(svc))
auth.GET("/library/selectablemediafolders", embyVirtualFoldersHandler(svc))
}
func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Container) {
auth.GET("/items", embyItemsHandler(svc))
auth.GET("/users/:userId/items", embyItemsHandler(svc))
auth.GET("/items/counts", embyItemsCountsHandler(svc))
auth.GET("/users/:userId/items/counts", embyItemsCountsHandler(svc))
auth.GET("/items/latest", embyLatestItemsHandler(svc))
auth.GET("/items/resume", embyResumeItemsHandler(svc))
auth.GET("/items/:id", embyItemByIDHandler(svc))
auth.GET("/users/:userId/items/:id", embyUserItemByIDHandler(svc))
auth.GET("/shows/:id/seasons", embyShowSeasonsHandler(svc))
auth.GET("/shows/:id/episodes", embyShowEpisodesHandler(svc))
auth.GET("/users/:userId/shows/:id/seasons", embyShowSeasonsHandler(svc))
auth.GET("/users/:userId/shows/:id/episodes", embyShowEpisodesHandler(svc))
auth.GET("/shows/nextup", embyEmptyItemsHandler(svc))
auth.GET("/users/:userId/shows/nextup", embyEmptyItemsHandler(svc))
auth.GET("/mediasegments/:id", embyEmptyItemsHandler(svc))
auth.GET("/artists", embyEmptyItemsHandler(svc))
auth.GET("/persons", embyEmptyItemsHandler(svc))
auth.GET("/genres", embyEmptyItemsHandler(svc))
auth.GET("/shows/upcoming", embyEmptyItemsHandler(svc))
auth.GET("/users/:userId/shows/upcoming", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/similar", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/thumbnailset", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/thememedia", embyThemeMediaHandler(svc))
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
auth.GET("/users/:userId/items/:id/intros", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/intros", embyEmptyItemsHandler(svc))
}
func registerLowercaseEmbyPlaybackRoutes(auth *gin.RouterGroup, svc *service.Container) {
auth.GET("/items/:id/playbackinfo", embyPlaybackInfoHandler(svc))
auth.POST("/items/:id/playbackinfo", embyPlaybackInfoHandler(svc))
auth.GET("/users/:userId/items/:id/playbackinfo", embyPlaybackInfoHandler(svc))
auth.POST("/users/:userId/items/:id/playbackinfo", embyPlaybackInfoHandler(svc))
registerEmbyVideoStreamRoutes(auth, svc, "/videos")
auth.GET("/videos/:id/subtitles/:index/stream", embySubtitleStreamHandler(svc))
auth.HEAD("/videos/:id/subtitles/:index/stream", embySubtitleStreamHandler(svc))
auth.GET("/users/:userId/videos/:id/subtitles/:index/stream", embySubtitleStreamHandler(svc))
auth.HEAD("/users/:userId/videos/:id/subtitles/:index/stream", embySubtitleStreamHandler(svc))
// Official Emby/Swagger shape:
// /videos/{Id}/{MediaSourceId}/subtitles/{Index}/stream.{Format}
// The mediaSourceId segment is a bare path param named :seg (shared with the
// HLS /videos/:id/:seg catch-all) so gin allows both routes to coexist.
auth.GET("/videos/:id/:seg/subtitles/:index/stream.:format", embySubtitleStreamHandler(svc))
auth.HEAD("/videos/:id/:seg/subtitles/:index/stream.:format", embySubtitleStreamHandler(svc))
auth.GET("/users/:userId/videos/:id/:seg/subtitles/:index/stream.:format", embySubtitleStreamHandler(svc))
auth.HEAD("/users/:userId/videos/:id/:seg/subtitles/:index/stream.:format", embySubtitleStreamHandler(svc))
auth.GET("/videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc))
auth.HEAD("/videos/:id/master.m3u8", embyVideoHLSPlaylistHandler(svc))
auth.GET("/videos/:id/main.m3u8", embyVideoHLSPlaylistHandler(svc))
auth.HEAD("/videos/:id/main.m3u8", embyVideoHLSPlaylistHandler(svc))
auth.GET("/videos/:id/:seg", embyVideoHLSSegmentHandler(svc))
}
func registerLowercaseEmbyProgressRoutes(auth *gin.RouterGroup, svc *service.Container) {
auth.POST("/sessions/playing", embyPlayingProgressHandler(svc))
auth.POST("/sessions/playing/progress", embyPlayingProgressHandler(svc))
auth.POST("/sessions/playing/stopped", embyPlayingProgressHandler(svc))
}
func registerLowercaseEmbyUserDataRoutes(auth *gin.RouterGroup, svc *service.Container) {
auth.POST("/users/:userId/favoriteitems/:itemId", embyFavoriteHandler(svc, true))
auth.DELETE("/users/:userId/favoriteitems/:itemId", embyFavoriteHandler(svc, false))
auth.POST("/users/:userId/playeditems/:itemId", embyMarkPlayedHandler(svc, true))
auth.DELETE("/users/:userId/playeditems/:itemId", embyMarkPlayedHandler(svc, false))
}
func registerLowercaseEmbySystemRoutes(auth *gin.RouterGroup, svc *service.Container) {
auth.GET("/sessions", embySessionsHandler(svc))
auth.GET("/system/configuration", embyServerConfigurationHandler(svc))
auth.GET("/system/wakeonlaninfo", embyEmptyArrayHandler(svc))
auth.GET("/scheduledtasks", embyEmptyArrayHandler(svc))
auth.GET("/livetv/recordings", embyEmptyItemsHandler(svc))
auth.GET("/system/activitylog/entries", embyEmptyItemsHandler(svc))
auth.GET("/web/configurationpages", embyEmptyArrayHandler(svc))
auth.POST("/users/:userId/configuration", embyNoContentHandler(svc))
}
@@ -0,0 +1,466 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
)
func TestEmbyMarkPlayedRefreshesPlaybackDevice(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if sqlDB, err := db.DB(); err == nil {
sqlDB.SetMaxOpenConns(1)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
if err := repos.DB.Create(&model.Media{
Base: model.Base{ID: "media-1"},
LibraryID: lib.ID,
Title: "Watched Movie",
Path: `/media/movies/Watched Movie.mkv`,
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
Device: service.NewDeviceService(zap.NewNop(), repos),
})
token := signedTestToken(t, secret)
req := httptest.NewRequest(http.MethodPost, "/emby/Users/user-1/PlayedItems/media-1", nil)
req.Header.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="iPhone", DeviceId="played-device", Token="`+token+`"`)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
devices, err := repos.UserDevice.ListByUser(context.Background(), "user-1")
if err != nil {
t.Fatalf("list devices: %v", err)
}
if len(devices) != 1 || devices[0].LastPlayAt == nil {
t.Fatalf("mark played should refresh playback device, got %#v", devices)
}
if devices[0].DeviceID != "played-device" || devices[0].DeviceName != "iPhone" || devices[0].Client != "Infuse" {
t.Fatalf("playback device info not parsed: %#v", devices[0])
}
}
func TestEmbyCompatSessionAllowsSameClientRequestsWithoutToken(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("get sql db: %v", err)
}
sqlDB.SetMaxOpenConns(1)
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
log := zap.NewNop()
permissions := service.NewPermissionService(log, repos)
auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions)
user, _, err := auth.Register(context.Background(), "viewer", "secret-pass")
if err != nil {
t.Fatalf("register: %v", err)
}
if err := repos.Library.Create(t.Context(), &model.Library{Name: "Movies", Path: "D:\\media", Type: "movie", Enabled: true}); err != nil {
t.Fatalf("create library: %v", err)
}
embyCompatSessions.Lock()
embyCompatSessions.items = map[string]embyCompatSession{}
embyCompatSessions.Unlock()
router := gin.New()
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
Repo: repos,
Auth: auth,
Emby: service.NewEmbyService(cfg, log, repos),
Audit: service.NewAuditService(log, repos),
})
req := httptest.NewRequest(http.MethodPost, "/emby/Users/authenticatebyname", strings.NewReader(`{"Username":"viewer","Pw":"secret-pass"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Emby Theater")
req.Header.Set("X-Emby-Device-Id", "pc-device")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("login status: %d body=%s", w.Code, w.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/emby/Users/"+user.ID+"/Views", nil)
req.Header.Set("User-Agent", "Emby Theater")
req.Header.Set("X-Emby-Device-Id", "pc-device")
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("views status: %d body=%s", w.Code, w.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode views: %v", err)
}
if _, ok := payload["Items"]; !ok {
t.Fatalf("missing Items: %#v", payload)
}
}
func TestEmbyAuthenticatedRequestRefreshesRealtimeUserActivity(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if sqlDB, err := db.DB(); err == nil {
sqlDB.SetMaxOpenConns(1)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
oldLogin := time.Now().Add(-6 * time.Hour)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "viewer",
Role: "admin",
Tier: "plus",
IsActive: true,
LastLoginAt: &oldLogin,
}); err != nil {
t.Fatalf("create user: %v", err)
}
const secret = "test-secret"
log := zap.NewNop()
tracker := service.NewSessionTrackerService(log)
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, log, repos),
Sessions: tracker,
}
router := gin.New()
registerEmbyRoutes(router, secret, svc)
router.GET("/admin/users", listUsersHandler(svc))
req := httptest.NewRequest(http.MethodGet, "/emby/Users/Me", nil)
req.Header.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="iPhone", DeviceId="phone-1", Token="`+signedTestToken(t, secret)+`"`)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("me status: %d body=%s", w.Code, w.Body.String())
}
sessions := tracker.List(t.Context())
if len(sessions) != 1 {
t.Fatalf("sessions = %#v, want one realtime session", sessions)
}
if sessions[0].UserID != "user-1" || sessions[0].DeviceID != "phone-1" || sessions[0].Client != "Infuse" {
t.Fatalf("session did not capture client info: %#v", sessions[0])
}
req = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("admin users status: %d body=%s", w.Code, w.Body.String())
}
var users []model.User
if err := json.Unmarshal(w.Body.Bytes(), &users); err != nil {
t.Fatalf("decode users: %v", err)
}
if len(users) != 1 {
t.Fatalf("users = %#v", users)
}
if users[0].LastLoginAt == nil || !users[0].LastLoginAt.After(oldLogin) {
t.Fatalf("last_login_at = %v, want realtime value after %v", users[0].LastLoginAt, oldLogin)
}
if !users[0].RealtimeOnline || users[0].RealtimeDeviceCount != 1 {
t.Fatalf("realtime flags online=%v devices=%d", users[0].RealtimeOnline, users[0].RealtimeDeviceCount)
}
}
func TestEmbySessionCapabilitiesRefreshesRealtimeActivity(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "viewer",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
const secret = "test-secret"
tracker := service.NewSessionTrackerService(zap.NewNop())
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Sessions: tracker,
})
req := httptest.NewRequest(http.MethodPost, "/emby/Sessions/Capabilities/Full?deviceId=phone-1&device=iPhone&client=Infuse", strings.NewReader(`{}`))
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("capabilities status: %d body=%s", w.Code, w.Body.String())
}
sessions := tracker.List(t.Context())
if len(sessions) != 1 {
t.Fatalf("sessions = %#v, want one heartbeat session", sessions)
}
if sessions[0].DeviceID != "phone-1" || sessions[0].DeviceName != "iPhone" || sessions[0].Client != "Infuse" || sessions[0].UserName != "viewer" {
t.Fatalf("capabilities did not refresh client session: %#v", sessions[0])
}
}
func TestEmbySessionCapabilitiesIgnoresScopedPlaybackToken(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "viewer",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
const secret = "test-secret"
tracker := service.NewSessionTrackerService(zap.NewNop())
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Sessions: tracker,
})
req := httptest.NewRequest(http.MethodPost, "/emby/Sessions/Capabilities/Full?deviceId=phone-1&device=iPhone&client=Infuse", strings.NewReader(`{}`))
req.Header.Set("X-Emby-Token", signedTestTokenWithPurpose(t, secret, service.ExternalPlaybackTokenPurpose))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("capabilities status: %d body=%s", w.Code, w.Body.String())
}
if sessions := tracker.List(t.Context()); len(sessions) != 0 {
t.Fatalf("scoped external playback token must not create realtime session: %#v", sessions)
}
}
func TestEmbyLogoutRemovesRealtimeSessionFromPublicRoute(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "viewer",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
const secret = "test-secret"
tracker := service.NewSessionTrackerService(zap.NewNop())
tracker.RecordActivity(t.Context(), "user-1", "viewer", "phone-1", "iPhone", "Infuse", "192.0.2.10")
tracker.RecordActivity(t.Context(), "user-1", "viewer", "tv-1", "Apple TV", "Yamby", "192.0.2.11")
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Sessions: tracker,
})
req := httptest.NewRequest(http.MethodPost, "/emby/Sessions/Logout", nil)
req.Header.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="iPhone", DeviceId="phone-1", Token="`+signedTestToken(t, secret)+`"`)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("logout status: %d body=%s", w.Code, w.Body.String())
}
sessions := tracker.List(t.Context())
if len(sessions) != 1 {
t.Fatalf("sessions after logout = %#v, want only the other device", sessions)
}
if sessions[0].DeviceID != "tv-1" {
t.Fatalf("remaining session = %#v, want tv-1", sessions[0])
}
}
func TestEmbyLogoutIgnoresScopedPlaybackToken(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "viewer",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
const secret = "test-secret"
tracker := service.NewSessionTrackerService(zap.NewNop())
tracker.RecordActivity(t.Context(), "user-1", "viewer", "phone-1", "iPhone", "Infuse", "192.0.2.10")
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Sessions: tracker,
})
req := httptest.NewRequest(http.MethodPost, "/emby/Sessions/Logout", nil)
req.Header.Set("X-MediaBrowser-Authorization", `MediaBrowser Client="Infuse", Device="iPhone", DeviceId="phone-1", Token="`+signedTestTokenWithPurpose(t, secret, service.ExternalPlaybackTokenPurpose)+`"`)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("logout status: %d body=%s", w.Code, w.Body.String())
}
sessions := tracker.List(t.Context())
if len(sessions) != 1 || sessions[0].DeviceID != "phone-1" {
t.Fatalf("scoped external playback token must not logout realtime session: %#v", sessions)
}
}
func TestEmbyUppercaseSessionCapabilitiesRouteNoContent(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{Repo: repos})
req := httptest.NewRequest(http.MethodPost, "/Sessions/Capabilities/Full", strings.NewReader(`{}`))
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
}
func TestEmbySessionCapabilitiesRouteAllowsPreAuthProbe(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
registerEmbyRoutes(router, "test-secret", &service.Container{})
for _, path := range []string{"/Sessions/Capabilities", "/Sessions/Capabilities/Full", "/emby/Sessions/Capabilities", "/emby/Sessions/Capabilities/Full"} {
t.Run(path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
})
}
}
+107
View File
@@ -0,0 +1,107 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/ShukeBta/MMTL/internal/middleware"
"github.com/ShukeBta/MMTL/internal/service"
)
func embySessionsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc.Sessions == nil {
c.JSON(http.StatusOK, []any{})
return
}
out := make([]gin.H, 0)
for _, sess := range svc.Sessions.List(c.Request.Context()) {
last := sess.LastActivityAt
itemID := sess.ItemID
playState := gin.H{
"PositionTicks": sess.PositionTicks,
"IsPaused": sess.IsPaused,
"PlayMethod": "DirectStream",
"CanSeek": true,
}
row := gin.H{
"Id": sess.ID,
"ServerId": "mmtl-001",
"Client": sess.Client,
"DeviceId": sess.DeviceID,
"DeviceName": sess.DeviceName,
"UserId": sess.UserID,
"UserName": sess.UserName,
"LastActivityDate": last,
"RemoteEndPoint": sess.RemoteEndPoint,
"PlayState": playState,
"SupportsRemoteControl": true,
}
if itemID != "" && sess.IsPlaying {
row["NowPlayingItem"] = gin.H{"Id": itemID}
}
out = append(out, row)
}
c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, out)
}
}
func embySessionLogoutHandler(svc *service.Container, jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
if svc.Sessions != nil {
uid, _ := embyPublicSessionIdentity(c, svc, jwtSecret)
clientInfo := embyClientInfoFromRequest(c)
svc.Sessions.Logout(c.Request.Context(), uid, clientInfo.DeviceID, c.ClientIP())
}
c.Status(http.StatusNoContent)
}
}
func embySessionCapabilitiesHandler(svc *service.Container, jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
recordEmbyPublicSessionActivity(c, svc, jwtSecret)
c.Status(http.StatusNoContent)
}
}
func recordEmbyPublicSessionActivity(c *gin.Context, svc *service.Container, jwtSecret string) {
uid, username := embyPublicSessionIdentity(c, svc, jwtSecret)
recordEmbySessionActivity(c, svc, uid, username)
}
func embyPublicSessionIdentity(c *gin.Context, svc *service.Container, jwtSecret string) (string, string) {
if uid := embyUserID(c); uid != "" {
return uid, embyContextUserName(c)
}
token := embyRequestToken(c)
if strings.TrimSpace(token) == "" || strings.TrimSpace(jwtSecret) == "" {
return "", ""
}
claims := &middleware.Claims{}
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrTokenSignatureInvalid
}
return []byte(jwtSecret), nil
})
if err != nil || !parsed.Valid {
return "", ""
}
if strings.TrimSpace(claims.Purpose) != "" {
return "", ""
}
uid := strings.TrimSpace(claims.UserID)
if uid == "" {
return "", ""
}
if svc != nil && svc.Repo != nil && svc.Repo.User != nil {
if user, err := svc.Repo.User.FindByID(c.Request.Context(), uid); err == nil && user != nil {
return uid, user.Username
}
}
return uid, ""
}
+47
View File
@@ -0,0 +1,47 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/service"
)
func TestEmbySessionsReturnsRealtimeSession(t *testing.T) {
gin.SetMode(gin.TestMode)
tracker := service.NewSessionTrackerService(zap.NewNop())
tracker.RecordPlayback(t.Context(), "user-1", "viewer", "dev-1", "Apple TV", "Yamby", "10.0.0.8", "media-1", 1000, 2000, false)
svc := &service.Container{Sessions: tracker}
router := gin.New()
router.GET("/Sessions", embySessionsHandler(svc))
req := httptest.NewRequest(http.MethodGet, "/Sessions", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if got := w.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("cache-control = %q, want no-store", got)
}
var rows []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil {
t.Fatal(err)
}
if len(rows) != 1 {
t.Fatalf("sessions = %d, want 1: %s", len(rows), w.Body.String())
}
if rows[0]["UserId"] != "user-1" || rows[0]["DeviceId"] != "dev-1" || rows[0]["Client"] != "Yamby" {
t.Fatalf("session payload = %#v", rows[0])
}
if _, err := time.Parse(time.RFC3339Nano, rows[0]["LastActivityDate"].(string)); err != nil {
t.Fatalf("LastActivityDate should be RFC3339 time, got %#v", rows[0]["LastActivityDate"])
}
}
+191
View File
@@ -0,0 +1,191 @@
package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/ShukeBta/MMTL/internal/service"
)
func embyNoContentHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.Status(http.StatusNoContent)
}
}
func embyWebSocketHandler(svc *service.Container, jwtSecret string) gin.HandlerFunc {
return func(c *gin.Context) {
recordEmbyPublicSessionActivity(c, svc, jwtSecret)
if !websocket.IsWebSocketUpgrade(c.Request) {
c.Status(http.StatusNoContent)
return
}
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
done := make(chan struct{})
go func() {
defer close(done)
for {
if _, _, err := conn.NextReader(); err != nil {
return
}
}
}()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
recordEmbyPublicSessionActivity(c, svc, jwtSecret)
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
}
func embyServerConfigurationHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"EnableFolderView": true,
"EnableGroupingIntoCollections": true,
"EnableExternalContentInSuggestions": false,
"ImageSavingConvention": "Compatible",
})
}
}
func embyPublicServerConfigurationHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"IsStartupWizardCompleted": true,
"EnableRemoteAccess": true,
"EnableUPnP": false,
"EnableHttps": false,
"RequireHttps": false,
"LocalNetworkSubnets": []string{},
"LocalNetworkAddresses": []string{},
"RemoteClientBitrateLimit": 0,
})
}
}
func embyStartupConfigurationHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"IsStartupWizardCompleted": true,
"StartupWizardCompleted": true,
"EnableRemoteAccess": true,
"UICulture": "zh-CN",
"MetadataCountryCode": "CN",
"PreferredMetadataLanguage": "zh-CN",
})
}
}
func embyQuickConnectEnabledHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, false)
}
}
func embyEmptyItemsHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"Items": []any{}, "TotalRecordCount": 0})
}
}
func embyEmptyArrayHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, []any{})
}
}
func embyCustomCSSJSScriptsHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.Data(http.StatusOK, "application/javascript; charset=utf-8", nil)
}
}
func embyLocalizationCulturesHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, []gin.H{
{
"DisplayName": "简体中文",
"Name": "zh-CN",
"ThreeLetterISOLanguageName": "zho",
"TwoLetterISOLanguageName": "zh",
"ThreeLetterISOLanguageNames": []string{"zho", "chi"},
"IsRightToLeft": false,
},
{
"DisplayName": "English",
"Name": "en-US",
"ThreeLetterISOLanguageName": "eng",
"TwoLetterISOLanguageName": "en",
"ThreeLetterISOLanguageNames": []string{"eng"},
"IsRightToLeft": false,
},
})
}
}
func embyThemeMediaHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
empty := gin.H{"Items": []any{}, "TotalRecordCount": 0}
c.JSON(http.StatusOK, gin.H{
"ThemeVideosResult": empty,
"ThemeSongsResult": empty,
"SoundtrackSongsResult": empty,
})
}
}
func embyServerDomainsHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, []any{})
}
}
func embyDanmuRawHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.Data(http.StatusOK, "text/plain; charset=utf-8", nil)
}
}
func embyBrandingConfigHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"LoginDisclaimer": "",
"CustomCss": "",
"SplashscreenEnabled": false,
})
}
}
func embyBrandingCSSHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.Data(http.StatusOK, "text/css; charset=utf-8", []byte(""))
}
}
func embyLocalizationOptionsHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, []map[string]any{
{"Name": "简体中文", "Value": "zh-CN"},
{"Name": "English", "Value": "en-US"},
})
}
}
@@ -0,0 +1,190 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/model"
"github.com/ShukeBta/MMTL/internal/repository"
"github.com/ShukeBta/MMTL/internal/service"
)
// TestEmbySubtitleOfficialRouteServesRawASS verifies that the official Emby
// subtitle route shape
// (/Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/Stream.{format}) is
// registered, resolves the media, and streams the RAW ASS bytes — matching the
// advertised Codec — so a real Emby client can load the subtitle.
func TestEmbySubtitleOfficialRouteServesRawASS(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
dir := t.TempDir()
videoPath := filepath.Join(dir, "Movie.mkv")
subPath := filepath.Join(dir, "Movie.ass")
rawASS := "Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,test line\n"
if err := os.WriteFile(videoPath, []byte("video"), 0o644); err != nil {
t.Fatalf("write video: %v", err)
}
if err := os.WriteFile(subPath, []byte(rawASS), 0o644); err != nil {
t.Fatalf("write subtitle: %v", err)
}
mediaID := "media-1"
if err := db.Create(&model.Media{
Base: model.Base{ID: mediaID},
LibraryID: "lib-1",
Title: "Movie",
Path: videoPath,
Container: "mkv",
VideoCodec: "h264",
AudioCodec: "aac",
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos).SetSubtitleService(
service.NewSubtitleService(&config.Config{}, zap.NewNop(), repos),
),
})
// Official-format route:
// /Videos/{Id}/{MediaSourceId}/Subtitles/{Index}/Stream.{Format}
// Index 2 = first subtitle when audio present (Video 0, Audio 1, Sub 2).
req := httptest.NewRequest(http.MethodGet, "/emby/Videos/"+mediaID+"/"+mediaID+"/Subtitles/2/Stream.ass", nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status %d body=%s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct != "text/plain; charset=utf-8" {
t.Fatalf("Content-Type = %q, want text/plain", ct)
}
if got := w.Body.String(); got != rawASS {
t.Fatalf("served body = %q, want raw ASS %q", got, rawASS)
}
}
// TestEmbySubtitleDeliveryUrlGetsToken ensures the subtitle DeliveryUrl carries
// the auth token in PlaybackInfo (delivered via embyAttachTokenToMediaSources).
func TestEmbySubtitleDeliveryUrlGetsToken(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
dir := t.TempDir()
videoPath := filepath.Join(dir, "Movie.mkv")
subPath := filepath.Join(dir, "Movie.ass")
if err := os.WriteFile(videoPath, []byte("video"), 0o644); err != nil {
t.Fatalf("write video: %v", err)
}
if err := os.WriteFile(subPath, []byte("Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,x\n"), 0o644); err != nil {
t.Fatalf("write subtitle: %v", err)
}
mediaID := "media-1"
if err := db.Create(&model.Media{
Base: model.Base{ID: mediaID},
LibraryID: "lib-1",
Title: "Movie",
Path: videoPath,
Container: "mkv",
VideoCodec: "h264",
AudioCodec: "aac",
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
const secret = "test-secret"
token := signedTestToken(t, secret)
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos).SetSubtitleService(
service.NewSubtitleService(&config.Config{}, zap.NewNop(), repos),
),
})
req := httptest.NewRequest(http.MethodGet, "/emby/Items/"+mediaID+"/PlaybackInfo", nil)
req.Header.Set("X-Emby-Token", token)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status %d body=%s", w.Code, w.Body.String())
}
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
source := body["MediaSources"].([]any)[0].(map[string]any)
streams := source["MediaStreams"].([]any)
var deliveryURL string
for _, s := range streams {
stream := s.(map[string]any)
if stream["Type"] == "Subtitle" {
deliveryURL, _ = stream["DeliveryUrl"].(string)
break
}
}
if deliveryURL == "" {
t.Fatalf("no subtitle DeliveryUrl found: %#v", source)
}
// Official shape with bare MediaSourceId + format suffix and the token appended.
wantPrefix := "/Videos/" + mediaID + "/" + mediaID + "/Subtitles/2/Stream.ass"
if deliveryURL[:len(wantPrefix)] != wantPrefix {
t.Fatalf("DeliveryUrl %q does not start with %q", deliveryURL, wantPrefix)
}
if !strings.Contains(deliveryURL, "api_key="+token) {
t.Fatalf("DeliveryUrl %q missing auth token", deliveryURL)
}
}
+98
View File
@@ -0,0 +1,98 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
)
func embySystemInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, embyWithRequestAddress(c, svc.Emby.SystemInfo()))
}
}
func embySystemInfoPublicHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, embyWithRequestAddress(c, svc.Emby.SystemInfoPublic()))
}
}
func embyRequestBaseURL(c *gin.Context) string {
proto := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto"))
if proto == "" {
if c.Request != nil && c.Request.TLS != nil {
proto = "https"
} else {
proto = "http"
}
}
if comma := strings.Index(proto, ","); comma >= 0 {
proto = strings.TrimSpace(proto[:comma])
}
host := strings.TrimSpace(c.GetHeader("X-Forwarded-Host"))
if host == "" && c.Request != nil {
host = strings.TrimSpace(c.Request.Host)
}
if host == "" {
return ""
}
return strings.TrimRight(proto+"://"+host, "/")
}
func embyWithRequestAddress(c *gin.Context, payload map[string]any) map[string]any {
out := make(map[string]any, len(payload)+2)
for key, value := range payload {
out[key] = value
}
if address := embyRequestBaseURL(c); address != "" {
out["LocalAddress"] = address
out["WanAddress"] = address
out["PublishedServerUrl"] = address
}
return out
}
func embySystemEndpointHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"IsLocal": true,
"IsInNetwork": true,
})
}
}
func embyPingHandler(_ *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
// Emby/Jellyfin 期望 plain text "Emby Server"
c.String(http.StatusOK, "Emby Server")
}
}
func embyRootHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, embyPublicSystemInfoPayload(c, svc))
}
}
func embyPublicSystemInfoPayload(c *gin.Context, svc *service.Container) map[string]any {
if svc != nil && svc.Emby != nil {
return embyWithRequestAddress(c, svc.Emby.SystemInfoPublic())
}
return embyWithRequestAddress(c, map[string]any{
"Id": "mmtl-001",
"ServerId": "mmtl-001",
"ServerName": "MMTL",
"Version": "4.8.10.0",
"ServerVersion": "4.8.10.0",
"ProductName": "Emby Server",
"OperatingSystem": "Windows",
"SupportsHttps": false,
"SupportsAutoDiscovery": true,
"StartupWizardCompleted": true,
})
}
@@ -0,0 +1,35 @@
package handler
import (
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/ShukeBta/MMTL/internal/middleware"
)
func signedTestToken(t *testing.T, secret string) string {
t.Helper()
return signedTestTokenWithPurpose(t, secret, "")
}
func signedTestTokenWithPurpose(t *testing.T, secret, purpose string) string {
t.Helper()
claims := middleware.Claims{
UserID: "user-1",
Role: "admin",
Tier: "plus",
Purpose: purpose,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
Issuer: "mmtl-test",
Subject: "user-1",
},
}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
if err != nil {
t.Fatalf("sign token: %v", err)
}
return token
}
+174
View File
@@ -0,0 +1,174 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
)
// ─── Users / Auth ────────────────────────────────────────────────────────────
// embyAuthByNameHandler 处理 POST /Users/AuthenticateByName。
//
// 这是 Emby 客户端登录的唯一入口(Infuse / Yamby / Hills 等都走这里)。
// 用户名+密码 → 调用我们已有的 AuthService.Login → 返回 AccessToken + User。
func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
req, err := parseEmbyAuthByNameReq(c)
if err != nil {
embyError(c, http.StatusBadRequest, "invalid body")
return
}
password := req.Pw
if password == "" {
password = req.Password
}
if strings.TrimSpace(req.Username) == "" || password == "" {
if req.PasswordMd5 != "" || req.PasswordSha1 != "" {
embyError(c, http.StatusBadRequest, "plain password required")
return
}
embyError(c, http.StatusBadRequest, "missing username or password")
return
}
resp, err := svc.Auth.Login(c.Request.Context(), req.Username, password)
if err != nil {
embyError(c, http.StatusUnauthorized, err.Error())
return
}
// 记录登录设备会话并执行防共享检测(登录客户端数 / 设备指纹)。
clientInfo := embyClientInfoFromRequest(c)
if svc.Sessions != nil {
svc.Sessions.RecordLogin(c.Request.Context(), resp.User.ID, resp.User.Username,
clientInfo.DeviceID,
clientInfo.DeviceName,
clientInfo.Client,
c.ClientIP())
}
if svc.Device != nil {
svc.Device.RecordLogin(c.Request.Context(), resp.User.ID,
clientInfo.DeviceID,
clientInfo.DeviceName,
clientInfo.Client,
c.ClientIP())
}
userPayload, _ := svc.Emby.FindUser(c.Request.Context(), resp.User.ID)
// Emby/Jellyfin 客户端没有 refresh token 机制:它们把这里返回的
// AccessToken 长期保存并反复使用。若返回 60 分钟的普通 access
// token,客户端每小时就会掉登录、无法播放、媒体库无法刷新。因此
// 签发长期令牌(IssueEmbyToken)匹配 Emby 持久化令牌语义。
accessToken := resp.Tokens.AccessToken
if longLived, err := svc.Auth.IssueEmbyToken(resp.User); err == nil && longLived != "" {
accessToken = longLived
}
embyRememberCompatSession(c, accessToken)
c.JSON(http.StatusOK, gin.H{
"AccessToken": accessToken,
"ServerId": "mmtl-001",
"User": userPayload,
"SessionInfo": gin.H{
"Id": resp.User.ID,
"UserId": resp.User.ID,
"UserName": resp.User.Username,
"Client": clientInfo.Client,
"DeviceId": clientInfo.DeviceID,
"DeviceName": clientInfo.DeviceName,
},
})
}
}
func embyPublicUsersHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
// 公开用户列表(Emby Web 客户端登录页拉这个,列出可见用户)。
users, err := svc.Emby.ListUsers(c.Request.Context())
if err != nil {
c.JSON(http.StatusOK, []any{})
return
}
// 公开版本只暴露 Id + Name,不包含 Policy。
out := make([]map[string]any, 0, len(users))
for _, u := range users {
out = append(out, map[string]any{
"Id": u["Id"],
"Name": u["Name"],
"ServerId": u["ServerId"],
"HasPassword": true,
})
}
c.JSON(http.StatusOK, out)
}
}
func embyListUsersHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
users, err := svc.Emby.ListUsers(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, users)
}
}
func embyMeHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := embyUserID(c)
if uid == "" {
embyError(c, http.StatusUnauthorized, "not authenticated")
return
}
u, err := svc.Emby.FindUser(c.Request.Context(), uid)
if err != nil || u == nil {
embyError(c, http.StatusNotFound, "user not found")
return
}
c.JSON(http.StatusOK, u)
}
}
func embyGetUserByIDHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
u, err := svc.Emby.FindUser(c.Request.Context(), c.Param("userId"))
if err == nil && u != nil {
c.JSON(http.StatusOK, u)
return
}
if authUID := embyUserID(c); authUID != "" && authUID != c.Param("userId") {
u, err = svc.Emby.FindUser(c.Request.Context(), authUID)
if err == nil && u != nil {
c.JSON(http.StatusOK, u)
return
}
}
c.JSON(http.StatusOK, embyFallbackUser(c.Param("userId")))
}
}
func embyFallbackUser(id string) gin.H {
if strings.TrimSpace(id) == "" {
id = "mmtl-user"
}
return gin.H{
"Id": id,
"Name": "MMTL",
"ServerId": "mmtl-001",
"HasPassword": true,
"HasConfiguredPassword": true,
"HasConfiguredEasyPassword": false,
"EnableAutoLogin": false,
"Policy": gin.H{
"IsAdministrator": true,
"EnableContentDeletion": true,
"EnableRemoteControlOfOtherUsers": true,
"EnableSharedDeviceControl": true,
"EnableRemoteAccess": true,
"EnableAllDevices": true,
"EnableAllChannels": true,
"EnableAllFolders": true,
},
}
}
+71
View File
@@ -0,0 +1,71 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MMTL/internal/service"
)
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
out, err := svc.Emby.Views(c.Request.Context(), uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
embyAttachRequestTokenToMediaSources(c, out)
c.JSON(http.StatusOK, out)
}
}
func embyVirtualFoldersHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Cache-Control", "no-store")
libs, err := svc.Repo.Library.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
libs = service.FilterDisplayCloudLibraries(c.Request.Context(), svc.Repo, libs)
uid := embyUserID(c)
visibility := service.UserDefaultMediaVisibility(c.Request.Context(), svc.Repo, uid)
out := make([]gin.H, 0, len(libs))
for _, lib := range libs {
if !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, lib, visibility) {
continue
}
collectionType := "movies"
switch lib.Type {
case "tv", "anime", "variety":
collectionType = "tvshows"
case "music":
collectionType = "music"
}
// 库封面广告:能解析出封面时才填 ImageTags.Primary,让客户端去请求
// /Items/{libID}/Images/Primary(MediaFolders 是老式端点,客户端主要
// 靠 ImageTags 判断有无主图)。
imageTags := map[string]string{}
if svc.Emby.LibraryHasCover(c.Request.Context(), lib.ID) {
imageTags["Primary"] = lib.ID
}
out = append(out, gin.H{
"Name": lib.Name,
"Locations": []string{lib.Path},
"CollectionType": collectionType,
"ItemId": lib.ID,
"Id": lib.ID,
"PrimaryImageItemId": lib.ID,
"ImageTags": imageTags,
"RefreshStatus": "Idle",
"LibraryOptions": gin.H{},
})
}
c.JSON(http.StatusOK, out)
}
}

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