Compare commits

..

140 Commits

Author SHA1 Message Date
github-actions[bot] 4ceacf84fd chore: bump version to 0.1.154 2026-09-27 05:52:32 +00:00
github-actions[bot] 7808747414 chore: bump version to 0.1.153 2026-09-27 05:04:40 +00:00
github-actions[bot] 36a21ce02f chore: bump version to 0.1.152 2026-09-25 12:31:06 +00:00
github-actions[bot] 52e77c18b4 chore: bump version to 0.1.151 2026-09-25 05:24:22 +00:00
github-actions[bot] 2f877abd66 chore: bump version to 0.1.150 2026-09-25 05:10:37 +00:00
github-actions[bot] 5c685a92ee chore: bump version to 0.1.149 2026-09-24 14:27:30 +00:00
github-actions[bot] 3a86a46b3a chore: bump version to 0.1.148 2026-09-24 03:43:33 +00:00
github-actions[bot] 2a095b6242 chore: bump version to 0.1.147 2026-09-23 15:07:21 +00:00
github-actions[bot] 130c44bc53 chore: bump version to 0.1.146 2026-09-23 14:31:29 +00:00
github-actions[bot] 38a65f2ef9 chore: bump version to 0.1.145 2026-09-23 09:01:30 +00:00
github-actions[bot] 295e23cb79 chore: bump version to 0.1.144 2026-09-23 08:07:44 +00:00
github-actions[bot] 6e32d9c1b2 chore: bump version to 0.1.143 2026-09-23 05:54:10 +00:00
github-actions[bot] 2514e9f208 chore: bump version to 0.1.142 2026-09-23 02:22:30 +00:00
github-actions[bot] 76c79ba917 chore: bump version to 0.1.141 2026-09-22 14:31:15 +00:00
github-actions[bot] c4928f60b2 chore: bump version to 0.1.140 2026-09-22 08:22:48 +00:00
github-actions[bot] abf4e80f89 chore: bump version to 0.1.139 2026-09-20 07:16:58 +00:00
github-actions[bot] 3dd9d7d92a chore: bump version to 0.1.138 2026-09-20 06:15:08 +00:00
github-actions[bot] f555fd049a chore: bump version to 0.1.137 2026-09-20 05:34:06 +00:00
github-actions[bot] 3204a1d7ee chore: bump version to 0.1.136 2026-09-19 13:30:51 +00:00
github-actions[bot] 36faddad1f chore: bump version to 0.1.135 2026-09-18 15:36:09 +00:00
github-actions[bot] 5ce1b8846e chore: bump version to 0.1.134 2026-09-18 14:52:25 +00:00
github-actions[bot] 7d3bf9e961 chore: bump version to 0.1.133 2026-09-18 13:55:35 +00:00
github-actions[bot] 74825339e2 chore: bump version to 0.1.132 2026-09-18 04:00:24 +00:00
github-actions[bot] 68cbb70139 chore: bump version to 0.1.131 2026-09-18 03:28:47 +00:00
github-actions[bot] 92044702ae chore: bump version to 0.1.130 2026-09-18 02:09:28 +00:00
github-actions[bot] 0201eac24a chore: bump version to 0.1.129 2026-09-17 15:53:38 +00:00
github-actions[bot] 0ed637cfcf chore: bump version to 0.1.128 2026-09-17 15:21:11 +00:00
github-actions[bot] 84a5fe3858 chore: bump version to 0.1.127 2026-09-17 14:58:04 +00:00
github-actions[bot] 474f6fe2d6 chore: bump version to 0.1.126 2026-09-17 14:34:12 +00:00
github-actions[bot] 60fdebcaa0 chore: bump version to 0.1.125 2026-09-17 14:34:08 +00:00
github-actions[bot] 45782db6bb chore: bump version to 0.1.124 2026-09-17 06:17:33 +00:00
github-actions[bot] 48f723762f chore: bump version to 0.1.123 2026-09-17 03:19:56 +00:00
github-actions[bot] 47e83f22be chore: bump version to 0.1.122 2026-09-17 01:22:16 +00:00
github-actions[bot] 0e551035d1 chore: bump version to 0.1.121 2026-09-16 17:29:26 +00:00
github-actions[bot] 62f6757f9d chore: bump version to 0.1.120 2026-09-16 17:08:47 +00:00
github-actions[bot] a428459833 chore: bump version to 0.1.119 2026-09-16 16:32:05 +00:00
github-actions[bot] f095c96a24 chore: bump version to 0.1.118 2026-09-16 15:59:57 +00:00
github-actions[bot] ae05c2673a chore: bump version to 0.1.117 2026-09-16 15:36:19 +00:00
github-actions[bot] 103a5e472a chore: bump version to 0.1.116 2026-09-16 14:20:40 +00:00
github-actions[bot] 757009bbfb chore: bump version to 0.1.115 2026-09-16 13:45:02 +00:00
github-actions[bot] d362d4eee6 chore: bump version to 0.1.114 2026-09-16 13:20:03 +00:00
github-actions[bot] 593de90ca6 chore: bump version to 0.1.113 2026-09-16 10:34:42 +00:00
github-actions[bot] 8c0c6c8d7c chore: bump version to 0.1.112 2026-09-16 05:50:19 +00:00
github-actions[bot] 5be3e316c8 chore: bump version to 0.1.111 2026-09-16 04:30:54 +00:00
github-actions[bot] 94b90678df chore: bump version to 0.1.110 2026-09-15 15:13:46 +00:00
github-actions[bot] eaa96ba58a chore: bump version to 0.1.109 2026-09-15 14:05:52 +00:00
github-actions[bot] 44a2af354e chore: bump version to 0.1.108 2026-09-15 10:51:08 +00:00
github-actions[bot] 3a80e0e099 chore: bump version to 0.1.107 2026-09-15 10:26:37 +00:00
github-actions[bot] 12e72b3c2c chore: bump version to 0.1.106 2026-09-15 08:48:59 +00:00
github-actions[bot] 4fde0062b7 chore: bump version to 0.1.105 2026-09-15 08:13:14 +00:00
github-actions[bot] de06a7b2d9 chore: bump version to 0.1.104 2026-09-15 07:24:07 +00:00
github-actions[bot] c2b0f85f89 chore: bump version to 0.1.103 2026-09-15 03:30:12 +00:00
github-actions[bot] 5c64086828 chore: bump version to 0.1.102 2026-09-15 01:59:02 +00:00
github-actions[bot] 2c6bfbf747 chore: bump version to 0.1.101 2026-09-14 16:01:14 +00:00
github-actions[bot] 64229a087e chore: bump version to 0.1.100 2026-09-14 14:55:33 +00:00
github-actions[bot] 1bd3f5bb60 chore: bump version to 0.1.99 2026-09-14 13:57:06 +00:00
github-actions[bot] 6c8d6bc70b chore: bump version to 0.1.98 2026-09-14 13:40:32 +00:00
github-actions[bot] b8f65dc957 chore: bump version to 0.1.97 2026-09-14 13:16:47 +00:00
github-actions[bot] fa219c6037 chore: bump version to 0.1.96 2026-09-14 09:58:11 +00:00
github-actions[bot] 9821189ab1 chore: bump version to 0.1.95 2026-09-14 08:48:00 +00:00
github-actions[bot] 5d4cc0f4ea chore: bump version to 0.1.94 2026-09-14 07:49:17 +00:00
github-actions[bot] 01c0e9f833 chore: bump version to 0.1.93 2026-09-14 07:13:07 +00:00
github-actions[bot] 141c2c8456 chore: bump version to 0.1.92 2026-09-14 04:34:34 +00:00
github-actions[bot] 8a5ca22804 chore: bump version to 0.1.91 2026-09-13 16:03:05 +00:00
github-actions[bot] 9754c2c392 chore: bump version to 0.1.90 2026-09-13 15:45:36 +00:00
github-actions[bot] 6b53f6ebd3 chore: bump version to 0.1.89 2026-09-13 15:39:18 +00:00
github-actions[bot] cdb0c5fe9b chore: bump version to 0.1.88 2026-09-13 15:11:42 +00:00
github-actions[bot] 11e04196cb chore: bump version to 0.1.87 2026-09-13 15:02:02 +00:00
github-actions[bot] 29edc90ebc chore: bump version to 0.1.86 2026-09-13 14:01:10 +00:00
github-actions[bot] 6e18fb01c6 chore: bump version to 0.1.85 2026-09-13 13:23:18 +00:00
github-actions[bot] 32beac3065 chore: bump version to 0.1.84 2026-09-13 13:03:23 +00:00
github-actions[bot] 6192e05e66 chore: bump version to 0.1.83 2026-09-13 12:36:31 +00:00
github-actions[bot] af4c65f15f chore: bump version to 0.1.82 2026-09-12 16:41:16 +00:00
github-actions[bot] 4bca14699b chore: bump version to 0.1.81 2026-09-12 15:06:58 +00:00
github-actions[bot] d98944ed63 chore: bump version to 0.1.80 2026-09-12 14:31:43 +00:00
github-actions[bot] 7dbd05b21e chore: bump version to 0.1.79 2026-09-12 08:56:07 +00:00
github-actions[bot] 3cc4dbce77 chore: bump version to 0.1.78 2026-09-12 08:32:08 +00:00
github-actions[bot] 074c147da5 chore: bump version to 0.1.77 2026-09-12 08:07:33 +00:00
github-actions[bot] 5b8e3dd3d3 chore: bump version to 0.1.76 2026-09-12 04:46:46 +00:00
github-actions[bot] 2141ee0aed chore: bump version to 0.1.75 2026-09-12 03:47:48 +00:00
github-actions[bot] 8b80b5f8c5 chore: bump version to 0.1.74 2026-09-12 03:02:38 +00:00
github-actions[bot] e2af6d9100 chore: bump version to 0.1.73 2026-09-12 02:34:07 +00:00
github-actions[bot] 805fe48b8e chore: bump version to 0.1.72 2026-09-12 02:28:08 +00:00
github-actions[bot] d202c81467 chore: bump version to 0.1.71 2026-09-11 17:00:35 +00:00
github-actions[bot] 07f1b16595 chore: bump version to 0.1.70 2026-09-11 13:30:45 +00:00
github-actions[bot] 6bd8322cc0 chore: bump version to 0.1.69 2026-09-11 12:46:05 +00:00
github-actions[bot] c52efcdb15 chore: bump version to 0.1.68 2026-09-11 11:53:04 +00:00
github-actions[bot] ae41ebb511 chore: bump version to 0.1.67 2026-09-11 11:35:25 +00:00
github-actions[bot] 24c9717540 chore: bump version to 0.1.66 2026-09-11 07:35:16 +00:00
github-actions[bot] 539398efb9 chore: bump version to 0.1.65 2026-09-11 07:14:43 +00:00
github-actions[bot] d989354a16 chore: bump version to 0.1.64 2026-09-10 14:30:31 +00:00
github-actions[bot] 55a5eb443a chore: bump version to 0.1.63 2026-09-10 14:06:43 +00:00
github-actions[bot] 17a29d7832 chore: bump version to 0.1.62 2026-09-10 08:58:55 +00:00
github-actions[bot] 912d86dd8c chore: bump version to 0.1.61 2026-09-09 14:25:04 +00:00
github-actions[bot] 5342be569e chore: bump version to 0.1.60 2026-09-09 14:04:07 +00:00
github-actions[bot] 9a2e0b4291 chore: bump version to 0.1.59 2026-09-09 12:32:17 +00:00
github-actions[bot] 88df42ca5d chore: bump version to 0.1.58 2026-09-09 11:26:37 +00:00
github-actions[bot] f33aa6798c chore: bump version to 0.1.57 2026-09-09 09:36:25 +00:00
github-actions[bot] 02b7f7afe9 chore: bump version to 0.1.56 2026-09-09 09:07:42 +00:00
github-actions[bot] ebb07bc5ed chore: bump version to 0.1.55 2026-09-08 16:39:12 +00:00
github-actions[bot] 61d05163a5 chore: bump version to 0.1.54 2026-09-08 16:18:15 +00:00
github-actions[bot] 9387d999c7 chore: bump version to 0.1.53 2026-09-08 16:03:32 +00:00
github-actions[bot] ffe78bf0c0 chore: bump version to 0.1.52 2026-09-08 15:44:34 +00:00
github-actions[bot] 2e5a055c0b chore: bump version to 0.1.51 2026-09-08 15:12:16 +00:00
github-actions[bot] 7110dc62e5 chore: bump version to 0.1.50 2026-09-08 15:05:22 +00:00
github-actions[bot] 26379d6558 chore: bump version to 0.1.49 2026-09-08 14:44:50 +00:00
github-actions[bot] 121a8602cf chore: bump version to 0.1.48 2026-09-08 14:12:39 +00:00
github-actions[bot] 83f4443dc3 chore: bump version to 0.1.47 2026-09-08 14:00:23 +00:00
github-actions[bot] fc233f01f1 chore: bump version to 0.1.46 2026-09-08 13:09:04 +00:00
github-actions[bot] f2cafa54c1 chore: bump version to 0.1.45 2026-09-08 12:52:51 +00:00
github-actions[bot] 703d0b3fc4 chore: bump version to 0.1.44 2026-09-08 12:24:57 +00:00
github-actions[bot] 4ae0de712c chore: bump version to 0.1.43 2026-09-08 10:13:07 +00:00
github-actions[bot] 770ec29fba chore: bump version to 0.1.42 2026-09-08 01:46:20 +00:00
github-actions[bot] ed3b47c532 chore: bump version to 0.1.41 2026-09-08 01:24:48 +00:00
github-actions[bot] 24bfe99216 chore: bump version to 0.1.40 2026-09-08 00:41:21 +00:00
github-actions[bot] 431704008a chore: bump version to 0.1.39 2026-09-07 16:37:21 +00:00
github-actions[bot] ce9ab433cd chore: bump version to 0.1.38 2026-09-07 16:10:38 +00:00
github-actions[bot] a650e9fb37 chore: bump version to 0.1.37 2026-09-07 15:46:13 +00:00
github-actions[bot] 1108670ae5 chore: bump version to 0.1.36 2026-09-07 15:21:39 +00:00
github-actions[bot] e2fde74b75 chore: bump version to 0.1.35 2026-09-07 15:11:05 +00:00
github-actions[bot] 1bfc5e12e7 chore: bump version to 0.1.34 2026-09-07 14:56:41 +00:00
github-actions[bot] 9f208cd0a2 chore: bump version to 0.1.33 2026-09-07 05:56:43 +00:00
github-actions[bot] 9f4b1fed96 chore: bump version to 0.1.32 2026-09-07 01:02:43 +00:00
github-actions[bot] 08f64d3788 chore: bump version to 0.1.31 2026-09-06 15:22:46 +00:00
github-actions[bot] 4f5a45b415 chore: bump version to 0.1.30 2026-09-06 14:58:57 +00:00
github-actions[bot] 7a846ccd45 chore: bump version to 0.1.29 2026-09-06 14:10:39 +00:00
github-actions[bot] 251afa68a6 chore: bump version to 0.1.28 2026-09-06 12:32:27 +00:00
github-actions[bot] 93d1065772 chore: bump version to 0.1.27 2026-09-06 11:03:06 +00:00
github-actions[bot] bbc5af6302 chore: bump version to 0.1.26 2026-09-06 09:37:32 +00:00
github-actions[bot] 9da2d2601f chore: bump version to 0.1.25 2026-09-06 09:08:12 +00:00
github-actions[bot] dc08d3eb0a chore: bump version to 0.1.24 2026-09-06 08:17:54 +00:00
github-actions[bot] cf286869dc chore: bump version to 0.1.23 2026-09-06 05:42:34 +00:00
github-actions[bot] b6dfc3ff3f chore: bump version to 0.1.22 2026-09-06 04:30:51 +00:00
github-actions[bot] b3f1ba49fd chore: bump version to 0.1.21 2026-09-06 04:28:08 +00:00
github-actions[bot] 165c1cec54 chore: bump version to 0.1.20 2026-09-05 17:16:43 +00:00
github-actions[bot] d49e3d49d2 chore: bump version to 0.1.19 2026-09-05 16:01:51 +00:00
github-actions[bot] 8e0e77f15c chore: bump version to 0.1.18 2026-09-05 15:02:42 +00:00
github-actions[bot] ed23ac59d4 chore: bump version to 0.1.17 2026-09-05 10:33:43 +00:00
github-actions[bot] 10489c6e69 chore: bump version to 0.1.16 2026-09-05 10:27:53 +00:00
truewhile d1f43e82af init: version 分支仅跟踪 VERSION 文件,供 CI 读写版本号 2026-09-05 18:26:57 +08:00
828 changed files with 1 additions and 202107 deletions
-63
View File
@@ -1,63 +0,0 @@
.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/
.mebox.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
@@ -1,20 +0,0 @@
* 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
@@ -1,52 +0,0 @@
---
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
@@ -1,5 +0,0 @@
blank_issues_enabled: true
contact_links:
- name: Telegram MeBox 交流群
url: https://t.me/MeBox
about: 适合快速交流部署经验、使用问题和排查线索。
-29
View File
@@ -1,29 +0,0 @@
---
name: 功能建议
about: 提出新功能、体验改进或兼容性需求
title: "[Feature] "
labels: enhancement
assignees: ""
---
## 需求背景
这个功能解决什么问题?当前使用流程哪里不方便?
## 期望方案
请描述你希望 MeBox 如何工作。
## 使用场景
- 部署环境:
- 相关页面或模块:
- 受影响用户:
## 可接受的替代方案
如果有其他实现方式或临时解决办法,也请写出来。
## 补充信息
截图、竞品参考、API 文档、相关讨论链接等。
-4
View File
@@ -1,4 +0,0 @@
name: MeBox CodeQL
paths-ignore:
- internal/service/fileid_other.go
-40
View File
@@ -1,40 +0,0 @@
## 背景
说明这个 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 已按需同步。
-305
View File
@@ -1,305 +0,0 @@
name: AuTo Docker Image
on:
push:
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=MeBox-v${NEW_VERSION}" >> $GITHUB_OUTPUT
echo "tag=mebox-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 }}/mebox
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 "MeBox ${{ 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/mebox-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server
- name: Package
run: |
mkdir -p package/mebox
cp "dist/mebox-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" package/mebox/mebox${{ matrix.ext }}
cp README.md package/mebox/ 2>/dev/null || true
if [ "${{ matrix.goos }}" = "windows" ]; then
(cd package && zip -r "../mebox_${{ matrix.goos }}_${{ matrix.goarch }}.zip" mebox)
else
tar -czf "mebox_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz" -C package mebox
fi
- name: Upload to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.version-and-publish.outputs.tag }}
run: |
set -eux
PKG="mebox_${{ matrix.goos }}_${{ matrix.goarch }}.zip"
TAR="mebox_${{ matrix.goos }}_${{ matrix.goarch }}.tar.gz"
# 并发上传到同一 release 各自文件,--clobber 幂等覆盖
if [ -f "$PKG" ]; then
for i in 1 2 3; do gh release upload "$RELEASE_TAG" "$PKG" --clobber && break || sleep 5; done
fi
if [ -f "$TAR" ]; then
for i in 1 2 3; do gh release upload "$RELEASE_TAG" "$TAR" --clobber && break || sleep 5; done
fi
deploy:
name: Deploy to Server
needs: [version-and-publish]
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
password: ${{ secrets.SERVER_PASSWORD }}
port: ${{ secrets.SERVER_PORT }}
script: |
set -e
echo "==== 开始部署 MeBox ===="
cd /root/dockerData/mebox
# 判断 compose 命令版本兼容性(docker compose 或 docker-compose)
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
else
echo "错误: 未找到 docker compose 或 docker-compose"
exit 1
fi
echo "正在拉取最新镜像..."
$COMPOSE_CMD pull
echo "正在重启服务..."
$COMPOSE_CMD up -d
echo "清理旧的无用镜像..."
docker image prune -f
echo "==== 部署完成并已启动 ===="
-133
View File
@@ -1,133 +0,0 @@
# Beta 分支自动构建流水线
#
# 触发:push 到 beta 分支 / PR 到 beta / 手动触发。
# 产出:
# 1. 前端 + 后端编译验证(go vet / go test / go build)
# 2. 多平台可执行二进制 artifact(linux/amd64、linux/arm64、windows/amd64)
# 3. ghcr.io/{owner}/mebox: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/mebox-beta-linux-amd64 ./cmd/server
- name: Build linux/arm64
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server
- name: Build windows/amd64
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: mebox-beta-binaries
path: dist/*
if-no-files-found: error
# ─────────────────────────────────────────────────────────────────────────────
# 2) Beta Docker 镜像(ghcr.io/{owner}/mebox: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 }}/mebox: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
@@ -1,84 +0,0 @@
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"
-101
View File
@@ -1,101 +0,0 @@
name: Publish Docker image
on:
workflow_dispatch:
inputs:
version:
description: 'Image tag to publish, for example MeBox-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 }}/mebox
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
deploy:
name: Deploy to Server
needs: [docker]
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
password: ${{ secrets.SERVER_PASSWORD }}
port: ${{ secrets.SERVER_PORT }}
script: |
set -e
echo "==== 开始部署 MeBox ===="
cd /root/dockerData/mebox
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
else
echo "错误: 未找到 docker compose 或 docker-compose"
exit 1
fi
echo "正在拉取最新镜像..."
$COMPOSE_CMD pull
echo "正在重启服务..."
$COMPOSE_CMD up -d
echo "清理旧的无用镜像..."
docker image prune -f
echo "==== 部署完成并已启动 ===="
-82
View File
@@ -1,82 +0,0 @@
# 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.*
.mebox.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
@@ -1,114 +0,0 @@
# 贡献规范
感谢你愿意帮助 MeBox 变得更稳定。这个项目主要面向 NAS、Docker 部署、媒体库整理、订阅下载和多端播放场景;提交 Issue 或 Pull Request 时,请尽量提供可复现、可验证的信息。
## Issue 提交规范
提交 Issue 前,请先确认:
- 已搜索现有 Issues,避免重复提交同一个问题。
- 使用的是最新镜像、最新主分支,或已说明当前版本号 / 镜像摘要。
- 如果是部署或运行问题,已附上部署方式和关键配置。
### Bug Report 必填信息
- 问题现象:实际发生了什么,是否稳定复现。
- 期望行为:你认为正确结果应该是什么。
- 复现步骤:从哪个页面、点击什么、填写什么、触发什么任务。
- 部署方式:Docker 第一档 / 第二档 / 第三档、裸机运行、反代方式等。
- 环境信息:NAS 型号或系统、Docker / Compose 版本、浏览器、MeBox 镜像版本。
- 相关配置:路径映射、下载器保存路径、媒体库路径、站点类型等。请隐藏 Cookie、API Key、密码和 Token。
- 日志和任务信息:优先提供应用日志、任务队列详情、浏览器控制台错误、网络请求错误。
### 日志建议
排查订阅、站点搜索、下载器、整理入库、网盘扫描时,建议临时把日志级别调整为 `info` 或 `debug`,复现后再恢复。
Docker 部署常用命令:
```bash
docker compose ps
docker compose logs --tail=300 mebox
docker compose exec mebox sh -lc 'ls -la /data/logs || true'
```
PostgreSQL 部署查询示例:
```bash
docker compose exec postgres psql -U mebox -d mebox -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
@@ -1,95 +0,0 @@
# syntax=docker/dockerfile:1.6
# =============================================================================
# Multi-architecture build for MeBox.
#
# Stage 1 (frontend) : Node 20 -> static SPA bundle
# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0)
# Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user
#
# Build:
# docker buildx build --platform linux/amd64,linux/arm64 \
# --build-arg VERSION=MeBox-v0.1.16 -t mebox:latest --push .
#
# Optional Intel VAAPI/QSV runtime packages:
# docker buildx build --build-arg WITH_VAAPI=true ...
# =============================================================================
# ---- 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 mebox ./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 mebox && adduser -S mebox -G mebox
WORKDIR /app
COPY --from=backend /app/mebox /usr/local/bin/mebox
COPY --from=frontend /app/web/dist /app/web/dist
RUN mkdir -p /data /cache /media \
&& chown -R mebox:mebox /data /cache /media
# Default environment (overridable via docker-compose / `docker run -e`).
ENV MEBOX_APP_PORT=8080 \
MEBOX_APP_DATA_DIR=/data \
MEBOX_APP_WEB_DIR=/app/web/dist \
MEBOX_DATABASE_DB_PATH=/data/mebox.db \
MEBOX_CACHE_CACHE_DIR=/cache \
MEBOX_LOGGING_LEVEL=info \
TZ=Asia/Shanghai
EXPOSE 8080
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
@@ -1,9 +0,0 @@
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
-253
View File
@@ -1,253 +0,0 @@
# MeBox
<p align="center">
<img src="web/public/brand/logo-192.png" width="96" height="96" alt="MeBox Logo" />
</p>
<h3 align="center">面向 NAS 与家庭影音场景的私人媒体中心</h3>
<p align="center">
<strong>媒体库 · 刮削整理 · 网盘 STRM · Emby 协议 · 远程 Emby 挂载 · 多用户权限 · Docker 一键部署</strong>
</p>
<p align="center">
<a href="#项目简介">项目简介</a> ·
<a href="#快速开始">快速开始</a> ·
<a href="#部署档位">部署档位</a> ·
<a href="#鸣谢">鸣谢</a> ·
<a href="#开发构建">开发构建</a> ·
<a href="README_EN.md">English</a> ·
<a href="CONTRIBUTING.md">贡献规范</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>
---
## 项目简介
**MeBox** 是一个自托管私人媒体管理系统,适合 NAS、小主机、家庭共享和多端播放场景。本项目由 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) fork 并持续二开维护,在保留「一套服务覆盖网页、手机、电视与第三方播放器」思路的同时,围绕网盘播放、任务队列、远程挂载和权限体系做了大量增强。
你可以把 MeBox 理解为:
- 一个带现代 Web UI 的**媒体库后台**
- 一个兼容 Emby/Jellyfin 客户端的**协议网关**
- 一个连接本地硬盘、下载目录与网盘存储的**整理与播放入口**
### 核心能力
| 模块 | 说明 |
| --- | --- |
| **媒体库** | 电影、电视剧、动漫、综艺、音乐与自定义库;多根目录、扫库、海报墙、继续观看 |
| **元数据刮削** | TMDb、Bangumi、Douban、TheTVDB、Fanart 等;支持 NFO、手动匹配、刮削队列 |
| **播放** | 网页播放器、HLS 转码、弹幕、字幕、播放配置档、观看历史与收藏 |
| **Emby 协议** | Infuse、SenPlayer、Fileball 等客户端可直接添加本服务,使用 MeBox 账号登录 |
| **远程 Emby 挂载** | 将远程 Emby 媒体库挂载到本地界面统一浏览(无需单独开 Emby 客户端) |
| **网盘与 STRM** | OpenList、CloudDrive2、115、WebDAV 等;STRM 同步、上传/下载队列、直链/302 播放 |
| **下载与整理** | qBittorrent 接入、站点搜索与订阅、下载后自动整理、文件管理器(复制/移动/硬链/软链) |
| **用户与权限** | 管理员/普通用户、有效期、成人内容开关、播放配置 PIN、细粒度操作权限 |
| **运维能力** | 统一任务队列、回收站、存储统计、DLNA 投屏、系统设置与日志 |
### 技术栈
- **后端**:Go · Gin · GORM · SQLite / PostgreSQL · 可选 Redis · 可选 OpenSearch
- **前端**:React 18 · Vite · TypeScript · Tailwind CSS · Zustand
- **部署**:Docker Compose 多档模板,支持 amd64 / arm64 镜像与单文件可执行发布
---
## 快速开始
推荐使用 Docker Compose。仓库提供四份**互相独立**的完整模板,无需 `.env` 即可起步。
```bash
mkdir -p MeBox && cd MeBox
# 最省心:单镜像 + 内置 SQLite
curl -fsSL https://raw.githubusercontent.com/truewhile/MeBox/main/docker-compose.simple.yml -o docker-compose.yml
# 或多用户场景:PostgreSQL 第一档
# curl -fsSL https://raw.githubusercontent.com/truewhile/MeBox/main/docker-compose.yml -o docker-compose.yml
docker compose up -d
```
浏览器访问:
```text
http://服务器IP:18080
```
默认账号:`admin` / `admin123`(首次登录后请立即修改密码)
镜像地址:
```text
ghcr.io/truewhile/mebox:latest
```
---
## 部署档位
按机器资源选择档位。每份 Compose 文件均可单独使用,**不要**叠加多个 `-f`。
| 档位 | 配置文件 | 组件 | 适合场景 |
| --- | --- | --- | --- |
| 单镜像档 | `docker-compose.simple.yml` | MeBox + SQLite | 新手、单人、低配 NAS,只想一个容器跑起来 |
| 第一档 | `docker-compose.yml` | MeBox + PostgreSQL | 大多数家庭 NAS,多用户更稳 |
| 第二档 | `docker-compose.standard.yml` | + Redis | 多用户、Emby 客户端频繁刷新、首页/列表访问多 |
| 第三档 | `docker-compose.search.yml` | + OpenSearch | 超大媒体库、复杂全文搜索(内存占用更高) |
### 单镜像档要点
- 只启动 **一个** MeBox 容器,数据在 `./data/mebox.db`
- 通常只需改端口与媒体目录挂载
- **不要**设置 `MEBOX_DATABASE_DSN`,否则会切到 PostgreSQL
```yaml
ports:
- "18080:8080"
volumes:
- ./data:/data # 必须备份
- ./cache:/cache # 可重建
- ./media:/media # 改成你的媒体目录
```
网页添加媒体库时填写容器内路径,例如 `/media`、`/media/电影`。
### PostgreSQL 档位要点
- 主库在 `./postgres`,配置与密钥在 `./data`
- 若存在旧版 `./data/mebox.db`,首次启动会自动迁移到 PostgreSQL
- 迁移完成后可将 `MEBOX_DATABASE_DB_PATH` 改为不存在路径,避免重复检查:
```yaml
MEBOX_DATABASE_DB_PATH: /data/no-sqlite-migration.db
```
### 必须备份与可重建
| 路径 | 说明 |
| --- | --- |
| `./data` | JWT 密钥、运行配置、SQLite 主库或迁移源 |
| `./postgres` | PostgreSQL 主库(PG 档位) |
| `./cache` | 海报/转码缓存,可重建 |
| `./redis` | 热缓存,可重建 |
| `./opensearch` | 搜索索引,可重建 |
### 更新镜像
```bash
docker compose pull mebox
docker compose up -d --no-deps mebox
```
日常更新只拉 `mebox` 服务即可,不要随意 `docker compose pull` 升级 PostgreSQL/Redis/OpenSearch 基础镜像。
---
## 路径映射
Docker 部署最常见的问题是路径填错。记住:
- `volumes` **左侧**是宿主机真实路径,**右侧**是容器内路径
- 网页后台添加媒体库时,应填写**容器内**路径(如 `/media/电影`)
- 若使用自动整理/下载入库,`MEBOX_MEDIA_DIR` 与 `MEBOX_DOWNLOAD_DIR` 需与挂载一致
NAS 示例:
```yaml
volumes:
- /vol1/1000/Media:/media
- /vol1/1000/Downloads:/downloads
environment:
MEBOX_MEDIA_DIR: /vol1/1000/Media
MEBOX_MEDIA_CONTAINER_DIR: /media
MEBOX_DOWNLOAD_DIR: /vol1/1000/Downloads
MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads
```
---
## 首次使用建议
1. **创建媒体库** → 填写 `/media/...` → 执行扫库
2. **配置元数据源** → 系统设置中添加 TMDb、Bangumi 等 API
3. **(可选)连接 qBittorrent** → 下载客户端设置,宿主机可用 `http://host.docker.internal:8085`
4. **(可选)配置网盘账号** → STRM 管理中添加 OpenList / 115 / WebDAV 等
5. **第三方播放器** → 以 Emby 服务器添加 `http://服务器IP:18080`,使用 MeBox 账号登录
---
## 常见问题
**扫库或入库很慢?**
先确认路径映射与数据库档位。网盘扫描还受接口限速与目录规模影响;大库可考虑第二档 Redis 或第三档 OpenSearch。
**qBittorrent 下载后无法整理?**
确认下载目录已通过 `volumes` 挂进容器,且 `MEBOX_DOWNLOAD_*` 环境变量对应正确。
**硬链接失败(cross-device link)?**
硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 btrfs 子卷或网盘挂载时请改用复制或软链接。
**第三方播放器连不上?**
确认地址为 `http://IP:18080`,使用 MeBox 用户账号;反代部署需正确配置外部 URL 与 HTTPS 头。
---
## 开发构建
后端通过 `go:embed` 嵌入 `web/dist`,**编译前必须先构建前端**。
```bash
npm --prefix web ci
npm --prefix web run build
go test ./...
go run ./cmd/server # http://127.0.0.1:8080
npm --prefix web run dev # http://127.0.0.1:3000
```
CI 会在 Release 中提供 Windows / Linux / macOS 的 amd64、arm64 单文件可执行程序。
---
## 鸣谢
MeBox 在 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) 的基础上 fork 并持续演进。感谢上游项目在媒体库架构、Emby 协议兼容和自托管体验上的奠基工作。
项目中许多网盘同步、STRM 与媒体整理相关的设计与实现,也参考了 [qmediasync](https://github.com/qicfan/qmediasync)。感谢该项目的思路与实践经验。
---
## 贡献与反馈
提交 Issue 或 Pull Request 前,请阅读 [贡献规范](CONTRIBUTING.md) 与 [安全策略](SECURITY.md)。
- Bug 请附部署方式、复现步骤与相关日志
- 功能建议请说明使用场景与期望行为
- PR 请从独立分支发起,提交前运行 `go test ./...` 与 `npm --prefix web run build`
---
## Star History
<a href="https://www.star-history.com/?repos=truewhile%2FMeBox&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=truewhile/MeBox&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=truewhile/MeBox&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=truewhile/MeBox&type=date&legend=top-left" />
</picture>
</a>
---
## 许可证
本项目采用 [GPL-3.0](LICENSE) 许可证。
-229
View File
@@ -1,229 +0,0 @@
# MeBox
<p align="center">
<img src="web/public/brand/logo-192.png" width="96" height="96" alt="MeBox Logo" />
</p>
<h3 align="center">A self-hosted media center for NAS and home theater</h3>
<p align="center">
<strong>Libraries · Metadata · Cloud STRM · Emby protocol · Remote Emby mounts · Multi-user · Docker-first</strong>
</p>
<p align="center">
<a href="README.md">中文</a> ·
<a href="#overview">Overview</a> ·
<a href="#quick-start">Quick Start</a> ·
<a href="#deployment-tiers">Deployment</a> ·
<a href="#acknowledgements">Acknowledgements</a> ·
<a href="#development">Development</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>
---
## Overview
**MeBox** is a self-hosted private media management system for NAS, mini PCs, family sharing, and multi-device playback. This repository is a maintained fork of [MediaStationGo](https://github.com/ShukeBta/MediaStationGo), extended with stronger cloud playback, task queues, remote mounts, and permission controls.
In practice, MeBox gives you:
- A modern **web media library**
- An **Emby/Jellyfin-compatible protocol gateway** for third-party players
- A single panel for **local disks, download folders, and cloud storage**
### Key capabilities
| Area | Highlights |
| --- | --- |
| **Libraries** | Movies, TV, anime, variety, music, custom libraries; multi-root scanning; poster wall; continue watching |
| **Metadata** | TMDb, Bangumi, Douban, TheTVDB, Fanart, NFO import, manual matching, scrape queue |
| **Playback** | Web player, HLS transcoding, danmaku, subtitles, play profiles, history and favourites |
| **Emby protocol** | Add MeBox in Infuse, SenPlayer, Fileball, etc. and sign in with MeBox accounts |
| **Remote Emby mounts** | Browse remote Emby libraries inside MeBox without a separate Emby client |
| **Cloud & STRM** | OpenList, CloudDrive2, 115, WebDAV; STRM sync; upload/download queues; direct or 302 playback |
| **Downloads & organize** | qBittorrent, site search/subscriptions, post-download organization, file manager |
| **Users & permissions** | Admin/regular users, expiry, NSFW toggle, play-profile PIN, granular permissions |
| **Operations** | Unified task queue, recycle bin, storage stats, DLNA casting, settings and logs |
### Tech stack
- **Backend**: Go, Gin, GORM, SQLite or PostgreSQL, optional Redis and OpenSearch
- **Frontend**: React 18, Vite, TypeScript, Tailwind CSS, Zustand
- **Deployment**: Standalone Docker Compose templates, amd64/arm64 images, single-binary releases
---
## Quick Start
Docker Compose is the recommended path. The repo ships four **standalone** templates; no `.env` is required.
```bash
mkdir -p MeBox && cd MeBox
# Simplest: one container with built-in SQLite
curl -fsSL https://raw.githubusercontent.com/truewhile/MeBox/main/docker-compose.simple.yml -o docker-compose.yml
# Or PostgreSQL tier for multi-user setups
# curl -fsSL https://raw.githubusercontent.com/truewhile/MeBox/main/docker-compose.yml -o docker-compose.yml
docker compose up -d
```
Open:
```text
http://SERVER_IP:18080
```
Default login: `admin` / `admin123` — change the password immediately.
Image:
```text
ghcr.io/truewhile/mebox:latest
```
---
## Deployment tiers
Pick one compose file. Do **not** stack multiple `-f` files.
| Tier | File | Stack | Best for |
| --- | --- | --- | --- |
| Single image | `docker-compose.simple.yml` | MeBox + SQLite | Beginners, single-user, low-resource NAS |
| Tier 1 | `docker-compose.yml` | MeBox + PostgreSQL | Most home NAS deployments |
| Tier 2 | `docker-compose.standard.yml` | + Redis | Multi-user, frequent Emby client refreshes |
| Tier 3 | `docker-compose.search.yml` | + OpenSearch | Very large libraries, advanced full-text search |
### Single-image notes
- Only one MeBox container; database lives in `./data/mebox.db`
- Do **not** set `MEBOX_DATABASE_DSN` or it switches to PostgreSQL
- Back up `./data`; `./cache` can be rebuilt
### PostgreSQL notes
- Primary DB: `./postgres`; secrets and runtime files: `./data`
- Existing `./data/mebox.db` migrates automatically on first start
- After migration, point `MEBOX_DATABASE_DB_PATH` at a non-existent file to disable re-checks
### Backup
| Path | Notes |
| --- | --- |
| `./data` | JWT secret, config, SQLite DB or migration source |
| `./postgres` | PostgreSQL primary DB |
| `./cache`, `./redis`, `./opensearch` | Rebuildable |
### Update
```bash
docker compose pull mebox
docker compose up -d --no-deps mebox
```
---
## Path mapping
The most common Docker mistake is mixing host paths with container paths.
- Left side of `volumes` = real host/NAS path
- Right side = container path; use `/media/...` in the web UI
- Keep `MEBOX_MEDIA_DIR` / `MEBOX_DOWNLOAD_DIR` aligned with mounts when organizing or ingesting downloads
Example:
```yaml
volumes:
- /vol1/1000/Media:/media
- /vol1/1000/Downloads:/downloads
environment:
MEBOX_MEDIA_DIR: /vol1/1000/Media
MEBOX_MEDIA_CONTAINER_DIR: /media
MEBOX_DOWNLOAD_DIR: /vol1/1000/Downloads
MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads
```
---
## First-time setup
1. Create a library with a container path such as `/media/Movies`, then scan
2. Add metadata providers (TMDb, Bangumi, etc.) in system settings
3. Optionally connect qBittorrent (`http://host.docker.internal:8085` when qB runs on the host)
4. Optionally configure cloud accounts under STRM management
5. Add the server in Emby-compatible players at `http://SERVER_IP:18080` using MeBox credentials
---
## FAQ
**Library scan is slow**
Check path mapping and DB tier. Cloud scans also depend on API limits and folder size.
**qBittorrent downloads are not organized**
Ensure the download directory is mounted into the container and env vars match.
**Hardlink fails with cross-device link**
Hardlinks require the same filesystem/subvolume; use copy or symlink across disks or cloud mounts.
**External player cannot connect**
Use `http://IP:18080` and a MeBox user account; reverse proxies need correct external URL and HTTPS headers.
---
## Development
The backend embeds `web/dist` via `go:embed`. Build the frontend first.
```bash
npm --prefix web ci
npm --prefix web run build
go test ./...
go run ./cmd/server
npm --prefix web run dev
```
Release builds ship single-file binaries for Windows, Linux, and macOS on amd64 and arm64.
---
## Acknowledgements
MeBox is forked from and continues to evolve [MediaStationGo](https://github.com/ShukeBta/MediaStationGo). Thank you to the upstream project for the media-library architecture, Emby-protocol compatibility, and self-hosted foundation.
Many cloud sync, STRM, and media-organization ideas in this project were also informed by [qmediasync](https://github.com/qicfan/qmediasync). Thank you for the reference implementation and design patterns.
---
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md) before opening issues or pull requests.
---
## Star History
<a href="https://www.star-history.com/?repos=truewhile%2FMeBox&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=truewhile/MeBox&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=truewhile/MeBox&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=truewhile/MeBox&type=date&legend=top-left" />
</picture>
</a>
---
## License
This project is licensed under [GPL-3.0](LICENSE).
-83
View File
@@ -1,83 +0,0 @@
# 安全策略
MeBox 是自托管媒体系统,常部署在 NAS、家庭网络、Docker、反向代理和第三方下载器环境中。安全问题通常会同时涉及应用代码、容器配置、路径映射、站点 Cookie / API Key、下载器凭据和外部访问入口。请按本策略报告和处理安全问题。
## 支持范围
我们优先支持以下版本和部署方式的安全修复:
- 当前 `main` 分支。
- 最新发布镜像:`ghcr.io/truewhile/mebox:latest`。
- README 中推荐的 Docker Compose 第一档、第二档、第三档部署方式。
历史版本、私有魔改镜像、未公开补丁分支和非标准部署仍可报告,但维护者可能要求先在最新 `main` 或最新镜像中复现。
## 如何报告安全漏洞
请不要在公开 Issue、PR、讨论区或群聊中披露可利用细节。优先使用 GitHub Security Advisory 私密报告:
<https://github.com/truewhile/MeBox/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 或性能问题。
## 自托管安全基线
部署 MeBox 时建议:
- 首次登录后立即修改默认 `admin / admin123`。
- 不要把 PostgreSQL、Redis、OpenSearch、qBittorrent WebUI 暴露到公网。
- 反向代理公网访问时启用 HTTPS,并限制管理后台访问来源。
- 使用强随机的 JWT / 加密密钥,妥善备份 `./data` 和数据库。
- 不要在 Issue、PR、截图或日志中公开站点 Cookie、API Key、Passkey、下载器密码。
- Docker `volumes` 只挂载 MeBox 需要访问的目录,媒体库目录需要写入时再授予写权限。
- 定期更新镜像,并在升级前备份 `./postgres` 和 `./data`。
## 安全修复 PR
安全修复 PR 请遵循 [贡献规范](CONTRIBUTING.md),但不要在公开 PR 中暴露可利用细节。必要时先通过私密安全报告确认修复方案,再提交脱敏后的补丁。
+1 -1
View File
@@ -1 +1 @@
0.0.93
0.1.154
-140
View File
@@ -1,140 +0,0 @@
package main
import (
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/truewhile/MeBox/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
@@ -1,100 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"github.com/truewhile/MeBox/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
@@ -1,118 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/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
@@ -1,139 +0,0 @@
// Package main is the MeBox HTTP server entry point.
//
// MeBox is a Go rewrite of the legacy Python implementation,
// adopting the same tech stack as cropflre/nowen-video:
//
// Backend: Go 1.25 + Gin + GORM + PostgreSQL/SQLite + Viper + Zap + JWT
// 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/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// version is overwritten at build time via -ldflags="-X main.version=...".
var version = "dev"
func effectiveVersion(buildVersion string) string {
buildVersion = strings.TrimSpace(buildVersion)
if buildVersion != "" && buildVersion != "dev" {
return buildVersion
}
if envVersion := strings.TrimSpace(os.Getenv("MEBOX_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 MeBox",
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("MeBox stopped")
}
-169
View File
@@ -1,169 +0,0 @@
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("MEBOX_VERSION", "MeBox-v0.1.15")
if got := effectiveVersion("MeBox-v0.1.16"); got != "MeBox-v0.1.16" {
t.Fatalf("effectiveVersion = %q, want MeBox-v0.1.16", got)
}
}
func TestEffectiveVersionUsesEnvWhenBuildVersionIsDev(t *testing.T) {
t.Setenv("MEBOX_VERSION", " MeBox-v0.1.16 ")
if got := effectiveVersion("dev"); got != "MeBox-v0.1.16" {
t.Fatalf("effectiveVersion = %q, want MeBox-v0.1.16", got)
}
}
func TestEffectiveVersionDefaultsToDev(t *testing.T) {
t.Setenv("MEBOX_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", "mebox-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/mebox-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
@@ -1,55 +0,0 @@
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
@@ -1,197 +0,0 @@
package main
import (
"io/fs"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/handler"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/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, "MeBox 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
@@ -1,267 +0,0 @@
package main
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/service"
)
// tlsPair 记录当前正在服务的证书,用于判断是否需要重新绑定监听。
type tlsPair struct {
cert tls.Certificate
certPEM string
keyPEM string
// version 是解析后的证书/私钥指纹;内容或磁盘文件变化都会导致其改变,
// 据此决定是否需要重新绑定监听。
version string
}
// serverManager 负责 MeBox 的 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
@@ -1,107 +0,0 @@
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/truewhile/MeBox/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
@@ -1,46 +0,0 @@
package main
import (
"context"
"database/sql"
"runtime"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/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
}
-200
View File
@@ -1,200 +0,0 @@
# MeBox 第三档完整 Docker Compose 部署文件
#
# 组件:
# MeBox + 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:
mebox:
image: ghcr.io/truewhile/mebox: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"
MEBOX_APP_HOST: 0.0.0.0
MEBOX_APP_PORT: 8080
MEBOX_APP_WEB_DIR: /app/web/dist
MEBOX_APP_DATA_DIR: /data
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "50"
MEBOX_LOGGING_MAX_BACKUPS: "20"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
MEBOX_DATABASE_TYPE: postgres
MEBOX_DATABASE_DSN: postgres://mebox:mebox@postgres:5432/mebox?sslmode=disable
MEBOX_DATABASE_DB_PATH: /data/mebox.db
MEBOX_CACHE_REDIS_URL: redis://redis:6379/0
MEBOX_CACHE_CACHE_DIR: /cache
# OpenSearch 只做搜索索引,主数据仍以 PostgreSQL 为准。
MEBOX_SEARCH_BACKEND: opensearch
MEBOX_SEARCH_OPENSEARCH_URL: http://opensearch:9200
MEBOX_SEARCH_INDEX: mebox_media
MEBOX_UPDATE_IMAGE: ghcr.io/truewhile/mebox:latest
# 默认推荐在网页里使用容器路径 /media。
# 如果旧媒体库已经保存了宿主机路径 /vol1/1000/Media,
# 再把这里改成同一个宿主机真实路径用于旧路径换算。
MEBOX_MEDIA_DIR: /media
MEBOX_MEDIA_CONTAINER_DIR: /media
MEBOX_DOWNLOAD_DIR: /downloads
MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads
MEBOX_TRANSCODER_ENABLED: "true"
MEBOX_TRANSCODER_HARDWARE_ACCEL: "false"
MEBOX_TRANSCODER_REALTIME: "true"
MEBOX_TRANSCODER_THREADS: "2"
MEBOX_TRANSCODER_MAX_CONCURRENT: "1"
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
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 mebox。
pull_policy: missing
restart: unless-stopped
environment:
POSTGRES_DB: mebox
POSTGRES_USER: mebox
POSTGRES_PASSWORD: mebox
TZ: Asia/Shanghai
volumes:
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mebox -d mebox"]
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"
-86
View File
@@ -1,86 +0,0 @@
# MeBox 单镜像部署模板(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:
mebox:
image: ghcr.io/truewhile/mebox:latest
container_name: mebox
restart: unless-stopped
init: true
ports:
# 左边是宿主机访问端口,右边固定为容器内 8080。
- "18080:8080"
volumes:
# 必须备份:SQLite 数据库、系统配置、JWT 密钥、日志。
- ./data:/data
# 可重建:海报缓存、临时文件、转码缓存。
- ./cache:/cache
# 媒体库。网页里添加媒体库时填写 /media 或 /media/子目录。
- ./media:/media
# 可选:Intel 核显硬解/转码。需要时取消注释,并在后台开启硬件加速。
# - /dev/dri:/dev/dri
# 可选:管理面板一键更新需要访问 Docker 引擎。需要时取消注释。
# - /var/run/docker.sock:/var/run/docker.sock
environment:
TZ: Asia/Shanghai
# Linux/NAS 文件权限。写入文件权限异常时,改成宿主机实际 uid/gid。
PUID: "0"
PGID: "0"
MEBOX_APP_HOST: 0.0.0.0
MEBOX_APP_PORT: 8080
MEBOX_APP_WEB_DIR: /app/web/dist
MEBOX_APP_DATA_DIR: /data
# 单镜像档固定使用 SQLite。主数据库文件:./data/mebox.db。
MEBOX_DATABASE_TYPE: sqlite
MEBOX_DATABASE_DB_PATH: /data/mebox.db
MEBOX_CACHE_CACHE_DIR: /cache
# 路径映射保持容器内统一,网页和下载器里优先使用 /media、/downloads。
MEBOX_MEDIA_DIR: /media
MEBOX_MEDIA_CONTAINER_DIR: /media
# 完整应用日志默认写入 ./data/logs/app.log;排查复杂问题时可临时改成 debug。
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "50"
MEBOX_LOGGING_MAX_BACKUPS: "20"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
extra_hosts:
# 容器访问宿主机服务用,例如 qBittorrent: http://host.docker.internal:8085
- "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"
-165
View File
@@ -1,165 +0,0 @@
# MeBox 第二档完整 Docker Compose 部署文件
#
# 组件:
# MeBox + 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:
mebox:
image: ghcr.io/truewhile/mebox: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"
MEBOX_APP_HOST: 0.0.0.0
MEBOX_APP_PORT: 8080
MEBOX_APP_WEB_DIR: /app/web/dist
MEBOX_APP_DATA_DIR: /data
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "50"
MEBOX_LOGGING_MAX_BACKUPS: "20"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
MEBOX_DATABASE_TYPE: postgres
MEBOX_DATABASE_DSN: postgres://mebox:mebox@postgres:5432/mebox?sslmode=disable
MEBOX_DATABASE_DB_PATH: /data/mebox.db
# Redis 只做热缓存,源数据仍在 PostgreSQL;Redis 丢失可自动重建。
MEBOX_CACHE_REDIS_URL: redis://redis:6379/0
MEBOX_CACHE_CACHE_DIR: /cache
MEBOX_UPDATE_IMAGE: ghcr.io/truewhile/mebox:latest
# 路径换算配置。左边宿主机真实路径要和 volumes 左边保持一致。
MEBOX_MEDIA_DIR: /media
MEBOX_MEDIA_CONTAINER_DIR: /media
MEBOX_DOWNLOAD_DIR: /downloads
MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads
MEBOX_TRANSCODER_ENABLED: "true"
MEBOX_TRANSCODER_HARDWARE_ACCEL: "false"
MEBOX_TRANSCODER_REALTIME: "true"
MEBOX_TRANSCODER_THREADS: "2"
MEBOX_TRANSCODER_MAX_CONCURRENT: "1"
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
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 mebox。
pull_policy: missing
restart: unless-stopped
environment:
POSTGRES_DB: mebox
POSTGRES_USER: mebox
POSTGRES_PASSWORD: mebox
TZ: Asia/Shanghai
volumes:
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mebox -d mebox"]
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"
-170
View File
@@ -1,170 +0,0 @@
# MeBox 最简单 Docker Compose 部署文件
#
# 新手建议:
# 1. 不用 .env。
# 2. 直接改本文件。
# 3. 第一次可以不改路径,先用当前目录下的 ./media 和 ./downloads 体验。
#
# 启动:
# docker compose up -d
#
# 访问:
# http://服务器IP:18080
#
# 默认账号:
# admin / admin123
services:
mebox:
image: ghcr.io/truewhile/mebox: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"
# 程序基础配置,通常不用改。
MEBOX_APP_HOST: 0.0.0.0
MEBOX_APP_PORT: 8080
MEBOX_APP_WEB_DIR: /app/web/dist
MEBOX_APP_DATA_DIR: /data
# 详细应用日志会保存在 ./data/logs/app.log,warn/error 也会拆分保存。
# 排查复杂问题时可临时改成 debug。
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "50"
MEBOX_LOGGING_MAX_BACKUPS: "20"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
# 轻量模式默认只使用 PostgreSQL,适合大多数 NAS。
# 旧版 ./data/mmtl.db 或 ./data/mebox.db 存在时,首次启动会自动迁移到 PostgreSQL。
MEBOX_DATABASE_TYPE: postgres
MEBOX_DATABASE_DSN: postgres://mebox:mebox@postgres:5432/mebox?sslmode=disable
# SQLite 旧库迁移源:
# - 首次从旧版 SQLite(mmtl.db / mebox.db)导入时保持此路径。
# - 确认迁移完成后,建议改成 /data/no-sqlite-migration.db 这类不存在的路径。
MEBOX_DATABASE_DB_PATH: /data/mebox.db
MEBOX_CACHE_CACHE_DIR: /cache
# 管理面板热更新默认拉取此镜像,并用 Watchtower 一次性重建当前容器。
MEBOX_UPDATE_IMAGE: ghcr.io/truewhile/mebox:latest
# 路径换算配置。
# 默认推荐在网页里使用容器路径 /media。
# 如果旧媒体库已经保存了宿主机路径 /vol1/1000/Media,
# 再把这里改成同一个宿主机真实路径用于旧路径换算。
MEBOX_MEDIA_DIR: /media
MEBOX_MEDIA_CONTAINER_DIR: /media
# 默认推荐下载器保存路径使用 /downloads。
# 如果下载器只能返回宿主机路径,再改成同一个宿主机真实路径。
MEBOX_DOWNLOAD_DIR: /downloads
MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads
# NAS 友好的低负载默认值。
MEBOX_TRANSCODER_ENABLED: "true"
MEBOX_TRANSCODER_HARDWARE_ACCEL: "false"
MEBOX_TRANSCODER_REALTIME: "true"
MEBOX_TRANSCODER_THREADS: "2"
MEBOX_TRANSCODER_MAX_CONCURRENT: "1"
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
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 mebox。
# 如需升级 PostgreSQL,请先备份 ./postgres 后再手动调整镜像版本并拉取。
pull_policy: missing
restart: unless-stopped
environment:
POSTGRES_DB: mebox
POSTGRES_USER: mebox
POSTGRES_PASSWORD: mebox
TZ: Asia/Shanghai
volumes:
# PostgreSQL 主数据目录。升级/重建容器时必须保留。
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mebox -d mebox"]
interval: 10s
timeout: 5s
retries: 10
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
-28
View File
@@ -1,28 +0,0 @@
#!/bin/sh
set -eu
run_uid="${PUID:-$(id -u mebox 2>/dev/null || echo 1000)}"
run_gid="${PGID:-$(id -g mebox 2>/dev/null || echo 1000)}"
case "$run_uid" in
''|*[!0-9]*)
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 mebox
fi
chown -R "$run_uid:$run_gid" /data /cache 2>/dev/null || true
chown "$run_uid:$run_gid" /media 2>/dev/null || true
exec su-exec "$run_uid:$run_gid" mebox
-68506
View File
File diff suppressed because it is too large Load Diff
-85
View File
@@ -1,85 +0,0 @@
module github.com/truewhile/MeBox
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
github.com/ulikunitz/xz v0.5.12
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.21.0
golang.org/x/sys v0.20.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/postgres v1.5.7
gorm.io/gorm v1.30.0
)
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/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
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
@@ -1,210 +0,0 @@
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
@@ -1,78 +0,0 @@
// Package config 加载分层配置:默认值、配置文件和环境变量。
//
// 优先级(低 -> 高):
// 1. 内置默认值
// 2. 工作目录中的 config.yaml(嵌套格式)
// 3. config/*.yaml 分片文件(按模块)
// 4. 以 MEBOX_ 为前缀的环境变量
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/viper"
)
// EnvPrefix 是所有环境变量驱动的覆盖使用的前缀。
const EnvPrefix = "MeBox"
// 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
@@ -1,138 +0,0 @@
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 != "mebox" {
t.Fatalf("expected default redis prefix, got %q", cfg.Cache.RedisPrefix)
}
if cfg.Cache.MediaTTLSeconds != 15 {
t.Fatalf("expected default media cache ttl 15, got %d", cfg.Cache.MediaTTLSeconds)
}
if cfg.Search.Index != "mebox_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 MEBOX_* 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("MEBOX_APP_PORT", "9090")
t.Setenv("MEBOX_DATABASE_TYPE", "postgres")
t.Setenv("MEBOX_DATABASE_DSN", "postgres://mebox:secret@postgres:5432/mebox?sslmode=disable")
t.Setenv("MEBOX_CACHE_REDIS_URL", "redis://redis:6379/0")
t.Setenv("MEBOX_CACHE_MEDIA_TTL_SECONDS", "30")
t.Setenv("MEBOX_SEARCH_BACKEND", "opensearch")
t.Setenv("MEBOX_SEARCH_OPENSEARCH_URL", "http://opensearch:9200")
t.Setenv("MEBOX_LICENSE_SERVER_URL", "https://license.example.com")
t.Setenv("MEBOX_LICENSE_HMAC_SECRET", "override-secret")
t.Setenv("MEBOX_LICENSE_PUBLIC_KEY", "override-public-key")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
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
@@ -1,123 +0,0 @@
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/mebox.db")
v.SetDefault("database.dsn", "")
v.SetDefault("database.wal_mode", true)
v.SetDefault("database.busy_timeout", 5000)
v.SetDefault("database.cache_size", -40000)
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", "mebox")
v.SetDefault("cache.media_ttl_seconds", 15)
v.SetDefault("search.backend", "")
v.SetDefault("search.opensearch_url", "")
v.SetDefault("search.index", "mebox_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", "mebox")
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
@@ -1,76 +0,0 @@
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, "mebox.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 = "mebox"
}
if c.Cache.MediaTTLSeconds < 1 {
c.Cache.MediaTTLSeconds = 15
}
c.Search.Backend = strings.ToLower(strings.TrimSpace(c.Search.Backend))
if c.Search.Index == "" {
c.Search.Index = "mebox_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
@@ -1,41 +0,0 @@
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
@@ -1,36 +0,0 @@
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/mebox?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
@@ -1,175 +0,0 @@
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 MeBox 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
@@ -1,122 +0,0 @@
// 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/truewhile/MeBox/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
@@ -1,223 +0,0 @@
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/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/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
@@ -1,71 +0,0 @@
package database
import (
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
func TestMaskDSN(t *testing.T) {
cases := []struct {
in string
want string
}{
{
in: "postgres://admin:secret123@localhost:5432/mebox?sslmode=disable",
want: "postgres://admin:******@localhost:5432/mebox?sslmode=disable",
},
{
in: "host=localhost port=5432 user=admin password=secret dbname=mebox sslmode=disable",
want: "host=localhost port=5432 user=admin password=****** dbname=mebox sslmode=disable",
},
{
in: "sqlite://data/mebox.db",
want: "sqlite://data/mebox.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/mebox.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/mebox.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
@@ -1,425 +0,0 @@
package database
import (
"path/filepath"
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/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(), "mebox.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, "mebox.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, "mebox.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
@@ -1,111 +0,0 @@
package database
import (
"path/filepath"
"strings"
"gorm.io/gorm"
"github.com/truewhile/MeBox/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
@@ -1,91 +0,0 @@
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
}
-112
View File
@@ -1,112 +0,0 @@
package database
import (
"gorm.io/gorm"
"github.com/truewhile/MeBox/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) {
if err := ensureMediaSearchIndex(db); err != nil {
return err
}
return ensureSQLiteQueryOptimizer(db)
}
return nil
}
func ensureSQLiteQueryOptimizer(db *gorm.DB) error {
// Refresh planner statistics so indexes on large media tables are used.
return db.Exec("ANALYZE").Error
}
func ensurePostgresColumnCompatibility(db *gorm.DB) error {
if !isPostgres(db) {
return nil
}
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
}
@@ -1,62 +0,0 @@
package database
import (
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
)
func TestEnsureEmbyMountsCompatibility(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
// Create a table without sort_order simulating an older schema
if err := db.Exec(`CREATE TABLE emby_mounts (
id varchar(36) PRIMARY KEY,
created_at datetime,
updated_at datetime,
deleted_at datetime,
account_id text,
remote_view_id text,
remote_view_name text,
collection_type text,
name text,
proxy_play numeric DEFAULT false,
enabled numeric DEFAULT true
)`).Error; err != nil {
t.Fatal(err)
}
// Insert older rows
now := time.Now()
_ = db.Exec("INSERT INTO emby_mounts (id, name, created_at) VALUES (?, ?, ?)", "m1", "Mount 1", now.Add(-2*time.Hour)).Error
_ = db.Exec("INSERT INTO emby_mounts (id, name, created_at) VALUES (?, ?, ?)", "m2", "Mount 2", now.Add(-1*time.Hour)).Error
// Run compatibility migration
if err := ensureEmbyMountsCompatibility(db); err != nil {
t.Fatalf("ensureEmbyMountsCompatibility failed: %v", err)
}
// Verify column sort_order exists and values are initialized sequentially
if !db.Migrator().HasColumn(&model.EmbyMount{}, "sort_order") {
t.Fatal("expected sort_order column to be added")
}
var m1, m2 model.EmbyMount
if err := db.Where("id = ?", "m1").First(&m1).Error; err != nil {
t.Fatal(err)
}
if err := db.Where("id = ?", "m2").First(&m2).Error; err != nil {
t.Fatal(err)
}
if m1.SortOrder != 0 || m2.SortOrder != 1 {
t.Fatalf("unexpected sort orders: m1=%d, m2=%d", m1.SortOrder, m2.SortOrder)
}
}
-65
View File
@@ -1,65 +0,0 @@
package database
import (
"fmt"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/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
}
@@ -1,126 +0,0 @@
package database
import (
"fmt"
"strings"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/truewhile/MeBox/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
@@ -1,228 +0,0 @@
package database
import (
"fmt"
"reflect"
"sort"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
"github.com/truewhile/MeBox/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, `"`, `""`) + `"`
}
@@ -1,84 +0,0 @@
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/truewhile/MeBox/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
}
}
// Prefer the new default filename, then fall back to legacy MMTL SQLite files.
candidates := []string{
filepath.Join(strings.TrimSpace(cfg.App.DataDir), "mebox.db"),
filepath.Join(strings.TrimSpace(cfg.App.DataDir), "mmtl.db"),
}
for _, fallback := range candidates {
if fallback == "" || sameCleanPath(configured, fallback) {
continue
}
exists, err := regularFileExists(fallback)
if err != nil {
return "", fmt.Errorf("stat default sqlite migration source: %w", err)
}
if !exists {
continue
}
if log != nil && configured != "" {
log.Warn("configured sqlite migration source not found; using data-dir default",
zap.String("configured", configured),
zap.String("fallback", fallback))
}
return fallback, nil
}
return "", 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)
}
-130
View File
@@ -1,130 +0,0 @@
package database
import (
"context"
"fmt"
"path/filepath"
"strings"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
)
func installSQLiteWriteGate(db *gorm.DB) {
if db == nil {
return
}
const lockedKey = "mebox: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("mebox:sqlite_write_lock", lock)
_ = db.Callback().Create().After("gorm:create").Register("mebox:sqlite_write_unlock", unlock)
_ = db.Callback().Update().Before("gorm:update").Register("mebox:sqlite_write_lock", lock)
_ = db.Callback().Update().After("gorm:update").Register("mebox:sqlite_write_unlock", unlock)
_ = db.Callback().Delete().Before("gorm:delete").Register("mebox:sqlite_write_lock", lock)
_ = db.Callback().Delete().After("gorm:delete").Register("mebox:sqlite_write_unlock", unlock)
_ = db.Callback().Raw().Before("gorm:raw").Register("mebox:sqlite_write_lock", rawLock)
_ = db.Callback().Raw().After("gorm:raw").Register("mebox:sqlite_write_unlock", unlock)
}
func isReadOnlySQL(sql string) bool {
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(536870912)"
if cfg.Database.WALMode {
dsn += "&_pragma=wal_autocheckpoint(1000)"
}
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
@@ -1,80 +0,0 @@
package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/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()
}
}
-359
View File
@@ -1,359 +0,0 @@
// Package handler — admin endpoints (users / settings / logs).
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/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()
}
maxUsers, err := service.LoadMaxUsers(c.Request.Context(), svc.Repo)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"users": users,
"max_users": maxUsers,
"current_users": len(users),
})
}
}
type updateUserLimitReq struct {
MaxUsers int `json:"max_users" binding:"required"`
}
func getUserLimitHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
maxUsers, err := service.LoadMaxUsers(c.Request.Context(), svc.Repo)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
currentUsers, err := svc.Repo.User.Count(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"max_users": maxUsers,
"current_users": currentUsers,
})
}
}
func updateUserLimitHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req updateUserLimitReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := service.SaveMaxUsers(c.Request.Context(), svc.Repo, req.MaxUsers); err != nil {
if errors.Is(err, service.ErrInvalidMaxUsers) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
currentUsers, err := svc.Repo.User.Count(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"max_users": req.MaxUsers,
"current_users": currentUsers,
})
}
}
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):
maxUsers, loadErr := service.LoadMaxUsers(c.Request.Context(), svc.Repo)
if loadErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": loadErr.Error()})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "user limit reached", "max_users": maxUsers})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
}
-145
View File
@@ -1,145 +0,0 @@
package handler
import (
"fmt"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/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 = "mebox"
}
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
@@ -1,101 +0,0 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/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: "mebox_prod",
SSLMode: "disable",
},
want: "postgres://postgres:secretpassword@127.0.0.1:5432/mebox_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/mebox.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":"mebox"}`)
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
@@ -1,299 +0,0 @@
package handler
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/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
@@ -1,135 +0,0 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func 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")
}
}
-55
View File
@@ -1,55 +0,0 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestUserLimitHandlers(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &service.Container{Repo: repos, Auth: service.NewAuthService(nil, zap.NewNop(), repos, service.NewTokenService(nil, zap.NewNop(), repos), service.NewPermissionService(zap.NewNop(), repos))}
router := gin.New()
router.GET("/admin/users/limit", getUserLimitHandler(cfg))
router.PUT("/admin/users/limit", updateUserLimitHandler(cfg))
req := httptest.NewRequest(http.MethodGet, "/admin/users/limit", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET limit status = %d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"max_users":20`) {
t.Fatalf("expected default max_users=20, got %s", w.Body.String())
}
req = httptest.NewRequest(http.MethodPut, "/admin/users/limit", strings.NewReader(`{"max_users":42}`))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("PUT limit status = %d body=%s", w.Code, w.Body.String())
}
got, err := service.LoadMaxUsers(t.Context(), repos)
if err != nil || got != 42 {
t.Fatalf("stored max users = %d err=%v, want 42", got, err)
}
}
-77
View File
@@ -1,77 +0,0 @@
// 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/truewhile/MeBox/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
@@ -1,186 +0,0 @@
// Package handler — API 配置 HTTP Handler。
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/truewhile/MeBox/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,
},
})
}
-125
View File
@@ -1,125 +0,0 @@
// Package handler — auth-related HTTP endpoints.
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/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
}
if errors.Is(err, service.ErrUserLimitReached) {
maxUsers, loadErr := service.LoadMaxUsers(c.Request.Context(), svc.Repo)
if loadErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": loadErr.Error()})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "user limit reached", "max_users": maxUsers})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
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
@@ -1,55 +0,0 @@
package handler
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/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
@@ -1,71 +0,0 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/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
@@ -1,45 +0,0 @@
// 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/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/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
@@ -1,52 +0,0 @@
// Package handler — database backup/restore endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/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
@@ -1,33 +0,0 @@
// Package handler — danmaku endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/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
@@ -1,42 +0,0 @@
// Package handler — DLNA / UPnP discovery + cast endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/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
@@ -1,129 +0,0 @@
// 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/truewhile/MeBox/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
@@ -1,3 +0,0 @@
// 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
@@ -1,351 +0,0 @@
package handler
import (
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/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
@@ -1,174 +0,0 @@
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
@@ -1,242 +0,0 @@
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/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestParseEmbyAuthByNameReqAcceptsLowercaseJSON(t *testing.T) {
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
@@ -1,147 +0,0 @@
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/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// TestEmbyLoginThenAuthedRequest simulates the exact flow an Emby-compatible
// 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())
}
}
@@ -1,256 +0,0 @@
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/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestEmbyWithRequestAddressUsesHost(t *testing.T) {
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": "mebox-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": "mebox-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
@@ -1,67 +0,0 @@
package handler
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/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
@@ -1,247 +0,0 @@
package handler
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/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
@@ -1,243 +0,0 @@
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/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestEmbyItemImageServesWithoutAPIAuth(t *testing.T) {
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
@@ -1,236 +0,0 @@
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/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/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])
}
}
@@ -1,148 +0,0 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/golang-jwt/jwt/v5"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
user := &model.User{
Base: model.Base{ID: "user-1"},
Username: "test_viewer",
PasswordHash: "x",
Role: "user",
Tier: "free",
IsActive: true,
}
if err := repos.User.Create(t.Context(), user); err != nil {
t.Fatalf("create user: %v", err)
}
cfg := &config.Config{}
logger := zap.NewNop()
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(cfg, logger, repos),
Sessions: service.NewSessionTrackerService(logger),
Playback: service.NewPlaybackService(logger, repos),
}
router := gin.New()
// 注册带认证的路由,模拟已登录用户
router.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, user.ID)
c.Next()
})
router.POST("/Sessions/Playing/Progress", embyPlayingProgressHandler(svc))
router.GET("/Items", embyItemsHandler(svc))
router.GET("/Users/:userId/Items/Resume", embyResumeItemsHandler(svc))
router.GET("/Sessions", embySessionsHandler(svc))
remoteMediaID := service.EncodeEmbyRemoteID("mount-1", "remote-item-123")
// 1. 测试上报进度:客户端使用小写 query 参数 itemId / positionTicks
progressReq := httptest.NewRequest(
http.MethodPost,
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000",
nil,
)
wProgress := httptest.NewRecorder()
router.ServeHTTP(wProgress, progressReq)
if wProgress.Code != http.StatusNoContent {
t.Fatalf("progress status = %d, body = %s", wProgress.Code, wProgress.Body.String())
}
// 验证已持久化到 PlaybackHistory
var hist model.PlaybackHistory
if err := db.Where("user_id = ? AND media_id = ?", user.ID, remoteMediaID).First(&hist).Error; err != nil {
t.Fatalf("playback history not saved: %v", err)
}
if hist.PositionMs != 30000 {
t.Fatalf("expected position_ms = 30000, got %d", hist.PositionMs)
}
// 2. 测试 Filters=IsResumable 能够包含该远程条目
resumableReq := httptest.NewRequest(
http.MethodGet,
"/Items?Filters=IsResumable",
nil,
)
wResumable := httptest.NewRecorder()
router.ServeHTTP(wResumable, resumableReq)
if wResumable.Code != http.StatusOK {
t.Fatalf("items resumable status = %d, body = %s", wResumable.Code, wResumable.Body.String())
}
var resumableEnvelope map[string]any
if err := json.Unmarshal(wResumable.Body.Bytes(), &resumableEnvelope); err != nil {
t.Fatalf("decode resumable: %v", err)
}
// 因为没有配置真实的远程客户端连接,该远程条目在当前离线测试中不会 panic 崩溃,并且正常响应 Envelope
if resumableEnvelope["TotalRecordCount"] == nil {
t.Fatalf("missing TotalRecordCount in resumable envelope")
}
// 3. 测试 /Users/:userId/Items/Resume 别名路由
resumeAliasReq := httptest.NewRequest(
http.MethodGet,
"/Users/"+user.ID+"/Items/Resume",
nil,
)
wResumeAlias := httptest.NewRecorder()
router.ServeHTTP(wResumeAlias, resumeAliasReq)
if wResumeAlias.Code != http.StatusOK {
t.Fatalf("resume alias status = %d, body = %s", wResumeAlias.Code, wResumeAlias.Body.String())
}
// 4. 测试 /Sessions 返回 NowPlayingItem
sessionsReq := httptest.NewRequest(http.MethodGet, "/Sessions", nil)
wSessions := httptest.NewRecorder()
router.ServeHTTP(wSessions, sessionsReq)
if wSessions.Code != http.StatusOK {
t.Fatalf("sessions status = %d, body = %s", wSessions.Code, wSessions.Body.String())
}
var sessionsList []map[string]any
if err := json.Unmarshal(wSessions.Body.Bytes(), &sessionsList); err != nil {
t.Fatalf("decode sessions: %v", err)
}
if len(sessionsList) == 0 {
t.Fatalf("expected at least 1 session")
}
nowPlaying, ok := sessionsList[0]["NowPlayingItem"].(map[string]any)
if !ok || nowPlaying["Id"] != remoteMediaID {
t.Fatalf("expected NowPlayingItem with id %q, got %#v", remoteMediaID, sessionsList[0]["NowPlayingItem"])
}
}
func signMockToken(secret, userID string) string {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": userID,
"exp": time.Now().Add(time.Hour).Unix(),
})
s, _ := token.SignedString([]byte(secret))
return s
}
-222
View File
@@ -1,222 +0,0 @@
// Emby 挂载管理 HTTP 层:远程 Emby 服务器(账号)下的媒体库挂载 CRUD,
// 以及账号远程媒体库(View)列表预览。
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/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})
}
}
@@ -1,65 +0,0 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestReorderEmbyMountsHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
ctx := t.Context()
m1 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-1", Name: "Mount 1"}
m2 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-2", Name: "Mount 2"}
_ = repos.EmbyMount.Create(ctx, m1)
_ = repos.EmbyMount.Create(ctx, m2)
svc := &service.Container{
Repo: repos,
EmbyRemote: service.NewEmbyRemoteService(nil, zap.NewNop(), repos, nil),
}
router := gin.New()
router.PUT("/admin/emby/mounts/reorder", reorderEmbyMountsHandler(svc))
body, _ := json.Marshal(map[string]any{
"ids": []string{m2.ID, m1.ID},
})
req := httptest.NewRequest(http.MethodPut, "/admin/emby/mounts/reorder", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
list, err := repos.EmbyMount.List(ctx)
if err != nil {
t.Fatal(err)
}
if len(list) != 2 || list[0].ID != m2.ID || list[1].ID != m1.ID {
t.Fatalf("expected order [m2, m1], got [m%s, m%s]", list[0].ID, list[1].ID)
}
}
-364
View File
@@ -1,364 +0,0 @@
package handler
import (
"errors"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/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)
}
}
}
@@ -1,245 +0,0 @@
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/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestEmbyLowercasePlaybackInfoRouteReturnsJSON(t *testing.T) {
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)
}
}
@@ -1,258 +0,0 @@
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/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestEmbyLowercaseVideoStreamRouteServesMedia(t *testing.T) {
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())
}
}
-136
View File
@@ -1,136 +0,0 @@
package handler
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
type embyPlayingReq struct {
ItemId string `json:"ItemId"`
ItemIDLower string `json:"itemId"`
ID string `json:"Id"`
IDLower string `json:"id"`
PositionTicks int64 `json:"PositionTicks"`
PositionLower int64 `json:"positionTicks"`
RunTimeTicks int64 `json:"RunTimeTicks"`
RunTimeLower int64 `json:"runTimeTicks"`
}
func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := embyUserID(c)
if uid == "" {
c.Status(http.StatusUnauthorized)
return
}
var req embyPlayingReq
_ = c.ShouldBindJSON(&req)
itemID := embyFirstNonEmptyString(req.ItemId, req.ItemIDLower, req.ID, req.IDLower)
if itemID == "" {
itemID = embyFirstNonEmptyString(firstQueryValue(c, "ItemId", "itemId", "Id", "id"))
}
pos := req.PositionTicks
if pos == 0 {
pos = req.PositionLower
}
if pos == 0 {
pos, _ = strconv.ParseInt(firstQueryValue(c, "PositionTicks", "positionTicks"), 10, 64)
}
runTime := req.RunTimeTicks
if runTime == 0 {
runTime = req.RunTimeLower
}
if runTime == 0 {
runTime, _ = strconv.ParseInt(firstQueryValue(c, "RunTimeTicks", "runTimeTicks"), 10, 64)
}
if itemID == "" {
c.Status(http.StatusOK)
return
}
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
}
if err := svc.Emby.RecordProgress(c.Request.Context(), uid, itemID, pos, runTime); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
stopped := strings.Contains(strings.ToLower(c.FullPath()+" "+c.Request.URL.Path), "stopped")
if svc.Sessions != nil {
svc.Sessions.RecordPlayback(c.Request.Context(), uid, "",
clientInfo.DeviceID,
clientInfo.DeviceName,
clientInfo.Client,
c.ClientIP(),
itemID,
pos,
runTime,
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})
}
}
-256
View File
@@ -1,256 +0,0 @@
package handler
import (
"time"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/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("/Users/:userId/Items/Resume", embyResumeItemsHandler(svc))
auth.GET("/UserItems/Resume", embyResumeItemsHandler(svc))
auth.GET("/Items/:id", embyItemByIDHandler(svc))
auth.GET("/Users/:userId/Items/:id", embyUserItemByIDHandler(svc))
auth.GET("/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
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))
}
-108
View File
@@ -1,108 +0,0 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/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("/users/:userId/items/resume", embyResumeItemsHandler(svc))
auth.GET("/useritems/resume", embyResumeItemsHandler(svc))
auth.GET("/items/:id", embyItemByIDHandler(svc))
auth.GET("/users/:userId/items/:id", embyUserItemByIDHandler(svc))
auth.GET("/shows/:id/seasons", embyShowSeasonsHandler(svc))
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))
}
@@ -1,469 +0,0 @@
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/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestEmbyMarkPlayedRefreshesPlaybackDevice(t *testing.T) {
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 payload struct {
Users []model.User `json:"users"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode users: %v", err)
}
users := payload.Users
if len(users) != 1 {
t.Fatalf("users = %#v", users)
}
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())
}
})
}
}
-117
View File
@@ -1,117 +0,0 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/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": "mebox-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 {
nowPlaying := gin.H{"Id": itemID}
if svc.Emby != nil {
if item, _ := svc.Emby.Item(c.Request.Context(), itemID, sess.UserID); item != nil {
for _, key := range []string{"Name", "Type", "RunTimeTicks", "PrimaryImageItemId", "ImageTags", "SeriesName", "SeasonName", "IndexNumber", "ParentIndexNumber"} {
if val, ok := item[key]; ok && val != nil {
nowPlaying[key] = val
}
}
}
}
row["NowPlayingItem"] = nowPlaying
}
out = append(out, row)
}
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
@@ -1,47 +0,0 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/truewhile/MeBox/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
@@ -1,191 +0,0 @@
package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/truewhile/MeBox/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"},
})
}
}
@@ -1,190 +0,0 @@
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/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// TestEmbySubtitleOfficialRouteServesRawASS verifies that the official Emby
// 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
@@ -1,98 +0,0 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/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": "mebox-001",
"ServerId": "mebox-001",
"ServerName": "MeBox",
"Version": "4.8.10.0",
"ServerVersion": "4.8.10.0",
"ProductName": "Emby Server",
"OperatingSystem": "Windows",
"SupportsHttps": false,
"SupportsAutoDiscovery": true,
"StartupWizardCompleted": true,
})
}
@@ -1,35 +0,0 @@
package handler
import (
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/truewhile/MeBox/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: "mebox-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
}

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