Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b23b1d728e | |||
| ad427fc0d2 | |||
| c489374b61 | |||
| 5a47e6f52d | |||
| c4b8114115 | |||
| 0e592105c4 | |||
| f20c9c3234 | |||
| 38b85bb487 | |||
| 6bf8a6878a | |||
| 2e636ce43f | |||
| 28aa466107 | |||
| f1b8010496 | |||
| 394d1a3cda | |||
| 465e98a792 | |||
| 292ae22dcd | |||
| bc7e5fc79d | |||
| f7fec93d44 | |||
| b4a1dc38bb | |||
| 3150971f10 | |||
| a71a18ce82 | |||
| 5a189a44fc | |||
| fb84c62e9a | |||
| 355fd06036 | |||
| 4173caac5d | |||
| 2aeedcc182 | |||
| d96782622d | |||
| ebe425036b | |||
| edbaa1c84b | |||
| 0179332013 | |||
| 7fb3db0f4c | |||
| e4a101b502 | |||
| 28551c7883 | |||
| a826ed9613 | |||
| 030ed5f325 | |||
| 60308800fb | |||
| 582495dece | |||
| 2a4545eb44 | |||
| 2bde71099e | |||
| 1b06a01603 | |||
| fe407caf9e | |||
| ae02c14d27 | |||
| 5404e7773f | |||
| a368110e60 | |||
| 0e9fb2c937 |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"ssh": {
|
||||
"command": "cmd.exe",
|
||||
"args": [
|
||||
"/c",
|
||||
"npx",
|
||||
"-y",
|
||||
"@aiondadotcom/mcp-ssh"
|
||||
],
|
||||
"env": {
|
||||
"ProgramData": "C:\\ProgramData"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,122 +1,78 @@
|
||||
name: AuTo Docker Image
|
||||
|
||||
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:
|
||||
inputs:
|
||||
version_type:
|
||||
description: '版本递增类型'
|
||||
required: true
|
||||
default: 'patch'
|
||||
type: choice
|
||||
options:
|
||||
- patch # 0.0.x
|
||||
- minor # 0.x.0
|
||||
- major # x.0.0
|
||||
|
||||
permissions:
|
||||
contents: write # 需要写入权限来更新版本文件
|
||||
contents: write # 读写 version 分支、发布 Release 与上传二进制需要
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
version-and-publish:
|
||||
build-image:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
new_version: ${{ steps.bump_version.outputs.new_version }}
|
||||
tag: ${{ steps.bump_version.outputs.tag }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
release_tag: ${{ steps.version.outputs.release_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # 获取完整历史以便版本计算
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# 1. 获取或初始化版本号
|
||||
- name: Get current version
|
||||
id: get_version
|
||||
# 1. 解析版本号:tag 触发取 tag 名(去掉 v 前缀);其余场景读 version 分支并 patch+1
|
||||
- name: Resolve version
|
||||
id: version
|
||||
run: |
|
||||
# 从文件读取版本号,或使用默认值
|
||||
if [ -f VERSION ]; then
|
||||
CURRENT_VERSION=$(cat VERSION)
|
||||
if [ "${{ github.ref_type }}" = "tag" ]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
else
|
||||
CURRENT_VERSION="0.0.0"
|
||||
echo $CURRENT_VERSION > VERSION
|
||||
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 "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# 分离版本组成部分
|
||||
MAJOR=$(echo $CURRENT_VERSION | cut -d. -f1)
|
||||
MINOR=$(echo $CURRENT_VERSION | cut -d. -f2)
|
||||
PATCH=$(echo $CURRENT_VERSION | cut -d. -f3)
|
||||
echo "major=$MAJOR" >> $GITHUB_OUTPUT
|
||||
echo "minor=$MINOR" >> $GITHUB_OUTPUT
|
||||
echo "patch=$PATCH" >> $GITHUB_OUTPUT
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=mebox-v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "new_version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# 2. 计算新版本号
|
||||
- name: Bump version
|
||||
id: bump_version
|
||||
run: |
|
||||
MAJOR=${{ steps.get_version.outputs.major }}
|
||||
MINOR=${{ steps.get_version.outputs.minor }}
|
||||
PATCH=${{ steps.get_version.outputs.patch }}
|
||||
|
||||
# 手动触发时根据选择递增
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
TYPE="${{ github.event.inputs.version_type }}"
|
||||
if [ "$TYPE" = "major" ]; then
|
||||
MAJOR=$((MAJOR + 1))
|
||||
MINOR=0
|
||||
PATCH=0
|
||||
elif [ "$TYPE" = "minor" ]; then
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
else # patch
|
||||
PATCH=$((PATCH + 1))
|
||||
fi
|
||||
else
|
||||
# 自动触发时默认 patch 递增
|
||||
PATCH=$((PATCH + 1))
|
||||
fi
|
||||
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=MeBox-v${NEW_VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "tag=mebox-v${NEW_VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
# 3. 更新 VERSION 文件
|
||||
- name: Update version file
|
||||
run: |
|
||||
echo "${{ steps.bump_version.outputs.new_version }}" > VERSION
|
||||
|
||||
# 如果存在 go.mod,也更新其中的版本(可选)
|
||||
# if [ -f go.mod ]; then
|
||||
# sed -i "s/^version .*/version ${{ steps.bump_version.outputs.new_version }}/" go.mod
|
||||
# fi
|
||||
|
||||
# 4. 提交版本变更
|
||||
- name: Commit version bump
|
||||
# 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"
|
||||
git add VERSION
|
||||
git commit -m "chore: bump version to ${{ steps.bump_version.outputs.new_version }} [skip ci]"
|
||||
git push
|
||||
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; }
|
||||
|
||||
# 5. 创建 Git Tag
|
||||
- name: Create and push tag
|
||||
run: |
|
||||
TAG="${{ steps.bump_version.outputs.tag }}"
|
||||
git tag $TAG
|
||||
git push origin $TAG
|
||||
|
||||
# 6. 设置 Docker QEMU 和 Buildx
|
||||
# 3. 设置 Docker QEMU 和 Buildx
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
# 7. 登录 GHCR
|
||||
# 3. 登录 GHCR
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -124,7 +80,7 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# 8. 提取镜像元数据
|
||||
# 4. 提取镜像元数据
|
||||
- name: Extract image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -132,10 +88,9 @@ jobs:
|
||||
images: ghcr.io/${{ github.repository_owner }}/mebox
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=raw,value=${{ steps.bump_version.outputs.tag }}
|
||||
type=raw,value=${{ steps.bump_version.outputs.new_version }}
|
||||
type=raw,value=${{ steps.version.outputs.version }}
|
||||
|
||||
# 9. 构建并推送
|
||||
# 5. 构建并推送
|
||||
- name: Build & push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
@@ -147,13 +102,14 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.bump_version.outputs.new_version }}
|
||||
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
|
||||
@@ -178,7 +134,7 @@ jobs:
|
||||
# 先创建(幂等)空的 GitHub Release,供后续 build-binaries 并行上传附件,
|
||||
# 也避免矩阵各 job 并发 upload 时 release 尚不存在而互相竞争。
|
||||
publish-create-release:
|
||||
needs: [version-and-publish]
|
||||
needs: [build-image]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -187,17 +143,21 @@ jobs:
|
||||
- name: Create release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ needs.version-and-publish.outputs.tag }}
|
||||
RELEASE_TAG: ${{ needs.build-image.outputs.release_tag }}
|
||||
run: |
|
||||
set -eux
|
||||
# tag 已由 version-and-publish 推送;若 release 已存在则忽略(--verify-tag 幂等)
|
||||
# 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.version-and-publish.outputs.new_version }}" \
|
||||
--notes "自动化发布 ${{ needs.version-and-publish.outputs.new_version }}" \
|
||||
--title "MeBox ${{ needs.build-image.outputs.version }}" \
|
||||
--notes "自动化发布 ${{ needs.build-image.outputs.version }}" \
|
||||
--verify-tag --latest || true
|
||||
|
||||
build-binaries:
|
||||
needs: [version-and-publish, build-frontend, publish-create-release]
|
||||
needs: [build-image, build-frontend, publish-create-release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -237,7 +197,7 @@ jobs:
|
||||
- name: Build binary
|
||||
run: |
|
||||
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${{ needs.version-and-publish.outputs.tag }}" \
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${{ needs.build-image.outputs.release_tag }}" \
|
||||
-o "dist/mebox-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server
|
||||
- name: Package
|
||||
run: |
|
||||
@@ -252,7 +212,7 @@ jobs:
|
||||
- name: Upload to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ needs.version-and-publish.outputs.tag }}
|
||||
RELEASE_TAG: ${{ needs.build-image.outputs.release_tag }}
|
||||
run: |
|
||||
set -eux
|
||||
PKG="mebox_${{ matrix.goos }}_${{ matrix.goarch }}.zip"
|
||||
@@ -267,7 +227,7 @@ jobs:
|
||||
|
||||
deploy:
|
||||
name: Deploy to Server
|
||||
needs: [version-and-publish]
|
||||
needs: [build-image]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy via SSH
|
||||
@@ -302,4 +262,3 @@ jobs:
|
||||
docker image prune -f
|
||||
|
||||
echo "==== 部署完成并已启动 ===="
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
## 删除用户云下载任务
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-------------|:----------------------------------|
|
||||
| 接口名称 | 删除用户云下载任务 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,111 @@
|
||||
## 添加云下载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 | 创建文档 |
|
||||
@@ -0,0 +1,115 @@
|
||||
## 添加云下载链接任务
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-------------|:----------------------------------|
|
||||
| 接口名称 | 添加云下载链接任务 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,104 @@
|
||||
## 清空云下载任务
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-------------|:----------------------------------|
|
||||
| 接口名称 | 清空云下载任务 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,101 @@
|
||||
## 获取云下载配额信息
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-------------|:----------------------------------|
|
||||
| 接口名称 | 获取云下载配额信息 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,136 @@
|
||||
## 获取用户云下载任务列表
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-------------|:----------------------------------|
|
||||
| 接口名称 | 获取用户云下载任务列表 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,118 @@
|
||||
## 解析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 | 创建文档 |
|
||||
@@ -0,0 +1,172 @@
|
||||
## 开发者商业价值转化:推广产品得收益
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-------|:--------------------|
|
||||
| 文档名称 | 开发者商业价值转化:推广产品得收益 |
|
||||
| 文档版本 | 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 服务购买页面示例:
|
||||
|
||||

|
||||
|
||||
长期存储空间扩容购买页面示例:
|
||||
|
||||

|
||||
|
||||
## 三、接入流程
|
||||
|
||||
### 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/),查看收益明细等推广收益情况并进行提现操作。
|
||||
|
||||

|
||||
|
||||
### 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默认产品名称改为至尊版 |
|
||||
@@ -0,0 +1,79 @@
|
||||
## 删除或清空回收站
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-----------|:--------------------------------|
|
||||
| 接口名称 | 删除或清空回收站 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,80 @@
|
||||
## 删除文件
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-----------|:--------------------------------|
|
||||
| 接口名称 | 删除文件 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,126 @@
|
||||
## 回收站列表
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-----------|:--------------------------------|
|
||||
| 接口名称 | 回收站列表 |
|
||||
| 接口版本 | 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 | 补充单页记录数上限 |
|
||||
@@ -0,0 +1,78 @@
|
||||
## 回收站还原
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-----------|:--------------------------------|
|
||||
| 接口名称 | 回收站还原 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,87 @@
|
||||
## 文件(夹)更新
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-----------|:--------------------------------|
|
||||
| 接口名称 | 文件(夹)更新 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,41 @@
|
||||
# 上传流程
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:---------|:------------------------------|
|
||||
| 文档名称 | 上传流程 |
|
||||
| 文档版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,141 @@
|
||||
## 文件上传
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:---------|:------------------------------|
|
||||
| 接口名称 | 文件上传 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,110 @@
|
||||
## 断点续传
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:---------|:------------------------------|
|
||||
| 接口名称 | 断点续传 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,87 @@
|
||||
## 获取上传凭证
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:---------|:------------------------------|
|
||||
| 接口名称 | 获取上传凭证 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,84 @@
|
||||
## 文件复制
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-----------|:---------------------------------|
|
||||
| 接口名称 | 文件复制 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,121 @@
|
||||
## 文件搜索
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-----------|:---------------------------------|
|
||||
| 接口名称 | 文件搜索 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,91 @@
|
||||
## 文件移动
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-----------|:---------------------------------|
|
||||
| 接口名称 | 文件移动 |
|
||||
| 接口版本 | 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 | 补充移动目标目录有效性约束及错误码 |
|
||||
@@ -0,0 +1,86 @@
|
||||
## 新建文件夹
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-----------|:---------------------------------|
|
||||
| 接口名称 | 新建文件夹 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,114 @@
|
||||
## 按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 | 创建文档 |
|
||||
@@ -0,0 +1,115 @@
|
||||
## 按路径获取
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-----------|:---------------------------------|
|
||||
| 接口名称 | 按路径获取 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,98 @@
|
||||
## 获取文件下载地址
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-----------|:--------------------------------|
|
||||
| 接口名称 | 获取文件下载地址 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,204 @@
|
||||
## 获取文件列表
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-----------|:---------------------------------|
|
||||
| 接口名称 | 获取文件列表 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,140 @@
|
||||
## 用户信息
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:---------|:------------------------------|
|
||||
| 接口名称 | 用户信息 |
|
||||
| 接口版本 | 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等级名称枚举 |
|
||||
@@ -0,0 +1,88 @@
|
||||
## 提交视频转码
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-------------|:--------------------------------|
|
||||
| 接口名称 | 提交视频转码 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,125 @@
|
||||
## 获取视频在线播放地址
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-------------|:--------------------------------|
|
||||
| 接口名称 | 获取视频在线播放地址 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,96 @@
|
||||
## 获取视频播放进度
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-------------|:--------------------------------|
|
||||
| 接口名称 | 获取视频播放进度 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,119 @@
|
||||
## 视频字幕列表
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-------------|:--------------------------------|
|
||||
| 接口名称 | 视频字幕列表 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,89 @@
|
||||
## 记忆视频播放进度
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-------------|:--------------------------------|
|
||||
| 接口名称 | 记忆视频播放进度 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,30 @@
|
||||
# 开发须知
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-----------|:---------------------------------|
|
||||
| 文档名称 | 开发须知 |
|
||||
| 文档版本 | v1.0 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
为共同建设开放、共赢的合作生态并保障平台服务的可持续发展,开发者在接入平台服务前,须认真阅读并严格遵循《115生活开放平台开发者协议》的相关要求,确保合规运营、维护平台秩序。
|
||||
|
||||
基于平台与用户权益保护原则,平台会持续监测开发者的服务使用行为。如发现违反平台规范的行为,平台将视情节采取包括但不限于服务限流、接口冻结、资质回收等限制措施,并保留依法追责的权利。
|
||||
|
||||
开发者严禁实施包括但不限于以下行为:
|
||||
|
||||
1. **数据隐私违规行为**:侵害用户数据隐私安全,包括未经用户授权或未明确用途,违规收集、下载、存储、传播、加工用户存储数据等。开发者需要确保用户数据的获取与使用全程透明、可追溯。
|
||||
2. **商业利益侵犯行为**:损害 115 科技的商业利益,包括多人共享开发者账号及会员权益、开展竞争关系业务、未经授权获取平台相关服务运营数据等。
|
||||
3. **不当使用行为**:违规或未按要求使用 API 服务,包括违反国家相关政策法规、侵犯第三方合法权益、调用非公开接口、实际用途与申请信息不符等。
|
||||
|
||||
## 限流说明
|
||||
|
||||
为确保系统安全并保障服务稳定运行,115生活开放平台对所有 API 实施频率控制策略。出于安全防护需要,相关策略细则暂不公开,平台会持续动态优化该机制。
|
||||
|
||||
### 修改历史
|
||||
|
||||
| 修改时间 | 修改说明 |
|
||||
|:-----------------------------|:-----|
|
||||
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
|
||||
@@ -0,0 +1,60 @@
|
||||
# 授权错误码
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-----------|:---------------------------------|
|
||||
| 文档名称 | 授权错误码 |
|
||||
| 文档版本 | 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` 的永久失效判定说明 |
|
||||
@@ -0,0 +1,92 @@
|
||||
## 刷新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` 说明 |
|
||||
@@ -0,0 +1,88 @@
|
||||
## 获取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 | 创建文档 |
|
||||
@@ -0,0 +1,103 @@
|
||||
## 获取设备码和二维码内容
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-----------|:---------------------------------|
|
||||
| 接口名称 | 获取设备码和二维码内容 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,89 @@
|
||||
## 轮询二维码状态
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-----------|:---------------------------------|
|
||||
| 接口名称 | 轮询二维码状态 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,95 @@
|
||||
## 用授权码换取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 | 创建文档 |
|
||||
@@ -0,0 +1,87 @@
|
||||
## 请求授权
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-----------|:---------------------------------|
|
||||
| 接口名称 | 请求授权 |
|
||||
| 接口版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,57 @@
|
||||
# 接入流程
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-----------|:---------------------------------|
|
||||
| 文档名称 | 接入流程 |
|
||||
| 文档版本 | 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 | 创建文档 |
|
||||
@@ -0,0 +1,23 @@
|
||||
# 更新记录
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-----------|:---------------------------------|
|
||||
| 文档名称 | 更新记录 |
|
||||
| 文档版本 | v1.0 |
|
||||
|
||||
## 更新内容
|
||||
|
||||
| 更新模块 | 更新内容 | 更新时间 |
|
||||
|:-----------|:-------------------------|:--------------|
|
||||
| 增值服务产品 | 新增增值服务产品,获得推广收益 | 2025年4月17日周四 |
|
||||
| 视频播放、云下载 | 新增视频播放、云下载接口 | 2025年4月3日周四 |
|
||||
| 接入授权 | 支持 H5 账号密码/短信授权 | 2025年4月2日周三 |
|
||||
| 基本框架 | 新增开放平台文档接口 | 2025年1月22日周三 |
|
||||
|
||||
### 修改历史
|
||||
|
||||
| 修改时间 | 修改说明 |
|
||||
|:-----------------------------|:-----|
|
||||
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
|
||||
@@ -0,0 +1,30 @@
|
||||
# 概述
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 属性 | 内容 |
|
||||
|:-----------|:---------------------------------|
|
||||
| 文档名称 | 概述 |
|
||||
| 文档版本 | v1.0 |
|
||||
|
||||
## 115生活简介
|
||||
|
||||
“115生活”是一款面向个人用户的数字生活平台,提供海量数据的安全存储、多端同步与快速访问。用户不仅可以便捷地管理和使用各类数字资源,还能使用多维社交、生活服务等多元化功能。
|
||||
|
||||
## 115生活开放平台能力说明
|
||||
|
||||
115生活开放平台提供“115生活”数据存储、同步、管理等功能的 API 服务。开发者通过对接 API,可以将“115生活”的存储能力集成到自己的应用中。
|
||||
|
||||
目前已开放以下能力:
|
||||
|
||||
- **用户管理能力**:用户授权与信息查询等。
|
||||
- **文件管理能力**:获取文件列表,查看文件属性,以及文件上传、下载、搜索、移动、删除等。
|
||||
- **视频管理能力**:视频文件的在线转码与播放等。
|
||||
- **云下载服务**:获取云下载任务列表、配额信息,以及添加、删除下载任务等。
|
||||
- **商业价值转化**:开发者参与“推广产品得收益”计划,可基于用户实际购买的产品获取相应推广收益。
|
||||
|
||||
### 修改历史
|
||||
|
||||
| 修改时间 | 修改说明 |
|
||||
|:-----------------------------|:-----|
|
||||
| 2025年04月01日(周二) 00:00:00 | 创建文档 |
|
||||
@@ -0,0 +1,67 @@
|
||||
# 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开放平台/接入指南/接入授权/`
|
||||
@@ -0,0 +1,308 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
<h3 align="center">面向 NAS 与家庭影音场景的私人媒体中心</h3>
|
||||
|
||||
<p align="center">
|
||||
<strong>媒体库 · 刮削整理 · 网盘 STRM · Emby 协议 · 远程 Emby 挂载 · 多用户权限 · Docker 一键部署</strong>
|
||||
<strong>媒体库 · 刮削整理 · 网盘 STRM · 兼容 Emby/Jellyfin 客户端 · 远程 Emby 挂载 · 多用户权限 · Docker 一键部署</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -17,7 +17,8 @@
|
||||
<a href="#鸣谢">鸣谢</a> ·
|
||||
<a href="#开发构建">开发构建</a> ·
|
||||
<a href="README_EN.md">English</a> ·
|
||||
<a href="CONTRIBUTING.md">贡献规范</a>
|
||||
<a href="CONTRIBUTING.md">贡献规范</a> ·
|
||||
<a href="https://t.me/MeBoxGroup">Telegram 群组</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -46,10 +47,10 @@
|
||||
| **媒体库** | 电影、电视剧、动漫、综艺、音乐与自定义库;多根目录、扫库、海报墙、继续观看 |
|
||||
| **元数据刮削** | TMDb、Bangumi、Douban、TheTVDB、Fanart 等;支持 NFO、手动匹配、刮削队列 |
|
||||
| **播放** | 网页播放器、HLS 转码、弹幕、字幕、播放配置档、观看历史与收藏 |
|
||||
| **Emby 协议** | Infuse、SenPlayer、Fileball 等客户端可直接添加本服务,使用 MeBox 账号登录 |
|
||||
| **Emby/Jellyfin 客户端兼容** | 内置完整 Emby 服务端协议实现:Infuse、SenPlayer、Fileball、Emby/Jellyfin 官方客户端等可直接把本服务当作 Emby 服务器添加,使用 MeBox 账号登录,海报墙、进度同步、多用户无缝衔接 |
|
||||
| **远程 Emby 挂载** | 将远程 Emby 媒体库挂载到本地界面统一浏览(无需单独开 Emby 客户端) |
|
||||
| **网盘与 STRM** | OpenList、CloudDrive2、115、WebDAV 等;STRM 同步、上传/下载队列、直链/302 播放 |
|
||||
| **下载与整理** | qBittorrent 接入、站点搜索与订阅、下载后自动整理、文件管理器(复制/移动/硬链/软链) |
|
||||
| **下载与整理** | 下载目录定时自动整理(智能分类、自动注册媒体库)、文件管理器(复制/移动/硬链/软链) |
|
||||
| **用户与权限** | 管理员/普通用户、有效期、成人内容开关、播放配置 PIN、细粒度操作权限 |
|
||||
| **运维能力** | 统一任务队列、存储统计、DLNA 投屏、系统设置与日志 |
|
||||
|
||||
@@ -85,6 +86,8 @@ http://服务器IP:18080
|
||||
|
||||
默认账号:`admin` / `admin123`(首次登录后请立即修改密码)
|
||||
|
||||
> 💡 **Emby 用户无缝切换**:MeBox 完整兼容 Emby/Jellyfin 客户端协议。手机、电视、平板上的 Infuse、SenPlayer、Fileball、Emby/Jellyfin 官方客户端,直接按「添加 Emby 服务器」填入 `http://服务器IP:18080`,用 MeBox 账号登录即可,无需改变原有使用习惯。
|
||||
|
||||
镜像地址:
|
||||
|
||||
```text
|
||||
@@ -179,7 +182,7 @@ environment:
|
||||
|
||||
1. **创建媒体库** → 填写 `/media/...` → 执行扫库
|
||||
2. **配置元数据源** → 系统设置中添加 TMDb、Bangumi 等 API
|
||||
3. **(可选)连接 qBittorrent** → 下载客户端设置,宿主机可用 `http://host.docker.internal:8085`
|
||||
3. **(可选)配置下载目录自动整理** → 文件管理中将下载目录设为整理源,下载完成后自动分类入库
|
||||
4. **(可选)配置网盘账号** → STRM 管理中添加 OpenList / 115 / WebDAV 等
|
||||
5. **第三方播放器** → 以 Emby 服务器添加 `http://服务器IP:18080`,使用 MeBox 账号登录
|
||||
|
||||
@@ -190,8 +193,8 @@ environment:
|
||||
**扫库或入库很慢?**
|
||||
先确认路径映射与数据库档位。网盘扫描还受接口限速与目录规模影响;大库可考虑第二档 Redis 或第三档 OpenSearch。
|
||||
|
||||
**qBittorrent 下载后无法整理?**
|
||||
确认下载目录已通过 `volumes` 挂进容器,且 `MEBOX_DOWNLOAD_*` 环境变量对应正确。
|
||||
**下载目录文件没有被自动整理?**
|
||||
确认下载目录已通过 `volumes` 挂进容器,且 `MEBOX_DOWNLOAD_*` 环境变量对应正确。MeBox 负责目录整理入库,qBittorrent 等下载器按普通软件自行部署即可。
|
||||
|
||||
**硬链接失败(cross-device link)?**
|
||||
硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 btrfs 子卷或网盘挂载时请改用复制或软链接。
|
||||
@@ -251,3 +254,18 @@ MeBox 在 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) 的基础
|
||||
## 许可证
|
||||
|
||||
本项目采用 [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>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<h3 align="center">A self-hosted media center for NAS and home theater</h3>
|
||||
|
||||
<p align="center">
|
||||
<strong>Libraries · Metadata · Cloud STRM · Emby protocol · Remote Emby mounts · Multi-user · Docker-first</strong>
|
||||
<strong>Libraries · Metadata · Cloud STRM · Emby/Jellyfin client compatible · Remote Emby mounts · Multi-user · Docker-first</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -16,7 +16,8 @@
|
||||
<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="#development">Development</a> ·
|
||||
<a href="https://t.me/MeBoxGroup">Telegram</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -45,10 +46,10 @@ In practice, MeBox gives you:
|
||||
| **Libraries** | Movies, TV, anime, variety, music, custom libraries; multi-root scanning; poster wall; continue watching |
|
||||
| **Metadata** | TMDb, Bangumi, Douban, TheTVDB, Fanart, NFO import, manual matching, scrape queue |
|
||||
| **Playback** | Web player, HLS transcoding, danmaku, subtitles, play profiles, history and favourites |
|
||||
| **Emby protocol** | Add MeBox in Infuse, SenPlayer, Fileball, etc. and sign in with MeBox accounts |
|
||||
| **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** | qBittorrent, site search/subscriptions, post-download organization, file manager |
|
||||
| **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 |
|
||||
|
||||
@@ -84,6 +85,8 @@ 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
|
||||
@@ -159,7 +162,7 @@ environment:
|
||||
|
||||
1. Create a library with a container path such as `/media/Movies`, then scan
|
||||
2. Add metadata providers (TMDb, Bangumi, etc.) in system settings
|
||||
3. Optionally connect qBittorrent (`http://host.docker.internal:8085` when qB runs on the host)
|
||||
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
|
||||
|
||||
@@ -170,8 +173,8 @@ environment:
|
||||
**Library scan is slow**
|
||||
Check path mapping and DB tier. Cloud scans also depend on API limits and folder size.
|
||||
|
||||
**qBittorrent downloads are not organized**
|
||||
Ensure the download directory is mounted into the container and env vars match.
|
||||
**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.
|
||||
@@ -227,3 +230,18 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md) before ope
|
||||
## 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>
|
||||
|
||||
@@ -70,8 +70,11 @@ func serveSPA(r *gin.Engine, root fs.FS) {
|
||||
r.GET(rootFile, serveFSFile(root, name))
|
||||
r.HEAD(rootFile, serveFSFile(root, name))
|
||||
}
|
||||
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
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
|
||||
|
||||
@@ -55,7 +55,7 @@ services:
|
||||
bind:
|
||||
create_host_path: false
|
||||
|
||||
# 下载目录。需要和 qBittorrent 保存路径保持一致。
|
||||
# 下载目录。需要和下载器保存路径保持一致。
|
||||
# NAS 示例:source: /vol1/1000/Downloads
|
||||
# Windows Docker Desktop 示例:source: D:/Downloads
|
||||
- type: bind
|
||||
|
||||
@@ -69,7 +69,7 @@ services:
|
||||
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
|
||||
|
||||
extra_hosts:
|
||||
# 容器访问宿主机服务用,例如 qBittorrent: http://host.docker.internal:8085
|
||||
# 容器访问宿主机服务(如下载器等)用: http://host.docker.internal:8085
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
healthcheck:
|
||||
|
||||
@@ -50,7 +50,7 @@ services:
|
||||
bind:
|
||||
create_host_path: false
|
||||
|
||||
# 下载目录。需要和 qBittorrent 保存路径保持一致。
|
||||
# 下载目录。需要和下载器保存路径保持一致。
|
||||
# NAS 示例:source: /vol1/1000/Downloads
|
||||
# Windows Docker Desktop 示例:source: D:/Downloads
|
||||
- type: bind
|
||||
|
||||
@@ -29,7 +29,7 @@ services:
|
||||
ports:
|
||||
- "18080:8080"
|
||||
|
||||
# 让容器可以访问宿主机上的 qBittorrent。
|
||||
# 让容器可以访问宿主机上的服务(如下载器)。
|
||||
# qB 地址通常可填:http://host.docker.internal:8085
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# 【开源推荐】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,海报墙开箱即用**
|
||||
|
||||

|
||||
|
||||
深色系登录页,默认账号 `admin / admin123`(首次登录请立即改密)。
|
||||
|
||||

|
||||
|
||||
首页自带焦点推荐轮播 + 媒体库入口卡片,继续观看、最近添加直接呈现。
|
||||
|
||||
**2. 媒体库与刮削**
|
||||
|
||||

|
||||
|
||||
20 个媒体库、1600+ 条目一眼尽收:每库自带封面拼贴、条目数统计,支持「全库修复+重刮」「刮削队列」批量处理。
|
||||
|
||||

|
||||
|
||||
库内海报墙带评分、集数角标,支持按最后集添加日期排序,点开即看。
|
||||
|
||||
**3. 详情页与多季管理**
|
||||
|
||||

|
||||
|
||||
剧情简介、类型标签、多季分集(特别篇/第 1-N 季)、每集缩略图与时长;一键立即播放、调用外部播放器、加入收藏。
|
||||
|
||||
**4. Emby/Jellyfin 客户端无缝兼容**
|
||||
|
||||
这是我最想强调的一点:**MeBox 内置了完整的 Emby 服务端协议实现**。手机、电视、平板上的 Infuse、SenPlayer、Fileball,甚至 Emby/Jellyfin 官方客户端,都不需要任何插件或改造——按「添加 Emby 服务器」填入 `http://服务器IP:18080`,用 MeBox 账号登录,海报墙、观看进度、收藏、多用户权限全部无缝衔接。已经习惯 Emby 生态的朋友可以零成本迁移,家人只用电视端 App 也完全无感。
|
||||
|
||||
**5. 网页播放器 + 弹幕自动匹配**
|
||||
|
||||

|
||||
|
||||
内置网页播放器支持 HLS 转码、字幕、播放配置档;**弹幕按剧名自动匹配全季分集**(截图中自动匹配到《一拳超人》39 集),屏幕占比/透明度/字号随意调,追新番体验直接拉满。
|
||||
|
||||
**6. 网盘 STRM:网盘当本地盘用**
|
||||
|
||||

|
||||
|
||||
添加网盘账号(**115 支持二维码扫码登录**)→ 添加同步目录 → 系统把网盘/本地目录里的视频生成 `.strm` 文件,元数据经下载/上传队列双向同步,播放走直链/302 不落盘。
|
||||
|
||||
**7. 远程 Emby 挂载(特色功能)**
|
||||
|
||||

|
||||
|
||||
已有远程 Emby 服务器?填一次账号,按需勾选要挂载的媒体库(支持同服务器多线路自动切换、直连开关、排序),远程库直接出现在 MeBox 首页,不必再开 Emby 客户端。
|
||||
|
||||
**8. 任务队列统一管理**
|
||||
|
||||

|
||||
|
||||
刮削 / 下载 / 上传三类任务统一看板,排队中、进行中、已匹配、失败分类计数,支持搜索与批量清理。
|
||||
|
||||
**9. 下载与自动整理**
|
||||
|
||||

|
||||
|
||||
配合任意下载器(qBittorrent、Transmission 等下载到本地目录即可),MeBox 定时自动整理入媒体库:智能分类子库、自动注册目的地媒体库、复制/移动/硬链/软链多种整理方式,命名规则可配。
|
||||
|
||||
**10. 多用户与权限**
|
||||
|
||||

|
||||
|
||||
管理员/普通用户分级、单实例用户数上限、账号有效期、成人内容开关、播放配置 PIN——给家人开号放心给。
|
||||
|
||||
**11. 运维省心**
|
||||
|
||||

|
||||
|
||||
FFmpeg/FFprobe 一键下载安装、转码与硬件加速开关、TMDb 语言、识别词、弹幕、Adult/NSFW 开关全在设置页分组管理;另有 DLNA 投屏、存储统计、海报墙聚合视图:
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 使用教程:从零到海报墙只要 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 ⭐,也欢迎论坛里的朋友反馈使用体验,我长期维护。
|
||||
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 620 KiB |
|
After Width: | Height: | Size: 743 KiB |
|
After Width: | Height: | Size: 793 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 612 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 376 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
@@ -120,6 +120,25 @@ func changePasswordHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func temporaryPasswordHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid, ok := c.Get(middleware.CtxUserID)
|
||||
if !ok || uid == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
code, expireSec, err := svc.Auth.CreateTemporaryPassword(c.Request.Context(), uid.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": code,
|
||||
"expires_in": expireSec,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +257,10 @@ func embyClientInfoFromRequest(c *gin.Context) embyClientInfo {
|
||||
firstHeaderValue(c, "X-Emby-Client", "X-MediaBrowser-Client"),
|
||||
c.Query("Client"),
|
||||
c.Query("client"),
|
||||
c.Query("X-Emby-Client"),
|
||||
c.Query("X-MediaBrowser-Client"),
|
||||
auth["Client"],
|
||||
auth["client"],
|
||||
),
|
||||
}
|
||||
ua := strings.TrimSpace(c.GetHeader("User-Agent"))
|
||||
@@ -315,10 +318,36 @@ func embyClientFromUserAgent(ua string) string {
|
||||
return "Emby"
|
||||
case strings.Contains(lower, "jellyfin"):
|
||||
return "Jellyfin"
|
||||
case strings.Contains(lower, "capyplayer") || strings.Contains(lower, "capy player") || strings.Contains(lower, "卡皮巴拉"):
|
||||
return "CapyPlayer"
|
||||
case strings.Contains(lower, "senplayer") || strings.Contains(lower, "sen player") || strings.Contains(lower, "森播"):
|
||||
return "SenPlayer"
|
||||
case strings.Contains(lower, "yamby"):
|
||||
return "Yamby"
|
||||
case strings.Contains(lower, "vidhub"):
|
||||
return "VidHub"
|
||||
case strings.Contains(lower, "fileball"):
|
||||
return "Fileball"
|
||||
case strings.Contains(lower, "hamhub"):
|
||||
return "HamHub"
|
||||
case strings.Contains(lower, "afusekt") || strings.Contains(lower, "afuse"):
|
||||
return "AfuseKt"
|
||||
case strings.Contains(lower, "cony"):
|
||||
return "Cony"
|
||||
case strings.Contains(lower, "kodi"):
|
||||
return "Kodi"
|
||||
case strings.Contains(lower, "mrmc"):
|
||||
return "MrMC"
|
||||
case strings.Contains(lower, "forward"):
|
||||
return "Forward"
|
||||
case strings.Contains(lower, "alpha"):
|
||||
return "Alpha"
|
||||
case strings.Contains(lower, "dandanplay") || strings.Contains(lower, "弹弹play"):
|
||||
return "DanDanPlay"
|
||||
case strings.Contains(lower, "potplayer"):
|
||||
return "PotPlayer"
|
||||
case strings.Contains(lower, "vlc"):
|
||||
return "VLC"
|
||||
case strings.Contains(lower, "hills"):
|
||||
return "Hills"
|
||||
default:
|
||||
@@ -343,7 +372,7 @@ func embyDeviceFromUserAgent(ua string) string {
|
||||
return "Mac"
|
||||
case strings.Contains(lower, "linux"):
|
||||
return "Linux PC"
|
||||
case strings.Contains(lower, "appletv") || strings.Contains(lower, "apple tv"):
|
||||
case strings.Contains(lower, "appletv") || strings.Contains(lower, "apple tv") || strings.Contains(lower, "appletvos"):
|
||||
return "Apple TV"
|
||||
default:
|
||||
return ""
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func TestNormalizeEmbyPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantPath string
|
||||
changed bool
|
||||
}{
|
||||
{
|
||||
input: "/emby/System/Info",
|
||||
wantPath: "/emby/system/info",
|
||||
changed: true,
|
||||
},
|
||||
{
|
||||
input: "/emby/emby/System/Info",
|
||||
wantPath: "/emby/system/info",
|
||||
changed: true,
|
||||
},
|
||||
{
|
||||
input: "/emby/emby/emby/items/123/playbackInfo",
|
||||
wantPath: "/emby/items/123/playbackinfo",
|
||||
changed: true,
|
||||
},
|
||||
{
|
||||
input: "//emby//System//Info//Public",
|
||||
wantPath: "/emby/system/info/public",
|
||||
changed: true,
|
||||
},
|
||||
{
|
||||
input: "/Items/msgo-series-1/PlaybackInfo",
|
||||
wantPath: "/items/msgo-series-1/playbackinfo",
|
||||
changed: true,
|
||||
},
|
||||
{
|
||||
input: "/Videos/m-123/Master.m3u8",
|
||||
wantPath: "/videos/m-123/master.m3u8",
|
||||
changed: true,
|
||||
},
|
||||
{
|
||||
input: "/api/unknown/other",
|
||||
wantPath: "/api/unknown/other",
|
||||
changed: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
gotPath, changed := NormalizeEmbyPath(tt.input)
|
||||
if gotPath != tt.wantPath || changed != tt.changed {
|
||||
t.Errorf("NormalizeEmbyPath(%q) = (%q, %v), want (%q, %v)", tt.input, gotPath, changed, tt.wantPath, tt.changed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyDuplicatePrefixHandling(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(nil, nil, repos),
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "secret", svc)
|
||||
|
||||
// 模拟重复拼接前缀的客户端请求: /emby/emby/System/Info/Public
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/emby/System/Info/Public", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for /emby/emby/System/Info/Public, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "ServerName") {
|
||||
t.Fatalf("expected server info body, got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyMixedCaseHandling(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(nil, nil, repos),
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "secret", svc)
|
||||
|
||||
// 混合大小写驼峰: /emby/system/Info/Public
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/system/Info/Public", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for /emby/system/Info/Public, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyClientIdentification(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ua string
|
||||
query string
|
||||
headerAuth string
|
||||
wantClient string
|
||||
}{
|
||||
{
|
||||
name: "CapyPlayer via UA",
|
||||
ua: "CapyPlayer/1.2.0 (iOS)",
|
||||
wantClient: "CapyPlayer",
|
||||
},
|
||||
{
|
||||
name: "SenPlayer via UA",
|
||||
ua: "SenPlayer/2.1",
|
||||
wantClient: "SenPlayer",
|
||||
},
|
||||
{
|
||||
name: "Fileball via UA",
|
||||
ua: "Fileball/1.0.0",
|
||||
wantClient: "Fileball",
|
||||
},
|
||||
{
|
||||
name: "Kodi via UA",
|
||||
ua: "Kodi/20.2",
|
||||
wantClient: "Kodi",
|
||||
},
|
||||
{
|
||||
name: "Client in query",
|
||||
ua: "CustomApp/1.0",
|
||||
query: "?X-Emby-Client=CapyPlayer",
|
||||
wantClient: "CapyPlayer",
|
||||
},
|
||||
{
|
||||
name: "Client in auth header",
|
||||
ua: "Custom/1.0",
|
||||
headerAuth: `MediaBrowser Client="SenPlayer", Device="AppleTV", DeviceId="abc"`,
|
||||
wantClient: "SenPlayer",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
url := "/test"
|
||||
if tt.query != "" {
|
||||
url += tt.query
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
if tt.ua != "" {
|
||||
req.Header.Set("User-Agent", tt.ua)
|
||||
}
|
||||
if tt.headerAuth != "" {
|
||||
req.Header.Set("X-Emby-Authorization", tt.headerAuth)
|
||||
}
|
||||
c.Request = req
|
||||
|
||||
info := embyClientInfoFromRequest(c)
|
||||
if info.Client != tt.wantClient {
|
||||
t.Fatalf("embyClientInfoFromRequest Client = %q, want %q", info.Client, tt.wantClient)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyAdaptivePrefixPlaybackInfo(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(nil, nil, repos),
|
||||
}
|
||||
const secret = "test-secret"
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, secret, svc)
|
||||
|
||||
token := signedTestToken(t, secret)
|
||||
if err := repos.User.Create(t.Context(), &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "tester",
|
||||
PasswordHash: "x",
|
||||
Role: "admin",
|
||||
Tier: "plus",
|
||||
IsActive: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{
|
||||
Base: model.Base{ID: "m-adaptive-1"},
|
||||
Title: "测试媒体",
|
||||
Path: "D:\\media\\test.mkv",
|
||||
LibraryID: "lib-1",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
// 1. 从 /emby 前缀请求 PlaybackInfo
|
||||
req1 := httptest.NewRequest(http.MethodGet, "/emby/Items/m-adaptive-1/PlaybackInfo", nil)
|
||||
req1.Header.Set("X-Emby-Token", token)
|
||||
w1 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w1, req1)
|
||||
|
||||
if w1.Code != http.StatusOK {
|
||||
t.Fatalf("playbackinfo /emby code = %d: %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
var res1 struct {
|
||||
MediaSources []struct {
|
||||
DirectStreamURL string `json:"DirectStreamUrl"`
|
||||
} `json:"MediaSources"`
|
||||
}
|
||||
if err := json.Unmarshal(w1.Body.Bytes(), &res1); err != nil || len(res1.MediaSources) == 0 {
|
||||
t.Fatalf("unmarshal /emby response: %v, body: %s", err, w1.Body.String())
|
||||
}
|
||||
if !strings.Contains(res1.MediaSources[0].DirectStreamURL, "/Videos/m-adaptive-1/stream") {
|
||||
t.Fatalf("DirectStreamUrl should point to video stream endpoint, got: %s", res1.MediaSources[0].DirectStreamURL)
|
||||
}
|
||||
if !strings.Contains(res1.MediaSources[0].DirectStreamURL, "api_key="+token) {
|
||||
t.Fatalf("DirectStreamUrl should carry api_key token, got: %s", res1.MediaSources[0].DirectStreamURL)
|
||||
}
|
||||
|
||||
// 2. 从重复前缀 /emby/emby 请求 PlaybackInfo (模拟客户端再次追加 BaseUrl 场景)
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/emby/emby/Items/m-adaptive-1/PlaybackInfo", nil)
|
||||
req2.Header.Set("X-Emby-Token", token)
|
||||
w2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w2, req2)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("playbackinfo /emby/emby code = %d: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
var res2 struct {
|
||||
MediaSources []struct {
|
||||
DirectStreamURL string `json:"DirectStreamUrl"`
|
||||
} `json:"MediaSources"`
|
||||
}
|
||||
if err := json.Unmarshal(w2.Body.Bytes(), &res2); err != nil || len(res2.MediaSources) == 0 {
|
||||
t.Fatalf("unmarshal /emby/emby response: %v, body: %s", err, w2.Body.String())
|
||||
}
|
||||
|
||||
// 3. 从根路径 /Items 请求 PlaybackInfo
|
||||
req3 := httptest.NewRequest(http.MethodGet, "/Items/m-adaptive-1/PlaybackInfo", nil)
|
||||
req3.Header.Set("X-Emby-Token", token)
|
||||
w3 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w3, req3)
|
||||
|
||||
if w3.Code != http.StatusOK {
|
||||
t.Fatalf("playbackinfo root code = %d: %s", w3.Code, w3.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyImageClearNoStore(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Emby: service.NewEmbyService(nil, nil, repos),
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "secret", svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/emby/Items/non-existent-item/Images/Primary", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("placeholder image should return 200, got %d", w.Code)
|
||||
}
|
||||
cacheControl := w.Header().Get("Cache-Control")
|
||||
if strings.Contains(cacheControl, "no-store") {
|
||||
t.Fatalf("image response should not have no-store, got: %s", cacheControl)
|
||||
}
|
||||
if !strings.Contains(cacheControl, "public") {
|
||||
t.Fatalf("image response should have public cache-control, got: %s", cacheControl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyTemporaryPasswordLogin(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-jwt-secret-very-secure-key-12345"
|
||||
log := zap.NewNop()
|
||||
permissions := service.NewPermissionService(log, repos)
|
||||
tokenSvc := service.NewTokenService(cfg, log, repos)
|
||||
authSvc := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
|
||||
embySvc := service.NewEmbyService(nil, nil, repos)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Auth: authSvc,
|
||||
Token: tokenSvc,
|
||||
Emby: embySvc,
|
||||
}
|
||||
|
||||
user, _, err := authSvc.Register(t.Context(), "tvuser", "strongpassword123")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
// 1. 生成 6 位纯数字临时密码 (OTP)
|
||||
code, expireSec, err := authSvc.CreateTemporaryPassword(t.Context(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create temp password: %v", err)
|
||||
}
|
||||
if len(code) != 6 || expireSec <= 0 {
|
||||
t.Fatalf("invalid temp password format: %s, expire: %d", code, expireSec)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, "test-jwt-secret-very-secure-key-12345", svc)
|
||||
|
||||
// 2. 使用临时密码在 Emby 接口登录
|
||||
body := `{"Username":"tvuser","Pw":"` + code + `"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("login with temp password code = %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var loginResp struct {
|
||||
AccessToken string `json:"AccessToken"`
|
||||
User struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
} `json:"User"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &loginResp); err != nil {
|
||||
t.Fatalf("unmarshal login resp: %v", err)
|
||||
}
|
||||
if loginResp.AccessToken == "" || loginResp.User.ID != user.ID {
|
||||
t.Fatalf("unexpected login payload: %#v", loginResp)
|
||||
}
|
||||
|
||||
// 3. 验证阅后即焚:第二次使用同一临时密码应登录失败 (401)
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/emby/Users/AuthenticateByName", strings.NewReader(body))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
w2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(w2, req2)
|
||||
|
||||
if w2.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("second login with consumed temp password should fail, got %d", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbySeriesArtworkInheritanceAndRunTimeTicksFallback(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret-compat"
|
||||
log := zap.NewNop()
|
||||
permissions := service.NewPermissionService(log, repos)
|
||||
tokenSvc := service.NewTokenService(cfg, log, repos)
|
||||
authSvc := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
|
||||
embySvc := service.NewEmbyService(nil, nil, repos)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Auth: authSvc,
|
||||
Token: tokenSvc,
|
||||
Emby: embySvc,
|
||||
}
|
||||
|
||||
user, _, err := authSvc.Register(t.Context(), "artworkuser", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
token, err := authSvc.IssueEmbyToken(user)
|
||||
if err != nil {
|
||||
t.Fatalf("issue token: %v", err)
|
||||
}
|
||||
|
||||
// 创建 TV Library
|
||||
lib := &model.Library{
|
||||
Name: "电视剧",
|
||||
Path: "/media/电视剧",
|
||||
Type: "tv",
|
||||
}
|
||||
lib.ID = "lib-tv-1"
|
||||
if err := db.Create(lib).Error; err != nil {
|
||||
t.Fatalf("create lib: %v", err)
|
||||
}
|
||||
|
||||
// 创建 Series
|
||||
series := &model.Series{
|
||||
LibraryID: "lib-tv-1",
|
||||
Title: "Test Drama",
|
||||
PosterURL: "https://example.com/series_poster.jpg",
|
||||
BackdropURL: "https://example.com/series_backdrop.jpg",
|
||||
}
|
||||
series.ID = "s-test-1"
|
||||
if err := db.Create(series).Error; err != nil {
|
||||
t.Fatalf("create series: %v", err)
|
||||
}
|
||||
|
||||
// 创建单集 Episode(DurationSec 为 0,但有播放进度 posMs,用于测试 RunTimeTicks 兜底)
|
||||
ep := &model.Media{
|
||||
LibraryID: "lib-tv-1",
|
||||
SeriesID: "s-test-1",
|
||||
Title: "Test Episode 1",
|
||||
Path: "/media/电视剧/Test Drama/Season 1/S01E01.mp4",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
DurationSec: 0, // 未知时长
|
||||
PosterURL: "https://example.com/ep1_still.jpg",
|
||||
}
|
||||
ep.ID = "ep-test-1"
|
||||
if err := db.Create(ep).Error; err != nil {
|
||||
t.Fatalf("create ep: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, svc)
|
||||
|
||||
// 1. 获取 Series 详情
|
||||
reqSeries := httptest.NewRequest(http.MethodGet, "/emby/Items/s-test-1", nil)
|
||||
reqSeries.Header.Set("X-Emby-Token", token)
|
||||
wSeries := httptest.NewRecorder()
|
||||
router.ServeHTTP(wSeries, reqSeries)
|
||||
if wSeries.Code != http.StatusOK {
|
||||
t.Fatalf("get series code = %d: %s", wSeries.Code, wSeries.Body.String())
|
||||
}
|
||||
var seriesPayload map[string]any
|
||||
_ = json.Unmarshal(wSeries.Body.Bytes(), &seriesPayload)
|
||||
if seriesPayload["PrimaryImageTag"] != "s-test-1" {
|
||||
t.Fatalf("series PrimaryImageTag should match series ID, got %v", seriesPayload["PrimaryImageTag"])
|
||||
}
|
||||
if _, ok := seriesPayload["People"]; !ok {
|
||||
t.Fatalf("series payload should include People array")
|
||||
}
|
||||
|
||||
// 2. 获取 Episode 详情,验证继承 SeriesPrimaryImageTag 和 ParentBackdropItemId
|
||||
// 添加一条播放进度记录 (posMs = 60000)
|
||||
hist := &model.PlaybackHistory{
|
||||
UserID: user.ID,
|
||||
MediaID: ep.ID,
|
||||
PositionMs: 60000,
|
||||
}
|
||||
_ = db.Create(hist).Error
|
||||
|
||||
reqEp := httptest.NewRequest(http.MethodGet, "/emby/Users/"+user.ID+"/Items/ep-test-1", nil)
|
||||
reqEp.Header.Set("X-Emby-Token", token)
|
||||
wEp := httptest.NewRecorder()
|
||||
router.ServeHTTP(wEp, reqEp)
|
||||
if wEp.Code != http.StatusOK {
|
||||
t.Fatalf("get ep code = %d: %s", wEp.Code, wEp.Body.String())
|
||||
}
|
||||
var epPayload map[string]any
|
||||
_ = json.Unmarshal(wEp.Body.Bytes(), &epPayload)
|
||||
t.Logf("epPayload: %#v", epPayload)
|
||||
|
||||
// 验证图片继承
|
||||
if epPayload["SeriesPrimaryImageTag"] != "s-test-1" {
|
||||
t.Fatalf("ep SeriesPrimaryImageTag should inherit series ID, got %v", epPayload["SeriesPrimaryImageTag"])
|
||||
}
|
||||
if epPayload["ParentBackdropItemId"] != "s-test-1" {
|
||||
t.Fatalf("ep ParentBackdropItemId should inherit series ID, got %v", epPayload["ParentBackdropItemId"])
|
||||
}
|
||||
if _, ok := epPayload["People"]; !ok {
|
||||
t.Fatalf("ep payload should include People array")
|
||||
}
|
||||
|
||||
// 验证 RunTimeTicks 兜底
|
||||
runTimeTicks, _ := epPayload["RunTimeTicks"].(float64)
|
||||
if runTimeTicks <= 0 {
|
||||
t.Fatalf("ep RunTimeTicks should be safely fallback to positive value, got %v", runTimeTicks)
|
||||
}
|
||||
userData, _ := epPayload["UserData"].(map[string]any)
|
||||
playedPct, _ := userData["PlayedPercentage"].(float64)
|
||||
if playedPct <= 0 {
|
||||
t.Fatalf("ep PlayedPercentage should be > 0, got %v", playedPct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeTemporaryPasswordEndpoint(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret-temp"
|
||||
log := zap.NewNop()
|
||||
permissions := service.NewPermissionService(log, repos)
|
||||
tokenSvc := service.NewTokenService(cfg, log, repos)
|
||||
authSvc := service.NewAuthService(cfg, log, repos, tokenSvc, permissions)
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Auth: authSvc,
|
||||
Token: tokenSvc,
|
||||
}
|
||||
|
||||
user, tokens, err := authSvc.Register(t.Context(), "optuser", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
api := router.Group("/api")
|
||||
authed := api.Group("")
|
||||
authed.Use(func(c *gin.Context) {
|
||||
c.Set(middleware.CtxUserID, user.ID)
|
||||
c.Next()
|
||||
})
|
||||
registerAuthedUserAndLicenseRoutes(authed, svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/me/temporary-password", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("generate temp password code = %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Code string `json:"code"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal resp: %v", err)
|
||||
}
|
||||
if len(resp.Code) != 6 || resp.ExpiresIn <= 0 {
|
||||
t.Fatalf("invalid temp password resp: %#v", resp)
|
||||
}
|
||||
|
||||
// 验证使用生成的临时密码能登录
|
||||
loginResp, err := authSvc.LoginWithTemporaryPassword(t.Context(), "optuser", resp.Code)
|
||||
if err != nil || loginResp == nil || loginResp.User.ID != user.ID {
|
||||
t.Fatalf("login with temp pass failed: %v", err)
|
||||
}
|
||||
_ = tokens
|
||||
}
|
||||
@@ -51,13 +51,14 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
func clearEmbyImageNoStoreHeaders(c *gin.Context) {
|
||||
c.Writer.Header().Del("Cache-Control")
|
||||
c.Writer.Header().Del("Pragma")
|
||||
c.Writer.Header().Del("Expires")
|
||||
}
|
||||
|
||||
func embyServePlaceholderImage(c *gin.Context) {
|
||||
c.Header("Content-Type", "image/png")
|
||||
c.Header("Cache-Control", "public, max-age=3600")
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.Header("Content-Length", strconv.Itoa(len(embyPlaceholderPNG)))
|
||||
if c.Request.Method == http.MethodHead {
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
multipleSlashesRE = regexp.MustCompile(`/{2,}`)
|
||||
)
|
||||
|
||||
// embyStaticSegments 包含 Emby API 中已知的保留静态路由分段(全部小写),
|
||||
// 用于在遇到客户端混合大小写(如 /System/info, /items/:id/playbackInfo)时,
|
||||
// 将静态段规范化为对应的小写形式,而保留动态参数段(:id, :userId 等)的原大小写。
|
||||
var embyStaticSegments = map[string]struct{}{
|
||||
"system": {},
|
||||
"info": {},
|
||||
"public": {},
|
||||
"endpoint": {},
|
||||
"configuration": {},
|
||||
"ping": {},
|
||||
"users": {},
|
||||
"useritems": {},
|
||||
"me": {},
|
||||
"authenticatebyname": {},
|
||||
"items": {},
|
||||
"counts": {},
|
||||
"latest": {},
|
||||
"resume": {},
|
||||
"playbackinfo": {},
|
||||
"shows": {},
|
||||
"seasons": {},
|
||||
"episodes": {},
|
||||
"nextup": {},
|
||||
"upcoming": {},
|
||||
"similar": {},
|
||||
"thumbnailset": {},
|
||||
"thememedia": {},
|
||||
"specialfeatures": {},
|
||||
"intros": {},
|
||||
"videos": {},
|
||||
"stream": {},
|
||||
"subtitles": {},
|
||||
"master.m3u8": {},
|
||||
"main.m3u8": {},
|
||||
"sessions": {},
|
||||
"playing": {},
|
||||
"progress": {},
|
||||
"stopped": {},
|
||||
"capabilities": {},
|
||||
"full": {},
|
||||
"logout": {},
|
||||
"views": {},
|
||||
"library": {},
|
||||
"mediafolders": {},
|
||||
"virtualfolders": {},
|
||||
"selectablemediafolders": {},
|
||||
"branding": {},
|
||||
"css": {},
|
||||
"localization": {},
|
||||
"options": {},
|
||||
"cultures": {},
|
||||
"customcssjs": {},
|
||||
"scripts": {},
|
||||
"displaypreferences": {},
|
||||
"quickconnect": {},
|
||||
"enabled": {},
|
||||
"startup": {},
|
||||
"complete": {},
|
||||
"favoriteitems": {},
|
||||
"playeditems": {},
|
||||
"images": {},
|
||||
"primary": {},
|
||||
"backdrop": {},
|
||||
"banner": {},
|
||||
"thumb": {},
|
||||
"logo": {},
|
||||
"serverdomains": {},
|
||||
"ext": {},
|
||||
"danmu": {},
|
||||
"raw": {},
|
||||
"mediasegments": {},
|
||||
"artists": {},
|
||||
"persons": {},
|
||||
"genres": {},
|
||||
"embywebsocket": {},
|
||||
}
|
||||
|
||||
// NormalizeEmbyPath 规范化 Emby 请求路径:
|
||||
// 1. 折叠重复斜杠(如 //emby/ -> /emby/);
|
||||
// 2. 折叠重复前缀(如 /emby/emby/System/Info -> /emby/System/Info);
|
||||
// 3. 将静态关键字段归一化为小写,同时保留动态 ID/参数的原有大小写。
|
||||
func NormalizeEmbyPath(p string) (string, bool) {
|
||||
if p == "" {
|
||||
return "", false
|
||||
}
|
||||
original := p
|
||||
|
||||
// 1. 折叠多余斜杠
|
||||
p = multipleSlashesRE.ReplaceAllString(p, "/")
|
||||
|
||||
// 2. 折叠重复的 /emby 前缀
|
||||
for {
|
||||
lower := strings.ToLower(p)
|
||||
if strings.HasPrefix(lower, "/emby/emby/") {
|
||||
p = "/emby/" + p[len("/emby/emby/"):]
|
||||
continue
|
||||
}
|
||||
if lower == "/emby/emby" {
|
||||
p = "/emby"
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// 3. 分析是否具有 Emby 路由特征
|
||||
hasEmbyPrefix := false
|
||||
workPath := p
|
||||
if strings.HasPrefix(strings.ToLower(workPath), "/emby/") {
|
||||
hasEmbyPrefix = true
|
||||
workPath = workPath[len("/emby"):]
|
||||
} else if strings.EqualFold(workPath, "/emby") {
|
||||
return "/emby", original != "/emby"
|
||||
}
|
||||
|
||||
segments := strings.Split(strings.Trim(workPath, "/"), "/")
|
||||
if len(segments) == 0 || segments[0] == "" {
|
||||
return p, p != original
|
||||
}
|
||||
|
||||
// 检查第一段是否为 Emby 根路由关键字
|
||||
firstLower := strings.ToLower(segments[0])
|
||||
if _, ok := embyStaticSegments[firstLower]; !ok && firstLower != "api" {
|
||||
// 不是 Emby 相关路径,保持原样
|
||||
return original, false
|
||||
}
|
||||
|
||||
// 4. 将已知静态段转为小写,动态段保留原样
|
||||
for i, seg := range segments {
|
||||
segLower := strings.ToLower(seg)
|
||||
if _, isStatic := embyStaticSegments[segLower]; isStatic {
|
||||
if seg != segLower {
|
||||
segments[i] = segLower
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
if hasEmbyPrefix {
|
||||
builder.WriteString("/emby")
|
||||
}
|
||||
for _, seg := range segments {
|
||||
builder.WriteString("/")
|
||||
builder.WriteString(seg)
|
||||
}
|
||||
if strings.HasSuffix(original, "/") && !strings.HasSuffix(builder.String(), "/") {
|
||||
builder.WriteString("/")
|
||||
}
|
||||
|
||||
normalized := builder.String()
|
||||
return normalized, normalized != original
|
||||
}
|
||||
|
||||
const embyNormalizedCtxKey = "emby_normalized_path"
|
||||
|
||||
// TryHandleEmbyNormalizedRoute 尝试在 404 NoRoute 阶段对 Emby 路径做前缀与大小写纠偏并重定向分发。
|
||||
// 若成功分发并处理,返回 true;否则返回 false。
|
||||
func TryHandleEmbyNormalizedRoute(c *gin.Context, r *gin.Engine) bool {
|
||||
if c == nil || r == nil {
|
||||
return false
|
||||
}
|
||||
if c.GetBool(embyNormalizedCtxKey) {
|
||||
return false
|
||||
}
|
||||
normalized, changed := NormalizeEmbyPath(c.Request.URL.Path)
|
||||
if !changed {
|
||||
return false
|
||||
}
|
||||
|
||||
c.Set(embyNormalizedCtxKey, true)
|
||||
c.Request.URL.Path = normalized
|
||||
|
||||
// 重置 context 状态并由 engine 重新查找路由树
|
||||
c.Params = nil
|
||||
c.Writer.Header().Del("Content-Type")
|
||||
r.HandleContext(c)
|
||||
return true
|
||||
}
|
||||
@@ -23,6 +23,14 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container)
|
||||
auth := grp.Group("", embyAuthRequiredWithSessionFallback(jwtSecret), activeEmbyUserRequired(svc), embyRealtimeSessionActivity(svc))
|
||||
registerEmbyAuthenticatedRoutes(auth, prefix, svc)
|
||||
}
|
||||
|
||||
// 为没有设置 NoRoute 的 Engine 提供 Emby 路径前缀与大小写纠偏(如单测或独立挂载环境)
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
if TryHandleEmbyNormalizedRoute(c, r) {
|
||||
return
|
||||
}
|
||||
c.Status(404)
|
||||
})
|
||||
}
|
||||
|
||||
type embyRouteHandlerFactory func(*service.Container) gin.HandlerFunc
|
||||
|
||||
@@ -34,11 +34,18 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
|
||||
embyError(c, http.StatusBadRequest, "missing username or password")
|
||||
return
|
||||
}
|
||||
resp, err := svc.Auth.Login(c.Request.Context(), req.Username, password)
|
||||
if err != nil {
|
||||
embyError(c, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
resp, err := svc.Auth.Login(c.Request.Context(), req.Username, password)
|
||||
if err != nil {
|
||||
// 支持电视端/客户端一次性 6 位临时密码登录 (OTP)
|
||||
if tempResp, tempErr := svc.Auth.LoginWithTemporaryPassword(c.Request.Context(), req.Username, password); tempErr == nil {
|
||||
resp = tempResp
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
embyError(c, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
// 记录登录设备会话并执行防共享检测(登录客户端数 / 设备指纹)。
|
||||
clientInfo := embyClientInfoFromRequest(c)
|
||||
if svc.Sessions != nil {
|
||||
|
||||
@@ -170,7 +170,13 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if acct == nil {
|
||||
return
|
||||
}
|
||||
tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}}
|
||||
tmpMount := &model.EmbyMount{
|
||||
Base: model.Base{ID: v.MountID},
|
||||
AccountID: v.AccountID,
|
||||
RemoteViewID: v.RemoteID,
|
||||
CollectionType: v.CollectionType,
|
||||
Name: v.Library.Name,
|
||||
}
|
||||
itemTypes := remoteLibraryItemTypes(v.CollectionType)
|
||||
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
|
||||
remotePayloads[i].Total = total
|
||||
@@ -482,7 +488,7 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusOK, m)
|
||||
return
|
||||
}
|
||||
m, err := svc.Media.GetMedia(ctx, id)
|
||||
m, err := svc.Media.GetMediaItem(ctx, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -491,7 +497,7 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if !mediaVisibleForRequest(c, svc, m) {
|
||||
if !mediaVisibleForRequest(c, svc, &m.Media) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
@@ -660,14 +666,25 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
return
|
||||
}
|
||||
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 现代浏览器在 HTTPS 页面中请求不安全源(HTTP 视频流)会直接报 Mixed Content 拦截导致播放失败。
|
||||
// 仅当当前前端请求为 HTTPS 且远程直连目标为 HTTP 时,自动降级通过本机反向代理传输流,避免播放被浏览器阻断;
|
||||
// 其它场景(HTTP 页面访问 HTTP/HTTPS,或 HTTPS 访问 HTTPS)继续 302 直连,最大化节省服务器带宽与流量。
|
||||
if requestIsHTTPS(c) && strings.HasPrefix(strings.ToLower(target), "http://") {
|
||||
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
setRedirectNoStoreHeaders(c)
|
||||
c.Redirect(http.StatusFound, target)
|
||||
return
|
||||
}
|
||||
setRedirectNoStoreHeaders(c)
|
||||
c.Redirect(http.StatusFound, target)
|
||||
return
|
||||
}
|
||||
m, err := svc.Media.GetMedia(ctx, id)
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
|
||||
@@ -45,8 +45,8 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
token := externalPlaybackToken(c, svc, m.ID, m.DurationSec)
|
||||
profileQuery := externalProfileQuery(c)
|
||||
hlsURL := "/api/hls/" + m.ID + "/index.m3u8?token=" + url.QueryEscape(token) + profileQuery
|
||||
if service.IsEmbyRemoteID(m.ID) || service.IsStrmMediaRow(m) {
|
||||
// Emby 远程挂载与 STRM 媒体一样,默认直连播放,不提供转码地址
|
||||
if service.IsEmbyRemoteID(m.ID) {
|
||||
// 远程 Emby 挂载没有本地文件,不能提供转码地址。STRM 默认直连,直连失败时可走 HLS。
|
||||
hlsURL = ""
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
||||
@@ -413,7 +413,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
|
||||
return router, svc, cfg.Secrets.JWTSecret
|
||||
}
|
||||
|
||||
func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
|
||||
func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
|
||||
router, _, secret := newPlaybackScopeTestRouter(t)
|
||||
loginToken := signedTestToken(t, secret)
|
||||
|
||||
@@ -435,8 +435,8 @@ func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
|
||||
if payload.StreamURL == "" {
|
||||
t.Fatalf("expected non-empty stream_url")
|
||||
}
|
||||
if payload.HlsURL != "" {
|
||||
t.Fatalf("expected empty hls_url for STRM media, got %q", payload.HlsURL)
|
||||
if payload.HlsURL == "" || !strings.Contains(payload.HlsURL, "/api/hls/media-1/") {
|
||||
t.Fatalf("expected strm hls_url, got %q", payload.HlsURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.DELETE("/strm/accounts/:id", deleteStrmAccountHandler(svc))
|
||||
admin.POST("/strm/accounts/:id/test", testStrmAccountHandler(svc))
|
||||
admin.GET("/strm/accounts/:id/list", listStrmRemoteDirHandler(svc))
|
||||
admin.GET("/strm/accounts/:id/resolve", resolveStrmRemoteDirHandler(svc))
|
||||
admin.GET("/strm/115/sources", listStrm115SourcesHandler(svc))
|
||||
admin.POST("/strm/accounts/:id/oauth/start", startStrm115OAuthHandler(svc))
|
||||
admin.POST("/strm/accounts/:id/oauth/poll", pollStrm115OAuthHandler(svc))
|
||||
|
||||
@@ -14,6 +14,8 @@ func registerAuthedUserAndLicenseRoutes(authed *gin.RouterGroup, svc *service.Co
|
||||
authed.PUT("/me/pinned-libraries", setPinnedLibrariesHandler(svc))
|
||||
authed.POST("/me/password", changePasswordHandler(svc))
|
||||
authed.POST("/me/logout", logoutHandler(svc))
|
||||
authed.GET("/me/temporary-password", temporaryPasswordHandler(svc))
|
||||
authed.POST("/me/temporary-password", temporaryPasswordHandler(svc))
|
||||
|
||||
authed.GET("/auth/permissions", getMyPermissionsHandler(svc))
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
// 远程 Emby 挂载媒体与 STRM 一样,默认直连播放,不进行转码。
|
||||
// 远程 Emby 挂载没有本地文件,不能转码。STRM 允许直连失败后走 HLS。
|
||||
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
|
||||
return
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -172,6 +173,19 @@ func listStrmRemoteDirHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveStrmRemoteDirHandler 按远端目录引用(115 为目录 ID)反查完整展示路径。
|
||||
func resolveStrmRemoteDirHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
dir := strings.TrimSpace(c.Query("dir"))
|
||||
path, err := svc.Strm.ResolveRemoteDirPath(c.Request.Context(), c.Param("id"), dir)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"path": path})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 全局设置 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func getStrmSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
@@ -203,24 +217,26 @@ func updateStrmSettingsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// ─── 同步目录 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type strmSyncPathReq struct {
|
||||
Name string `json:"name"`
|
||||
AccountID string `json:"account_id"`
|
||||
Provider string `json:"provider"`
|
||||
RemotePath string `json:"remote_path"`
|
||||
LocalPath string `json:"local_path"`
|
||||
StrmBaseURL string `json:"strm_base_url"`
|
||||
VideoExt string `json:"video_ext"`
|
||||
MetaExt string `json:"meta_ext"`
|
||||
ExcludeName string `json:"exclude_name"`
|
||||
MinVideoSizeMB int64 `json:"min_video_size_mb"`
|
||||
AddPath int `json:"add_path"`
|
||||
DownloadMeta *bool `json:"download_meta"`
|
||||
UploadMeta *bool `json:"upload_meta"`
|
||||
DeleteDir *bool `json:"delete_dir"`
|
||||
Cron string `json:"cron"`
|
||||
EnableCron *bool `json:"enable_cron"`
|
||||
SyncMode string `json:"sync_mode"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name string `json:"name"`
|
||||
AccountID string `json:"account_id"`
|
||||
Provider string `json:"provider"`
|
||||
RemotePath string `json:"remote_path"`
|
||||
RemoteDisplayPath string `json:"remote_display_path"`
|
||||
LocalPath string `json:"local_path"`
|
||||
StrmBaseURL string `json:"strm_base_url"`
|
||||
VideoExt string `json:"video_ext"`
|
||||
MetaExt string `json:"meta_ext"`
|
||||
ExcludeName string `json:"exclude_name"`
|
||||
MinVideoSizeMB int64 `json:"min_video_size_mb"`
|
||||
AddPath int `json:"add_path"`
|
||||
DownloadMeta *bool `json:"download_meta"`
|
||||
UploadMeta *bool `json:"upload_meta"`
|
||||
DeleteDir *bool `json:"delete_dir"`
|
||||
KeepExt *bool `json:"keep_ext"`
|
||||
Cron string `json:"cron"`
|
||||
EnableCron *bool `json:"enable_cron"`
|
||||
SyncMode string `json:"sync_mode"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type strmSyncPathView struct {
|
||||
@@ -240,6 +256,16 @@ func strmSyncPathViews(svc *service.Container, c *gin.Context, paths []model.Str
|
||||
view.AccountEnabled = acct.Enabled
|
||||
}
|
||||
}
|
||||
// 历史 115 数据若尚未记录展示路径,尝试反查一次并回写数据库自愈
|
||||
if p.Provider == model.StrmProvider115 && strings.TrimSpace(p.RemoteDisplayPath) == "" && strings.TrimSpace(p.RemotePath) != "" && p.AccountID != "" {
|
||||
resolveCtx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||
if fullPath, err := svc.Strm.ResolveRemoteDirPath(resolveCtx, p.AccountID, p.RemotePath); err == nil && fullPath != "" {
|
||||
view.RemoteDisplayPath = fullPath
|
||||
p.RemoteDisplayPath = fullPath
|
||||
_ = svc.Repo.StrmSyncPath.Update(context.Background(), &p)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
out = append(out, view)
|
||||
}
|
||||
return out
|
||||
@@ -663,24 +689,26 @@ func strmPlayHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// strmSyncPathFromReq 组装同步目录模型(缺省值交给服务层处理)。
|
||||
func strmSyncPathFromReq(req strmSyncPathReq) *model.StrmSyncPath {
|
||||
return &model.StrmSyncPath{
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
AccountID: strings.TrimSpace(req.AccountID),
|
||||
Provider: strings.TrimSpace(req.Provider),
|
||||
RemotePath: strings.TrimSpace(req.RemotePath),
|
||||
LocalPath: strings.TrimSpace(req.LocalPath),
|
||||
StrmBaseURL: strings.TrimSpace(req.StrmBaseURL),
|
||||
VideoExt: req.VideoExt,
|
||||
MetaExt: req.MetaExt,
|
||||
ExcludeName: req.ExcludeName,
|
||||
MinVideoSizeMB: req.MinVideoSizeMB,
|
||||
AddPath: req.AddPath,
|
||||
DownloadMeta: boolValue(req.DownloadMeta, true),
|
||||
UploadMeta: boolValue(req.UploadMeta, false),
|
||||
DeleteDir: boolValue(req.DeleteDir, false),
|
||||
Cron: strings.TrimSpace(req.Cron),
|
||||
EnableCron: boolValue(req.EnableCron, false),
|
||||
SyncMode: strings.TrimSpace(req.SyncMode),
|
||||
Enabled: boolValue(req.Enabled, true),
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
AccountID: strings.TrimSpace(req.AccountID),
|
||||
Provider: strings.TrimSpace(req.Provider),
|
||||
RemotePath: strings.TrimSpace(req.RemotePath),
|
||||
RemoteDisplayPath: strings.TrimSpace(req.RemoteDisplayPath),
|
||||
LocalPath: strings.TrimSpace(req.LocalPath),
|
||||
StrmBaseURL: strings.TrimSpace(req.StrmBaseURL),
|
||||
VideoExt: req.VideoExt,
|
||||
MetaExt: req.MetaExt,
|
||||
ExcludeName: req.ExcludeName,
|
||||
MinVideoSizeMB: req.MinVideoSizeMB,
|
||||
AddPath: req.AddPath,
|
||||
DownloadMeta: boolValue(req.DownloadMeta, true),
|
||||
UploadMeta: boolValue(req.UploadMeta, false),
|
||||
DeleteDir: boolValue(req.DeleteDir, false),
|
||||
KeepExt: boolValue(req.KeepExt, false),
|
||||
Cron: strings.TrimSpace(req.Cron),
|
||||
EnableCron: boolValue(req.EnableCron, false),
|
||||
SyncMode: strings.TrimSpace(req.SyncMode),
|
||||
Enabled: boolValue(req.Enabled, true),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,16 +116,6 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
|
||||
{"key": "adult.pin", "type": "text"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "qbittorrent",
|
||||
"label": "qBittorrent",
|
||||
"items": []gin.H{
|
||||
{"key": "qbittorrent.url", "type": "text"},
|
||||
{"key": "qbittorrent.username", "type": "text"},
|
||||
{"key": "qbittorrent.password", "type": "text"},
|
||||
{"key": "qbittorrent.savepath", "type": "text"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": "system-update",
|
||||
"label": "系统更新",
|
||||
|
||||
@@ -36,7 +36,11 @@ type StrmSyncPath struct {
|
||||
AccountID string `gorm:"size:36;index" json:"account_id"` // StrmAccount.ID;local 为空
|
||||
Provider string `gorm:"size:32" json:"provider"` // StrmProvider*(冗余,便于列表展示)
|
||||
RemotePath string `gorm:"size:1024" json:"remote_path"` // 远端目录:115=目录ID,OpenList/CD2=路径,local=源目录
|
||||
LocalPath string `gorm:"size:1024" json:"local_path"` // STRM/元数据本地输出目录
|
||||
// RemoteDisplayPath 是远端目录的完整展示路径(如 /电影/剧集)。115 的
|
||||
// RemotePath 是目录 ID,用户无法辨认,浏览选择或按 ID 反查时把人类可读
|
||||
// 路径存到这里;路径型网盘(CD2/OpenList)与 local 留空(RemotePath 即路径)。
|
||||
RemoteDisplayPath string `gorm:"size:1024" json:"remote_display_path"`
|
||||
LocalPath string `gorm:"size:1024" json:"local_path"` // STRM/元数据本地输出目录
|
||||
// STRM 链接配置(空值继承全局 strm.* 设置)
|
||||
StrmBaseURL string `gorm:"size:512" json:"strm_base_url"` // 覆盖 strm.base_url
|
||||
VideoExt string `gorm:"size:512" json:"video_ext"` // 逗号分隔,覆盖 strm.video_ext
|
||||
@@ -47,6 +51,9 @@ type StrmSyncPath struct {
|
||||
DownloadMeta bool `gorm:"default:true" json:"download_meta"` // 同步时下载元数据文件(nfo/图片/字幕)
|
||||
UploadMeta bool `json:"upload_meta"` // 同步时把本地元数据上传到远端
|
||||
DeleteDir bool `json:"delete_dir"` // 清理多余文件时删除空目录
|
||||
// KeepExt=true 时为每个视频生成 name.mkv.strm / name.mp4.strm(保留全部版本);
|
||||
// false(默认)时同名不同扩展只择优生成一条 name.strm,避免互相覆盖与来回抖动。
|
||||
KeepExt bool `json:"keep_ext"`
|
||||
Cron string `gorm:"size:128" json:"cron"` // 5 段 cron 表达式(可选)
|
||||
EnableCron bool `json:"enable_cron"` // 是否按 Cron 定时同步
|
||||
SyncMode string `gorm:"size:32;default:'incremental'" json:"sync_mode"` // 默认同步模式:incremental / full
|
||||
@@ -124,6 +131,7 @@ type StrmUploadTask struct {
|
||||
FileName string `gorm:"size:512" json:"file_name"`
|
||||
LocalPath string `gorm:"size:1024" json:"local_path"` // 本地源文件
|
||||
RemotePath string `gorm:"size:1024" json:"remote_path"` // 远端目标路径
|
||||
RemoteRef string `gorm:"size:1024" json:"remote_ref"` // 上传前:远端同名旧文件 ID(逗号分隔,覆盖前先删);上传成功后:新文件 ID
|
||||
Size int64 `json:"size"`
|
||||
Status string `gorm:"size:16;index" json:"status"`
|
||||
Error string `gorm:"size:1024" json:"error"`
|
||||
|
||||
@@ -95,28 +95,30 @@ func (r *StrmSyncPathRepository) List(ctx context.Context) ([]model.StrmSyncPath
|
||||
func (r *StrmSyncPathRepository) Update(ctx context.Context, p *model.StrmSyncPath) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Model(&model.StrmSyncPath{}).Where("id = ?", p.ID).Updates(map[string]any{
|
||||
"name": p.Name,
|
||||
"account_id": p.AccountID,
|
||||
"provider": p.Provider,
|
||||
"remote_path": p.RemotePath,
|
||||
"local_path": p.LocalPath,
|
||||
"strm_base_url": p.StrmBaseURL,
|
||||
"video_ext": p.VideoExt,
|
||||
"meta_ext": p.MetaExt,
|
||||
"exclude_name": p.ExcludeName,
|
||||
"min_video_size_mb": p.MinVideoSizeMB,
|
||||
"add_path": p.AddPath,
|
||||
"download_meta": p.DownloadMeta,
|
||||
"upload_meta": p.UploadMeta,
|
||||
"delete_dir": p.DeleteDir,
|
||||
"cron": p.Cron,
|
||||
"enable_cron": p.EnableCron,
|
||||
"sync_mode": p.SyncMode,
|
||||
"enabled": p.Enabled,
|
||||
"last_sync_at": p.LastSyncAt,
|
||||
"last_sync_status": p.LastSyncStatus,
|
||||
"last_sync_message": p.LastSyncMessage,
|
||||
"updated_at": time.Now(),
|
||||
"name": p.Name,
|
||||
"account_id": p.AccountID,
|
||||
"provider": p.Provider,
|
||||
"remote_path": p.RemotePath,
|
||||
"remote_display_path": p.RemoteDisplayPath,
|
||||
"local_path": p.LocalPath,
|
||||
"strm_base_url": p.StrmBaseURL,
|
||||
"video_ext": p.VideoExt,
|
||||
"meta_ext": p.MetaExt,
|
||||
"exclude_name": p.ExcludeName,
|
||||
"min_video_size_mb": p.MinVideoSizeMB,
|
||||
"add_path": p.AddPath,
|
||||
"download_meta": p.DownloadMeta,
|
||||
"upload_meta": p.UploadMeta,
|
||||
"delete_dir": p.DeleteDir,
|
||||
"keep_ext": p.KeepExt,
|
||||
"cron": p.Cron,
|
||||
"enable_cron": p.EnableCron,
|
||||
"sync_mode": p.SyncMode,
|
||||
"enabled": p.Enabled,
|
||||
"last_sync_at": p.LastSyncAt,
|
||||
"last_sync_status": p.LastSyncStatus,
|
||||
"last_sync_message": p.LastSyncMessage,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
@@ -860,6 +862,34 @@ func (r *StrmUploadTaskRepository) GetActiveLocalPathMap(ctx context.Context, sy
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetRecentDoneUploadSizeMap 返回近期已成功上传的 local_path → size。
|
||||
// 用于缩短「上传已 done 但 115 列表尚未反映」窗口内的重复入队:同路径且大小未变则跳过。
|
||||
// 同一路径存在多条 done 时取最新一条(finished_at 降序)。
|
||||
func (r *StrmUploadTaskRepository) GetRecentDoneUploadSizeMap(ctx context.Context, syncPathID string, since time.Time) (map[string]int64, error) {
|
||||
var rows []model.StrmUploadTask
|
||||
err := r.db.WithContext(ctx).Model(&model.StrmUploadTask{}).
|
||||
Select("local_path", "size", "finished_at").
|
||||
Where("sync_path_id = ? AND status = ? AND finished_at IS NOT NULL AND finished_at >= ?",
|
||||
syncPathID, model.StrmTaskDone, since).
|
||||
Order("finished_at DESC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]int64, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.LocalPath == "" {
|
||||
continue
|
||||
}
|
||||
// 已按 finished_at DESC;先写入的是最新,后续同路径跳过
|
||||
if _, exists := out[row.LocalPath]; exists {
|
||||
continue
|
||||
}
|
||||
out[row.LocalPath] = row.Size
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *StrmUploadTaskRepository) DeleteFinishedOlderThan(ctx context.Context, before time.Time) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Unscoped().Where("status IN ? AND finished_at < ?",
|
||||
@@ -901,6 +931,53 @@ func (r *StrmDirCacheRepository) Set(ctx context.Context, syncPathID, dirID, pat
|
||||
})
|
||||
}
|
||||
|
||||
// SetBatch 批量 upsert 目录缓存(dirID → 相对路径)。单个事务内先查出已存在
|
||||
// 行再分流更新/插入,替代同步流程逐目录单条 Set,避免首次全量同步上万目录时
|
||||
// 的 SQLite 写锁竞争。同一 dirID 的重复项以 map 语义取最后一次写入。
|
||||
func (r *StrmDirCacheRepository) SetBatch(ctx context.Context, syncPathID string, paths map[string]string) error {
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
ids := make([]string, 0, len(paths))
|
||||
for dirID := range paths {
|
||||
ids = append(ids, dirID)
|
||||
}
|
||||
var existing []model.StrmDirCache
|
||||
if err := tx.Where("sync_path_id = ? AND dir_id IN ?", syncPathID, ids).Find(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
existingRowID := make(map[string]string, len(existing))
|
||||
for _, row := range existing {
|
||||
existingRowID[row.DirID] = row.ID
|
||||
}
|
||||
now := time.Now()
|
||||
var creates []model.StrmDirCache
|
||||
for dirID, path := range paths {
|
||||
if rowID, ok := existingRowID[dirID]; ok {
|
||||
if err := tx.Model(&model.StrmDirCache{}).Where("id = ?", rowID).Updates(map[string]any{
|
||||
"path": path,
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
creates = append(creates, model.StrmDirCache{
|
||||
SyncPathID: syncPathID,
|
||||
DirID: dirID,
|
||||
Path: path,
|
||||
})
|
||||
}
|
||||
if len(creates) > 0 {
|
||||
return tx.CreateInBatches(creates, 100).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (r *StrmDirCacheRepository) DeleteBySyncPathID(ctx context.Context, syncPathID string) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Unscoped().Where("sync_path_id = ?", syncPathID).Delete(&model.StrmDirCache{}).Error
|
||||
|
||||
@@ -3,10 +3,13 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -18,6 +21,12 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
type temporaryPasswordEntry struct {
|
||||
userID string
|
||||
username string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// AuthService handles registration, login, and JWT issuance.
|
||||
type AuthService struct {
|
||||
cfg *config.Config
|
||||
@@ -25,11 +34,21 @@ type AuthService struct {
|
||||
repo *repository.Container
|
||||
tokenSvc *TokenService
|
||||
permissionSvc *PermissionService
|
||||
|
||||
tempPassMu sync.RWMutex
|
||||
tempPasswords map[string]temporaryPasswordEntry
|
||||
}
|
||||
|
||||
// NewAuthService is the constructor.
|
||||
func NewAuthService(cfg *config.Config, log *zap.Logger, repo *repository.Container, tokenSvc *TokenService, permissionSvc *PermissionService) *AuthService {
|
||||
return &AuthService{cfg: cfg, log: log, repo: repo, tokenSvc: tokenSvc, permissionSvc: permissionSvc}
|
||||
return &AuthService{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
repo: repo,
|
||||
tokenSvc: tokenSvc,
|
||||
permissionSvc: permissionSvc,
|
||||
tempPasswords: make(map[string]temporaryPasswordEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// Common service-level errors.
|
||||
@@ -254,3 +273,98 @@ func hashPassword(p string) (string, error) {
|
||||
}
|
||||
return string(h), nil
|
||||
}
|
||||
|
||||
const temporaryPasswordTTL = 5 * time.Minute
|
||||
|
||||
// CreateTemporaryPassword 为指定用户生成一个 6 位数字的临时登录密码(有效期 5 分钟),
|
||||
// 供 Emby 电视端/客户端进行无键盘或快速输入登录。
|
||||
func (s *AuthService) CreateTemporaryPassword(ctx context.Context, userID string) (string, int, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return "", 0, errors.New("auth service unavailable")
|
||||
}
|
||||
user, err := s.repo.User.FindByID(ctx, userID)
|
||||
if err != nil || user == nil {
|
||||
return "", 0, ErrInvalidCredentials
|
||||
}
|
||||
if !user.IsActive {
|
||||
return "", 0, ErrUserInactive
|
||||
}
|
||||
if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) {
|
||||
return "", 0, ErrUserExpired
|
||||
}
|
||||
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(900000))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
code := fmt.Sprintf("%06d", n.Int64()+100000)
|
||||
|
||||
s.tempPassMu.Lock()
|
||||
defer s.tempPassMu.Unlock()
|
||||
now := time.Now()
|
||||
for k, v := range s.tempPasswords {
|
||||
if now.After(v.expiresAt) {
|
||||
delete(s.tempPasswords, k)
|
||||
}
|
||||
}
|
||||
s.tempPasswords[code] = temporaryPasswordEntry{
|
||||
userID: user.ID,
|
||||
username: user.Username,
|
||||
expiresAt: now.Add(temporaryPasswordTTL),
|
||||
}
|
||||
|
||||
return code, int(temporaryPasswordTTL.Seconds()), nil
|
||||
}
|
||||
|
||||
// VerifyAndConsumeTemporaryPassword 校验并消费临时登录密码(阅后即焚)。
|
||||
func (s *AuthService) VerifyAndConsumeTemporaryPassword(ctx context.Context, username, code string) (*model.User, bool) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, false
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if len(code) != 6 {
|
||||
return nil, false
|
||||
}
|
||||
s.tempPassMu.Lock()
|
||||
entry, ok := s.tempPasswords[code]
|
||||
if ok {
|
||||
delete(s.tempPasswords, code)
|
||||
}
|
||||
s.tempPassMu.Unlock()
|
||||
|
||||
if !ok || time.Now().After(entry.expiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if strings.TrimSpace(username) != "" && !strings.EqualFold(strings.TrimSpace(username), entry.username) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
user, err := s.repo.User.FindByID(ctx, entry.userID)
|
||||
if err != nil || user == nil || !user.IsActive {
|
||||
return nil, false
|
||||
}
|
||||
if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) {
|
||||
return nil, false
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
|
||||
// LoginWithTemporaryPassword 尝试使用 6 位数字临时登录密码 (OTP) 进行登录。
|
||||
func (s *AuthService) LoginWithTemporaryPassword(ctx context.Context, username, code string) (*LoginResponse, error) {
|
||||
user, ok := s.VerifyAndConsumeTemporaryPassword(ctx, username, code)
|
||||
if !ok || user == nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if s.tokenSvc == nil {
|
||||
return nil, errors.New("token service unavailable")
|
||||
}
|
||||
tokens, err := s.tokenSvc.IssuePair(ctx, user.ID, user.Role, user.Tier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LoginResponse{
|
||||
User: user,
|
||||
Tokens: tokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ type FileEntry struct {
|
||||
MTime int64 `json:"mtime,omitempty"`
|
||||
// PickCode is 115-specific; other providers use ID directly.
|
||||
PickCode string `json:"pick_code,omitempty"`
|
||||
// Sha1 is 115-specific content hash(大写 hex,目录/未完成文件可能为空或占位符)。
|
||||
// 其他网盘不提供,留空时调用方退回大小比对。用于元数据"是否同一文件"的精确判定。
|
||||
Sha1 string `json:"sha1,omitempty"`
|
||||
}
|
||||
|
||||
// DirectLink is a resolved playback target.
|
||||
@@ -72,6 +75,15 @@ type Provider interface {
|
||||
Resolve(ctx context.Context, fileRef string) (*DirectLink, error)
|
||||
}
|
||||
|
||||
// BatchResolver is implemented by providers that can resolve several file
|
||||
// references in fewer API calls(115 的 downurl 接口支持逗号分隔多个 pick_code,
|
||||
// 批量换取可显著降低下载队列的换链请求量)。返回以原始引用为键的直链 map;
|
||||
// 解析失败的引用不在结果中,err 汇报批量机制本身的失败,调用方应据此对缺失
|
||||
// 项回退到逐个 Resolve。
|
||||
type BatchResolver interface {
|
||||
ResolveBatch(ctx context.Context, fileRefs []string) (map[string]*DirectLink, error)
|
||||
}
|
||||
|
||||
// MutableProvider is implemented by cloud bridges that support safe folder
|
||||
// management through their official API or standard WebDAV methods.
|
||||
type MutableProvider interface {
|
||||
|
||||
@@ -81,7 +81,12 @@ func Test115OpenAPIListPaginates(t *testing.T) {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
count := 100
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
// 首页返回满页,之后返回 1 条:驱动按 offset/limit 翻页直到短页
|
||||
count := limit
|
||||
if offset > 0 {
|
||||
count = 1
|
||||
}
|
||||
@@ -97,11 +102,12 @@ func Test115OpenAPIListPaginates(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(entries) != 101 {
|
||||
t.Fatalf("entries = %d, want 101", len(entries))
|
||||
// List 使用文档上限 limit=1150:首页 1150 条 + 短页 1 条
|
||||
if len(entries) != 1151 {
|
||||
t.Fatalf("entries = %d, want 1151", len(entries))
|
||||
}
|
||||
if entries[100].ID != "100" || entries[100].PickCode != "pick100" {
|
||||
t.Fatalf("last entry wrong: %#v", entries[100])
|
||||
if entries[1150].ID != "1150" || entries[1150].PickCode != "pick1150" {
|
||||
t.Fatalf("last entry wrong: %#v", entries[1150])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,15 +87,15 @@ func (p *cloudDrive2Provider) Resolve(ctx context.Context, fileRef string) (*Dir
|
||||
if ref == "/" {
|
||||
return nil, fmt.Errorf("%s: file reference required", p.name)
|
||||
}
|
||||
if p.typ == TypeOpenList && isCloudVideoPlaybackCandidate(ref) {
|
||||
if p.apiBase == nil {
|
||||
return nil, fmt.Errorf("%s: pure 302 playback requires an OpenList API server address; configure server/api_url so /api/fs/get can return raw_url", p.name)
|
||||
}
|
||||
if p.typ == TypeOpenList && p.apiBase != nil {
|
||||
link, err := p.resolveOpenListAPIDirect(ctx, ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: pure 302 playback requires OpenList raw_url for %s: %w", p.name, ref, err)
|
||||
if err == nil {
|
||||
return link, nil
|
||||
}
|
||||
// API 获取直链失败:非视频文件(元数据)回退到 WebDAV;视频文件报错
|
||||
if isCloudVideoPlaybackCandidate(ref) {
|
||||
return nil, fmt.Errorf("%s: resolve download URL for %s via API failed: %w", p.name, ref, err)
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
if p.typ == TypeCloudDrive2 && isCloudVideoPlaybackCandidate(ref) {
|
||||
link, err := p.resolveCloudDAVRedirectDirect(ctx, ref)
|
||||
|
||||
@@ -61,9 +61,10 @@ func TestOpenListWebDAVListAndResolve(t *testing.T) {
|
||||
if len(entries) != 1 || entries[0].ID != "/Cloud/Movie.mkv" || entries[0].Size != 1024 {
|
||||
t.Fatalf("entries = %#v", entries)
|
||||
}
|
||||
// Video file: API fails → error (no WebDAV fallback for video)
|
||||
_, err = p.Resolve(context.Background(), entries[0].ID)
|
||||
if err == nil || !strings.Contains(err.Error(), "pure 302 playback requires OpenList raw_url") {
|
||||
t.Fatalf("openlist video resolve should require raw_url instead of WebDAV proxy fallback, err=%v", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "resolve download URL") || !strings.Contains(err.Error(), "via API failed") {
|
||||
t.Fatalf("openlist video resolve should error on API failure, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -195,10 +195,43 @@ func TestOpenListResolveDoesNotFallbackToWebDAVWhenAPIRawURLFails(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = p.Resolve(context.Background(), "/Cloud/Movie.mkv")
|
||||
if err == nil || !strings.Contains(err.Error(), "pure 302 playback requires OpenList raw_url") {
|
||||
t.Fatalf("resolve error = %v, want raw_url requirement", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "resolve download URL") || !strings.Contains(err.Error(), "via API failed") {
|
||||
t.Fatalf("resolve error = %v, want API resolve failure", err)
|
||||
}
|
||||
if davSeen {
|
||||
t.Fatal("openlist video resolve fell back to WebDAV after raw_url failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenListResolveMetadataUsesAPIInsteadOfWebDAV(t *testing.T) {
|
||||
var gotPath, gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/fs/get" {
|
||||
t.Fatalf("unexpected request %s %s; metadata should use API, not WebDAV", r.Method, r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":200,"data":{"raw_url":"https://cdn.example.test/poster.jpg?sign=1"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(TypeOpenList, map[string]any{"server": srv.URL, "token": "alist-token"}, srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// .nfo metadata file should use API, not WebDAV
|
||||
link, err := p.Resolve(context.Background(), "/Cloud/Movie/Movie.nfo")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if gotPath != "/api/fs/get" {
|
||||
t.Fatalf("api path = %q, want /api/fs/get (metadata should not use WebDAV)", gotPath)
|
||||
}
|
||||
if gotAuth != "alist-token" {
|
||||
t.Fatalf("Authorization = %q, want token", gotAuth)
|
||||
}
|
||||
if link.URL != "https://cdn.example.test/poster.jpg?sign=1" {
|
||||
t.Fatalf("url = %q", link.URL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,8 +56,9 @@ func (p *openAPI115Provider) Ping(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (p *openAPI115Provider) List(ctx context.Context, dirID string) ([]FileEntry, error) {
|
||||
// 115 开放平台列表接口按 offset/limit 分页,这里循环取完整个目录
|
||||
const pageSize = 100
|
||||
// 115 开放平台列表接口按 offset/limit 分页,这里循环取完整个目录;
|
||||
// limit 上限 1150(官方文档《获取文件列表》),取上限减少大目录翻页次数
|
||||
const pageSize = 1150
|
||||
var out []FileEntry
|
||||
for offset := 0; ; offset += pageSize {
|
||||
files, _, err := p.c.GetFsList(ctx, dirID, offset, pageSize)
|
||||
@@ -72,6 +73,7 @@ func (p *openAPI115Provider) List(ctx context.Context, dirID string) ([]FileEntr
|
||||
Size: f.FileSize,
|
||||
MTime: f.ModifiedAt(),
|
||||
PickCode: f.PickCode,
|
||||
Sha1: f.Sha1,
|
||||
})
|
||||
}
|
||||
if len(files) < pageSize {
|
||||
@@ -105,38 +107,57 @@ func (p *openAPI115Provider) ResolveWithUA(ctx context.Context, fileRef, ua stri
|
||||
return &DirectLink{URL: url, Proxy: false, Headers: map[string]string{"User-Agent": bound}}, nil
|
||||
}
|
||||
|
||||
// ResolveBatch 批量换取直链(downurl 支持逗号分隔多 pick_code,一次请求覆盖
|
||||
// 整批下载任务的换链)。返回 pickcode → 直链,未解析成功的引用不在结果中;
|
||||
// err 非 nil 表示批量过程部分/全部失败,调用方对缺失项回退到逐个 Resolve。
|
||||
// 下载队列统一使用默认 UA,与单个换取的防盗链绑定语义一致。
|
||||
func (p *openAPI115Provider) ResolveBatch(ctx context.Context, fileRefs []string) (map[string]*DirectLink, error) {
|
||||
urls, err := p.c.GetDownloadURLsBatch(ctx, fileRefs, "")
|
||||
out := make(map[string]*DirectLink, len(urls))
|
||||
for pc, u := range urls {
|
||||
out[pc] = &DirectLink{URL: u, Proxy: false, Headers: map[string]string{"User-Agent": cloud115.DefaultUA}}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
// OpenClient 暴露底层客户端(token 刷新用)。
|
||||
func (p *openAPI115Provider) OpenClient() *cloud115.OpenClient { return p.c }
|
||||
|
||||
// PutLocalFile 直接上传本地文件,避免通过 io.Reader 复制临时文件产生的磁盘开销与并发重命名碰撞。
|
||||
func (p *openAPI115Provider) PutLocalFile(ctx context.Context, parentCID, localPath string) error {
|
||||
_, err := p.c.Upload(ctx, localPath, parentCID, "", "")
|
||||
return err
|
||||
}
|
||||
|
||||
// PutFileNamed 把本地元数据上传到 115 指定父目录(parentCID 为父目录 cid)。
|
||||
// io.Reader 无法携带文件名,因此走独立的 named 上传接口。将内容落为临时文件后
|
||||
// 重命名为目标文件名,再交给 115 上传(/open/upload/init 的 file_name 取真实文件名)。
|
||||
// 为防止多并发上传线程在同一临时目录下发生同名文件(如 poster.jpg)碰撞覆盖与误删,
|
||||
// 为每个上传任务分配专属临时子目录。
|
||||
func (p *openAPI115Provider) PutFileNamed(ctx context.Context, parentCID, fileName string, r io.Reader) error {
|
||||
tmp, err := os.CreateTemp("", "mebox-upload-*")
|
||||
tmpDir, err := os.MkdirTemp("", "mebox-upload-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("115: 创建临时目录失败:%w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
}()
|
||||
|
||||
safeName := filepath.Base(fileName)
|
||||
if safeName == "" || safeName == "." {
|
||||
safeName = "file"
|
||||
}
|
||||
tmpPath := filepath.Join(tmpDir, safeName)
|
||||
dst, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("115: 创建临时文件失败:%w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer func() {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
}()
|
||||
if _, err := io.Copy(tmp, r); err != nil {
|
||||
if _, err := io.Copy(dst, r); err != nil {
|
||||
_ = dst.Close()
|
||||
return fmt.Errorf("115: 写入临时文件失败:%w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
if err := dst.Close(); err != nil {
|
||||
return fmt.Errorf("115: 关闭临时文件失败:%w", err)
|
||||
}
|
||||
// 重命名为目标文件名,保证上传到 115 后保留原始文件名。
|
||||
// 重命名失败必须 fail fast:静默用随机临时名上传会导致 115 上的文件名
|
||||
// 变成 mebox-upload-xxx,破坏元数据文件名契约。
|
||||
if fileName != "" && fileName != filepath.Base(tmpPath) {
|
||||
namedPath := filepath.Join(filepath.Dir(tmpPath), fileName)
|
||||
if err := os.Rename(tmpPath, namedPath); err != nil {
|
||||
return fmt.Errorf("115: 重命名临时文件为 %s 失败:%w", fileName, err)
|
||||
}
|
||||
tmpPath = namedPath
|
||||
}
|
||||
|
||||
_, err = p.c.Upload(ctx, tmpPath, parentCID, "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -472,3 +473,65 @@ func TestFsListRefreshContinue(t *testing.T) {
|
||||
t.Fatalf("want 1 file, got %d", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDownloadURLsBatch 验证批量换链:多个 pick_code 合并为一次逗号分隔
|
||||
// 请求;响应按文件 ID 为键、以条目内 pick_code 映射回请求侧;已缓存的
|
||||
// pick_code 不再发起请求;缺失项(空 URL)不出现在结果中。
|
||||
func TestGetDownloadURLsBatch(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var requests []string
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/open/ufile/downurl" {
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
pc := r.PostFormValue("pick_code")
|
||||
mu.Lock()
|
||||
requests = append(requests, pc)
|
||||
mu.Unlock()
|
||||
w.Write([]byte(`{"state":true,"data":{
|
||||
"111":{"pick_code":"batch-pc-a","url":{"url":"https://cdn/a.mkv"}},
|
||||
"222":{"pick_code":"batch-pc-b","url":{"url":"https://cdn/b.jpg"}},
|
||||
"333":{"pick_code":"batch-pc-empty","url":{"url":""}}}}`))
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
ClearDownloadURLCache("batch-pc-a")
|
||||
ClearDownloadURLCache("batch-pc-b")
|
||||
ClearDownloadURLCache("batch-pc-empty")
|
||||
})
|
||||
|
||||
c := NewOpenClient("100195125", "at1", "rt1")
|
||||
urls, err := c.GetDownloadURLsBatch(context.Background(),
|
||||
[]string{"batch-pc-a", "batch-pc-b", "batch-pc-empty", "", "batch-pc-a"}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("batch downurl: %v", err)
|
||||
}
|
||||
if urls["batch-pc-a"] != "https://cdn/a.mkv" || urls["batch-pc-b"] != "https://cdn/b.jpg" {
|
||||
t.Fatalf("bad urls: %#v", urls)
|
||||
}
|
||||
if _, ok := urls["batch-pc-empty"]; ok {
|
||||
t.Fatalf("empty-url entry should be absent: %#v", urls)
|
||||
}
|
||||
if _, ok := urls[""]; ok {
|
||||
t.Fatalf("empty pickcode should be absent: %#v", urls)
|
||||
}
|
||||
mu.Lock()
|
||||
if len(requests) != 1 || requests[0] != "batch-pc-a,batch-pc-b,batch-pc-empty" {
|
||||
mu.Unlock()
|
||||
t.Fatalf("unexpected downurl requests: %v", requests)
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
// 第二次调用全部命中缓存:不再发任何请求
|
||||
urls2, err := c.GetDownloadURLsBatch(context.Background(), []string{"batch-pc-a", "batch-pc-b"}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("cached batch downurl: %v", err)
|
||||
}
|
||||
if urls2["batch-pc-a"] != "https://cdn/a.mkv" {
|
||||
t.Fatalf("cached url lost: %#v", urls2)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(requests) != 1 {
|
||||
t.Fatalf("cache hit should not issue requests, got %v", requests)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// 115 开放平台删除类 API:元数据覆盖上传前清理远端旧文件。
|
||||
package cloud115
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DeleteFiles 批量删除 115 文件(官方接口 POST /open/ufile/delete)。
|
||||
// 删除为异步执行,文件移入回收站。parentID 为待删除文件所在父目录 ID
|
||||
//(可选提示,空串省略)。fileIDs 中的空项自动忽略,全为空时直接返回成功。
|
||||
func (c *OpenClient) DeleteFiles(ctx context.Context, parentID string, fileIDs ...string) error {
|
||||
ids := make([]string, 0, len(fileIDs))
|
||||
for _, id := range fileIDs {
|
||||
if id = strings.TrimSpace(id); id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
params := map[string]string{"file_ids": strings.Join(ids, ",")}
|
||||
if parentID = strings.TrimSpace(parentID); parentID != "" {
|
||||
params["parent_id"] = parentID
|
||||
}
|
||||
resp, err := c.doAuthJSON(ctx, "POST", ProAPIBase+"/open/ufile/delete", params, 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// doJSON 已把 state=false 转为错误返回,这里兜底防御响应外壳异常
|
||||
if !resp.State {
|
||||
return NewOpenAPIResponseError(resp.Code, resp.Errno, resp.Message, resp.Error, "115 删除文件失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -119,6 +119,60 @@ func (c *OpenClient) GetFsListFlat(ctx context.Context, cid string, offset, limi
|
||||
return files, resp.Count, nil
|
||||
}
|
||||
|
||||
// FindNamedContentInParent 在父目录下查找与本地内容一致的同名文件。
|
||||
// 匹配条件:文件名完全一致、大小一致,且 SHA1 为空或与 expectedSHA1 大小写不敏感相等。
|
||||
// 同时返回该目录下全部同名文件(含未匹配的脏副本),便于上传前清理。
|
||||
// 目录过大时分页扫描,最多拉取 maxListPages 页(每页 pageSize 条)。
|
||||
func (c *OpenClient) FindNamedContentInParent(ctx context.Context, parentCID, fileName, expectedSHA1 string, expectedSize int64) (matched *RemoteFile, sameName []RemoteFile, err error) {
|
||||
fileName = strings.TrimSpace(fileName)
|
||||
if fileName == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
const pageSize = 200
|
||||
const maxListPages = 20 // 最多扫描 4000 项,元数据父目录通常远小于此
|
||||
expectedSHA1 = strings.TrimSpace(expectedSHA1)
|
||||
offset := 0
|
||||
for page := 0; page < maxListPages; page++ {
|
||||
files, _, listErr := c.GetFsList(ctx, parentCID, offset, pageSize)
|
||||
if listErr != nil {
|
||||
return nil, sameName, listErr
|
||||
}
|
||||
if len(files) == 0 {
|
||||
break
|
||||
}
|
||||
for i := range files {
|
||||
f := files[i]
|
||||
if f.Category == TypeDir {
|
||||
continue
|
||||
}
|
||||
// fta=0/2 表示未上传完成,不可作为已存在副本
|
||||
if f.Fta == "0" || f.Fta == "2" {
|
||||
continue
|
||||
}
|
||||
if f.FileName != fileName {
|
||||
continue
|
||||
}
|
||||
sameName = append(sameName, f)
|
||||
if matched != nil {
|
||||
continue
|
||||
}
|
||||
if f.FileSize != expectedSize {
|
||||
continue
|
||||
}
|
||||
remoteSha := strings.TrimSpace(f.Sha1)
|
||||
if remoteSha == "" || remoteSha == "-" || strings.EqualFold(remoteSha, expectedSHA1) {
|
||||
cp := f
|
||||
matched = &cp
|
||||
}
|
||||
}
|
||||
if len(files) < pageSize {
|
||||
break
|
||||
}
|
||||
offset += len(files)
|
||||
}
|
||||
return matched, sameName, nil
|
||||
}
|
||||
|
||||
// GetFsDetailByCid 查询文件(夹)详情。
|
||||
func (c *OpenClient) GetFsDetailByCid(ctx context.Context, fileId string) (*RemoteFileDetail, error) {
|
||||
params := map[string]string{"file_id": fileId}
|
||||
@@ -225,6 +279,62 @@ func (c *OpenClient) GetDownloadURLWithUA(ctx context.Context, pickCode, ua stri
|
||||
return first.URL.URL, nil
|
||||
}
|
||||
|
||||
// downurlBatchSize 单次批量换取直链的 pick_code 数上限。官方 /open/ufile/downurl
|
||||
// 支持逗号分隔多个 pick_code,批量可大幅降低元数据下载的换链请求量;大小取
|
||||
// 保守值,减小单个违规/异常文件导致整批失败的爆炸半径。
|
||||
const downurlBatchSize = 10
|
||||
|
||||
// GetDownloadURLsBatch 批量获取下载直链(pickcode → URL)。先查进程内缓存,
|
||||
// 仅对未命中的 pick_code 分片发起批量请求;单个分片失败时返回已解析的部分与
|
||||
// 错误,调用方对缺失项回退到逐个 GetDownloadURLWithUA。UA 语义与单个换取
|
||||
// 一致:直链绑定换取时的 UA,后续下载必须携带同一 UA。
|
||||
func (c *OpenClient) GetDownloadURLsBatch(ctx context.Context, pickCodes []string, ua string) (map[string]string, error) {
|
||||
ua = strings.TrimSpace(ua)
|
||||
out := make(map[string]string, len(pickCodes))
|
||||
seen := make(map[string]struct{}, len(pickCodes))
|
||||
missing := make([]string, 0, len(pickCodes))
|
||||
for _, pc := range pickCodes {
|
||||
pc = strings.TrimSpace(pc)
|
||||
if pc == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[pc]; dup {
|
||||
continue
|
||||
}
|
||||
seen[pc] = struct{}{}
|
||||
if cached := GetDownloadURLCache(pc, ua); cached != "" {
|
||||
out[pc] = cached
|
||||
continue
|
||||
}
|
||||
missing = append(missing, pc)
|
||||
}
|
||||
for start := 0; start < len(missing); start += downurlBatchSize {
|
||||
end := start + downurlBatchSize
|
||||
if end > len(missing) {
|
||||
end = len(missing)
|
||||
}
|
||||
chunk := missing[start:end]
|
||||
params := map[string]string{"pick_code": strings.Join(chunk, ",")}
|
||||
resp, err := c.doAuthJSONWithUA(ctx, "POST", ProAPIBase+"/open/ufile/downurl", params, 1, ua)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
var data map[string]downloadURLData
|
||||
if err := json.Unmarshal(resp.Data, &data); err != nil {
|
||||
return out, fmt.Errorf("115: 解析下载地址失败:%w", err)
|
||||
}
|
||||
// 响应以文件 ID 为键,条目内的 pick_code 用于映射回请求侧
|
||||
for _, item := range data {
|
||||
if item.PickCode == "" || item.URL.URL == "" {
|
||||
continue
|
||||
}
|
||||
SetDownloadURLCache(item.PickCode, item.URL.URL, ua)
|
||||
out[item.PickCode] = item.URL.URL
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ─── 授权(设备码扫码) ──────────────────────────────────────────────────────
|
||||
|
||||
// QrCodeScanStatus 扫码状态。
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package cloud115
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindNamedContentInParentMatchAndSameName(t *testing.T) {
|
||||
sha := "AABBCCDDEEFF00112233445566778899AABBCCDD"
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/open/ufile/files" {
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
w.Write([]byte(fmt.Sprintf(`{"state":true,"data":[
|
||||
{"fid":"dir-1","fc":"0","fn":"sub","fs":0},
|
||||
{"fid":"keep","fc":"1","fn":"a.nfo","fs":10,"sha1":%q,"fta":"1"},
|
||||
{"fid":"dirty","fc":"1","fn":"a.nfo","fs":10,"sha1":"OTHER","fta":"1"},
|
||||
{"fid":"other","fc":"1","fn":"b.nfo","fs":10,"sha1":%q,"fta":"1"},
|
||||
{"fid":"incomplete","fc":"1","fn":"a.nfo","fs":10,"sha1":%q,"fta":"0"}
|
||||
]}`, sha, sha, sha)))
|
||||
})
|
||||
c := NewOpenClient("100195125", "at1", "rt1")
|
||||
matched, sameName, err := c.FindNamedContentInParent(context.Background(), "parent", "a.nfo", sha, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if matched == nil || matched.FileId != "keep" {
|
||||
t.Fatalf("matched = %+v, want keep", matched)
|
||||
}
|
||||
if len(sameName) != 2 { // keep + dirty;incomplete 被 fta 过滤
|
||||
t.Fatalf("sameName len=%d, want 2 (incomplete excluded)", len(sameName))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindNamedContentInParentNoMatch(t *testing.T) {
|
||||
mockAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"state":true,"data":[
|
||||
{"fid":"x","fc":"1","fn":"a.nfo","fs":9,"sha1":"OTHER","fta":"1"}
|
||||
]}`))
|
||||
})
|
||||
c := NewOpenClient("100195125", "at1", "rt1")
|
||||
matched, sameName, err := c.FindNamedContentInParent(context.Background(), "parent", "a.nfo", "WANT", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if matched != nil {
|
||||
t.Fatalf("expected no match, got %+v", matched)
|
||||
}
|
||||
if len(sameName) != 1 {
|
||||
t.Fatalf("sameName should still list name hits, got %d", len(sameName))
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,10 @@ import (
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FileSHA1 计算文件完整 SHA1(小写 hex)。
|
||||
// FileSHA1 计算文件完整 SHA1(大写 hex,115 全链路统一大写)。
|
||||
func FileSHA1(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
@@ -18,11 +19,12 @@ func FileSHA1(path string) (string, error) {
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
return strings.ToUpper(hex.EncodeToString(h.Sum(nil))), nil
|
||||
}
|
||||
|
||||
// FileSHA1Partial 计算文件 [start,end](含)字节区间的 SHA1(小写 hex)。
|
||||
// 用于 115 上传二次签名按 sign_check 指定的区间重算哈希。
|
||||
// FileSHA1Partial 计算文件 [start,end](含)字节区间的 SHA1(大写 hex)。
|
||||
// 用于 115 上传二次签名按 sign_check 指定的区间重算哈希;sign_val 必须为大写,
|
||||
// 否则 115 以 status=8「签名认证失败」拒绝。
|
||||
func FileSHA1Partial(path string, start, end int64) (string, error) {
|
||||
if start < 0 {
|
||||
start = 0
|
||||
@@ -45,5 +47,5 @@ func FileSHA1Partial(path string, start, end int64) (string, error) {
|
||||
if _, err := io.CopyN(h, f, length); err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
return strings.ToUpper(hex.EncodeToString(h.Sum(nil))), nil
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ func TestFileSHA1(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// sha1("hello") = aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d
|
||||
if sum != "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d" {
|
||||
// sha1("hello") = aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d,115 要求大写
|
||||
if sum != "AAF4C61DDCC5E8A2DABEDE0F3B482CD9AEA9434D" {
|
||||
t.Errorf("unexpected sha1: %s", sum)
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func TestFileSHA1Partial(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sum != "0ec09ef9836da03f1add21e3ef607627e687e790" {
|
||||
if sum != "0EC09EF9836DA03F1ADD21E3EF607627E687E790" {
|
||||
t.Errorf("unexpected partial sha1: %s", sum)
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func TestFileSHA1PartialSmallerThanWindow(t *testing.T) {
|
||||
t.Fatalf("compute partial sha1 for small file should not fail: %v", err)
|
||||
}
|
||||
// 应等于整个文件(6 字节)的 sha1
|
||||
if sum != "1f8ac10f23c5b5bc1167bda84b833e5c057a77d2" {
|
||||
if sum != "1F8AC10F23C5B5BC1167BDA84B833E5C057A77D2" {
|
||||
t.Errorf("unexpected partial sha1: %s", sum)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package service — AES-GCM crypto helper for at-rest secrets.
|
||||
//
|
||||
// Sensitive fields (third-party API keys, qBittorrent passwords, …) are
|
||||
// Sensitive fields (third-party API keys, service passwords, …) are
|
||||
// stored in SQLite. We encrypt them with AES-256-GCM keyed off the JWT
|
||||
// secret so a stolen DB file alone is not enough to recover the
|
||||
// plaintext credentials.
|
||||
|
||||