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
983 changed files with 1 additions and 235700 deletions
-16
View File
@@ -1,16 +0,0 @@
{
"mcpServers": {
"ssh": {
"command": "cmd.exe",
"args": [
"/c",
"npx",
"-y",
"@aiondadotcom/mcp-ssh"
],
"env": {
"ProgramData": "C:\\ProgramData"
}
}
}
}
-64
View File
@@ -1,64 +0,0 @@
.git
.github
.gitignore
.dockerignore
README.md
CONTRIBUTING.md
LICENSE
docs/
data/
cache/
logs/
verify-data/
verify-cache/
verify-media/
verify-downloads/
.codex-*
.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 已按需同步。
-268
View File
@@ -1,268 +0,0 @@
name: Build & Publish
# 版本策略(version 分支托管,main 零污染):
# - VERSION 文件单独存放在 version 分支,CI 构建时读取并自增写回 version 分支,
# main 分支不再出现任何 CI 提交,本地推送永不与远程冲突。
# - push 到 main:版本号自动 patch+1,发布 latest + 版本镜像、GitHub Release、
# 多平台单文件二进制,并部署服务器。
# - push tag v*:正式发版,版本号取 tag 名(不 bump version 分支),其余同上。
# - 手动触发:版本号在 version 分支当前值上自增,等同 push main 全量发布。
# 查看当前版本号:git show origin/version:VERSION
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
permissions:
contents: write # 读写 version 分支、发布 Release 与上传二进制需要
packages: write
jobs:
build-image:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
release_tag: ${{ steps.version.outputs.release_tag }}
steps:
- uses: actions/checkout@v4
# 1. 解析版本号:tag 触发取 tag 名(去掉 v 前缀);其余场景读 version 分支并 patch+1
- name: Resolve version
id: version
run: |
if [ "${{ github.ref_type }}" = "tag" ]; then
VERSION="${GITHUB_REF_NAME#v}"
else
git fetch origin version
BASE=$(git show FETCH_HEAD:VERSION 2>/dev/null || echo "0.0.0")
MAJOR=$(echo "$BASE" | cut -d. -f1)
MINOR=$(echo "$BASE" | cut -d. -f2)
PATCH=$(echo "$BASE" | cut -d. -f3)
VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "release_tag=mebox-v${VERSION}" >> "$GITHUB_OUTPUT"
echo "new_version=${VERSION}" >> "$GITHUB_OUTPUT"
# 2. 把新版本号写回 version 分支(clone 单分支写入,冲突时 rebase 重试)
# tag 触发的正式发版版本号来自 tag 本身,跳过自增。
- name: Bump version branch
if: github.ref_type != 'tag'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
REPO="https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git"
git clone --depth 1 --branch version "$REPO" "$RUNNER_TEMP/version-branch"
cd "$RUNNER_TEMP/version-branch"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
echo "${{ steps.version.outputs.new_version }}" > VERSION
git commit -am "chore: bump version to ${{ steps.version.outputs.new_version }}"
ok=0
for i in 1 2 3 4 5; do
if git push origin version; then ok=1; break; fi
git pull --rebase origin version || true
sleep 5
done
[ "$ok" = "1" ] || { echo "::error::version 分支推送冲突,重试 5 次仍失败"; exit 1; }
# 3. 设置 Docker QEMU 和 Buildx
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
# 3. 登录 GHCR
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# 4. 提取镜像元数据
- 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.version.outputs.version }}
# 5. 构建并推送
- 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.version.outputs.release_tag }}
cache-from: type=gha
cache-to: type=gha,mode=max
# 单文件可执行构建:把前端打包进二进制(go:embed),交叉编译 Windows /
# Linux / macOS 的 amd64 / arm64 产物,作为 GitHub Release 附件发布。
build-frontend:
needs: [build-image]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
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: [build-image]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Create release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ needs.build-image.outputs.release_tag }}
run: |
set -eux
# tag push 时 tag 已存在;手动触发时基于当前 main 创建 tag(幂等)
if ! git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then
git tag "$RELEASE_TAG"
git push origin "$RELEASE_TAG"
fi
gh release create "$RELEASE_TAG" \
--title "MeBox ${{ needs.build-image.outputs.version }}" \
--notes "自动化发布 ${{ needs.build-image.outputs.version }}" \
--verify-tag --latest || true
build-binaries:
needs: [build-image, 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: |
LDFLAGS="-s -w -X main.version=${{ needs.build-image.outputs.release_tag }}"
if [ "${{ matrix.goos }}" = "windows" ]; then
LDFLAGS="$LDFLAGS -H=windowsgui"
fi
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
go build -trimpath -ldflags="$LDFLAGS" \
-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.build-image.outputs.release_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: [build-image]
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-file: '.nvmrc'
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 -H=windowsgui -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-file: '.nvmrc'
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-file: '.nvmrc'
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 "==== 部署完成并已启动 ===="
-83
View File
@@ -1,83 +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-*
.codex/
downloads/
media/
*.pid
# 本地开发运行产物
.agents/
.claude/
.dev-cache/
.dev-data/
.dev-logs/
tools/
verify-cache/
verify-data/
.zcode/
-1
View File
@@ -1 +0,0 @@
20.19.0
@@ -1,96 +0,0 @@
## 删除用户云下载任务
### 基本信息
| 属性 | 内容 |
|:-------------|:----------------------------------|
| 接口名称 | 删除用户云下载任务 |
| 接口版本 | v1.0 |
| 接口路径 | /del_task |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
删除当前授权用户的指定云下载任务,可选择同时删除对应源文件。
### 接口地址
```
https://proapi.115.com/open/offline/del_task
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----------------|:-------|:-----|:-------|:---------------------------|:-------------|
| info_hash | string | 是 | - | 需删除的任务Hash | `<info_hash>` |
| del_source_file | int | 否 | 0 | 是否删除源文件,1=删除 0=不删除 | 0 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/offline/del_task' \
-H 'Authorization: Bearer <access_token>' \
--form-string 'info_hash=<info_hash>' \
--form-string 'del_source_file=0'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------|:--------|:----------------------|
| state | boolean | 操作结果状态 |
| message | string | 返回信息 |
| code | int | 错误码 |
| data | object[] | 返回数据,成功时为空数组 |
#### 响应的 code 字段(错误码)说明
| 错误码 | 说明 | 解决方案 |
|:-------|:---------|:-------------------------|
| 990002 | 参数错误 | 检查 `info_hash` 参数 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": []
}
```
### 业务规则
- 未传 `del_source_file` 时默认不删除源文件。
- `del_source_file=1` 会删除任务对应的源文件,操作前需确认影响范围。
### 性能与安全说明
- 用户身份由 Bearer access_token 解析,请求参数不能指定115账号。
- 接口按应用、用户和接口维度执行频率限制。
### 注意事项
- 删除结果以响应中的 `state`、`code` 和 `message` 字段为准。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,111 +0,0 @@
## 添加云下载BT任务
### 基本信息
| 属性 | 内容 |
|:-------------|:----------------------------------|
| 接口名称 | 添加云下载BT任务 |
| 接口版本 | v1.0 |
| 接口路径 | /add_task_bt |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
根据已解析的BT种子信息添加云下载BT任务。
### 接口地址
```
https://proapi.115.com/open/offline/add_task_bt
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-------------|:-------|:-----|:-------|:----------------------------------|:----------------|
| info_hash | string | 是 | - | BT任务Hash | `<info_hash>` |
| wanted | string | 是 | - | 选中下载的文件索引,使用半角逗号分隔 | `<file_indexes>` |
| save_path | string | 是 | - | BT任务文件保存路径 | `A/B` |
| torrent_sha1 | string | 是 | - | BT种子SHA1 | `<torrent_sha1>` |
| pick_code | string | 是 | - | BT种子文件提取码 | `<pick_code>` |
| wp_path_id | string | 否 | 0 | 保存目标文件夹ID | 0 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/offline/add_task_bt' \
-H 'Authorization: Bearer <access_token>' \
--form-string 'info_hash=<info_hash>' \
--form-string 'wanted=<file_indexes>' \
--form-string 'save_path=A/B' \
--form-string 'torrent_sha1=<torrent_sha1>' \
--form-string 'pick_code=<pick_code>' \
--form-string 'wp_path_id=0'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------|:--------|:----------------------|
| state | boolean | 操作结果状态 |
| message | string | 返回信息 |
| code | int | 错误码 |
| data | object[] | 返回数据,成功时为空数组 |
#### 响应的 code 字段(错误码)说明
| 错误码 | 说明 | 解决方案 |
|:--------|:---------------------|:-------------------------------|
| 20018 | 文件不存在或已删除 | 检查提取码、文件归属和种子SHA1 |
| 91006 | 存储空间不足 | 扩充存储空间后重试 |
| 990002 | 参数错误 | 检查必填参数 |
| 1000012 | 云下载配额已用完 | 购买配额或获得更多配额后重试 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": []
}
```
### 业务规则
- `wanted` 可以为 `0`,但不能是空字符串。
- 种子文件需属于当前授权用户、位于指定存储区域,并且文件SHA1与 `torrent_sha1` 一致。
- 不传 `wp_path_id` 时默认保存到根目录;`save_path` 是相对于 `wp_path_id` 所在文件夹的路径。例如,`wp_path_id` 不传或传云下载文件夹ID且 `save_path=A/B` 时,最终路径为根目录下的 `A/B/`。
- 添加任务前会检查当前授权账号的剩余存储空间;空间不足时不扣减云下载配额。
- 云下载服务返回状态码 `10007` 或 `10010` 时,接口统一返回错误码 `1000012`。
### 性能与安全说明
- 用户身份由 Bearer access_token 解析,请求参数不能指定115账号。
- 接口按应用、用户和接口维度执行频率限制。
- 种子文件必须属于当前授权账号,并且文件SHA1与 `torrent_sha1` 一致。
### 注意事项
- 任务处理结果以响应中的 `data` 字段为准。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,115 +0,0 @@
## 添加云下载链接任务
### 基本信息
| 属性 | 内容 |
|:-------------|:----------------------------------|
| 接口名称 | 添加云下载链接任务 |
| 接口版本 | v1.0 |
| 接口路径 | /add_task_urls |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
批量添加云下载链接任务。多个链接使用换行符分隔,支持HTTP(S)、FTP、磁力链和电驴链接。
### 接口地址
```
https://proapi.115.com/open/offline/add_task_urls
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-----------|:-------|:-----|:-------|:----------------------------------------|:----------------|
| urls | string | 是 | - | 云下载链接,多个链接使用换行符分隔 | `<download_url>` |
| wp_path_id | string | 否 | 0 | 保存目标文件夹ID;不传或传0时保存到根目录 | 0 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/offline/add_task_urls' \
-H 'Authorization: Bearer <access_token>' \
--form-string 'urls=<download_url>' \
--form-string 'wp_path_id=0'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:-----------------|:---------|:-------------------------------|
| state | boolean | 操作结果状态 |
| message | string | 返回信息 |
| code | int | 错误码 |
| data | object[] | 各链接任务的添加结果 |
| data[].state | boolean | 链接任务添加状态 |
| data[].code | int | 链接任务状态码 |
| data[].message | string | 链接任务状态描述 |
| data[].info_hash | string | 链接任务SHA1,仅任务成功时返回 |
| data[].url | string | 链接任务URL |
#### 响应的 code 字段(错误码)说明
| 错误码 | 说明 | 解决方案 |
|:--------|:---------------------------|:-----------------------------|
| 91006 | 存储空间不足 | 扩充存储空间后重试 |
| 980004 | 操作失败 | 稍后重试 |
| 990002 | 参数错误 | 检查必填参数 |
| 1000011 | 链接数量超过115个 | 将链接拆分为每批不超过115个 |
| 1000012 | 云下载配额已用完 | 购买配额或获得更多配额后重试 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": [
{
"state": true,
"code": 0,
"message": "",
"info_hash": "",
"url": ""
}
]
}
```
### 业务规则
- 服务端按换行符拆分并清理链接,单次最多提交115个链接。
- 添加任务前会检查当前授权账号的剩余存储空间;空间不足时不扣减云下载配额。
- 云下载服务返回状态码 `10007` 或 `10010` 时,接口统一返回错误码 `1000012`。
### 性能与安全说明
- 用户身份由 Bearer access_token 解析,请求参数不能指定115账号。
- 接口按应用、用户和接口维度执行频率限制。
- 本接口限制单批链接数量,避免不受控的批量请求。
### 注意事项
- 任务处理结果以响应中的 `data` 字段为准。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,104 +0,0 @@
## 清空云下载任务
### 基本信息
| 属性 | 内容 |
|:-------------|:----------------------------------|
| 接口名称 | 清空云下载任务 |
| 接口版本 | v1.0 |
| 接口路径 | /clear_task |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
按指定类型清空当前授权用户的云下载任务。
### 接口地址
```
https://proapi.115.com/open/offline/clear_task
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-------|:-----|:-----|:-------|:-----------------------|:----------|
| flag | int | 是 | - | 清空任务类型,见下方枚举表格 | 1 |
#### 请求参数中的 flag 参数枚举
| 值 | 说明 | 备注 |
|:---|:-----------------------------|:-----|
| 0 | 清空已完成任务 | - |
| 1 | 清空全部任务 | - |
| 2 | 清空失败任务 | - |
| 3 | 清空进行中任务 | - |
| 4 | 清空已完成任务并删除对应源文件 | - |
| 5 | 清空全部任务并删除对应源文件 | - |
### 请求示例
```shell
curl 'https://proapi.115.com/open/offline/clear_task' \
-H 'Authorization: Bearer <access_token>' \
--form-string 'flag=1'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------|:--------|:-----------------|
| state | boolean | 操作结果状态 |
| message | string | 返回信息 |
| code | int | 错误码 |
| data | object[] | 返回数据,成功时为空数组 |
#### 响应的 code 字段(错误码)说明
| 错误码 | 说明 | 解决方案 |
|:-------|:---------|:--------------------------|
| 990002 | 参数错误 | 检查 `flag` 是否为0至5的整数 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": []
}
```
### 业务规则
- `flag=4` 或 `flag=5` 会同时删除对应源文件,操作前需确认清理范围。
### 性能与安全说明
- 用户身份由 Bearer access_token 解析,请求参数不能指定115账号。
- 接口按应用、用户和接口维度执行频率限制。
### 注意事项
- 清空结果以响应中的 `state`、`code` 和 `message` 字段为准。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,101 +0,0 @@
## 获取云下载配额信息
### 基本信息
| 属性 | 内容 |
|:-------------|:----------------------------------|
| 接口名称 | 获取云下载配额信息 |
| 接口版本 | v1.0 |
| 接口路径 | /get_quota_info |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
获取当前授权用户各类云下载配额的使用情况和过期明细。
### 接口地址
```
https://proapi.115.com/open/offline/get_quota_info
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
无。
### 请求示例
```shell
curl 'https://proapi.115.com/open/offline/get_quota_info' \
-H 'Authorization: Bearer <access_token>'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------------------------------------------|:---------|:---------------------|
| state | boolean | 状态,true表示成功 |
| message | string | 返回信息 |
| code | int | 错误码 |
| data | object | 云下载配额数据 |
| data.package | object[] | 配额类型列表 |
| data.package[].surplus | int | 该类型剩余配额 |
| data.package[].used | int | 该类型已用配额 |
| data.package[].count | int | 该类型总配额 |
| data.package[].name | string | 该类型配额名称 |
| data.package[].expire_info | object[] | 该类型配额过期明细 |
| data.package[].expire_info[].surplus | int | 明细项剩余配额 |
| data.package[].expire_info[].expire_time | int | 明细项过期时间 |
| data.count | int | 用户总配额数量 |
| data.surplus | int | 用户总剩余配额数量 |
| data.used | int | 用户总已用配额数量 |
#### 响应的 code 字段(错误码)说明
| 错误码 | 说明 | 解决方案 |
|:-------|:---------|:-------------|
| 990002 | 参数错误 | 检查授权信息 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"package": [],
"count": 0,
"surplus": 0,
"used": 0
}
}
```
### 性能与安全说明
- 用户身份由 Bearer access_token 解析,请求参数不能指定115账号。
- 接口按应用、用户和接口维度执行频率限制。
### 注意事项
- 云下载配额以响应中的 `data` 字段为准。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,136 +0,0 @@
## 获取用户云下载任务列表
### 基本信息
| 属性 | 内容 |
|:-------------|:----------------------------------|
| 接口名称 | 获取用户云下载任务列表 |
| 接口版本 | v1.0 |
| 接口路径 | /get_task_list |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
分页获取当前授权用户的云下载任务列表。
### 接口地址
```
https://proapi.115.com/open/offline/get_task_list
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-------|:-----|:-----|:-------|:---------|:----------|
| page | int | 否 | 1 | 页码 | 1 |
### 请求示例
```shell
curl -G 'https://proapi.115.com/open/offline/get_task_list' \
-H 'Authorization: Bearer <access_token>' \
--data-urlencode 'page=1'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:----------------------------|:---------|:--------------------------------------|
| state | boolean | 状态,true表示成功 |
| message | string | 返回信息 |
| code | int | 错误码 |
| data | object | 分页任务数据 |
| data.page | int | 当前页码 |
| data.page_count | int | 总页数 |
| data.count | int | 任务总数 |
| data.tasks | object[] | 云下载任务列表 |
| data.tasks[].info_hash | string | 任务SHA1 |
| data.tasks[].add_time | int | 任务添加时间戳 |
| data.tasks[].percentDone | int | 任务下载进度 |
| data.tasks[].size | int | 任务总大小,单位为字节 |
| data.tasks[].name | string | 任务名称 |
| data.tasks[].last_update | int | 任务最后更新时间戳 |
| data.tasks[].file_id | string | 任务源文件或文件夹ID |
| data.tasks[].delete_file_id | string | 删除任务并删除源文件时需传递的文件或文件夹ID |
| data.tasks[].status | int | 任务状态,见下方枚举表格 |
| data.tasks[].url | string | 链接任务URL |
| data.tasks[].wp_path_id | string | 任务源文件所在父文件夹ID |
| data.tasks[].def2 | int | 视频清晰度,见下方枚举表格 |
| data.tasks[].play_long | int | 视频时长 |
| data.tasks[].can_appeal | int | 是否可以申诉 |
#### 响应的 data.tasks[].status 字段枚举
| 值 | 说明 | 备注 |
|:---|:---------|:-----|
| -1 | 下载失败 | - |
| 0 | 分配中 | - |
| 1 | 下载中 | - |
| 2 | 下载成功 | - |
#### 响应的 data.tasks[].def2 字段枚举
| 值 | 说明 | 备注 |
|:----|:------|:-----|
| 1 | 标清 | - |
| 2 | 高清 | - |
| 3 | 超清 | - |
| 4 | 1080P | - |
| 5 | 4K | - |
| 100 | 原画 | - |
#### 响应的 code 字段(错误码)说明
| 错误码 | 说明 | 解决方案 |
|:-------|:---------|:---------------|
| 990002 | 参数错误 | 检查授权信息 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"page": 1,
"page_count": 0,
"count": 0,
"tasks": []
}
}
```
### 业务规则
- 未传 `page` 或参数值为空时,默认查询第1页。
### 性能与安全说明
- 用户身份由 Bearer access_token 解析,请求参数不能指定115账号。
- 接口按应用、用户和接口维度执行频率限制。
- 任务列表按 `page` 分页,实际返回数量以响应为准。
### 注意事项
- 云下载任务状态和字段以响应中的 `data` 字段为准。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,118 +0,0 @@
## 解析BT种子
### 基本信息
| 属性 | 内容 |
|:-------------|:----------------------------------|
| 接口名称 | 解析BT种子 |
| 接口版本 | v1.0 |
| 接口路径 | /torrent |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
解析已上传的BT种子文件,返回种子任务信息和文件列表。
### 接口地址
```
https://proapi.115.com/open/offline/torrent
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-------------|:-------|:-----|:-------|:---------------|:----------------|
| torrent_sha1 | string | 是 | - | BT种子文件SHA1 | `<torrent_sha1>` |
| pick_code | string | 是 | - | BT种子文件提取码 | `<pick_code>` |
### 请求示例
```shell
curl 'https://proapi.115.com/open/offline/torrent' \
-H 'Authorization: Bearer <access_token>' \
--form-string 'torrent_sha1=<torrent_sha1>' \
--form-string 'pick_code=<pick_code>'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:-------------------------------|:---------|:---------------------|
| state | boolean | 状态,true表示成功 |
| message | string | 返回信息 |
| code | int | 错误码 |
| data | object | 种子解析结果 |
| data.file_size | int | 任务大小 |
| data.torrent_name | string | 任务名称 |
| data.file_count | int | 文件数量 |
| data.info_hash | string | 任务SHA1 |
| data.torrent_filelist | object[] | 文件列表 |
| data.torrent_filelist[].size | int | 文件大小 |
| data.torrent_filelist[].path | string | 文件路径 |
| data.torrent_filelist[].wanted | int | 文件是否默认选中 |
#### 响应的 code 字段(错误码)说明
| 错误码 | 说明 | 解决方案 |
|:-------|:---------------------|:---------------------------------|
| 20018 | 文件不存在或已删除 | 检查提取码、文件归属和种子SHA1 |
| 990002 | 参数错误 | 检查必填参数 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"file_size": 0,
"torrent_name": "",
"file_count": 0,
"info_hash": "",
"torrent_filelist": [
{
"size": 0,
"path": "",
"wanted": 0
}
]
}
}
```
### 业务规则
- 种子文件需属于当前授权用户、位于指定存储区域,并且文件SHA1与 `torrent_sha1` 一致。
- 现有开放平台文档建议先将种子文件上传至“云下载/种子文件”文件夹,但该目录不是硬性要求。
### 性能与安全说明
- 用户身份由 Bearer access_token 解析,请求参数不能指定115账号。
- 接口按应用、用户和接口维度执行频率限制。
- 种子文件必须属于当前授权账号,并且文件SHA1与 `torrent_sha1` 一致。
### 注意事项
- 种子解析结果以响应中的 `data` 字段为准。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,172 +0,0 @@
## 开发者商业价值转化:推广产品得收益
### 基本信息
| 属性 | 内容 |
|:-------|:--------------------|
| 文档名称 | 开发者商业价值转化:推广产品得收益 |
| 文档版本 | v1.0 |
## 一、简介
为帮助开发者实现商业价值转化,115生活开放平台推出“推广产品得收益”计划,通过“推广有奖、收益共享”的方式,与开发者共同构建可持续发展的开放生态。
开发者接入标准化服务接口后,可以在用户需要升级使用权限、扩充长期存储空间或增加云下载配额时,引导用户购买对应的 115 增值服务,并基于用户实际购买的产品获取相应推广收益。
## 二、服务形式说明
### 1. 标准化服务接口
标准化服务接口覆盖以下核心场景。
#### 1.1. VIP 服务
适用于用户对应功能使用权限不足的场景:
- 视频播放权限升级:解决非 VIP 用户不支持在线预览视频、年费VIP以下用户不支持视频 4K 超轻转码等权限限制。
- 大文件上传权限升级:解决月费VIP以下用户不支持上传 115GB 大文件等权限限制。
开发者可以引导用户升级至更高的 VIP 类型,以获取对应使用权限。
#### 1.2. 长期存储空间扩容服务
适用于用户存储空间不足的场景,可解决因空间容量不足导致文件上传、复制及添加云下载失败等问题。
开发者可以引导用户购买 VIP 服务以获取更多长期存储空间,或单独购买长期存储空间进行扩容。
#### 1.3. 云下载配额
适用于用户云下载配额不足的场景,可解决因服务配额不足导致添加云下载失败等问题。
开发者可以引导用户升级至更高的 VIP 类型以获取更多云下载配额,或单独购买云下载配额。
### 2. 收益获取模式
用户通过开发者应用进入购买页面并成功购买以下任一服务产品后,开发者可以获得对应推广收益:
- 115生活 VIP 服务,包括月费VIP、年费VIP等。
- 长期存储空间扩容。
- 云下载配额。
VIP 服务购买页面示例:
![VIP服务购买页](图片/推广产品得收益/VIP服务购买页.png)
长期存储空间扩容购买页面示例:
![长期存储空间购买页](图片/推广产品得收益/长期存储空间购买页.png)
## 三、接入流程
### 1. 成为开发者
已成为 115 生活开发者的用户可以直接进行接口接入;未注册的开发者,请先参考[接入流程](../接入指南/接入流程.md)完成注册。
### 2. 接口接入
开发者可以调用“获取产品列表地址”接口取得购买页面地址,并在对应业务场景中引导用户访问。
#### 接口信息
| 属性 | 内容 |
|:-------|:------------|
| 接口名称 | 获取产品列表地址 |
| 接口版本 | v1.0 |
| 接口路径 | /vip/qr_url |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
该接口用于获取 115 生活开放平台增值服务产品列表地址。
### 接口地址
```
https://proapi.115.com/open/vip/qr_url
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-------------------|:-------|:---|:-----|:---------------------------|:--------------------------------------------------------|
| default_product_id | int | 否 | null | 打开产品列表时默认选中的产品 ID | 月费:`5`;年费:`1`;尝鲜 1 天:`101`;长期VIP(至尊版):`24072401` |
| open_device | string | 是 | - | 设备号 | `DEVICE_ID_PLACEHOLDER` |
| hide_title | int | 否 | 0 | 是否隐藏购买页推荐人信息:0-不隐藏;1-隐藏 | `0` |
### 请求示例
```shell
curl -G 'https://proapi.115.com/open/vip/qr_url' \
-H 'Authorization: Bearer ACCESS_TOKEN_PLACEHOLDER' \
--data-urlencode 'default_product_id=1' \
--data-urlencode 'open_device=DEVICE_ID_PLACEHOLDER' \
--data-urlencode 'hide_title=0'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:----------------|:--------|:--------------|
| state | boolean | 状态码,true 表示成功 |
| message | string | 响应信息 |
| code | int | 错误码 |
| data | object | 响应数据 |
| data.qrcode_url | string | 开放平台产品列表地址 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"qrcode_url": "PRODUCT_LIST_URL_PLACEHOLDER"
}
}
```
### 注意事项
- 接口根据 `access_token` 识别授权信息,调用方无需额外传入 115 账号和 AppID。
- `open_device` 不能为空;未传或传入空值时,接口返回参数错误。
- `default_product_id` 未传或为空时,服务端不指定默认产品。
- `hide_title` 大于 0 时,服务端会将该参数传递给产品列表服务。
- `access_token` 属于敏感凭证,不得写入公开仓库、客户端日志或公开沟通内容。
### 3. 场景触发与收益
完成接口接入后,开发者可以在用户使用权限不足、长期存储空间不足或云下载配额不足时触发对应引导。用户完成购买后,开发者可以获得对应推广收益。
## 四、收益管理与结算
### 1. 收益查看与提现
开发者可以登录“115生活-生活-联盟”,或直接访问[115联盟](https://union.115.com/),查看收益明细等推广收益情况并进行提现操作。
![推广收益管理页](图片/推广产品得收益/推广收益管理页.png)
### 2. 规则说明
收益计算方式、结算方式和提现流程等详情,参见[联盟规则](https://union.115.com/?ac=help&i=10)。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----------------|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
| 2026年08月05日(周三) 17:30:01 | 恢复业务说明、接入流程及示例图片 |
| 2026年08月19日(周三) 11:00:08 | 长期VIP默认产品名称改为至尊版 |
@@ -1,79 +0,0 @@
## 删除或清空回收站
### 基本信息
| 属性 | 内容 |
|:-----------|:--------------------------------|
| 接口名称 | 删除或清空回收站 |
| 接口版本 | v1.0 |
| 接口路径 | /del |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
批量彻底删除回收站中的文件(夹),或在不传 `tid` 时清空回收站。
### 接口地址
```
https://proapi.115.com/open/rb/del
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----|:-------|:---|:----|:--------------------------------|:------|
| tid | string | 否 | "" | 需要删除的回收站ID,多个ID用半角逗号分隔,最多 1150 个;不传时清空回收站 | 1,2,3,4 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/rb/del' \
-H 'Authorization: Bearer access_token' \
--form-string 'tid=1,2,3,4'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------|:---------|:--------------|
| state | boolean | 状态码,true 表示成功 |
| message | string | 错误信息 |
| code | int | 错误码 |
| data | string[] | 响应数据 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": []
}
```
### 注意事项
- `access_token` 由开放平台授权流程获取,并通过 `Authorization` 请求头传递。
- 不传 `tid` 会清空当前授权用户的整个回收站,操作后无法恢复。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,80 +0,0 @@
## 删除文件
### 基本信息
| 属性 | 内容 |
|:-----------|:--------------------------------|
| 接口名称 | 删除文件 |
| 接口版本 | v1.0 |
| 接口路径 | /delete |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
批量删除文件(夹),删除操作异步执行并将目标移入回收站。
### 接口地址
```
https://proapi.115.com/open/ufile/delete
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----------|:-------|:---|:----|:-----------------------|:-------------------------------------------|
| file_ids | string | 是 | - | 需要删除的文件(夹)ID,多个ID用半角逗号分隔 | 3073323042143855813,3073323042143855822 |
| parent_id | string | 否 | 0 | 待删除文件(夹)所在的父目录ID | 3073311192547189943 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/ufile/delete' \
-H 'Authorization: Bearer access_token' \
--form-string 'file_ids=3073323042143855813,3073323042143855822' \
--form-string 'parent_id=3073311192547189943'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------|:---------|:-----------------|
| state | boolean | 状态码,true 表示请求已受理 |
| message | string | 错误信息 |
| code | int | 错误码 |
| data | string[] | 响应数据 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": []
}
```
### 注意事项
- `access_token` 由开放平台授权流程获取,并通过 `Authorization` 请求头传递。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,126 +0,0 @@
## 回收站列表
### 基本信息
| 属性 | 内容 |
|:-----------|:--------------------------------|
| 接口名称 | 回收站列表 |
| 接口版本 | v1.0 |
| 接口路径 | /list |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
分页获取当前授权用户的回收站文件(夹)列表。
### 接口地址
```
https://proapi.115.com/open/rb/list
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-------|:----|:---|:----|:--------|:------|
| limit | int | 否 | 30 | 单页记录数 | 最大 200 |
| offset | int | 否 | 0 | 数据显示偏移量 | 0 |
### 请求示例
```shell
curl -G 'https://proapi.115.com/open/rb/list' \
-H 'Authorization: Bearer access_token' \
--data-urlencode 'limit=30' \
--data-urlencode 'offset=0'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:----------------------|:--------|:-------------------------------|
| state | boolean | 状态码,true 表示成功 |
| message | string | 错误信息 |
| code | int | 错误码 |
| data | object | 响应数据 |
| data.offset | int | 数据显示偏移量 |
| data.limit | int | 单页记录数 |
| data.count | string | 回收站文件(夹)总数 |
| data.rb_pass | int | 是否设置回收站密码:1-是,0-否 |
| data.{回收站ID} | object | 以回收站ID为键的文件(夹)信息 |
| data.{回收站ID}.id | string | 回收站ID |
| data.{回收站ID}.file_name | string | 文件(夹)名称 |
| data.{回收站ID}.type | string | 类型:1-文件,2-文件夹 |
| data.{回收站ID}.file_size | string | 文件大小,单位为字节 |
| data.{回收站ID}.dtime | string | 删除时间 |
| data.{回收站ID}.thumb_url | string | 缩略图地址 |
| data.{回收站ID}.status | string | 还原状态:-1-还原中,0-正常 |
| data.{回收站ID}.cid | int | 原文件(夹)的父目录ID |
| data.{回收站ID}.parent_name | string | 原文件(夹)的父目录名称 |
| data.{回收站ID}.pick_code | string | 文件提取码 |
| data.{回收站ID}.isv | int | 是否为视频文件,按文件类型返回 |
| data.{回收站ID}.def2 | int | 视频清晰度,按文件类型返回 |
| data.{回收站ID}.ico | string | 文件扩展名,按文件类型返回 |
| data.{回收站ID}.muc | string | 音频封面地址,按文件类型返回 |
| data.{回收站ID}.d_img | string | 文档缩略图地址,按文件类型返回 |
| data.{回收站ID}.play_long | int | 音视频时长,按文件类型返回 |
| data.{回收站ID}.sha1 | string | 文件 SHA1 值,按文件类型返回 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"offset": 0,
"limit": 30,
"count": "0",
"rb_pass": 0,
"3074054555277845747": {
"id": "3074054555277845747",
"file_name": "",
"type": "1",
"file_size": "0",
"dtime": "",
"thumb_url": "",
"status": "0",
"cid": 0,
"parent_name": "",
"pick_code": "",
"isv": 0,
"def2": 0,
"ico": "",
"muc": "",
"d_img": "",
"play_long": 0,
"sha1": ""
}
}
}
```
### 注意事项
- `access_token` 由开放平台授权流程获取,并通过 `Authorization` 请求头传递。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
| 2026年08月05日(周三) 17:15:28 | 补充单页记录数上限 |
@@ -1,78 +0,0 @@
## 回收站还原
### 基本信息
| 属性 | 内容 |
|:-----------|:--------------------------------|
| 接口名称 | 回收站还原 |
| 接口版本 | v1.0 |
| 接口路径 | /revert |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
批量还原回收站中的文件(夹)。
### 接口地址
```
https://proapi.115.com/open/rb/revert
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----|:-------|:---|:----|:---------------------------|:--------------|
| tid | string | 是 | - | 需要还原的回收站ID,多个ID用半角逗号分隔,最多 1150 个 | 111,222,333,444 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/rb/revert' \
-H 'Authorization: Bearer access_token' \
--form-string 'tid=111,222,333,444'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------|:---------|:--------------|
| state | boolean | 状态码,true 表示成功 |
| message | string | 错误信息 |
| code | int | 错误码 |
| data | string[] | 响应数据 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": []
}
```
### 注意事项
- `access_token` 由开放平台授权流程获取,并通过 `Authorization` 请求头传递。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,87 +0,0 @@
## 文件(夹)更新
### 基本信息
| 属性 | 内容 |
|:-----------|:--------------------------------|
| 接口名称 | 文件(夹)更新 |
| 接口版本 | v1.0 |
| 接口路径 | /update |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
更新文件(夹)名称或星标状态。`file_name` 与 `star` 至少传入一个。
### 接口地址
```
https://proapi.115.com/open/ufile/update
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:---------|:-------|:---|:----|:----------------------|:------------------|
| file_id | string | 是 | - | 需要更新的文件(夹)ID | 3073323042143855813 |
| file_name | string | 否 | null | 新的文件(夹)名称,文件夹名称限制 255 字节 | 新的名字 |
| star | int | 否 | null | 是否星标:1-星标,0-取消星标 | 1 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/ufile/update' \
-H 'Authorization: Bearer access_token' \
--form-string 'file_id=3073323042143855813' \
--form-string 'file_name=新的名字' \
--form-string 'star=1'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:---------------|:--------|:------------------|
| state | boolean | 状态码,true 表示成功 |
| message | string | 错误信息 |
| code | int | 错误码 |
| data | object | 响应数据 |
| data.file_name | string | 更新后的文件(夹)名称 |
| data.star | int | 更新后的星标状态:1-星标,0-取消星标 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"file_name": "新的名字",
"star": 1
}
}
```
### 注意事项
- `access_token` 由开放平台授权流程获取,并通过 `Authorization` 请求头传递。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,41 +0,0 @@
# 上传流程
### 基本信息
| 属性 | 内容 |
|:---------|:------------------------------|
| 文档名称 | 上传流程 |
| 文档版本 | v1.0 |
| 适用场景 | 115开放平台文件上传 |
### 文档说明
本文档说明 115 开放平台的文件秒传、普通上传和断点续传流程。
## 流程概览
1. 请求「文件上传」接口初始化上传。
2. 若响应中 `status=2`,表示秒传成功,上传流程结束。
3. 若响应提示需要二次认证,按 `sign_check` 指定的字节范围计算大写 SHA1,然后携带 `sign_key` 和 `sign_val` 重新请求「文件上传」接口。
4. 若 `status=1`,携带初始化响应中的 `bucket`、`object`、`callback` 及「获取上传凭证」接口返回的凭证,向对象存储上传文件。
5. 需要续传时,携带初始化响应中的 `pick_code` 及待上传文件信息请求「断点续传」接口,获取新的对象存储上传参数。
6. 对象存储返回上传成功后,普通上传或断点续传完成。
## 相关接口
- [文件上传](文件上传.md)
- [获取上传凭证](获取上传凭证.md)
- [断点续传](断点续传.md)
- [阿里云 OSS 上传文件说明](https://help.aliyun.com/zh/oss/user-guide/upload-objects-to-oss/)
## 注意事项
- 对象存储上传不请求 `proapi.115.com`,应使用「获取上传凭证」和上传调度接口返回的域名、对象标识、临时凭证与回调参数发起请求。
- `sign_check` 的起止字节均在 SHA1 计算范围内。例如 `0-99` 表示计算共 100 字节的内容。
- 调用开放平台接口时必须携带 `Authorization: Bearer access_token`,并妥善保管临时上传凭证。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,141 +0,0 @@
## 文件上传
### 基本信息
| 属性 | 内容 |
|:---------|:------------------------------|
| 接口名称 | 文件上传 |
| 接口版本 | v1.0 |
| 接口路径 | /upload/init |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
初始化断点续传调度,完成秒传判定、二次认证调度或返回对象存储上传参数。
### 接口地址
```
https://proapi.115.com/open/upload/init
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----------|:-----|:---|:----|:---------------------------|:--------------------|
| file_name | string | 是 | - | 文件名 | 图片.jpg |
| file_size | int | 是 | - | 文件大小,单位为字节 | 5335 |
| target | string | 是 | - | 文件上传目标 | `U_1_0`,格式为 `U_1_<文件夹ID>` |
| fileid | string | 是 | - | 文件 SHA1 值 | `<FILE_SHA1>` |
| preid | string | 否 | "" | 文件前 128 KiB 内容的 SHA1 值 | `<PREID_SHA1>` |
| path | string | 否 | "" | 上传路径 | "" |
| pick_code | string | 否 | "" | 上传任务唯一标识,用于续传 | `<PICK_CODE>` |
| topupload | int | 否 | "" | 上传调度文件类型标记,见下方枚举表格 | 0 |
| sign_key | string | 否 | "" | 二次认证标识 | `<SIGN_KEY>` |
| sign_val | string | 否 | "" | 根据 `sign_check` 计算的大写 SHA1 值 | `<SIGN_VAL>` |
#### 请求参数中的 topupload 字段枚举
| 值 | 说明 | 备注 |
|:---|:---------------------------|:---|
| -1 | 没有上传调度文件类型标记 | - |
| 0 | 单文件上传任务,记录一条独立上传记录 | - |
| 1 | 文件夹任务的第一个子文件,记录一次文件夹上传 | - |
| 2 | 文件夹任务的其他子文件,不单独记录上传记录 | - |
### 请求示例
```shell
curl 'https://proapi.115.com/open/upload/init' \
-H 'Authorization: Bearer <ACCESS_TOKEN>' \
-F 'file_name=图片.jpg' \
-F 'file_size=5335' \
-F 'target=U_1_0' \
-F 'fileid=<FILE_SHA1>' \
-F 'topupload=0'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------------------------|:------|:----------------------------------------|
| state | boolean | 状态码,是表示成功,否表示异常 |
| message | string | 异常信息 |
| code | int | 异常码 |
| data | object | 上传调度数据 |
| data.status | int | 上传状态:1-非秒传 2-秒传 |
| data.code | int | 上传调度状态码 |
| data.pick_code | string | 上传任务唯一标识,用于续传 |
| data.target | string | 文件上传目标 |
| data.bucket | string | 对象存储 bucket |
| data.object | string | OSS 对象标识 |
| data.callback | object | 上传完成回调数据 |
| data.callback.callback | string | 上传完成回调信息 |
| data.callback.callback_var | string | 上传完成回调参数 |
| data.sign_key | string | 本次二次认证的 SHA1 标识 |
| data.sign_check | string | 二次认证所需本地文件 SHA1 计算的字节范围 |
| data.file_id | string | 秒传成功时新增文件 ID |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"status": 1,
"code": 0,
"pick_code": "",
"target": "U_1_0",
"bucket": "",
"object": "",
"callback": {
"callback": "",
"callback_var": ""
},
"sign_key": "",
"sign_check": "",
"file_id": ""
}
}
```
### 业务规则
- `target` 必须匹配 `U_1_<数字文件夹ID>`;`U_1_0` 表示网盘根目录。
- 非 VIP 用户单文件大小不得超过 5 GiB;尝鲜VIP、体验VIP用户单文件大小不得超过 15 GiB。
- 上传前会检查用户剩余空间和盗播上传封禁状态。
- 二次认证调度结果见下表。
| code | status | 说明 | 后续处理 |
|:-----|:-------|:---------|:------|
| 700 | 6 | 签名认证后失败 | 按 `sign_check` 截取包含起止字节的文件内容计算大写 SHA1,再传入 `sign_key` 和 `sign_val` |
| 701 | 7 | 需要认证签名 | 按 `sign_check` 截取包含起止字节的文件内容计算大写 SHA1,再传入 `sign_key` 和 `sign_val` |
| 702 | 8 | 签名认证失败 | 按 `sign_check` 截取包含起止字节的文件内容计算大写 SHA1,再传入 `sign_key` 和 `sign_val` |
### 注意事项
- `sign_check` 格式为 `起始字节-结束字节`,起止字节均包含在 SHA1 计算范围内;例如 `2392148-2392298` 需计算该范围内文件内容的 SHA1。
- 省略 `topupload` 时,服务端会将其归一化为空字符串;显式传入枚举值时会归一化为整数。
- 请妥善保管 `access_token`、上传凭证与回调数据,不要在日志或客户端可见信息中输出。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,110 +0,0 @@
## 断点续传
### 基本信息
| 属性 | 内容 |
|:---------|:------------------------------|
| 接口名称 | 断点续传 |
| 接口版本 | v1.0 |
| 接口路径 | /upload/resume |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
根据已有上传任务和待上传文件信息,获取断点续传所需的对象存储上传参数。
### 接口地址
```
https://proapi.115.com/open/upload/resume
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----------|:-----|:---|:----|:------------------|:--------------------|
| file_size | int | 是 | - | 文件大小,单位为字节 | 5335 |
| target | string | 是 | - | 文件上传目标 | `U_1_0`,格式为 `U_1_<文件夹ID>` |
| fileid | string | 是 | - | 文件 SHA1 值 | `<FILE_SHA1>` |
| pick_code | string | 是 | - | 上传任务唯一标识 | `<PICK_CODE>` |
### 请求示例
```shell
curl 'https://proapi.115.com/open/upload/resume' \
-H 'Authorization: Bearer <ACCESS_TOKEN>' \
-F 'file_size=5335' \
-F 'target=U_1_0' \
-F 'fileid=<FILE_SHA1>' \
-F 'pick_code=<PICK_CODE>'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------------------------|:------|:----------------------|
| state | boolean | 状态码,是表示成功,否表示异常 |
| message | string | 异常信息 |
| code | int | 异常码 |
| data | object | 续传调度数据 |
| data.version | string | 上传接口版本 |
| data.target | string | 文件上传目标 |
| data.pick_code | string | 上传任务唯一标识 |
| data.bucket | string | 对象存储 bucket |
| data.object | string | OSS 对象标识 |
| data.callback | object | 上传完成回调数据 |
| data.callback.callback | string | 上传完成回调信息 |
| data.callback.callback_var | string | 上传完成回调参数 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"version": "",
"target": "U_1_0",
"pick_code": "",
"bucket": "",
"object": "",
"callback": {
"callback": "",
"callback_var": ""
}
}
}
```
### 业务规则
- `target` 必须匹配 `U_1_<数字文件夹ID>`;`U_1_0` 表示网盘根目录。
- 非 VIP 用户单文件大小不得超过 5 GiB;尝鲜VIP、体验VIP用户单文件大小不得超过 15 GiB。
- 续传前会检查用户剩余空间和盗播上传封禁状态。
- 上游调度结果的 `status` 只有为 1 或 2 时才按成功响应返回续传参数。
### 注意事项
- `pick_code` 必须来自原上传初始化调度响应,并与当前文件信息匹配。
- 请妥善保管 `access_token`、上传凭证与回调数据,不要在日志或客户端可见信息中输出。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,87 +0,0 @@
## 获取上传凭证
### 基本信息
| 属性 | 内容 |
|:---------|:------------------------------|
| 接口名称 | 获取上传凭证 |
| 接口版本 | v1.0 |
| 接口路径 | /upload/get_token |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
获取对象存储上传域名和临时上传凭证。
### 接口地址
```
https://proapi.115.com/open/upload/get_token
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
无
### 请求示例
```shell
curl 'https://proapi.115.com/open/upload/get_token' \
-H 'Authorization: Bearer <ACCESS_TOKEN>'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:-------------------|:------|:---------------|
| state | boolean | 状态码,是表示成功,否表示异常 |
| message | string | 异常信息 |
| code | int | 异常码 |
| data | object | 上传凭证数据 |
| data.endpoint | string | 上传域名 |
| data.AccessKeySecret | string | 临时上传凭证密钥 |
| data.SecurityToken | string | 临时安全令牌 |
| data.Expiration | string | 上传凭证过期时间 |
| data.AccessKeyId | string | 临时上传凭证 ID |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"endpoint": "",
"AccessKeySecret": "",
"SecurityToken": "",
"Expiration": "",
"AccessKeyId": ""
}
}
```
### 注意事项
- 网页版文档中的密钥字段名存在拼写误差,服务端实际返回字段为 `AccessKeySecret`。
- 上传凭证为敏感信息,仅用于当前上传流程;不要写入日志、持久化存储或对外暴露。
- 服务端通过当前 `access_token` 识别用户,不接收用户账号请求参数。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,84 +0,0 @@
## 文件复制
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 文件复制 |
| 接口版本 | v1.0 |
| 接口路径 | /copy |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
将一个或多个文件、文件夹复制到指定目录。
### 接口地址
```
https://proapi.115.com/open/ufile/copy
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:--------|:-------|:---|:----|:------------------------------------|:--------------------|
| pid | string | 否 | "0" | 目标目录ID,根目录ID为 `0` | 1054251402869818368 |
| file_id | string | 是 | - | 待复制的文件或文件夹ID,多个ID使用半角逗号分隔 | 2323423573680609857 |
| nodupli | int | 否 | 0 | 目标目录是否不允许同名:0-允许,1-不允许 | 1 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/ufile/copy' \
-H 'Authorization: Bearer access_token' \
--form-string 'pid=1054251402869818368' \
--form-string 'file_id=2323423573680609857' \
--form-string 'nodupli=1'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------|:---------|:--------------------|
| state | boolean | 接口状态,true表示成功 |
| message | string | 异常信息 |
| code | int | 异常码 |
| data | object[] | 响应数据 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": []
}
```
### 注意事项
- `pid` 不传或传 `0` 时复制到根目录。
- 接口受文件操作频率限制;触发限制时返回异常。
- `user_id` 由服务端根据 access token 获取,无需传入。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,121 +0,0 @@
## 文件搜索
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 文件搜索 |
| 接口版本 | v1.0 |
| 接口路径 | /search |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
根据文件名搜索文件或文件夹,支持按目录、文件标签、时间范围和文件类型筛选。
### 接口地址
```
https://proapi.115.com/open/ufile/search
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:------------|:-------|:---|:----|:----------------------------------------------------------|:---------|
| search_value | string | 否 | "" | 搜索关键词;与 `file_label` 至少传一个,最多取前40个字符 | "文件" |
| limit | int | 否 | 20 | 单页记录数;`offset + limit` 最大不超过10000 | 20 |
| offset | int | 否 | 0 | 数据显示偏移量 | 0 |
| file_label | string | 否 | "" | 文件标签;与 `search_value` 至少传一个 | "1" |
| cid | int | 否 | 0 | 目标目录ID;`-1` 表示不返回任何列表内容 | 0 |
| gte_day | string | 否 | "" | 搜索结果匹配的开始日期 | 2020-11-19 |
| lte_day | string | 否 | "" | 搜索结果匹配的结束日期 | 2020-11-20 |
| fc | int | 否 | 0 | 显示类型:1-只显示文件夹,2-只显示文件,0-全部 | 0 |
| type | int | 否 | 0 | 一级筛选大分类,见下方枚举表格 | 1 |
| suffix | string | 否 | "" | 一级筛选选择“其他”时填写的后缀名 | "pdf" |
#### 请求参数中的 type 字段枚举
| 值 | 说明 | 备注 |
|:--|:----|:---|
| 1 | 文档 | - |
| 2 | 图片 | - |
| 3 | 音频 | - |
| 4 | 视频 | - |
| 5 | 压缩包 | - |
| 6 | 应用 | - |
### 请求示例
```shell
curl -G 'https://proapi.115.com/open/ufile/search' \
-H 'Authorization: Bearer access_token' \
--data-urlencode 'search_value=文件' \
--data-urlencode 'limit=20' \
--data-urlencode 'offset=0'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:-------------------|:---------|:----------------------------------------|
| count | int | 符合条件的文件或文件夹总数 |
| data | object[] | 文件或文件夹列表 |
| data[].file_id | string | 文件或文件夹ID |
| data[].user_id | string | 115账号 |
| data[].sha1 | string | 文件SHA-1值 |
| data[].file_name | string | 文件或文件夹名称 |
| data[].file_size | string | 文件大小 |
| data[].user_ptime | string | 上传时间 |
| data[].user_utime | string | 更新时间 |
| data[].pick_code | string | 文件提取码 |
| data[].parent_id | string | 父目录ID |
| data[].area_id | string | 文件状态:1-正常,7-已删除(回收站),120-彻底删除 |
| data[].is_private | int | 文件是否隐藏:0-未隐藏,1-已隐藏 |
| data[].file_category | string | 文件属性:1-文件,0-文件夹 |
| data[].ico | string | 文件后缀 |
| limit | int | 分页数量 |
| offset | int | 偏移量 |
| state | boolean | 接口状态,true表示成功 |
| message | string | 异常信息 |
| code | int | 异常码 |
### 响应示例
```json
{
"count": 0,
"data": [],
"limit": 20,
"offset": 0,
"state": true,
"message": "",
"code": 0
}
```
### 注意事项
- `search_value` 与 `file_label` 至少传一个;搜索关键词最多取前40个字符。
- 当 `offset + limit` 超过10000时,服务端按 `offset=0`、`limit=115` 查询。
- 搜索结果会过滤不属于正常区域的文件;当前账号未开启隐藏文件展示时,也会过滤隐藏文件。
- `user_id` 由服务端根据 access token 获取,无需传入。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,91 +0,0 @@
## 文件移动
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 文件移动 |
| 接口版本 | v1.0 |
| 接口路径 | /move |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
将一个或多个文件、文件夹移动到指定目录。
### 接口地址
```
https://proapi.115.com/open/ufile/move
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:---------|:-------|:---|:----|:------------------------------------|:----------------------------------------|
| file_ids | string | 是 | - | 待移动的文件或文件夹ID,多个ID使用半角逗号分隔 | 3073323042143855813,3073323042143855822 |
| to_cid | string | 否 | "0" | 目标目录ID;`0` 表示根目录,非 `0` 时必须指向正常可用的目录 | 3073311192547189943 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/ufile/move' \
-H 'Authorization: Bearer access_token' \
--form-string 'file_ids=3073323042143855813,3073323042143855822' \
--form-string 'to_cid=3073311192547189943'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:--------|:---------|:--------------------|
| state | boolean | 接口状态,true表示成功 |
| message | string | 异常信息 |
| code | int | 异常码 |
| data | object[] | 响应数据 |
#### 响应的 code 字段(错误码)说明
| 错误码 | 说明 | 解决方案 |
|:-------|:-----------------------------------|:-----------------------------|
| 20009 | 目标目录不存在或目标ID不是目录 | 确认 to_cid 指向存在的目录 |
| 20018 | 目标目录不在正常区域或已经删除 | 选择正常可用的目标目录 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": []
}
```
### 注意事项
- `to_cid` 不传或传 `0` 时移动到根目录。
- `to_cid` 非 `0` 时,目标必须是正常可用的目录;目标不存在、不是目录或已经删除时,移动失败。
- 接口受文件操作频率限制;触发限制时返回异常。
- `user_id` 由服务端根据 access token 获取,无需传入。
### 修改历史
| 修改时间 | 修改说明 |
|:---------------------------------|:---------------------------------------|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
| 2026年08月14日(周五) 15:38:44 | 补充移动目标目录有效性约束及错误码 |
@@ -1,86 +0,0 @@
## 新建文件夹
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 新建文件夹 |
| 接口版本 | v1.0 |
| 接口路径 | /add |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
在指定父目录下新建文件夹。
### 接口地址
```
https://proapi.115.com/open/folder/add
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:---------|:-------|:---|:----|:---------------------------|:--------------------|
| pid | string | 否 | "0" | 父目录ID,根目录ID为 `0` | 3073323042143855813 |
| file_name | string | 是 | - | 文件夹名称,最多255个字符 | 新建文件夹名称 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/folder/add' \
-H 'Authorization: Bearer access_token' \
--form-string 'pid=3073323042143855813' \
--form-string 'file_name=新建文件夹名称'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:---------------|:--------|:----------------------|
| state | boolean | 接口状态,true表示成功 |
| message | string | 异常信息 |
| code | int | 异常码 |
| data | object | 响应数据 |
| data.file_name | string | 新建的文件夹名称 |
| data.file_id | string | 新建的文件夹ID |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"file_name": "",
"file_id": ""
}
}
```
### 注意事项
- `pid` 不传或传 `0` 时在根目录下新建文件夹。
- `user_id` 由服务端根据 access token 获取,无需传入。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,114 +0,0 @@
## 按ID获取
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 按ID获取 |
| 接口版本 | v1.0 |
| 接口路径 | /get_info |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
根据文件或文件夹ID获取详情。
### 接口地址
```
https://proapi.115.com/open/folder/get_info
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:---------|:-------|:---|:----|:----------|:--------------------|
| file_id | string | 是 | - | 文件或文件夹ID | 1288444975268439877 |
### 请求示例
```shell
curl -G 'https://proapi.115.com/open/folder/get_info' \
-H 'Authorization: Bearer access_token' \
--data-urlencode 'file_id=1288444975268439877'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:----------------------|:---------|:----------------------------------------|
| state | boolean | 接口状态,true表示成功 |
| message | string | 异常信息 |
| code | int | 异常码 |
| data | object | 文件或文件夹详情 |
| data.count | int | 包含的文件总数 |
| data.size | string | 文件或文件夹总大小 |
| data.size_byte | int | 文件或文件夹总大小,单位为字节 |
| data.folder_count | int | 包含的文件夹总数 |
| data.play_long | int | 视频时长;`-1` 表示正在统计,其他数值单位为秒 |
| data.show_play_long | int | 是否开启展示视频时长 |
| data.ptime | string | 上传时间 |
| data.utime | string | 修改时间 |
| data.file_name | string | 文件或文件夹名称 |
| data.pick_code | string | 文件提取码 |
| data.sha1 | string | 文件SHA-1值 |
| data.file_id | string | 文件或文件夹ID |
| data.is_mark | string | 是否星标 |
| data.open_time | int | 文件或文件夹最近打开时间 |
| data.file_category | string | 文件属性:1-文件,0-文件夹 |
| data.paths | object[] | 文件或文件夹所在路径 |
| data.paths[].file_id | string | 父目录ID |
| data.paths[].file_name | string | 父目录名称 |
| data.paths[].iss | int | 父目录共享状态标识 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"count": 0,
"size": "",
"size_byte": 0,
"folder_count": 0,
"play_long": 0,
"show_play_long": 0,
"ptime": "",
"utime": "",
"file_name": "",
"pick_code": "",
"sha1": "",
"file_id": "",
"is_mark": "",
"open_time": 0,
"file_category": "",
"paths": []
}
}
```
### 注意事项
- 文件或文件夹已进入回收站或被彻底删除时,接口返回异常。
- `user_id` 由服务端根据 access token 获取,无需传入。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,115 +0,0 @@
## 按路径获取
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 按路径获取 |
| 接口版本 | v1.0 |
| 接口路径 | /get_info |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
根据文件或文件夹路径获取详情。
### 接口地址
```
https://proapi.115.com/open/folder/get_info
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----|:-------|:---|:----|:----------------------------------------------------------------|:-------------------|
| path | string | 是 | - | 文件路径,支持 `/`、`>` 两种分隔符;路径需以分隔符开头,并用同一分隔符分隔目录层级 | /a/b/c.png 或 >a>b>c |
### 请求示例
```shell
curl 'https://proapi.115.com/open/folder/get_info' \
-H 'Authorization: Bearer access_token' \
--form-string 'path=/a/b/c.png'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:----------------------|:---------|:----------------------------------------|
| state | boolean | 接口状态,true表示成功 |
| message | string | 异常信息 |
| code | int | 异常码 |
| data | object | 文件或文件夹详情 |
| data.count | int | 包含的文件总数 |
| data.size | string | 文件或文件夹总大小 |
| data.size_byte | int | 文件或文件夹总大小,单位为字节 |
| data.folder_count | int | 包含的文件夹总数 |
| data.play_long | int | 视频时长;`-1` 表示正在统计,其他数值单位为秒 |
| data.show_play_long | int | 是否开启展示视频时长 |
| data.ptime | string | 上传时间 |
| data.utime | string | 修改时间 |
| data.file_name | string | 文件或文件夹名称 |
| data.pick_code | string | 文件提取码 |
| data.sha1 | string | 文件SHA-1值 |
| data.file_id | string | 文件或文件夹ID |
| data.is_mark | string | 是否星标 |
| data.open_time | int | 文件或文件夹最近打开时间 |
| data.file_category | string | 文件属性:1-文件,0-文件夹 |
| data.paths | object[] | 文件或文件夹所在路径 |
| data.paths[].file_id | string | 父目录ID |
| data.paths[].file_name | string | 父目录名称 |
| data.paths[].iss | int | 父目录共享状态标识 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"count": 0,
"size": "",
"size_byte": 0,
"folder_count": 0,
"play_long": 0,
"show_play_long": 0,
"ptime": "",
"utime": "",
"file_name": "",
"pick_code": "",
"sha1": "",
"file_id": "",
"is_mark": "",
"open_time": 0,
"file_category": "",
"paths": []
}
}
```
### 注意事项
- 文件或文件夹已进入回收站或被彻底删除时,接口返回异常。
- `user_id` 由服务端根据 access token 获取,无需传入。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,98 +0,0 @@
## 获取文件下载地址
### 基本信息
| 属性 | 内容 |
|:-----------|:--------------------------------|
| 接口名称 | 获取文件下载地址 |
| 接口版本 | v1.0 |
| 接口路径 | /downurl |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
根据文件提取码获取文件下载地址。
### 接口地址
```
https://proapi.115.com/open/ufile/downurl
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:---------|:-------|:---|:----|:------|:-----------------|
| pick_code | string | 是 | - | 文件提取码,多个提取码用半角逗号分隔 | dtctprlmfkl4exiok |
### 请求示例
```shell
curl 'https://proapi.115.com/open/ufile/downurl' \
-H 'Authorization: Bearer access_token' \
--form-string 'pick_code=dtctprlmfkl4exiok'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:---------------------------|:--------|:---------------------------|
| state | boolean | 状态码,true 表示成功 |
| message | string | 错误信息 |
| code | int | 错误码 |
| errno | int | 下载地址获取失败时返回的错误码 |
| data | object | 响应数据 |
| data.{文件ID} | object | 以文件ID为键的文件下载信息 |
| data.{文件ID}.file_name | string | 文件名 |
| data.{文件ID}.file_size | int | 文件大小,单位为字节 |
| data.{文件ID}.pick_code | string | 文件提取码 |
| data.{文件ID}.sha1 | string | 文件 SHA1 值 |
| data.{文件ID}.url | object | 下载地址信息 |
| data.{文件ID}.url.url | string | 文件下载地址 |
| data.can_appeal | boolean | 文件违规时是否可以申诉,按错误场景返回 |
| data.want_appeal_id | string | 文件违规时的申诉标识,按错误场景返回 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"2323423573680609857": {
"file_name": "",
"file_size": 0,
"pick_code": "",
"sha1": "",
"url": {
"url": ""
}
}
}
}
```
### 注意事项
- `access_token` 由开放平台授权流程获取,并通过 `Authorization` 请求头传递。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,204 +0,0 @@
## 获取文件列表
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 获取文件列表 |
| 接口版本 | v1.0 |
| 接口路径 | /files |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
获取指定目录中的文件和文件夹列表,支持分页、排序以及按文件类型、后缀名和星标状态筛选。
### 接口地址
```
https://proapi.115.com/open/ufile/files
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-----------|:-------|:---|:----------|:-------------------------------------------------------|:----------|
| cid | string | 否 | "0" | 目录ID,对应 `parent_id`;根目录ID为 `0` | "0" |
| type | int | 否 | 0 | 文件类型,见下方枚举表格 | 1 |
| limit | int | 否 | 20 | 查询数量,最大1150 | 20 |
| offset | int | 否 | 0 | 查询起始位置 | 0 |
| suffix | string | 否 | "" | 文件后缀名 | "pdf" |
| asc | int | 否 | 0 | 排序方向:1-升序,0-降序 | 0 |
| o | string | 否 | user_ptime | 排序字段,见下方枚举表格 | file_name |
| custom_order | int | 否 | 0 | 排序模式,见下方枚举表格 | 0 |
| stdir | int | 否 | 0 | 筛选文件时是否显示文件夹:1-显示,0-不显示 | 1 |
| star | int | 否 | 0 | 星标筛选:1-仅显示星标文件,0-全部 | 0 |
| cur | int | 否 | 0 | 是否只显示当前文件夹内的文件:1-是,0-否 | 1 |
| show_dir | int | 否 | 0 | 是否显示目录:1-是,0-否 | 0 |
#### 请求参数中的 type 字段枚举
| 值 | 说明 | 备注 |
|:--|:----|:---|
| 1 | 文档 | - |
| 2 | 图片 | - |
| 3 | 音频 | - |
| 4 | 视频 | - |
| 5 | 压缩包 | - |
| 6 | 应用 | - |
| 7 | 书籍 | - |
#### 请求参数中的 o 字段枚举
| 值 | 说明 | 备注 |
|:-----------|:-------|:---|
| file_name | 文件名 | - |
| file_size | 文件大小 | - |
| user_ptime | 上传时间 | 默认值 |
| user_utime | 更新时间 | - |
| file_type | 文件类型 | - |
#### 请求参数中的 custom_order 字段枚举
| 值 | 说明 | 备注 |
|:--|:----------------------|:---|
| 0 | 使用记忆排序,自定义排序失效 | 默认值 |
| 1 | 使用自定义排序,不使用记忆排序 | - |
| 2 | 使用自定义排序,非文件夹置顶 | - |
### 请求示例
```shell
curl -G 'https://proapi.115.com/open/ufile/files' \
-H 'Authorization: Bearer access_token' \
--data-urlencode 'cid=0' \
--data-urlencode 'limit=20' \
--data-urlencode 'offset=0'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:----------------------|:---------|:----------------------------------------------------------|
| data | object[] | 文件和文件夹列表 |
| data[].fid | string | 文件或文件夹ID |
| data[].aid | string | 文件状态:1-正常,7-已删除(回收站),120-彻底删除 |
| data[].pid | string | 父目录ID |
| data[].fc | string | 文件分类:0-文件夹,1-文件 |
| data[].fn | string | 文件或文件夹名称 |
| data[].fco | string | 文件夹封面 |
| data[].ism | string | 是否星标,1表示星标 |
| data[].isp | int | 是否加密,1表示加密 |
| data[].pc | string | 文件提取码 |
| data[].upt | int | 修改时间 |
| data[].uet | int | 修改时间 |
| data[].uppt | int | 上传时间 |
| data[].cm | int | 特殊目录标识 |
| data[].fdesc | string | 文件备注 |
| data[].ispl | int | 是否统计文件夹下视频时长 |
| data[].fl | object[] | 文件标签 |
| data[].fl[].id | string | 文件标签ID |
| data[].fl[].name | string | 文件标签名称 |
| data[].fl[].sort | string | 文件标签排序 |
| data[].fl[].color | string | 文件标签颜色 |
| data[].fl[].is_default | int | 文件标签类型:0-最近使用,1-非最近使用,2-默认标签 |
| data[].fl[].update_time | int | 文件标签更新时间 |
| data[].fl[].create_time | int | 文件标签创建时间 |
| data[].sha1 | string | 文件SHA-1值 |
| data[].fs | int | 文件大小,单位为字节 |
| data[].fta | string | 文件状态:0或2-未上传完成,1-已上传完成 |
| data[].ico | string | 文件后缀名 |
| data[].fatr | string | 音频长度 |
| data[].isv | int | 是否为视频 |
| data[].def | int | 视频清晰度:1-标清,2-高清,3-超清,4-1080P,5-4K,100-原画 |
| data[].def2 | int | 视频清晰度:1-标清,2-高清,3-超清,4-1080P,5-4K,100-原画 |
| data[].play_long | int | 音视频时长,单位为秒 |
| data[].v_img | string | 视频缩略图地址 |
| data[].thumb | string | 图片缩略图地址 |
| data[].uo | string | 原图地址 |
| count | int | 当前目录文件数量 |
| sys_count | int | 系统文件夹数量 |
| offset | int | 偏移量 |
| limit | int | 分页数量 |
| aid | string | 文件状态:1-正常,7-已删除(回收站),120-彻底删除 |
| cid | int | 父目录ID |
| is_asc | int | 排序方向:1-升序,0-降序 |
| min_size | int | 最小文件大小筛选值 |
| max_size | int | 最大文件大小筛选值 |
| sys_dir | string | 系统目录 |
| hide_data | string | 是否返回文件数据 |
| record_open_time | string | 是否记录文件夹打开时间 |
| star | int | 是否星标:1-星标,0-未星标 |
| type | int | 一级筛选大分类,见请求参数中的 `type` 字段枚举 |
| suffix | string | 一级筛选选择“其他”时填写的后缀名 |
| path | object[] | 父目录树 |
| path[].name | string | 父目录名称 |
| path[].aid | int | 父目录文件状态 |
| path[].cid | int | 父目录ID |
| path[].pid | int | 上级父目录ID |
| path[].isp | int | 父目录是否加密 |
| path[].p_cid | string | 父目录路径标识 |
| path[].fv | string | 父目录属性 |
| cur | int | 是否只显示当前文件夹内的文件 |
| stdir | int | 筛选文件时是否显示文件夹 |
| fields | string | 指定返回字段 |
| order | string | 实际使用的排序字段 |
| state | boolean | 接口状态,true表示成功 |
| code | int | 异常码 |
| message | string | 异常信息 |
### 响应示例
```json
{
"data": [],
"count": 0,
"sys_count": 0,
"offset": 0,
"limit": 20,
"aid": "1",
"cid": 0,
"is_asc": 0,
"min_size": 0,
"max_size": 0,
"sys_dir": "",
"hide_data": "",
"record_open_time": "",
"star": 0,
"type": 0,
"suffix": "",
"path": [],
"cur": 0,
"stdir": 0,
"fields": "",
"order": "user_ptime",
"state": true,
"code": 0,
"message": ""
}
```
### 注意事项
- `limit` 最大为1150。
- 当 `cid` 指向不存在或已删除的目录时,接口返回异常;当目录为加密目录时,不返回目录内容。
- `user_id` 由服务端根据 access token 获取,无需传入。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,140 +0,0 @@
## 用户信息
### 基本信息
| 属性 | 内容 |
|:---------|:------------------------------|
| 接口名称 | 用户信息 |
| 接口版本 | v1.0 |
| 接口路径 | /user/info |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
获取当前授权用户的基本信息、网盘空间信息、VIP 等级信息以及第三方畅用权益信息。
### 接口地址
```
https://proapi.115.com/open/user/info
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
无
### 请求示例
```shell
curl 'https://proapi.115.com/open/user/info' \
-H 'Authorization: Bearer <ACCESS_TOKEN>'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:----------------------------------------|:------|:----------------------------------------|
| state | boolean | 状态码,是表示成功,否表示异常 |
| message | string | 异常信息 |
| code | int | 异常码 |
| data | object | 响应数据 |
| data.user_id | int | 用户115账号 |
| data.user_name | string | 用户名称 |
| data.user_face_s | string | 小尺寸用户头像 |
| data.user_face_m | string | 中尺寸用户头像 |
| data.user_face_l | string | 大尺寸用户头像 |
| data.rt_space_info | object | 用户实时空间信息 |
| data.rt_space_info.all_total | object | 用户总空间 |
| data.rt_space_info.all_total.size | int | 用户总空间大小,单位为字节 |
| data.rt_space_info.all_total.size_format | string | 用户总空间大小,格式化文本 |
| data.rt_space_info.all_remain | object | 用户剩余空间 |
| data.rt_space_info.all_remain.size | int | 用户剩余空间大小,单位为字节 |
| data.rt_space_info.all_remain.size_format | string | 用户剩余空间大小,格式化文本 |
| data.rt_space_info.all_use | object | 用户已使用空间 |
| data.rt_space_info.all_use.size | int | 用户已使用空间大小,单位为字节 |
| data.rt_space_info.all_use.size_format | string | 用户已使用空间大小,格式化文本 |
| data.vip_info | object | 用户 VIP 等级信息 |
| data.vip_info.level_name | string | VIP 等级名称,见下方枚举表格 |
| data.vip_info.expire | int | VIP 过期时间戳,无 VIP 时为 0 |
| data.vip_info.tp_rights | object | 第三方畅用权益信息 |
| data.vip_info.tp_rights.is_tp_rights | int | 是否具有当前应用的第三方畅用权益:0-否 1-是 |
| data.vip_info.tp_rights.tp_rights_time | int | 第三方畅用权益过期时间戳,无有效权益时为 0 |
#### 响应的 data.vip_info.level_name 字段枚举
| 值 | 说明 | 备注 |
|:----------|:---|:---|
| 原石会员 | 原石用户 | 无有效 VIP 等级时的服务端固定返回值 |
| 尝鲜VIP | 尝鲜VIP | - |
| 体验VIP | 体验VIP | - |
| 月费VIP | 月费VIP | - |
| 年费VIP | 年费VIP | - |
| 长期VIP(高级版) | 长期VIP(高级版) | - |
| 长期VIP(特级版) | 长期VIP(特级版) | - |
| 长期VIP(超级版) | 长期VIP(超级版) | - |
| 长期VIP(至尊版) | 长期VIP(至尊版) | - |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"user_id": 0,
"user_name": "",
"user_face_s": "",
"user_face_m": "",
"user_face_l": "",
"rt_space_info": {
"all_total": {
"size": 0,
"size_format": ""
},
"all_remain": {
"size": 0,
"size_format": ""
},
"all_use": {
"size": 0,
"size_format": ""
}
},
"vip_info": {
"expire": 0,
"level_name": "原石会员",
"tp_rights": {
"is_tp_rights": 0,
"tp_rights_time": 0
}
}
}
}
```
### 注意事项
- `access_token` 决定当前用户和开放应用,接口不接收用户账号请求参数。
- `rt_space_info` 为实时空间数据,`all_use` 由总空间减去剩余空间计算得出。
- 请妥善保管 `access_token`,不要在日志或客户端可见信息中输出。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
| 2026年08月18日(周二) 14:40:25 | 更新四种长期VIP等级名称枚举 |
@@ -1,88 +0,0 @@
## 提交视频转码
### 基本信息
| 属性 | 内容 |
|:-------------|:--------------------------------|
| 接口名称 | 提交视频转码 |
| 接口版本 | v1.0 |
| 接口路径 | /video_push |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
按 VIP 等级或消耗枫币提交视频加速转码。
### 接口地址
```
https://proapi.115.com/open/video/video_push
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----------|:-------|:---|:----|:----------------------------------------|:-----------------|
| pick_code | string | 是 | - | 视频文件提取码 | b53gu6z3hvqji8wrm |
| op | string | 是 | - | 加速转码方式,`vip_push`:按 VIP 等级加速;`pay_push`:消耗枫币加速 | vip_push |
### 请求示例
```shell
curl 'https://proapi.115.com/open/video/video_push' \
-H 'Authorization: Bearer <access_token>' \
-F 'pick_code=b53gu6z3hvqji8wrm' \
-F 'op=vip_push'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:------|:---------|:----------------------|
| state | boolean | 操作结果状态,true:成功;false:失败 |
| message | string | 返回信息,成功时为空字符串 |
| code | int | 错误码 |
| data | object[] | 响应数据,成功时为空数组 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": []
}
```
### 业务规则
- 仅支持为当前授权用户所属的视频文件提交加速转码。
- 已完成转码的视频无需重复提交。
- 两种加速转码方式均要求当前用户具备 VIP 权益;`pay_push` 在枫币余额不足时提交失败。
- 提交成功后,系统通知转码服务重新计算排队信息,并在 3 小时内记录已加速状态。
### 注意事项
- `access_token` 通过 `Authorization` 请求头传递,请将请求示例中的占位符替换为实际访问令牌。
- 请勿对同一视频重复提交加速转码。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,125 +0,0 @@
## 获取视频在线播放地址
### 基本信息
| 属性 | 内容 |
|:-------------|:--------------------------------|
| 接口名称 | 获取视频在线播放地址 |
| 接口版本 | v1.0 |
| 接口路径 | /play |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
获取指定视频的在线播放地址、清晰度、音轨及文件基础信息。
### 接口地址
```
https://proapi.115.com/open/video/play
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----------|:-------|:---|:----|:----------|:-----------------|
| pick_code | string | 是 | - | 视频文件提取码 | b53gu6z3hvqji8wrm |
### 请求示例
```shell
curl -G 'https://proapi.115.com/open/video/play' \
-H 'Authorization: Bearer <access_token>' \
--data-urlencode 'pick_code=b53gu6z3hvqji8wrm'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:---------------------------------|:---------|:----------------------------------------|
| state | boolean | 操作结果状态,true:成功;false:失败 |
| message | string | 返回信息,成功时为空字符串 |
| code | int | 错误码 |
| data | object | 视频播放及文件数据 |
| data.file_id | string | 文件ID |
| data.parent_id | string | 文件父目录ID |
| data.file_name | string | 文件名称 |
| data.file_size | string | 文件大小,单位为字节 |
| data.file_sha1 | string | 文件哈希值 |
| data.file_type | string | 文件类型 |
| data.is_private | string | 文件是否加密隐藏,0:否;1:是 |
| data.play_long | string | 视频时长 |
| data.user_def | int | 记忆的清晰度,1:标清;2:高清;3:超清;4:1080P;5:4K;100:原画 |
| data.user_rotate | int | 记忆的视频旋转角度,取值为 0、90、180、270 |
| data.user_turn | int | 视频翻转方向,0:不翻转;1:水平翻转;2:垂直翻转 |
| data.multitrack_list | object | 多音轨列表,键为音轨序号 |
| data.multitrack_list.*.title | string | 音轨标题 |
| data.multitrack_list.*.is_selected | string | 音轨是否为上次选中,1:是 |
| data.multitrack_list.*.sync_time | string | 音轨同步时间 |
| data.definition_list | object | 清晰度列表,键为清晰度值,值为清晰度名称 |
| data.definition_list_new | object | 新版清晰度列表,键为清晰度值,值为清晰度名称 |
| data.video_url | object[] | 各清晰度的播放地址信息 |
| data.video_url[].url | string | 播放地址 |
| data.video_url[].height | int | 视频高度 |
| data.video_url[].width | int | 视频宽度 |
| data.video_url[].definition | int | 视频清晰度 |
| data.video_url[].title | string | 视频清晰度名称 |
| data.video_url[].definition_n | int | 新版视频清晰度 |
| data.video_push_state | boolean | 视频尚未完成转码时是否已提交加速转码,仅对应失败响应返回 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"file_id": "",
"parent_id": "",
"file_name": "",
"file_size": "0",
"file_sha1": "",
"file_type": "",
"is_private": "0",
"play_long": "0",
"user_def": 0,
"user_rotate": 0,
"user_turn": 0,
"multitrack_list": {},
"definition_list": {},
"definition_list_new": {},
"video_url": []
}
}
```
### 业务规则
- 切换音轨时,在返回的播放地址后增加整型参数 `audio_track`,参数值取 `multitrack_list` 对应的键;音轨下标从 `0` 开始。
- 年费VIP以下用户不支持播放 4K 视频;选择 4K 清晰度时会返回引导升级的视频地址。
- 接口不返回下载地址字段 `down_url`。
### 注意事项
- `access_token` 通过 `Authorization` 请求头传递,请将请求示例中的占位符替换为实际访问令牌。
- 播放地址具有时效性,请勿缓存或向无关方披露。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,96 +0,0 @@
## 获取视频播放进度
### 基本信息
| 属性 | 内容 |
|:-------------|:--------------------------------|
| 接口名称 | 获取视频播放进度 |
| 接口版本 | v1.0 |
| 接口路径 | /history |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
获取指定视频已记录的播放进度。
### 接口地址
```
https://proapi.115.com/open/video/history
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----------|:-------|:---|:----|:----------|:-----------------|
| pick_code | string | 是 | - | 视频文件提取码 | b53gu6z3hvqji8wrm |
### 请求示例
```shell
curl -G 'https://proapi.115.com/open/video/history' \
-H 'Authorization: Bearer <access_token>' \
--data-urlencode 'pick_code=b53gu6z3hvqji8wrm'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:-------------|:--------|:-----------------------------|
| state | boolean | 操作结果状态,true:成功;false:失败 |
| message | string | 返回信息,成功时为空字符串 |
| code | int | 错误码 |
| data | object | 播放进度数据;没有记录时为空数组 |
| data.add_time | int | 记录添加时间,Unix 时间戳 |
| data.file_id | string | 文件ID |
| data.file_name | string | 文件名称 |
| data.hash | string | 文件哈希值 |
| data.pick_code | string | 文件提取码 |
| data.time | int | 已播放时长,单位为秒 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"add_time": 0,
"file_id": "",
"file_name": "",
"hash": "",
"pick_code": "b53gu6z3hvqji8wrm",
"time": 0
}
}
```
### 业务规则
- 接口只返回当前 `pick_code` 对应的单条播放进度记录。
- 视频是否播放完毕可通过文件列表中的 `played_end` 字段查看,`1` 表示已播放完毕。
### 注意事项
- `access_token` 通过 `Authorization` 请求头传递,请将请求示例中的占位符替换为实际访问令牌。
- `pick_code` 对应的文件不存在或不在有效文件区域时,接口返回失败。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,119 +0,0 @@
## 视频字幕列表
### 基本信息
| 属性 | 内容 |
|:-------------|:--------------------------------|
| 接口名称 | 视频字幕列表 |
| 接口版本 | v1.0 |
| 接口路径 | /subtitle |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
获取指定视频的自动载入字幕和可用字幕列表。
### 接口地址
```
https://proapi.115.com/open/video/subtitle
```
### 请求方式
```
GET
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----------|:-------|:---|:----|:----------|:-----------------|
| pick_code | string | 是 | - | 视频文件提取码 | b53gu6z3hvqji8wrm |
### 请求示例
```shell
curl -G 'https://proapi.115.com/open/video/subtitle' \
-H 'Authorization: Bearer <access_token>' \
--data-urlencode 'pick_code=b53gu6z3hvqji8wrm'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:----------------------------|:---------|:----------------------------|
| state | boolean | 操作结果状态,true:成功;false:失败 |
| message | string | 返回信息,成功时为空字符串 |
| code | int | 错误码 |
| data | object | 响应数据 |
| data.autoload | object | 默认自动载入的字幕;无可用字幕时为空数组 |
| data.autoload.sid | string | 字幕标识 |
| data.autoload.language | string | 字幕语言 |
| data.autoload.title | string | 字幕标题 |
| data.autoload.url | string | 字幕文件地址 |
| data.autoload.type | string | 字幕文件类型 |
| data.autoload.key | string | 内置字幕键,仅内置字幕返回 |
| data.autoload.sha1 | string | 字幕文件哈希值 |
| data.autoload.file_id | string | 外挂或内嵌字幕文件ID |
| data.autoload.file_name | string | 外挂或内嵌字幕文件名 |
| data.autoload.pick_code | string | 外挂或内嵌字幕文件提取码 |
| data.autoload.caption_map_id | string | 内嵌字幕映射ID |
| data.autoload.is_caption_map | int | 是否为内嵌字幕,0:否;1:是 |
| data.autoload.sync_time | float | 字幕同步时间 |
| data.autoload.from | int | 记忆字幕来源标识 |
| data.autoload.user_sub | int | 是否为用户记忆字幕,1:是 |
| data.list | object[] | 字幕列表 |
| data.list[].sid | string | 字幕标识 |
| data.list[].language | string | 字幕语言 |
| data.list[].title | string | 字幕标题 |
| data.list[].url | string | 字幕文件地址 |
| data.list[].type | string | 字幕文件类型 |
| data.list[].key | string | 内置字幕键,仅内置字幕返回 |
| data.list[].sha1 | string | 字幕文件哈希值 |
| data.list[].file_id | string | 外挂或内嵌字幕文件ID |
| data.list[].file_name | string | 外挂或内嵌字幕文件名 |
| data.list[].pick_code | string | 外挂或内嵌字幕文件提取码 |
| data.list[].caption_map_id | string | 内嵌字幕映射ID |
| data.list[].is_caption_map | int | 是否为内嵌字幕,0:否;1:是 |
| data.list[].sync_time | float | 字幕同步时间 |
| data.list[].from | int | 记忆字幕来源标识 |
| data.list[].user_sub | int | 是否为用户记忆字幕,1:是 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": {
"autoload": [],
"list": []
}
}
```
### 业务规则
- 用户记忆字幕不在列表中时会被前置;其余字幕按同目录同名外挂字幕、内嵌字幕、内置字幕、其他外挂字幕的顺序组合。
- 存在用户记忆字幕时优先将其作为自动载入字幕;否则依次选择内嵌字幕、同名外挂字幕或内置字幕。
- 字幕项来源不同,部分来源专属字段可能不返回。
### 注意事项
- `access_token` 通过 `Authorization` 请求头传递,请将请求示例中的占位符替换为实际访问令牌。
- 字幕文件地址具有时效性,请以本次接口返回值为准。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,89 +0,0 @@
## 记忆视频播放进度
### 基本信息
| 属性 | 内容 |
|:-------------|:--------------------------------|
| 接口名称 | 记忆视频播放进度 |
| 接口版本 | v1.0 |
| 接口路径 | /history |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
记录指定视频的播放进度及是否播放完毕。
### 接口地址
```
https://proapi.115.com/open/video/history
```
### 请求方式
```
POST
Content-Type: multipart/form-data
```
### 认证方式
```
Authorization: Bearer access_token
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----------|:-------|:---|:----|:-------------------|:-----------------|
| pick_code | string | 是 | - | 视频文件提取码 | b53gu6z3hvqji8wrm |
| time | int | 否 | 0 | 视频播放进度,单位为秒 | 10 |
| watch_end | int | 否 | 0 | 是否播放完毕,0:否;1:是 | 0 |
### 请求示例
```shell
curl 'https://proapi.115.com/open/video/history' \
-H 'Authorization: Bearer <access_token>' \
-F 'pick_code=b53gu6z3hvqji8wrm' \
-F 'time=10' \
-F 'watch_end=0'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:------|:---------|:----------------------|
| state | boolean | 操作结果状态,true:成功;false:失败 |
| message | string | 返回信息,成功时为空字符串 |
| code | int | 错误码 |
| data | object[] | 响应数据,成功时为空数组 |
### 响应示例
```json
{
"state": true,
"message": "",
"code": 0,
"data": []
}
```
### 业务规则
- `time` 和 `watch_end` 均不传时,两个参数均按 `0` 处理,即播放进度为 0 秒且未播放完毕。
- 非本人文件不写入播放进度,但接口按操作成功返回。
- 同一视频未播放完毕的进度在 5 秒内重复上报时不重复持久化;标记为播放完毕时会立即持久化。
### 注意事项
- `access_token` 通过 `Authorization` 请求头传递,请将请求示例中的占位符替换为实际访问令牌。
- `pick_code` 必须属于当前授权用户可记录播放进度的视频文件。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,30 +0,0 @@
# 开发须知
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 文档名称 | 开发须知 |
| 文档版本 | v1.0 |
## 注意事项
为共同建设开放、共赢的合作生态并保障平台服务的可持续发展,开发者在接入平台服务前,须认真阅读并严格遵循《115生活开放平台开发者协议》的相关要求,确保合规运营、维护平台秩序。
基于平台与用户权益保护原则,平台会持续监测开发者的服务使用行为。如发现违反平台规范的行为,平台将视情节采取包括但不限于服务限流、接口冻结、资质回收等限制措施,并保留依法追责的权利。
开发者严禁实施包括但不限于以下行为:
1. **数据隐私违规行为**:侵害用户数据隐私安全,包括未经用户授权或未明确用途,违规收集、下载、存储、传播、加工用户存储数据等。开发者需要确保用户数据的获取与使用全程透明、可追溯。
2. **商业利益侵犯行为**:损害 115 科技的商业利益,包括多人共享开发者账号及会员权益、开展竞争关系业务、未经授权获取平台相关服务运营数据等。
3. **不当使用行为**:违规或未按要求使用 API 服务,包括违反国家相关政策法规、侵犯第三方合法权益、调用非公开接口、实际用途与申请信息不符等。
## 限流说明
为确保系统安全并保障服务稳定运行,115生活开放平台对所有 API 实施频率控制策略。出于安全防护需要,相关策略细则暂不公开,平台会持续动态优化该机制。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,60 +0,0 @@
# 授权错误码
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 文档名称 | 授权错误码 |
| 文档版本 | v1.0 |
| 错误码 | 描述 | 建议 |
|:-------|:------------------------------------|:-------------------------------------------------------------|
| 40100000 | 参数缺失 | - |
| 40101017 | 用户验证失败 | - |
| 40110000 | 请求异常,需要重试 | - |
| 40140100 | `client_id` 错误 | - |
| 40140101 | `code_challenge` 必填 | - |
| 40140102 | `code_challenge_method` 必须是 `sha256`、`sha1`、`md5` 之一 | - |
| 40140103 | `sign` 必填 | - |
| 40140104 | `sign` 签名失败 | - |
| 40140105 | 生成二维码失败 | - |
| 40140106 | AppID 无效 | - |
| 40140107 | 应用不存在 | - |
| 40140108 | 应用未审核通过 | - |
| 40140109 | 应用已被停用 | - |
| 40140110 | 应用已过期 | - |
| 40140111 | AppSecret 错误 | - |
| 40140112 | `code_verifier` 长度要求为 43 至 128 位 | - |
| 40140113 | `code_verifier` 验证失败 | - |
| 40140114 | `refresh_token` 格式错误(防篡改) | - |
| 40140115 | `refresh_token` 签名校验失败(防篡改) | - |
| 40140116 | `refresh_token` 无效(已解除授权) | 重新授权。终态错误,重试不会成功;继续重试将被标记为永久失效,见 `40140137` |
| 40140117 | `access_token` 刷新太频繁 | - |
| 40140118 | 开发者认证已过期 | - |
| 40140119 | `refresh_token` 已过期 | 重新授权。终态错误,重试不会成功;继续重试将被标记为永久失效,见 `40140137` |
| 40140120 | `refresh_token` 检验失败(防篡改) | 调用 `/open/refreshToken` 后会重新生成 `refresh_token`,检查本地是否已更新其值。终态错误,持续用旧值重试将被标记为永久失效,见 `40140137` |
| 40140121 | `access_token` 刷新失败 | 重试 |
| 40140122 | 超出授权应用数量上限 | - |
| 40140123 | `access_token` 格式错误(防篡改) | - |
| 40140124 | `access_token` 签名校验失败(防篡改) | - |
| 40140125 | `access_token` 无效(已过期、已解除授权或授权缓存不存在) | 调用 `/open/refreshToken` 获取新凭证;若 `refresh_token` 无效或已过期,重新授权 |
| 40140126 | `access_token` 与当前授权记录不匹配 | 重新读取刷新后保存的最新凭证,禁止使用原 `access_token` 重试;没有可用新凭证时调用 `/open/refreshToken` |
| 40140127 | `response_type` 错误 | - |
| 40140128 | `redirect_uri` 缺少协议 | - |
| 40140129 | `redirect_uri` 缺少域名 | - |
| 40140130 | 没有配置重定向域名 | 到应用管理中配置域名 |
| 40140131 | `redirect_uri` 域名不合法 | 需要与应用管理中配置的应用域名一致 |
| 40140132 | `grant_type` 错误 | - |
| 40140133 | `client_secret` 验证失败 | - |
| 40140134 | 授权码 `code` 验证失败 | - |
| 40140135 | `client_id` 验证失败 | - |
| 40140136 | `redirect_uri` 验证失败(防 MITM 攻击) | - |
| 40140137 | `refresh_token` 已失效,请停止重试并重新授权(终态) | 同一 `refresh_token` 连续多次以 `40140116`/`40140119`/`40140120` 失败后被服务端标记为永久失效。收到后必须停止用该令牌重试,引导用户重新授权;重新授权产生的新令牌不受影响 |
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
| 2026年08月10日(周一) 10:23:15 | 修正 `40140125`、`40140126` 的原因及处理建议 |
| 2026年08月24日(周一) 10:16:52 | 新增终态错误码 `40140137`,补充 `40140116`/`40140119`/`40140120` 的永久失效判定说明 |
@@ -1,92 +0,0 @@
## 刷新access_token
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 刷新access_token |
| 接口版本 | v1.0 |
| 接口路径 | /refreshToken |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
该接口用于通过 `refresh_token` 获取新的 `access_token` 和 `refresh_token`。
### 接口地址
```
https://passportapi.115.com/open/refreshToken
```
### 请求方式
```
POST
Content-Type: application/x-www-form-urlencoded
```
### 认证方式
```
OAuth 2.0 刷新凭证
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-------------|:-------|:---|:----|:---------------------|:----------------------------|
| refresh_token | string | 是 | - | 用于刷新访问凭证的刷新凭证 | `REFRESH_TOKEN_PLACEHOLDER` |
### 请求示例
```shell
curl 'https://passportapi.115.com/open/refreshToken' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'refresh_token=REFRESH_TOKEN_PLACEHOLDER'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:-------------------|:-------|:------------------------------------|
| state | int | 状态码 |
| code | int | 错误码 |
| message | string | 响应信息 |
| data | object | 响应数据 |
| data.access_token | string | 新的 `access_token`,同时刷新有效期 |
| data.refresh_token | string | 新的 `refresh_token`,其有效期不延长、不改变 |
| data.expires_in | int | `access_token` 有效期,单位为秒 |
### 响应示例
```json
{
"state": 1,
"code": 0,
"message": "",
"data": {
"access_token": "ACCESS_TOKEN_PLACEHOLDER",
"refresh_token": "REFRESH_TOKEN_PLACEHOLDER",
"expires_in": 7200
}
}
```
### 注意事项
- `access_token` 有效期为 7200 秒,调用方应以响应中的 `expires_in` 计算刷新时间。
- 同一授权在 60 秒内重复刷新会触发频率控制;多进程或多节点调用方应确保同一授权同一时间只有一个刷新请求。
- 调用后会同时生成新的 `access_token` 和 `refresh_token`。调用方必须将两者作为一组原子保存,并停止使用刷新前的旧凭证;刷新凭证本身的有效期不延长、不改变。
- 收到 `40140125` 或 `40140126` 时,不要使用原 `access_token` 重复重试。应先读取已保存的最新凭证;没有可用新凭证时,再调用本接口刷新。
- `40140116`、`40140119`、`40140120` 是终态错误,用同一个 `refresh_token` 重试永远不会成功。同一 `refresh_token` 连续多次以这三种原因失败会被服务端标记为永久失效,之后每次刷新都直接返回 `40140137`。客户端收到这三种错误或 `40140137` 时必须停止重试,引导用户重新授权;后台常驻程序(如 NAS 同步任务)尤其要实现该逻辑,避免长期无效轮询。
- `access_token` 和 `refresh_token` 属于敏感凭证,不得写入公开仓库、客户端日志或公开沟通内容。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
| 2026年08月10日(周一) 10:23:15 | 修正 `access_token` 有效期示例,补充凭证轮换及并发刷新说明 |
| 2026年08月24日(周一) 10:16:52 | 新增 `refresh_token` 永久失效判定规则与终态错误码 `40140137` 说明 |
@@ -1,88 +0,0 @@
## 获取access_token
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 获取access_token |
| 接口版本 | v1.0 |
| 接口路径 | /deviceCodeToToken |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
该接口用于在用户确认手机扫码授权后,使用设备码和 PKCE 原始校验值换取 `access_token`。
### 接口地址
```
https://passportapi.115.com/open/deviceCodeToToken
```
### 请求方式
```
POST
Content-Type: application/x-www-form-urlencoded
```
### 认证方式
```
OAuth 2.0 + PKCE
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-----------|:-------|:---|:----|:--------------------------------|:---------------------------------------------------------------|
| uid | string | 是 | - | 二维码 ID/设备码 | `DEVICE_CODE_PLACEHOLDER` |
| code_verifier | string | 是 | - | 计算 `code_challenge` 时使用的原始随机字符串 | `IGKN6CJanWxCDPDhHZJrhswQdlcPBGLqExkhyujysXaQ4fJKBk_6dlPJo47s` |
### 请求示例
```shell
curl 'https://passportapi.115.com/open/deviceCodeToToken' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'uid=DEVICE_CODE_PLACEHOLDER' \
--data-urlencode 'code_verifier=IGKN6CJanWxCDPDhHZJrhswQdlcPBGLqExkhyujysXaQ4fJKBk_6dlPJo47s'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:-------------------|:-------|:--------------------------------|
| state | int | 状态码 |
| code | int | 错误码 |
| message | string | 响应信息 |
| data | object | 响应数据 |
| data.access_token | string | 访问资源接口的凭证 |
| data.refresh_token | string | 刷新 `access_token` 的凭证,有效期 1 年 |
| data.expires_in | int | `access_token` 有效期,单位为秒 |
### 响应示例
```json
{
"state": 1,
"code": 0,
"message": "",
"data": {
"access_token": "ACCESS_TOKEN_PLACEHOLDER",
"refresh_token": "REFRESH_TOKEN_PLACEHOLDER",
"expires_in": 7200
}
}
```
### 注意事项
- `code_verifier` 必须与生成 `code_challenge` 时使用的原始值一致。
- `access_token` 和 `refresh_token` 属于敏感凭证,不得写入公开仓库、客户端日志或公开沟通内容。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,103 +0,0 @@
## 获取设备码和二维码内容
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 获取设备码和二维码内容 |
| 接口版本 | v1.0 |
| 接口路径 | /authDeviceCode |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
该接口用于 OAuth 2.0 + PKCE 手机扫码授权流程的第一步,获取设备码和二维码内容。此模式适用于无后端服务的第三方客户端,无需提供 AppSecret。
第三方客户端需要根据响应中的 `data.qrcode` 生成二维码,供 115 客户端扫码授权。
### 接口地址
```
https://passportapi.115.com/open/authDeviceCode
```
### 请求方式
```
POST
Content-Type: application/x-www-form-urlencoded
```
### 认证方式
```
OAuth 2.0 + PKCE
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:---------------------|:-------|:---|:----|:--------------------------------|:-----------------------------------------------------------|
| client_id | string | 是 | - | AppID | `YOUR_APP_ID` |
| code_challenge | string | 是 | - | PKCE 挑战码 | `THHodGWg-FZfv8XYz7QArNGIK_aVomSHPldlSOTUtkw` |
| code_challenge_method | string | 是 | - | `code_challenge` 的哈希算法,见下方枚举表格 | `sha256` |
#### 请求参数中的 code_challenge_method 字段枚举
| 值 | 说明 | 备注 |
|:-------|:---------------------------|:---|
| md5 | 使用 MD5 计算挑战码 | - |
| sha1 | 使用 SHA-1 计算挑战码 | - |
| sha256 | 使用 SHA-256 计算挑战码,推荐使用 | - |
### 请求示例
```shell
curl 'https://passportapi.115.com/open/authDeviceCode' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'client_id=YOUR_APP_ID' \
--data-urlencode 'code_challenge=THHodGWg-FZfv8XYz7QArNGIK_aVomSHPldlSOTUtkw' \
--data-urlencode 'code_challenge_method=sha256'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:-----------|:-----|:------------------------------------------|
| state | int | 状态码 |
| code | int | 错误码 |
| message | string | 响应信息 |
| data | object | 响应数据 |
| data.uid | string | 设备码,轮询二维码状态时使用 |
| data.time | int | 校验时间戳,轮询二维码状态时使用 |
| data.qrcode | string | 二维码内容,第三方客户端需要据此生成设备二维码 |
| data.sign | string | 校验签名,轮询二维码状态时使用 |
### 响应示例
```json
{
"state": 1,
"code": 0,
"message": "",
"data": {
"uid": "DEVICE_CODE_PLACEHOLDER",
"time": 0,
"qrcode": "QRCODE_CONTENT_PLACEHOLDER",
"sign": "SIGN_PLACEHOLDER"
}
}
```
### 注意事项
- `code_verifier` 为长度 43 至 128 位的随机字符串。
- `code_challenge` 的计算方式为 `url_safe(base64_encode(hash(code_verifier)))`。哈希结果按二进制数据参与 Base64 编码,算法需要与 `code_challenge_method` 一致。
- AppID 等应用凭证请使用实际应用配置,不要在公开仓库或日志中记录敏感凭证。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,89 +0,0 @@
## 轮询二维码状态
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 轮询二维码状态 |
| 接口版本 | v1.0 |
| 接口路径 | /get/status/ |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
该接口用于长轮询设备二维码的扫码和授权状态。当二维码状态没有变化时,接口不会立即响应,直到请求超时或状态发生变化。
### 接口地址
```
https://qrcodeapi.115.com/get/status/
```
### 请求方式
```
GET
```
### 认证方式
```
设备码参数校验
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:----|:-------|:---|:----|:------------------------------------|:------------------------|
| uid | string | 是 | - | 二维码 ID/设备码,从 `/open/authDeviceCode` 的 `data.uid` 获取 | `DEVICE_CODE_PLACEHOLDER` |
| time | int | 是 | - | 校验时间戳,从 `/open/authDeviceCode` 的 `data.time` 获取 | `0` |
| sign | string | 是 | - | 校验签名,从 `/open/authDeviceCode` 的 `data.sign` 获取 | `SIGN_PLACEHOLDER` |
### 请求示例
```shell
curl -G 'https://qrcodeapi.115.com/get/status/' \
--data-urlencode 'uid=DEVICE_CODE_PLACEHOLDER' \
--data-urlencode 'time=0' \
--data-urlencode 'sign=SIGN_PLACEHOLDER'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:------------|:-------|:-------------------------------------------|
| state | int | 轮询状态:0-二维码无效,结束轮询;1-继续轮询 |
| code | int | 错误码 |
| message | string | 响应信息 |
| data | object | 响应数据;115 客户端扫码或输入设备码后才有值 |
| data.msg | string | 操作提示 |
| data.status | int | 二维码状态:1-扫码成功,等待确认;2-确认登录或授权,结束轮询 |
| data.version | string | 版本信息 |
### 响应示例
```json
{
"state": 1,
"code": 0,
"message": "",
"data": {
"msg": "OPERATION_MESSAGE_PLACEHOLDER",
"status": 1,
"version": "VERSION_PLACEHOLDER"
}
}
```
### 注意事项
- `state=0` 表示二维码无效,应结束轮询;`state=1` 表示继续轮询。
- `data.status=1` 表示扫码成功并等待用户确认;`data.status=2` 表示用户已确认登录或授权,应结束轮询并进入换取访问凭证的步骤。
- 长轮询超时不等同于授权失败,客户端可以按照自身网络策略重新发起请求。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,95 +0,0 @@
## 用授权码换取access_token
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 用授权码换取access_token |
| 接口版本 | v1.0 |
| 接口路径 | /authCodeToToken |
| 请求方法 | POST |
| 接口状态 | 生产环境 |
### 接口说明
该接口用于通过授权码换取 `access_token`。建议在开发者服务端调用,避免泄露 AppSecret。
### 接口地址
```
https://passportapi.115.com/open/authCodeToToken
```
### 请求方式
```
POST
Content-Type: application/x-www-form-urlencoded
```
### 认证方式
```
OAuth 2.0 授权码模式
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-------------|:-------|:---|:----|:------------------------------------|:------------------------------|
| client_id | string | 是 | - | AppID | `YOUR_APP_ID` |
| client_secret | string | 是 | - | AppSecret | `YOUR_APP_SECRET` |
| code | string | 是 | - | 请求授权接口重定向返回的授权码 | `AUTHORIZATION_CODE_PLACEHOLDER` |
| redirect_uri | string | 是 | - | 与请求授权时传入的 `redirect_uri` 一致,用于防止 MITM 和 CSRF 攻击 | `https://foo.com?state=123456` |
| grant_type | string | 是 | - | 授权类型,固定为 `authorization_code` | `authorization_code` |
### 请求示例
```shell
curl 'https://passportapi.115.com/open/authCodeToToken' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'client_id=YOUR_APP_ID' \
--data-urlencode 'client_secret=YOUR_APP_SECRET' \
--data-urlencode 'code=AUTHORIZATION_CODE_PLACEHOLDER' \
--data-urlencode 'redirect_uri=https://foo.com?state=123456' \
--data-urlencode 'grant_type=authorization_code'
```
### 响应字段说明
| 字段 | 类型 | 描述 |
|:-------------------|:-------|:--------------------------------|
| state | int | 状态码:0-失败;1-成功 |
| code | int | 错误码 |
| message | string | 响应信息 |
| data | object | 响应数据 |
| data.access_token | string | 访问资源接口的凭证 |
| data.refresh_token | string | 刷新 `access_token` 的凭证,有效期 1 年 |
| data.expires_in | int | `access_token` 有效期,单位为秒 |
### 响应示例
```json
{
"state": 1,
"code": 0,
"message": "",
"data": {
"access_token": "ACCESS_TOKEN_PLACEHOLDER",
"refresh_token": "REFRESH_TOKEN_PLACEHOLDER",
"expires_in": 7200
}
}
```
### 注意事项
- 必须在服务端安全保存并使用 AppSecret,不得在客户端代码、公开仓库或日志中泄露。
- `redirect_uri` 必须与请求授权时传入的值一致。
- `access_token` 和 `refresh_token` 属于敏感凭证,应按照敏感数据规范存储。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,87 +0,0 @@
## 请求授权
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 接口名称 | 请求授权 |
| 接口版本 | v1.0 |
| 接口路径 | /authorize |
| 请求方法 | GET |
| 接口状态 | 生产环境 |
### 接口说明
该接口用于发起 OAuth 2.0 授权码模式授权,建议由开发者服务端参与授权流程。
用户未登录时,接口会重定向到登录页面;用户已登录时,接口会自动完成授权并重定向到 `redirect_uri` 指定的地址。
### 接口地址
```
https://passportapi.115.com/open/authorize
```
### 请求方式
```
GET
```
### 认证方式
```
OAuth 2.0 授权码模式
```
### 请求参数
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 |
|:-----------|:-------|:---|:----|:-----------------------------------------------|:---------------------|
| client_id | string | 是 | - | AppID | `YOUR_APP_ID` |
| redirect_uri | string | 是 | - | 授权完成后的重定向地址;接口会附加授权码 `code`,并原样附加请求中的 `state` | `https://foo.com/bar` |
| response_type | string | 是 | - | 授权模式,固定为 `code` | `code` |
| state | string | 否 | "" | 防止 CSRF 攻击的随机值,重定向时原样返回 | `123456` |
### 请求示例
```shell
curl -G 'https://passportapi.115.com/open/authorize' \
--data-urlencode 'client_id=YOUR_APP_ID' \
--data-urlencode 'redirect_uri=https://foo.com/bar' \
--data-urlencode 'response_type=code' \
--data-urlencode 'state=123456'
```
### 响应字段说明
接口调用成功时会重定向到 `redirect_uri`,并通过查询参数返回授权码 `code` 和请求中携带的 `state`。接口调用失败时返回以下字段:
| 字段 | 类型 | 描述 |
|:--------|:-------|:------------------|
| state | int | 状态码:0-失败;1-成功 |
| code | int | 错误码 |
| data | object | 响应数据 |
| message | string | 响应信息 |
### 响应示例
```json
{
"state": 0,
"code": 40140127,
"data": {},
"message": "response_type 错误"
}
```
### 注意事项
- `redirect_uri` 需要先在[115生活开放平台](https://open.115.com/)的应用管理中配置域名,并在请求时进行 URL 编码。
- 强烈建议传入随机 `state`,并在换取 `access_token` 前验证重定向返回的 `state` 与请求值一致,以防止 CSRF 攻击。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,57 +0,0 @@
# 接入流程
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 文档名称 | 接入流程 |
| 文档版本 | v1.0 |
## 1. 注册“115生活”账号
在使用 115 开放平台服务之前,开发者需要先通过[“115生活”官网](https://115.com/)注册“115生活”账号并登录,完成实名认证。
## 2. 提交入驻申请
访问[115生活开放平台](https://open.115.com/),按照页面流程填写相关信息。
### 2.1 选择开发者身份类型
开发者可以申请成为“个人开发者”或“企业开发者”。点击页面上方的“切换申请类型”,可以切换身份类型。
### 2.2 签署协议
认真阅读并确认相关协议内容;如无异议,勾选“同意”并点击“下一步”。
### 2.3 填写入驻资料
按照页面指引填写个人信息、API 对接需求、应用场景等入驻资料,然后点击“下一步”。
### 2.4 填写认证信息
根据所选择的开发者身份类型,按照页面指引填写并上传身份认证资料:
- **个人开发者**:姓名、身份证信息(证件号码、有效期)、证件照片(身份证件正反面照片、本人手持身份证照片)。
- **企业开发者**:企业负责人或法人的姓名、联系方式、身份证信息(证件号码、有效期)、证件照片(身份证件正反面照片、加盖公章的营业执照影印件)、统一社会信用代码。
### 2.5 提交入驻申请
确认上述信息填写无误后,点击“提交验证”提交入驻申请。平台将在 7 个工作日内完成审核。
## 3. 创建应用
入驻申请通过后,进入 115 生活开放平台管理页面,选择“应用管理”,点击“创建应用”,按照页面指引填写应用信息、应用域名、应用描述、接口信息等内容。确认信息填写无误后提交应用申请,平台将在 7 个工作日内完成审核。
应用审核通过后,开发者可以获取该应用的 AppID、AppKey 和 AppSecret 等接入凭证。请妥善保存这些凭证,不要在客户端代码、公开仓库或沟通内容中泄露。
## 4. 接口调试
按照开放平台接口文档完成接入和调用后,即可正式使用开放平台能力。
开放平台 API 基础域名:`https://proapi.115.com/`
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
@@ -1,23 +0,0 @@
# 更新记录
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 文档名称 | 更新记录 |
| 文档版本 | v1.0 |
## 更新内容
| 更新模块 | 更新内容 | 更新时间 |
|:-----------|:-------------------------|:--------------|
| 增值服务产品 | 新增增值服务产品,获得推广收益 | 2025年4月17日周四 |
| 视频播放、云下载 | 新增视频播放、云下载接口 | 2025年4月3日周四 |
| 接入授权 | 支持 H5 账号密码/短信授权 | 2025年4月2日周三 |
| 基本框架 | 新增开放平台文档接口 | 2025年1月22日周三 |
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
-30
View File
@@ -1,30 +0,0 @@
# 概述
### 基本信息
| 属性 | 内容 |
|:-----------|:---------------------------------|
| 文档名称 | 概述 |
| 文档版本 | v1.0 |
## 115生活简介
“115生活”是一款面向个人用户的数字生活平台,提供海量数据的安全存储、多端同步与快速访问。用户不仅可以便捷地管理和使用各类数字资源,还能使用多维社交、生活服务等多元化功能。
## 115生活开放平台能力说明
115生活开放平台提供“115生活”数据存储、同步、管理等功能的 API 服务。开发者通过对接 API,可以将“115生活”的存储能力集成到自己的应用中。
目前已开放以下能力:
- **用户管理能力**:用户授权与信息查询等。
- **文件管理能力**:获取文件列表,查看文件属性,以及文件上传、下载、搜索、移动、删除等。
- **视频管理能力**:视频文件的在线转码与播放等。
- **云下载服务**:获取云下载任务列表、配额信息,以及添加、删除下载任务等。
- **商业价值转化**:开发者参与“推广产品得收益”计划,可基于用户实际购买的产品获取相应推广收益。
### 修改历史
| 修改时间 | 修改说明 |
|:-----------------------------|:-----|
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
-67
View File
@@ -1,67 +0,0 @@
# 115 开放平台文档(离线版)
> 来源:https://open.115.com/doc/ (115生活开放平台开发者文档)
> 抓取时间:2026-09-05,共 42 篇 Markdown 文档,目录结构与官网一致。
> 目录索引:[tree-index.json](tree-index.json)(官网原始目录树,程序化遍历可用)。
## 文档目录
- [概述](115开放平台/简介/概述.md)
- [更新记录](115开放平台/简介/更新记录.md)
- [接入流程](115开放平台/接入指南/接入流程.md)
- [开发须知](115开放平台/接入指南/开发须知.md)
- [授权错误码](115开放平台/接入指南/授权错误码.md)
- **接入授权/**
- **手机扫码授权PKCE模式/**
- [获取设备码和二维码内容](115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取设备码和二维码内容.md)
- [轮询二维码状态](115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/轮询二维码状态.md)
- [获取access_token](115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取access_token.md)
- **授权码模式/**
- [请求授权](115开放平台/接入指南/接入授权/授权码模式/请求授权.md)
- [用授权码换取access_token](115开放平台/接入指南/接入授权/授权码模式/用授权码换取access_token.md)
- [刷新access_token](115开放平台/接入指南/接入授权/刷新access_token.md)
- [开发者商业价值转化:推广产品得收益](115开放平台/API列表/开发者商业价值转化:推广产品得收益.md)
- **用户管理/**
- [用户信息](115开放平台/API列表/用户管理/用户信息.md)
- **文件管理/**
- **文件上传/**
- [上传流程](115开放平台/API列表/文件管理/文件上传/上传流程.md)
- [获取上传凭证](115开放平台/API列表/文件管理/文件上传/获取上传凭证.md)
- [文件上传](115开放平台/API列表/文件管理/文件上传/文件上传.md)
- [断点续传](115开放平台/API列表/文件管理/文件上传/断点续传.md)
- [新建文件夹](115开放平台/API列表/文件管理/新建文件夹.md)
- [获取文件列表](115开放平台/API列表/文件管理/获取文件列表.md)
- **获取文件(夹)详情/**
- [按ID获取](115开放平台/API列表/文件管理/获取文件(夹)详情/按ID获取.md)
- [按路径获取](115开放平台/API列表/文件管理/获取文件(夹)详情/按路径获取.md)
- [文件搜索](115开放平台/API列表/文件管理/文件搜索.md)
- [文件复制](115开放平台/API列表/文件管理/文件复制.md)
- [文件移动](115开放平台/API列表/文件管理/文件移动.md)
- [获取文件下载地址](115开放平台/API列表/文件管理/获取文件下载地址.md)
- [文件(夹)更新](115开放平台/API列表/文件管理/文件(夹)更新.md)
- [删除文件](115开放平台/API列表/文件管理/删除文件.md)
- [回收站列表](115开放平台/API列表/文件管理/回收站列表.md)
- [回收站还原](115开放平台/API列表/文件管理/回收站还原.md)
- [删除或清空回收站](115开放平台/API列表/文件管理/删除或清空回收站.md)
- **视频播放/**
- [记忆视频播放进度](115开放平台/API列表/视频播放/记忆视频播放进度.md)
- [视频字幕列表](115开放平台/API列表/视频播放/视频字幕列表.md)
- [获取视频播放进度](115开放平台/API列表/视频播放/获取视频播放进度.md)
- [获取视频在线播放地址](115开放平台/API列表/视频播放/获取视频在线播放地址.md)
- [提交视频转码](115开放平台/API列表/视频播放/提交视频转码.md)
- **云下载/**
- [解析BT种子](115开放平台/API列表/云下载/解析BT种子.md)
- [获取用户云下载任务列表](115开放平台/API列表/云下载/获取用户云下载任务列表.md)
- [获取云下载配额信息](115开放平台/API列表/云下载/获取云下载配额信息.md)
- [清空云下载任务](115开放平台/API列表/云下载/清空云下载任务.md)
- [添加云下载链接任务](115开放平台/API列表/云下载/添加云下载链接任务.md)
- [删除用户云下载任务](115开放平台/API列表/云下载/删除用户云下载任务.md)
- [添加云下载BT任务](115开放平台/API列表/云下载/添加云下载BT任务.md)
## API 接口速查
主要 API 域名与认证方式(详见各文档):
- 开放 API 基础地址:`https://proapi.115.com/open/...`
- 认证方式:`Authorization: Bearer access_token`
- OAuth 授权相关文档见 `115开放平台/接入指南/接入授权/`
-308
View File
@@ -1,308 +0,0 @@
{
"name": "doc",
"path": "/doc/",
"children": [
{
"name": "115开放平台",
"path": "/doc/115开放平台/",
"children": [
{
"name": "简介",
"path": "/doc/115开放平台/简介/",
"children": [
{
"name": "概述.md",
"path": "/doc/115开放平台/简介/概述.md",
"type": "file"
},
{
"name": "更新记录.md",
"path": "/doc/115开放平台/简介/更新记录.md",
"type": "file"
}
],
"type": "directory"
},
{
"name": "接入指南",
"path": "/doc/115开放平台/接入指南/",
"children": [
{
"name": "接入流程.md",
"path": "/doc/115开放平台/接入指南/接入流程.md",
"type": "file"
},
{
"name": "开发须知.md",
"path": "/doc/115开放平台/接入指南/开发须知.md",
"type": "file"
},
{
"name": "授权错误码.md",
"path": "/doc/115开放平台/接入指南/授权错误码.md",
"type": "file"
},
{
"name": "接入授权",
"path": "/doc/115开放平台/接入指南/接入授权/",
"children": [
{
"name": "手机扫码授权PKCE模式",
"path": "/doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/",
"children": [
{
"name": "获取设备码和二维码内容.md",
"path": "/doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取设备码和二维码内容.md",
"type": "file"
},
{
"name": "轮询二维码状态.md",
"path": "/doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/轮询二维码状态.md",
"type": "file"
},
{
"name": "获取access_token.md",
"path": "/doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取access_token.md",
"type": "file"
}
],
"type": "directory"
},
{
"name": "授权码模式",
"path": "/doc/115开放平台/接入指南/接入授权/授权码模式/",
"children": [
{
"name": "请求授权.md",
"path": "/doc/115开放平台/接入指南/接入授权/授权码模式/请求授权.md",
"type": "file"
},
{
"name": "用授权码换取access_token.md",
"path": "/doc/115开放平台/接入指南/接入授权/授权码模式/用授权码换取access_token.md",
"type": "file"
}
],
"type": "directory"
},
{
"name": "刷新access_token.md",
"path": "/doc/115开放平台/接入指南/接入授权/刷新access_token.md",
"type": "file"
}
],
"type": "directory"
}
],
"type": "directory"
},
{
"name": "API列表",
"path": "/doc/115开放平台/API列表/",
"children": [
{
"name": "开发者商业价值转化:推广产品得收益.md",
"path": "/doc/115开放平台/API列表/开发者商业价值转化:推广产品得收益.md",
"type": "file"
},
{
"name": "用户管理",
"path": "/doc/115开放平台/API列表/用户管理/",
"children": [
{
"name": "用户信息.md",
"path": "/doc/115开放平台/API列表/用户管理/用户信息.md",
"type": "file"
}
],
"type": "directory"
},
{
"name": "文件管理",
"path": "/doc/115开放平台/API列表/文件管理/",
"children": [
{
"name": "文件上传",
"path": "/doc/115开放平台/API列表/文件管理/文件上传/",
"children": [
{
"name": "上传流程.md",
"path": "/doc/115开放平台/API列表/文件管理/文件上传/上传流程.md",
"type": "file"
},
{
"name": "获取上传凭证.md",
"path": "/doc/115开放平台/API列表/文件管理/文件上传/获取上传凭证.md",
"type": "file"
},
{
"name": "文件上传.md",
"path": "/doc/115开放平台/API列表/文件管理/文件上传/文件上传.md",
"type": "file"
},
{
"name": "断点续传.md",
"path": "/doc/115开放平台/API列表/文件管理/文件上传/断点续传.md",
"type": "file"
}
],
"type": "directory"
},
{
"name": "新建文件夹.md",
"path": "/doc/115开放平台/API列表/文件管理/新建文件夹.md",
"type": "file"
},
{
"name": "获取文件列表.md",
"path": "/doc/115开放平台/API列表/文件管理/获取文件列表.md",
"type": "file"
},
{
"name": "获取文件(夹)详情",
"path": "/doc/115开放平台/API列表/文件管理/获取文件(夹)详情/",
"children": [
{
"name": "按ID获取.md",
"path": "/doc/115开放平台/API列表/文件管理/获取文件(夹)详情/按ID获取.md",
"type": "file"
},
{
"name": "按路径获取.md",
"path": "/doc/115开放平台/API列表/文件管理/获取文件(夹)详情/按路径获取.md",
"type": "file"
}
],
"type": "directory"
},
{
"name": "文件搜索.md",
"path": "/doc/115开放平台/API列表/文件管理/文件搜索.md",
"type": "file"
},
{
"name": "文件复制.md",
"path": "/doc/115开放平台/API列表/文件管理/文件复制.md",
"type": "file"
},
{
"name": "文件移动.md",
"path": "/doc/115开放平台/API列表/文件管理/文件移动.md",
"type": "file"
},
{
"name": "获取文件下载地址.md",
"path": "/doc/115开放平台/API列表/文件管理/获取文件下载地址.md",
"type": "file"
},
{
"name": "文件(夹)更新.md",
"path": "/doc/115开放平台/API列表/文件管理/文件(夹)更新.md",
"type": "file"
},
{
"name": "删除文件.md",
"path": "/doc/115开放平台/API列表/文件管理/删除文件.md",
"type": "file"
},
{
"name": "回收站列表.md",
"path": "/doc/115开放平台/API列表/文件管理/回收站列表.md",
"type": "file"
},
{
"name": "回收站还原.md",
"path": "/doc/115开放平台/API列表/文件管理/回收站还原.md",
"type": "file"
},
{
"name": "删除或清空回收站.md",
"path": "/doc/115开放平台/API列表/文件管理/删除或清空回收站.md",
"type": "file"
}
],
"type": "directory"
},
{
"name": "视频播放",
"path": "/doc/115开放平台/API列表/视频播放/",
"children": [
{
"name": "记忆视频播放进度.md",
"path": "/doc/115开放平台/API列表/视频播放/记忆视频播放进度.md",
"type": "file"
},
{
"name": "视频字幕列表.md",
"path": "/doc/115开放平台/API列表/视频播放/视频字幕列表.md",
"type": "file"
},
{
"name": "获取视频播放进度.md",
"path": "/doc/115开放平台/API列表/视频播放/获取视频播放进度.md",
"type": "file"
},
{
"name": "获取视频在线播放地址.md",
"path": "/doc/115开放平台/API列表/视频播放/获取视频在线播放地址.md",
"type": "file"
},
{
"name": "提交视频转码.md",
"path": "/doc/115开放平台/API列表/视频播放/提交视频转码.md",
"type": "file"
}
],
"type": "directory"
},
{
"name": "云下载",
"path": "/doc/115开放平台/API列表/云下载/",
"children": [
{
"name": "解析BT种子.md",
"path": "/doc/115开放平台/API列表/云下载/解析BT种子.md",
"type": "file"
},
{
"name": "获取用户云下载任务列表.md",
"path": "/doc/115开放平台/API列表/云下载/获取用户云下载任务列表.md",
"type": "file"
},
{
"name": "获取云下载配额信息.md",
"path": "/doc/115开放平台/API列表/云下载/获取云下载配额信息.md",
"type": "file"
},
{
"name": "清空云下载任务.md",
"path": "/doc/115开放平台/API列表/云下载/清空云下载任务.md",
"type": "file"
},
{
"name": "添加云下载链接任务.md",
"path": "/doc/115开放平台/API列表/云下载/添加云下载链接任务.md",
"type": "file"
},
{
"name": "删除用户云下载任务.md",
"path": "/doc/115开放平台/API列表/云下载/删除用户云下载任务.md",
"type": "file"
},
{
"name": "添加云下载BT任务.md",
"path": "/doc/115开放平台/API列表/云下载/添加云下载BT任务.md",
"type": "file"
}
],
"type": "directory"
}
],
"type": "directory"
}
],
"type": "directory"
}
],
"type": "directory"
}
-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.19+ -> 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.19-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
-285
View File
@@ -1,285 +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/Jellyfin 客户端 · 远程 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> ·
<a href="https://t.me/MeBoxGroup">Telegram 群组</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/Jellyfin 客户端兼容** | 内置完整 Emby 服务端协议实现:Infuse、SenPlayer、Fileball、Emby/Jellyfin 官方客户端等可直接把本服务当作 Emby 服务器添加,使用 MeBox 账号登录,海报墙、进度同步、多用户无缝衔接 |
| **远程 Emby 挂载** | 将远程 Emby 媒体库挂载到本地界面统一浏览(无需单独开 Emby 客户端) |
| **网盘与 STRM** | OpenList、CloudDrive2、115、WebDAV 等;STRM 同步、上传/下载队列、直链/302 播放 |
| **下载与整理** | 下载目录定时自动整理(智能分类、自动注册媒体库)、文件管理器(复制/移动/硬链/软链) |
| **用户与权限** | 管理员/普通用户、有效期、成人内容开关、播放配置 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`(首次登录后请立即修改密码)
> 💡 **Emby 用户无缝切换**:MeBox 完整兼容 Emby/Jellyfin 客户端协议。手机、电视、平板上的 Infuse、SenPlayer、Fileball、Emby/Jellyfin 官方客户端,直接按「添加 Emby 服务器」填入 `http://服务器IP:18080`,用 MeBox 账号登录即可,无需改变原有使用习惯。
镜像地址:
```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. **(可选)配置下载目录自动整理** → 文件管理中将下载目录设为整理源,下载完成后自动分类入库
4. **(可选)配置网盘账号** → STRM 管理中添加 OpenList / 115 / WebDAV 等
5. **第三方播放器** → 以 Emby 服务器添加 `http://服务器IP:18080`,使用 MeBox 账号登录
---
## 常见问题
**扫库或入库很慢?**
先确认路径映射与数据库档位。网盘扫描还受接口限速与目录规模影响;大库可考虑第二档 Redis 或第三档 OpenSearch。
**下载目录文件没有被自动整理?**
确认下载目录已通过 `volumes` 挂进容器,且 `MEBOX_DOWNLOAD_*` 环境变量对应正确。MeBox 负责目录整理入库,qBittorrent 等下载器按普通软件自行部署即可。
**硬链接失败(cross-device link)?**
硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 btrfs 子卷或网盘挂载时请改用复制或软链接。
**日志保留时间太短?**
默认应用日志为 `20MB x 5`,容器 stdout 日志为 `20m x 3`。排障时可在 compose 中调大
`MEBOX_LOGGING_MAX_SIZE_MB`、`MEBOX_LOGGING_MAX_BACKUPS` 与服务的 `logging.options.max-size/max-file`。
**第三方播放器连不上?**
确认地址为 `http://IP:18080`,使用 MeBox 用户账号;反代部署需正确配置外部 URL 与 HTTPS 头。
---
## 开发构建
后端通过 `go:embed` 嵌入 `web/dist`,**编译前必须先构建前端**。
前端构建要求 Node.js `20.19+` 或 `22.12+`。
```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 单文件可执行程序。
Windows 本地打包:
```powershell
.\scripts\build-windows.ps1 -Version dev
```
Windows 可执行程序使用项目 Logo,不显示控制台窗口;启动后会常驻系统托盘。托盘菜单可打开 MeBox、切换开机自启、查看日志、重启或退出。
---
## 鸣谢
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) 许可证。
---
## 赞赏
如果 MeBox 帮你把家庭影音折腾明白了,欢迎请作者喝杯咖啡 ☕
<p align="center">
<img src="docs/images/donation-qr.png" width="320" alt="WhileTrue 的赞赏码" />
</p>
<p align="center">
<strong>Telegram 交流群</strong>:<a href="https://t.me/MeBoxGroup">https://t.me/MeBoxGroup</a><br/>
使用问题、功能建议、更新动态,欢迎来群里聊
</p>
-255
View File
@@ -1,255 +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/Jellyfin client compatible · 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> ·
<a href="https://t.me/MeBoxGroup">Telegram</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/Jellyfin client compatible** | Full Emby server protocol implementation: Infuse, SenPlayer, Fileball, and official Emby/Jellyfin clients can add MeBox as an Emby server and sign in with MeBox accounts — poster walls, watch progress, and multi-user work out of the box |
| **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** | Scheduled download-folder organization (smart classification, auto library registration), file manager (copy/move/hardlink/symlink) |
| **Users & permissions** | Admin/regular users, expiry, NSFW toggle, play-profile PIN, granular permissions |
| **Operations** | Unified task queue, 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.
> 💡 **Seamless for Emby users**: MeBox fully implements the Emby/Jellyfin client protocol. Infuse, SenPlayer, Fileball, and official Emby/Jellyfin apps on phones, TVs, and tablets can add it as an Emby server at `http://SERVER_IP:18080` and sign in with MeBox accounts — no change to your existing workflow.
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 set up download-folder auto-organization under file management so finished downloads land in the right library
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.
**Downloaded files are not organized**
Ensure the download directory is mounted into the container and env vars match. MeBox handles folder organization; run qBittorrent or any downloader yourself as a regular app.
**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.
Build the Windows executable locally:
```powershell
.\scripts\build-windows.ps1 -Version dev
```
The Windows executable uses the project logo and runs without a console window. It stays in the notification area, with menu actions for opening MeBox, toggling auto-start, viewing logs, restarting, and exiting.
---
## 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).
---
## Support & Donate
If MeBox makes your home theater life easier, feel free to buy the maintainer a coffee ☕
<p align="center">
<img src="docs/images/donation-qr.png" width="320" alt="WhileTrue donation QR" />
</p>
<p align="center">
<strong>Telegram group</strong>: <a href="https://t.me/MeBoxGroup">https://t.me/MeBoxGroup</a><br/>
Questions, feature requests, and release news — come chat with us
</p>
-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
View File
@@ -0,0 +1 @@
0.1.154
-197
View File
@@ -1,197 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"sync"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// application owns the long-running MeBox server and all resources created
// during startup. Platform entry points decide how the application is
// controlled: a console signal loop or a Windows notification-area icon.
type application struct {
cfg *config.Config
logger *zap.Logger
embyCompatLogger *zap.Logger
serverManager *serverManager
services *service.Container
closeMu sync.Mutex
closeFuncs []func()
shutdown sync.Once
shutdownErr error
}
func newApplication() (*application, error) {
cfg, err := config.Load()
if err != nil {
return nil, fmt.Errorf("config load failed: %w", err)
}
logger, closeLogger, err := newLoggerWithCloser(cfg)
if err != nil {
return nil, fmt.Errorf("logger init failed: %w", err)
}
app := &application{
cfg: cfg,
logger: logger,
closeFuncs: []func(){closeLogger},
}
if err := app.start(); err != nil {
app.closeLoggers()
return nil, err
}
return app, nil
}
func (a *application) start() error {
embyCompatLogger, closeEmbyCompatLogger, err := newEmbyCompatLogger(a.cfg)
if err != nil {
a.logger.Error("Emby compatibility logger init failed", zap.Error(err))
return fmt.Errorf("Emby compatibility logger init failed: %w", err)
}
a.embyCompatLogger = embyCompatLogger
a.addCloser(closeEmbyCompatLogger)
appVersion := effectiveVersion(version)
a.logger.Info("starting MeBox",
zap.String("version", appVersion),
zap.Int("port", a.cfg.App.Port),
zap.String("data_dir", a.cfg.App.DataDir),
zap.String("emby_compat_log", embyCompatLogPath(a.cfg)),
)
for _, dir := range []string{a.cfg.App.DataDir, a.cfg.Cache.CacheDir} {
if err := os.MkdirAll(dir, 0o750); err != nil {
a.logger.Error("create dir failed", zap.String("dir", dir), zap.Error(err))
return fmt.Errorf("create dir %s failed: %w", dir, err)
}
}
db, err := database.Open(a.cfg, a.logger)
if err != nil {
a.logger.Error("database open failed", zap.Error(err))
return fmt.Errorf("database open failed: %w", err)
}
if err := waitForDatabase(db, a.logger); err != nil {
a.logger.Error("database not ready", zap.Error(err))
return fmt.Errorf("database not ready: %w", err)
}
if err := database.AutoMigrate(db); err != nil {
a.logger.Error("auto-migrate failed", zap.Error(err))
return fmt.Errorf("auto-migrate failed: %w", err)
}
if err := database.MigrateSQLiteToCurrentIfNeeded(a.cfg, db, a.logger); err != nil {
a.logger.Error("sqlite to postgres migration failed", zap.Error(err))
return fmt.Errorf("sqlite to postgres migration failed: %w", err)
}
repos := repository.New(db)
service.ApplyRuntimeSettings(context.Background(), a.cfg, repos, a.logger)
applyCPUThreadLimit(a.cfg, a.logger)
a.services = service.NewWithVersion(a.cfg, a.logger, repos, appVersion)
// 一次性清洗历史脏数据: 老版本把单集 episode id / 单集名写进整剧字段, 导致
// 同一部剧被拆成多张单集卡。清空被污染的字段并重置为 pending(借后续重刮修正)。
if cleaned, err := a.services.NormalizePollutedEpisodeMetadata(context.Background()); err != nil {
a.logger.Warn("polluted episode metadata cleanup failed", zap.Error(err))
} else if cleaned > 0 {
a.logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned))
}
if err := a.services.Auth.SeedAdmin(context.Background()); err != nil {
a.logger.Warn("seed admin failed", zap.Error(err))
}
router := buildRouter(a.cfg, a.logger, a.embyCompatLogger, a.services)
a.serverManager = newServerManager(a.cfg, a.logger, router)
a.services.ReloadHTTPServer = a.serverManager.Reload
if err := a.serverManager.Start(); err != nil {
a.logger.Error("listen failed", zap.Error(err))
return fmt.Errorf("listen failed: %w", err)
}
go func() {
scheme := "http"
if a.cfg.App.HTTPSEnabled {
scheme = "https"
}
if publicIP := getPublicIP(3 * time.Second); publicIP != "" {
a.logger.Info("server public endpoint",
zap.String("public", fmt.Sprintf("%s://%s:%d", scheme, publicIP, a.cfg.App.Port)),
)
}
}()
helper.Go(a.logger, "services.boot", a.services.Boot)
return nil
}
// Shutdown is idempotent and safe to call from both the tray handler and the
// systray exit callback.
func (a *application) Shutdown() error {
a.shutdown.Do(func() {
a.logger.Info("shutdown requested")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
if a.serverManager != nil {
if err := a.serverManager.Shutdown(ctx); err != nil {
a.shutdownErr = err
a.logger.Error("graceful shutdown failed", zap.Error(err))
}
}
cancel()
if a.services != nil {
a.services.Close()
}
a.logger.Info("MeBox stopped")
_ = a.logger.Sync()
if a.embyCompatLogger != nil {
_ = a.embyCompatLogger.Sync()
}
a.closeLoggers()
})
return a.shutdownErr
}
func (a *application) addCloser(fn func()) {
if fn == nil {
return
}
a.closeMu.Lock()
a.closeFuncs = append(a.closeFuncs, fn)
a.closeMu.Unlock()
}
func (a *application) closeLoggers() {
a.closeMu.Lock()
closers := append([]func(){}, a.closeFuncs...)
a.closeFuncs = nil
a.closeMu.Unlock()
for i := len(closers) - 1; i >= 0; i-- {
closers[i]()
}
}
func (a *application) localURL() string {
config.RuntimeMu.RLock()
scheme := "http"
if a.cfg.App.HTTPSEnabled {
scheme = "https"
}
port := a.cfg.App.Port
config.RuntimeMu.RUnlock()
return fmt.Sprintf("%s://127.0.0.1:%d", scheme, port)
}
-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)
}
}
}
-138
View File
@@ -1,138 +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")
}
// newEmbyCompatLogger 构建只写入 Emby 兼容日志文件的独立 Zap 实例。
// 它不参与 app.log 的日志级别过滤,始终记录 INFO 及以上,确保成功请求也能
// 用于还原客户端的接口调用顺序;轮转参数沿用 logging 配置。
func newEmbyCompatLogger(cfg *config.Config) (*zap.Logger, func(), error) {
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)
}
writer, err := newRotatingFileWriter(embyCompatLogPath(cfg), cfg.Logging)
if err != nil {
return nil, nil, err
}
log := zap.New(
zapcore.NewCore(encoder, writer, zap.InfoLevel),
zap.AddCaller(),
zap.AddStacktrace(zapcore.ErrorLevel),
zap.ErrorOutput(zapcore.Lock(os.Stderr)),
)
return log, func() { _ = writer.Close() }, nil
}
func embyCompatLogPath(cfg *config.Config) string {
out := strings.TrimSpace(cfg.Logging.OutputPath)
if out == "" || strings.EqualFold(out, "stdout") || strings.EqualFold(out, "stderr") {
return filepath.Join(cfg.App.DataDir, "logs", "emby-compat.log")
}
if ext := filepath.Ext(out); ext != "" {
base := strings.TrimSuffix(out, ext)
return base + ".emby-compat" + ext
}
return filepath.Join(out, "emby-compat.log")
}
-149
View File
@@ -1,149 +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()
}
func TestEmbyCompatLoggerWritesDedicatedFile(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
cfg.Logging.MaxSizeMB = 1
cfg.Logging.MaxBackups = 2
log, closeFn, err := newEmbyCompatLogger(cfg)
if err != nil {
t.Fatal(err)
}
log.Info("emby request", zap.String("client", "Infuse"))
_ = log.Sync()
closeFn()
path := filepath.Join(dir, "logs", "emby-compat.log")
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "emby request") || !strings.Contains(string(data), "Infuse") {
t.Fatalf("dedicated Emby log missing request data: %s", data)
}
if _, err := os.Stat(filepath.Join(dir, "logs", "app.log")); !os.IsNotExist(err) {
t.Fatalf("Emby logger must not write app.log, stat err=%v", err)
}
}
-37
View File
@@ -1,37 +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 (
"os"
"strings"
)
// 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() {
runProgram()
}
-198
View File
@@ -1,198 +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, "fonts"), 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, "fonts", "geist-400.woff2"), []byte("wOF2-test-font"), 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)
}
fontReq := httptest.NewRequest(http.MethodGet, "/fonts/geist-400.woff2", nil)
fontResp := httptest.NewRecorder()
router.ServeHTTP(fontResp, fontReq)
if fontResp.Code != http.StatusOK {
t.Fatalf("font status = %d, want 200", fontResp.Code)
}
if got := fontResp.Header().Get("Cache-Control"); !strings.Contains(got, "max-age=86400") {
t.Fatalf("font Cache-Control = %q, want max-age=86400", got)
}
if got := fontResp.Body.String(); got != "wOF2-test-font" {
t.Fatalf("font body = %q, want wOF2-test-font", got)
}
missingFontReq := httptest.NewRequest(http.MethodGet, "/fonts/missing.woff2", nil)
missingFontResp := httptest.NewRecorder()
router.ServeHTTP(missingFontResp, missingFontReq)
if missingFontResp.Code != http.StatusNotFound {
t.Fatalf("missing font status = %d, want 404", missingFontResp.Code)
}
if strings.Contains(missingFontResp.Body.String(), "index") {
t.Fatalf("missing font should not serve SPA index: %q", missingFontResp.Body.String())
}
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))
}
-33
View File
@@ -1,33 +0,0 @@
//go:build !windows
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
)
func runProgram() {
app, err := newApplication()
if err != nil {
reportError("MeBox 启动失败", err)
os.Exit(1)
}
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
if err := app.Shutdown(); err != nil {
reportError("MeBox 退出失败", err)
}
}
func reportError(title string, err error) {
if err == nil {
return
}
fmt.Fprintf(os.Stderr, "%s: %v\n", title, err)
}
-330
View File
@@ -1,330 +0,0 @@
//go:build windows
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"syscall"
"fyne.io/systray"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"github.com/truewhile/MeBox/internal/brand"
)
const (
runRegistryKey = `Software\Microsoft\Windows\CurrentVersion\Run`
runRegistryValue = "MeBox"
singleInstanceName = `Local\MeBox-Server`
)
var errAlreadyRunning = errors.New("MeBox is already running")
func runProgram() {
if err := prepareWorkingDirectory(); err != nil {
reportError("MeBox 启动失败", fmt.Errorf("切换工作目录失败: %w", err))
return
}
instance, err := acquireSingleInstance()
if errors.Is(err, errAlreadyRunning) {
reportError("MeBox", errors.New("MeBox 已在运行,请查看右下角托盘图标"))
return
}
if err != nil {
reportError("MeBox 启动失败", fmt.Errorf("创建单实例锁失败: %w", err))
return
}
app, err := newApplication()
if err != nil {
_ = instance.Close()
reportError("MeBox 启动失败", err)
return
}
controller := &trayController{app: app}
systray.Run(controller.onReady, controller.onExit)
// Release the mutex before starting the replacement process. The new
// process must be able to acquire it immediately after the old one exits.
_ = instance.Close()
if !controller.readyClosed.Load() {
_ = app.Shutdown()
reportError("MeBox 启动失败", errors.New("系统托盘初始化失败"))
return
}
if controller.restartRequested.Load() {
if err := launchSelf(); err != nil {
reportError("MeBox 重启失败", err)
}
}
}
type trayController struct {
app *application
readyClosed atomic.Bool
restartRequested atomic.Bool
shutdownStarted atomic.Bool
}
func (c *trayController) onReady() {
defer func() {
c.readyClosed.Store(true)
}()
systray.SetIcon(brand.Icon)
systray.SetTooltip("MeBox")
mOpen := systray.AddMenuItem("打开 MeBox", "在浏览器中打开 MeBox")
mAutoStart := systray.AddMenuItemCheckbox("开机自启", "登录 Windows 后自动启动 MeBox", autoStartEnabled())
mLogs := systray.AddMenuItem("查看日志", "打开 MeBox 应用日志")
systray.AddSeparator()
mRestart := systray.AddMenuItem("重启 MeBox", "重启 MeBox 服务")
mQuit := systray.AddMenuItem("退出 MeBox", "停止服务并退出")
initialAutoStart := mAutoStart.Checked()
go func() {
for {
select {
case <-mOpen.ClickedCh:
if err := openURL(c.app.localURL()); err != nil {
reportError("MeBox", fmt.Errorf("打开 MeBox 失败: %w", err))
}
case <-mAutoStart.ClickedCh:
enable := !mAutoStart.Checked()
if err := setAutoStart(enable); err != nil {
if initialAutoStart {
mAutoStart.Check()
} else {
mAutoStart.Uncheck()
}
reportError("MeBox", fmt.Errorf("更新开机自启设置失败: %w", err))
continue
}
if enable {
mAutoStart.Check()
} else {
mAutoStart.Uncheck()
}
initialAutoStart = enable
case <-mLogs.ClickedCh:
if err := c.app.openLog(); err != nil {
reportError("MeBox", fmt.Errorf("打开日志失败: %w", err))
}
case <-mRestart.ClickedCh:
c.restart()
case <-mQuit.ClickedCh:
c.quit()
}
}
}()
}
func (c *trayController) onExit() {
_ = c.app.Shutdown()
}
func (c *trayController) quit() {
if !c.shutdownStarted.CompareAndSwap(false, true) {
return
}
go func() {
_ = c.app.Shutdown()
systray.Quit()
}()
}
func (c *trayController) restart() {
if !c.shutdownStarted.CompareAndSwap(false, true) {
return
}
c.restartRequested.Store(true)
go func() {
_ = c.app.Shutdown()
systray.Quit()
}()
}
func (a *application) openLog() error {
appLog, _, _ := logFilePaths(a.cfg)
if appLog != "" {
if _, err := os.Stat(appLog); err == nil {
return openPath(appLog)
}
_ = os.MkdirAll(filepath.Dir(appLog), 0o750)
return openPath(filepath.Dir(appLog))
}
logDir := filepath.Join(a.cfg.App.DataDir, "logs")
if err := os.MkdirAll(logDir, 0o750); err != nil {
return err
}
return openPath(logDir)
}
func prepareWorkingDirectory() error {
exe, err := os.Executable()
if err != nil {
return err
}
exeDir := filepath.Dir(exe)
cwd, err := os.Getwd()
if err != nil {
return err
}
if samePath(exeDir, cwd) || looksLikeProjectDirectory(cwd) {
return nil
}
return os.Chdir(exeDir)
}
func looksLikeProjectDirectory(dir string) bool {
for _, name := range []string{"go.mod", "config.yaml", "data", filepath.Join("web", "dist")} {
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
return true
}
}
return false
}
func samePath(left, right string) bool {
return strings.EqualFold(filepath.Clean(left), filepath.Clean(right))
}
type singleInstance struct {
handle windows.Handle
}
func acquireSingleInstance() (*singleInstance, error) {
name, err := windows.UTF16PtrFromString(singleInstanceName)
if err != nil {
return nil, err
}
handle, err := windows.CreateMutex(nil, false, name)
if errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
if handle != 0 {
_ = windows.CloseHandle(handle)
}
return nil, errAlreadyRunning
}
if err != nil {
return nil, err
}
return &singleInstance{handle: handle}, nil
}
func (s *singleInstance) Close() error {
if s == nil || s.handle == 0 {
return nil
}
err := windows.CloseHandle(s.handle)
s.handle = 0
return err
}
func launchSelf() error {
exe, err := os.Executable()
if err != nil {
return err
}
cwd, err := os.Getwd()
if err != nil {
return err
}
cmd := exec.Command(exe, os.Args[1:]...)
cmd.Dir = cwd
cmd.Env = os.Environ()
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS,
}
if err := cmd.Start(); err != nil {
return err
}
return cmd.Process.Release()
}
func autoStartEnabled() bool {
key, err := registry.OpenKey(registry.CURRENT_USER, runRegistryKey, registry.QUERY_VALUE)
if err != nil {
return false
}
defer key.Close()
value, _, err := key.GetStringValue(runRegistryValue)
if err != nil {
return false
}
exe, err := os.Executable()
if err != nil {
return false
}
return strings.EqualFold(strings.TrimSpace(strings.Trim(value, `"`)), filepath.Clean(exe))
}
func setAutoStart(enabled bool) error {
key, _, err := registry.CreateKey(registry.CURRENT_USER, runRegistryKey, registry.SET_VALUE)
if err != nil {
return err
}
defer key.Close()
if !enabled {
if err := key.DeleteValue(runRegistryValue); err != nil && !errors.Is(err, registry.ErrNotExist) {
return err
}
return nil
}
exe, err := os.Executable()
if err != nil {
return err
}
return key.SetStringValue(runRegistryValue, syscall.EscapeArg(filepath.Clean(exe)))
}
func openURL(url string) error {
return shellOpen(url)
}
func openPath(path string) error {
return shellOpen(path)
}
func shellOpen(target string) error {
targetPtr, err := windows.UTF16PtrFromString(target)
if err != nil {
return err
}
verbPtr, err := windows.UTF16PtrFromString("open")
if err != nil {
return err
}
return windows.ShellExecute(0, verbPtr, targetPtr, nil, nil, 1)
}
func reportError(title string, err error) {
if err == nil {
return
}
text, textErr := windows.UTF16PtrFromString(title + "\r\n\r\n" + err.Error())
if textErr != nil {
return
}
caption, captionErr := windows.UTF16PtrFromString("MeBox")
if captionErr != nil {
return
}
_, _ = windows.MessageBox(0, text, caption, windows.MB_OK|windows.MB_ICONERROR|windows.MB_SETFOREGROUND)
}
-11
View File
@@ -1,11 +0,0 @@
//go:build windows
package main
// Regenerate the linked Windows resources after changing the project logo or
// manifest:
//
// go generate ./cmd/server
//
//go:generate go run github.com/akavel/rsrc@v0.10.2 -arch amd64 -ico ../../internal/brand/logo.ico -manifest winres/mebox.manifest -o rsrc_windows_amd64.syso
//go:generate go run github.com/akavel/rsrc@v0.10.2 -arch arm64 -ico ../../internal/brand/logo.ico -manifest winres/mebox.manifest -o rsrc_windows_arm64.syso
-211
View File
@@ -1,211 +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, embyCompatLogger *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))
r.Use(middleware.EmbyCompatLogger(embyCompatLogger, func(path string) bool {
return !isFrontendLibraryRoute(path) && handler.IsEmbyPath(path)
}))
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(middleware.GzipStatic())
assets.Use(func(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Next()
})
assets.GET("/*filepath", serveFSDir(root, "assets"))
fonts := r.Group("/fonts")
fonts.Use(middleware.GzipStatic())
fonts.Use(func(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=86400")
c.Next()
})
fonts.GET("/*filepath", serveFSDir(root, "fonts"))
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(middleware.GzipStatic(), func(c *gin.Context) {
if handler.TryHandleEmbyNormalizedRoute(c, r) {
return
}
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
}
Binary file not shown.
Binary file not shown.
-278
View File
@@ -1,278 +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) {
// 与 ApplyRuntimeSetting 的写锁配对:HTTPS 相关字段可能被运行时设置
// 热更新,无锁读存在数据竞争(string 撕裂)。
config.RuntimeMu.RLock()
httpsEnabled := m.cfg != nil && m.cfg.App.HTTPSEnabled
cert := m.cfg.App.SSLCert
certPath := m.cfg.App.SSLCertPath
key := m.cfg.App.SSLKey
keyPath := m.cfg.App.SSLKeyPath
config.RuntimeMu.RUnlock()
if !httpsEnabled {
return nil, nil
}
certPEM, err := service.ResolveSSLMaterial(cert, certPath, "证书")
if err != nil {
return nil, err
}
keyPEM, err := service.ResolveSSLMaterial(key, keyPath, "私钥")
if err != nil {
return nil, err
}
if err := service.ValidateSSLKeyPair(certPEM, keyPEM); err != nil {
return nil, err
}
pairCert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
if err != nil {
return nil, fmt.Errorf("SSL 证书/私钥无效:%v", err)
}
return &tlsPair{
cert: pairCert,
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 {
config.RuntimeMu.RLock()
defer config.RuntimeMu.RUnlock()
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
}
-28
View File
@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity
version="1.0.0.0"
processorArchitecture="*"
name="MeBox"
type="win32"
/>
<description>MeBox media server</description>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>
-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
# 下载目录。需要和下载器保存路径保持一致。
# 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: "20"
MEBOX_LOGGING_MAX_BACKUPS: "5"
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: "20m"
max-file: "3"
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: "20m"
max-file: "3"
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: "20m"
max-file: "3"
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: "20m"
max-file: "3"
-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: "20"
MEBOX_LOGGING_MAX_BACKUPS: "5"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
extra_hosts:
# 容器访问宿主机服务(如下载器等)用: 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: "20m"
max-file: "3"
-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
# 下载目录。需要和下载器保存路径保持一致。
# 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: "20"
MEBOX_LOGGING_MAX_BACKUPS: "5"
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: "20m"
max-file: "3"
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: "20m"
max-file: "3"
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: "20m"
max-file: "3"
-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"
# 让容器可以访问宿主机上的服务(如下载器)。
# 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: "20"
MEBOX_LOGGING_MAX_BACKUPS: "5"
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: "20m"
max-file: "3"
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: "20m"
max-file: "3"
-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
-190
View File
@@ -1,190 +0,0 @@
# 【开源推荐】MeBox:把 NAS / 网盘 / 远程 Emby 统一家里的观影入口,Docker 一键部署
> 配图已托管在 GitHub 仓库(`raw.githubusercontent.com` 直链),发帖时可直接引用,或下载 `docs/tutorial-screenshots/` 后作为附件上传。
---
## 写在前面
给论坛的朋友们推荐一个我维护的开源项目 —— **MeBox**,一个面向 NAS 与家庭影音场景的**自托管私人媒体中心**(GPL-3.0,Go + React)。
GitHub:https://github.com/truewhile/MeBox
一句话介绍:**部署一个服务,同时获得媒体库后台、网盘 STRM 整理、Emby 客户端协议网关三件套。** 内置完整 Emby/Jellyfin 服务端协议实现——手机、电视、平板上的 Infuse、SenPlayer、Fileball、Emby/Jellyfin 官方客户端直接「添加 Emby 服务器」就能连,一套账号体系全搞定,Emby 老用户零学习成本。
项目 fork 自 MediaStationGo 并持续二开,围绕网盘播放、任务队列、远程挂载和权限体系做了大量增强。
---
## 它能解决什么问题?
家里看电影电视的痛点,MeBox 基本一把梭:
| 痛点 | MeBox 的解法 |
| --- | --- |
| 硬盘散落各处,海报墙乱七八糟 | 多根目录媒体库 + TMDb/Bangumi/Douban 自动刮削,海报墙、继续观看、多季剧集一应俱全 |
| 网盘资源看一部下一部太麻烦 | OpenList / CloudDrive2 / 115 / WebDAV 接入,STRM 同步 + 直链/302 播放,不占本地空间 |
| 已经有一台 Emby,出门还得开 App | **远程 Emby 挂载**:把远程 Emby 的媒体库直接挂进 MeBox 界面统一浏览 |
| 家人乱动设置、小孩看不该看的 | 多用户 + 有效期 + 成人内容开关 + 播放配置 PIN,细粒度权限 |
| 每个设备装一套专属 App 太折腾 | **完整兼容 Emby/Jellyfin 客户端**:Infuse、SenPlayer、Fileball、官方客户端按「添加 Emby 服务器」填地址 + MeBox 账号即可,海报墙、观看进度、多用户直接同步 |
---
## 特点一览
**1. 现代化 Web UI,海报墙开箱即用**
![登录页](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/01-login.png)
深色系登录页,默认账号 `admin / admin123`(首次登录请立即改密)。
![首页](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/02-home.png)
首页自带焦点推荐轮播 + 媒体库入口卡片,继续观看、最近添加直接呈现。
**2. 媒体库与刮削**
![媒体库总览](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/03-libraries.png)
20 个媒体库、1600+ 条目一眼尽收:每库自带封面拼贴、条目数统计,支持「全库修复+重刮」「刮削队列」批量处理。
![海报墙](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/04-library-posters.png)
库内海报墙带评分、集数角标,支持按最后集添加日期排序,点开即看。
**3. 详情页与多季管理**
![详情页](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/05-media-detail.png)
剧情简介、类型标签、多季分集(特别篇/第 1-N 季)、每集缩略图与时长;一键立即播放、调用外部播放器、加入收藏。
**4. Emby/Jellyfin 客户端无缝兼容**
这是我最想强调的一点:**MeBox 内置了完整的 Emby 服务端协议实现**。手机、电视、平板上的 Infuse、SenPlayer、Fileball,甚至 Emby/Jellyfin 官方客户端,都不需要任何插件或改造——按「添加 Emby 服务器」填入 `http://服务器IP:18080`,用 MeBox 账号登录,海报墙、观看进度、收藏、多用户权限全部无缝衔接。已经习惯 Emby 生态的朋友可以零成本迁移,家人只用电视端 App 也完全无感。
**5. 网页播放器 + 弹幕自动匹配**
![播放器与弹幕](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/06-player-danmu.png)
内置网页播放器支持 HLS 转码、字幕、播放配置档;**弹幕按剧名自动匹配全季分集**(截图中自动匹配到《一拳超人》39 集),屏幕占比/透明度/字号随意调,追新番体验直接拉满。
**6. 网盘 STRM:网盘当本地盘用**
![STRM 管理](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/07-strm-cloud.png)
添加网盘账号(**115 支持二维码扫码登录**)→ 添加同步目录 → 系统把网盘/本地目录里的视频生成 `.strm` 文件,元数据经下载/上传队列双向同步,播放走直链/302 不落盘。
**7. 远程 Emby 挂载(特色功能)**
![Emby 挂载](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/08-emby-mount.png)
已有远程 Emby 服务器?填一次账号,按需勾选要挂载的媒体库(支持同服务器多线路自动切换、直连开关、排序),远程库直接出现在 MeBox 首页,不必再开 Emby 客户端。
**8. 任务队列统一管理**
![任务队列](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/09-task-queue.png)
刮削 / 下载 / 上传三类任务统一看板,排队中、进行中、已匹配、失败分类计数,支持搜索与批量清理。
**9. 下载与自动整理**
![文件管理](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/11-file-manager.png)
配合任意下载器(qBittorrent、Transmission 等下载到本地目录即可),MeBox 定时自动整理入媒体库:智能分类子库、自动注册目的地媒体库、复制/移动/硬链/软链多种整理方式,命名规则可配。
**10. 多用户与权限**
![用户管理](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/12-user-admin.png)
管理员/普通用户分级、单实例用户数上限、账号有效期、成人内容开关、播放配置 PIN——给家人开号放心给。
**11. 运维省心**
![系统设置](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/10-settings.png)
FFmpeg/FFprobe 一键下载安装、转码与硬件加速开关、TMDb 语言、识别词、弹幕、Adult/NSFW 开关全在设置页分组管理;另有 DLNA 投屏、存储统计、海报墙聚合视图:
![海报墙聚合](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/13-poster-wall.png)
---
## 使用教程:从零到海报墙只要 5 步
### 第 1 步:Docker 一键部署
推荐 Docker Compose(仓库提供 4 份互相独立的完整模板,无需 `.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 档、Redis 档、OpenSearch 档,见仓库 README「部署档位」
docker compose up -d
```
浏览器访问 `http://服务器IP:18080`,镜像:`ghcr.io/truewhile/mebox:latest`(amd64 / arm64 都有,也提供 Windows/Linux/macOS 单文件可执行程序,不想装 Docker 直接下载跑)。
### 第 2 步:登录并修改密码
默认账号 `admin / admin123`,登录后右上角头像 → 个人资料修改密码。
### 第 3 步:创建媒体库 + 扫库
后台 → 媒体库 → 管理媒体库,添加本地路径(Docker 部署记得填**容器内**路径,如 `/media/电影`,`volumes` 左侧挂宿主机真实目录)→ 执行扫库。
### 第 4 步:配置元数据刮削
系统设置 → 外部 API,填入 TMDb / Bangumi / Douban 等 API Key;媒体库页可对单个库「全库修复+重刮」,刮削进度在任务队列实时可见。
### 第 5 步(可选但强烈推荐):
- **网盘用户**:STRM 管理 → 添加网盘账号(115 可扫码)→ 添加同步目录 → 生成 STRM 后直链播放;
- **已有 Emby**:Emby 挂载 → 添加 Emby 账号 → 勾选要挂载的媒体库;
- **第三方播放器(Emby 客户端全兼容)**:Infuse / SenPlayer / Fileball / Emby、Jellyfin 官方客户端,按「添加 Emby 服务器」填 `http://服务器IP:18080`,用 MeBox 账号登录即可,原有使用习惯完全不变;
- **下载党**:qBittorrent 等任意下载器把视频下到下载目录,在文件管理里把它设为整理源,下完自动分类入库。
### 路径映射小抄(Docker 最常见坑)
```yaml
volumes:
- /vol1/1000/Media:/media # 左:宿主机真实路径;右:容器内路径(网页里填这个)
environment:
MEBOX_MEDIA_DIR: /vol1/1000/Media
MEBOX_MEDIA_CONTAINER_DIR: /media
```
硬链接要求同一文件系统/子卷,跨盘请改复制或软链。
---
## 部署档位怎么选?
| 档位 | 文件 | 组件 | 适合 |
| --- | --- | --- | --- |
| 极简 | `docker-compose.simple.yml` | 单镜像 + SQLite | 个人使用、低配设备 |
| 标准 | `docker-compose.yml` | + PostgreSQL | 多用户家庭共享 |
| 增强 | `docker-compose.standard.yml` | + Redis | 大媒体库高频访问 |
| 搜索 | `docker-compose.search.yml` | + OpenSearch | 超大库全文搜索 |
---
## 技术栈与致谢
- 后端:Go · Gin · GORM · SQLite/PostgreSQL · 可选 Redis / OpenSearch
- 前端:React 18 · Vite · TypeScript · Tailwind CSS · Zustand
- 部署:Docker Compose 多档模板,amd64/arm64 镜像 + 单文件可执行
感谢上游 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) 的奠基,网盘同步/STRM/整理部分参考了 [qmediasync](https://github.com/qicfan/qmediasync) 的思路。
---
## 链接
- GitHub:https://github.com/truewhile/MeBox
- Issue / PR:欢迎提 bug(附部署方式+复现步骤+日志)与功能建议
- License:GPL-3.0
觉得有用的话求个 Star ⭐,也欢迎论坛里的朋友反馈使用体验,我长期维护。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 743 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 793 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 612 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

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