diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..4075ad5 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "ssh": { + "command": "cmd.exe", + "args": [ + "/c", + "npx", + "-y", + "@aiondadotcom/mcp-ssh" + ], + "env": { + "ProgramData": "C:\\ProgramData" + } + } + } +} diff --git a/.dockerignore b/.dockerignore index aa7dbce..903a561 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,6 +14,7 @@ verify-cache/ verify-media/ verify-downloads/ .codex-* +.codex/ .tmp/ .tmp_* .tmp-deploy-* diff --git a/.github/workflows/Auto-docker-publish.yml b/.github/workflows/Auto-docker-publish.yml index 08be768..fd02333 100644 --- a/.github/workflows/Auto-docker-publish.yml +++ b/.github/workflows/Auto-docker-publish.yml @@ -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,19 +102,20 @@ 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 - uses: actions/setup-node@v4 with: - node-version: '20' + node-version-file: '.nvmrc' cache: 'npm' cache-dependency-path: web/package-lock.json - name: Install @@ -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 @@ -236,8 +196,12 @@ jobs: path: web/dist - name: Build binary run: | + LDFLAGS="-s -w -X main.version=${{ needs.build-image.outputs.release_tag }}" + if [ "${{ matrix.goos }}" = "windows" ]; then + LDFLAGS="$LDFLAGS -H=windowsgui" + fi CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \ - go build -trimpath -ldflags="-s -w -X main.version=${{ needs.version-and-publish.outputs.tag }}" \ + go build -trimpath -ldflags="$LDFLAGS" \ -o "dist/mebox-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server - name: Package run: | @@ -252,7 +216,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" @@ -264,3 +228,41 @@ jobs: if [ -f "$TAR" ]; then for i in 1 2 3; do gh release upload "$RELEASE_TAG" "$TAR" --clobber && break || sleep 5; done fi + + deploy: + name: Deploy to Server + needs: [build-image] + runs-on: ubuntu-latest + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + port: ${{ secrets.SERVER_PORT }} + script: | + set -e + echo "==== 开始部署 MeBox ====" + cd /root/dockerData/mebox + + # 判断 compose 命令版本兼容性(docker compose 或 docker-compose) + if docker compose version >/dev/null 2>&1; then + COMPOSE_CMD="docker compose" + elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE_CMD="docker-compose" + else + echo "错误: 未找到 docker compose 或 docker-compose" + exit 1 + fi + + echo "正在拉取最新镜像..." + $COMPOSE_CMD pull + + echo "正在重启服务..." + $COMPOSE_CMD up -d + + echo "清理旧的无用镜像..." + docker image prune -f + + echo "==== 部署完成并已启动 ====" diff --git a/.github/workflows/beta-build.yml b/.github/workflows/beta-build.yml index e700abb..44c7208 100644 --- a/.github/workflows/beta-build.yml +++ b/.github/workflows/beta-build.yml @@ -48,7 +48,7 @@ jobs: # before the Go toolchain touches the web package. - uses: actions/setup-node@v4 with: - node-version: '20' + node-version-file: '.nvmrc' cache: 'npm' cache-dependency-path: web/package-lock.json - name: Build SPA @@ -77,7 +77,7 @@ jobs: - name: Build linux/arm64 run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server - name: Build windows/amd64 - run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server + run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server - name: Upload artifacts uses: actions/upload-artifact@v4 @@ -130,4 +130,4 @@ jobs: build-args: | VERSION=${{ steps.version.outputs.full_version }} cache-from: type=gha - cache-to: type=gha,mode=max \ No newline at end of file + cache-to: type=gha,mode=max diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad26953..5e69d2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: # before the Go toolchain touches the `web` package. - uses: actions/setup-node@v4 with: - node-version: '20' + node-version-file: '.nvmrc' cache: 'npm' cache-dependency-path: web/package-lock.json - name: Build SPA @@ -51,7 +51,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version-file: '.nvmrc' cache: 'npm' cache-dependency-path: web/package-lock.json diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 3f8d236..1dee0bf 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -61,3 +61,41 @@ jobs: VERSION=${{ env.RELEASE_VERSION }} cache-from: type=gha cache-to: type=gha,mode=max + + deploy: + name: Deploy to Server + needs: [docker] + runs-on: ubuntu-latest + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + port: ${{ secrets.SERVER_PORT }} + script: | + set -e + echo "==== 开始部署 MeBox ====" + cd /root/dockerData/mebox + + if docker compose version >/dev/null 2>&1; then + COMPOSE_CMD="docker compose" + elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE_CMD="docker-compose" + else + echo "错误: 未找到 docker compose 或 docker-compose" + exit 1 + fi + + echo "正在拉取最新镜像..." + $COMPOSE_CMD pull + + echo "正在重启服务..." + $COMPOSE_CMD up -d + + echo "清理旧的无用镜像..." + docker image prune -f + + echo "==== 部署完成并已启动 ====" + diff --git a/.gitignore b/.gitignore index 3f7bb76..d2ca998 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,7 @@ config.yaml .tmp-live-backups/ .tmp-* .codex-* +.codex/ downloads/ media/ *.pid @@ -79,4 +80,5 @@ media/ tools/ verify-cache/ verify-data/ -.zcode/ \ No newline at end of file +.zcode/ +.tmp-src diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..5bd6811 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.19.0 diff --git a/.zcodeignore b/.zcodeignore new file mode 100644 index 0000000..79c3199 --- /dev/null +++ b/.zcodeignore @@ -0,0 +1,114 @@ +# Binaries +bin/ +*.exe +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test +*.out + +# Go workspace +go.work + +# Dependency directories +node_modules/ + +# Build artifacts +web/dist/ +web/.vite/ +web/coverage/ +web/tsconfig.tsbuildinfo +dist-release/ + +# Data / runtime +data/ +cache/ +logs/ +.tmp-deploy-data/ +.tmp-deploy-smoke-data/ +.tmp-deploy-smoke-cache/ +.tmp-deploy-cache/ +.tmp-deploy-server.* +.tmp-live-server.* +.mebox.pid +*.log +*.db +*.db-journal +*.db-shm +*.db-wal + +# Editor / OS +.idea/ +.vscode/ +.DS_Store +Thumbs.db + +# Env files +.env +.env.local +.env.*.local + +# Local configs (keep examples) +config/secrets.yaml +config.yaml + +# WorkBuddy workspace (local AI assistant memory) +.workbuddy/ + +# Editor backups +*~ +.tmp_* + +# Runtime / local-only artifacts (清理补充) +.tmp/ +.tmp-live-backups/ +.tmp-* +.codex-* +.codex/ +downloads/ +media/ +*.pid + +# 本地开发运行产物 +.agents/ +.claude/ +.dev-cache/ +.dev-data/ +.dev-logs/ +tools/ +verify-cache/ +verify-data/ +.zcode/ + +# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)===== +.git/ +.hg/ +.svn/ +bower_components/ +jspm_packages/ +__pycache__/ +site-packages/ +venv/ +coverage/ +htmlcov/ +lcov-report/ +cmakefiles/ +cmake-build-*/ +bazel-*/ +pods/ +deriveddata/ +storybook-static/ +playwright-report/ +test-results/ +allure-results/ +allure-report/ +cdk.out/ +*.egg-info/ +*.dist-info/ +eggs/ +pip-wheel-metadata/ +wheels/ +# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)----- +# 自定义规则写在下方(本行提示可删除) diff --git a/115doc/115开放平台/API列表/云下载/删除用户云下载任务.md b/115doc/115开放平台/API列表/云下载/删除用户云下载任务.md new file mode 100644 index 0000000..e5bc5cd --- /dev/null +++ b/115doc/115开放平台/API列表/云下载/删除用户云下载任务.md @@ -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 | `` | +| del_source_file | int | 否 | 0 | 是否删除源文件,1=删除 0=不删除 | 0 | + +### 请求示例 + +```shell +curl 'https://proapi.115.com/open/offline/del_task' \ + -H 'Authorization: Bearer ' \ + --form-string '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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/云下载/添加云下载BT任务.md b/115doc/115开放平台/API列表/云下载/添加云下载BT任务.md new file mode 100644 index 0000000..1847b86 --- /dev/null +++ b/115doc/115开放平台/API列表/云下载/添加云下载BT任务.md @@ -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 | `` | +| wanted | string | 是 | - | 选中下载的文件索引,使用半角逗号分隔 | `` | +| save_path | string | 是 | - | BT任务文件保存路径 | `A/B` | +| torrent_sha1 | string | 是 | - | BT种子SHA1 | `` | +| pick_code | string | 是 | - | BT种子文件提取码 | `` | +| wp_path_id | string | 否 | 0 | 保存目标文件夹ID | 0 | + +### 请求示例 + +```shell +curl 'https://proapi.115.com/open/offline/add_task_bt' \ + -H 'Authorization: Bearer ' \ + --form-string 'info_hash=' \ + --form-string 'wanted=' \ + --form-string 'save_path=A/B' \ + --form-string 'torrent_sha1=' \ + --form-string '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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/云下载/添加云下载链接任务.md b/115doc/115开放平台/API列表/云下载/添加云下载链接任务.md new file mode 100644 index 0000000..417dfe4 --- /dev/null +++ b/115doc/115开放平台/API列表/云下载/添加云下载链接任务.md @@ -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 | 是 | - | 云下载链接,多个链接使用换行符分隔 | `` | +| wp_path_id | string | 否 | 0 | 保存目标文件夹ID;不传或传0时保存到根目录 | 0 | + +### 请求示例 + +```shell +curl 'https://proapi.115.com/open/offline/add_task_urls' \ + -H 'Authorization: Bearer ' \ + --form-string 'urls=' \ + --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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/云下载/清空云下载任务.md b/115doc/115开放平台/API列表/云下载/清空云下载任务.md new file mode 100644 index 0000000..c4bc41c --- /dev/null +++ b/115doc/115开放平台/API列表/云下载/清空云下载任务.md @@ -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 ' \ + --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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/云下载/获取云下载配额信息.md b/115doc/115开放平台/API列表/云下载/获取云下载配额信息.md new file mode 100644 index 0000000..b8cc491 --- /dev/null +++ b/115doc/115开放平台/API列表/云下载/获取云下载配额信息.md @@ -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 ' +``` + +### 响应字段说明 + +| 字段 | 类型 | 描述 | +|:--------------------------------------------|:---------|:---------------------| +| 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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/云下载/获取用户云下载任务列表.md b/115doc/115开放平台/API列表/云下载/获取用户云下载任务列表.md new file mode 100644 index 0000000..5c41589 --- /dev/null +++ b/115doc/115开放平台/API列表/云下载/获取用户云下载任务列表.md @@ -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 ' \ + --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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/云下载/解析BT种子.md b/115doc/115开放平台/API列表/云下载/解析BT种子.md new file mode 100644 index 0000000..f37cc7e --- /dev/null +++ b/115doc/115开放平台/API列表/云下载/解析BT种子.md @@ -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 | `` | +| pick_code | string | 是 | - | BT种子文件提取码 | `` | + +### 请求示例 + +```shell +curl 'https://proapi.115.com/open/offline/torrent' \ + -H 'Authorization: Bearer ' \ + --form-string 'torrent_sha1=' \ + --form-string '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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/开发者商业价值转化:推广产品得收益.md b/115doc/115开放平台/API列表/开发者商业价值转化:推广产品得收益.md new file mode 100644 index 0000000..aec9042 --- /dev/null +++ b/115doc/115开放平台/API列表/开发者商业价值转化:推广产品得收益.md @@ -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 服务购买页面示例: + +![VIP服务购买页](图片/推广产品得收益/VIP服务购买页.png) + +长期存储空间扩容购买页面示例: + +![长期存储空间购买页](图片/推广产品得收益/长期存储空间购买页.png) + +## 三、接入流程 + +### 1. 成为开发者 + +已成为 115 生活开发者的用户可以直接进行接口接入;未注册的开发者,请先参考[接入流程](../接入指南/接入流程.md)完成注册。 + +### 2. 接口接入 + +开发者可以调用“获取产品列表地址”接口取得购买页面地址,并在对应业务场景中引导用户访问。 + +#### 接口信息 + +| 属性 | 内容 | +|:-------|:------------| +| 接口名称 | 获取产品列表地址 | +| 接口版本 | v1.0 | +| 接口路径 | /vip/qr_url | +| 请求方法 | GET | +| 接口状态 | 生产环境 | + +### 接口说明 + +该接口用于获取 115 生活开放平台增值服务产品列表地址。 + +### 接口地址 + +``` +https://proapi.115.com/open/vip/qr_url +``` + +### 请求方式 + +``` +GET +``` + +### 认证方式 + +``` +Authorization: Bearer access_token +``` + +### 请求参数 + +| 参数名 | 类型 | 必填 | 默认值 | 说明 | 约束/示例 | +|:-------------------|:-------|:---|:-----|:---------------------------|:--------------------------------------------------------| +| default_product_id | int | 否 | null | 打开产品列表时默认选中的产品 ID | 月费:`5`;年费:`1`;尝鲜 1 天:`101`;长期VIP(至尊版):`24072401` | +| open_device | string | 是 | - | 设备号 | `DEVICE_ID_PLACEHOLDER` | +| hide_title | int | 否 | 0 | 是否隐藏购买页推荐人信息:0-不隐藏;1-隐藏 | `0` | + +### 请求示例 + +```shell +curl -G 'https://proapi.115.com/open/vip/qr_url' \ + -H 'Authorization: Bearer ACCESS_TOKEN_PLACEHOLDER' \ + --data-urlencode 'default_product_id=1' \ + --data-urlencode 'open_device=DEVICE_ID_PLACEHOLDER' \ + --data-urlencode 'hide_title=0' +``` + +### 响应字段说明 + +| 字段 | 类型 | 描述 | +|:----------------|:--------|:--------------| +| state | boolean | 状态码,true 表示成功 | +| message | string | 响应信息 | +| code | int | 错误码 | +| data | object | 响应数据 | +| data.qrcode_url | string | 开放平台产品列表地址 | + +### 响应示例 + +```json +{ + "state": true, + "message": "", + "code": 0, + "data": { + "qrcode_url": "PRODUCT_LIST_URL_PLACEHOLDER" + } +} +``` + +### 注意事项 + +- 接口根据 `access_token` 识别授权信息,调用方无需额外传入 115 账号和 AppID。 +- `open_device` 不能为空;未传或传入空值时,接口返回参数错误。 +- `default_product_id` 未传或为空时,服务端不指定默认产品。 +- `hide_title` 大于 0 时,服务端会将该参数传递给产品列表服务。 +- `access_token` 属于敏感凭证,不得写入公开仓库、客户端日志或公开沟通内容。 + +### 3. 场景触发与收益 + +完成接口接入后,开发者可以在用户使用权限不足、长期存储空间不足或云下载配额不足时触发对应引导。用户完成购买后,开发者可以获得对应推广收益。 + +## 四、收益管理与结算 + +### 1. 收益查看与提现 + +开发者可以登录“115生活-生活-联盟”,或直接访问[115联盟](https://union.115.com/),查看收益明细等推广收益情况并进行提现操作。 + +![推广收益管理页](图片/推广产品得收益/推广收益管理页.png) + +### 2. 规则说明 + +收益计算方式、结算方式和提现流程等详情,参见[联盟规则](https://union.115.com/?ac=help&i=10)。 + +### 修改历史 + +| 修改时间 | 修改说明 | +|:-----------------------------|:-----------------| +| 2025年04月01日(周二) 00:00:00 | 创建文档 | +| 2026年08月05日(周三) 17:30:01 | 恢复业务说明、接入流程及示例图片 | +| 2026年08月19日(周三) 11:00:08 | 长期VIP默认产品名称改为至尊版 | diff --git a/115doc/115开放平台/API列表/文件管理/删除或清空回收站.md b/115doc/115开放平台/API列表/文件管理/删除或清空回收站.md new file mode 100644 index 0000000..1e192c6 --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/删除或清空回收站.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/删除文件.md b/115doc/115开放平台/API列表/文件管理/删除文件.md new file mode 100644 index 0000000..85e82e2 --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/删除文件.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/回收站列表.md b/115doc/115开放平台/API列表/文件管理/回收站列表.md new file mode 100644 index 0000000..5468bf9 --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/回收站列表.md @@ -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 | 补充单页记录数上限 | diff --git a/115doc/115开放平台/API列表/文件管理/回收站还原.md b/115doc/115开放平台/API列表/文件管理/回收站还原.md new file mode 100644 index 0000000..f1fc3ce --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/回收站还原.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/文件(夹)更新.md b/115doc/115开放平台/API列表/文件管理/文件(夹)更新.md new file mode 100644 index 0000000..628ab47 --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/文件(夹)更新.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/文件上传/上传流程.md b/115doc/115开放平台/API列表/文件管理/文件上传/上传流程.md new file mode 100644 index 0000000..7a5eddc --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/文件上传/上传流程.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/文件上传/文件上传.md b/115doc/115开放平台/API列表/文件管理/文件上传/文件上传.md new file mode 100644 index 0000000..329e42f --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/文件上传/文件上传.md @@ -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 值 | `` | +| preid | string | 否 | "" | 文件前 128 KiB 内容的 SHA1 值 | `` | +| path | string | 否 | "" | 上传路径 | "" | +| pick_code | string | 否 | "" | 上传任务唯一标识,用于续传 | `` | +| topupload | int | 否 | "" | 上传调度文件类型标记,见下方枚举表格 | 0 | +| sign_key | string | 否 | "" | 二次认证标识 | `` | +| sign_val | string | 否 | "" | 根据 `sign_check` 计算的大写 SHA1 值 | `` | + +#### 请求参数中的 topupload 字段枚举 + +| 值 | 说明 | 备注 | +|:---|:---------------------------|:---| +| -1 | 没有上传调度文件类型标记 | - | +| 0 | 单文件上传任务,记录一条独立上传记录 | - | +| 1 | 文件夹任务的第一个子文件,记录一次文件夹上传 | - | +| 2 | 文件夹任务的其他子文件,不单独记录上传记录 | - | + +### 请求示例 + +```shell +curl 'https://proapi.115.com/open/upload/init' \ + -H 'Authorization: Bearer ' \ + -F 'file_name=图片.jpg' \ + -F 'file_size=5335' \ + -F 'target=U_1_0' \ + -F 'fileid=' \ + -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/文件上传/断点续传.md b/115doc/115开放平台/API列表/文件管理/文件上传/断点续传.md new file mode 100644 index 0000000..55ac97c --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/文件上传/断点续传.md @@ -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 值 | `` | +| pick_code | string | 是 | - | 上传任务唯一标识 | `` | + +### 请求示例 + +```shell +curl 'https://proapi.115.com/open/upload/resume' \ + -H 'Authorization: Bearer ' \ + -F 'file_size=5335' \ + -F 'target=U_1_0' \ + -F 'fileid=' \ + -F '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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/文件上传/获取上传凭证.md b/115doc/115开放平台/API列表/文件管理/文件上传/获取上传凭证.md new file mode 100644 index 0000000..605678d --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/文件上传/获取上传凭证.md @@ -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 ' +``` + +### 响应字段说明 + +| 字段 | 类型 | 描述 | +|:-------------------|:------|:---------------| +| 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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/文件复制.md b/115doc/115开放平台/API列表/文件管理/文件复制.md new file mode 100644 index 0000000..6057e76 --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/文件复制.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/文件搜索.md b/115doc/115开放平台/API列表/文件管理/文件搜索.md new file mode 100644 index 0000000..7d359f5 --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/文件搜索.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/文件移动.md b/115doc/115开放平台/API列表/文件管理/文件移动.md new file mode 100644 index 0000000..51d0311 --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/文件移动.md @@ -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 | 补充移动目标目录有效性约束及错误码 | diff --git a/115doc/115开放平台/API列表/文件管理/新建文件夹.md b/115doc/115开放平台/API列表/文件管理/新建文件夹.md new file mode 100644 index 0000000..d4b7626 --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/新建文件夹.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/获取文件(夹)详情/按ID获取.md b/115doc/115开放平台/API列表/文件管理/获取文件(夹)详情/按ID获取.md new file mode 100644 index 0000000..f93bbf1 --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/获取文件(夹)详情/按ID获取.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/获取文件(夹)详情/按路径获取.md b/115doc/115开放平台/API列表/文件管理/获取文件(夹)详情/按路径获取.md new file mode 100644 index 0000000..28aa58b --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/获取文件(夹)详情/按路径获取.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/获取文件下载地址.md b/115doc/115开放平台/API列表/文件管理/获取文件下载地址.md new file mode 100644 index 0000000..dab785b --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/获取文件下载地址.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/文件管理/获取文件列表.md b/115doc/115开放平台/API列表/文件管理/获取文件列表.md new file mode 100644 index 0000000..df77cae --- /dev/null +++ b/115doc/115开放平台/API列表/文件管理/获取文件列表.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/用户管理/用户信息.md b/115doc/115开放平台/API列表/用户管理/用户信息.md new file mode 100644 index 0000000..9954a72 --- /dev/null +++ b/115doc/115开放平台/API列表/用户管理/用户信息.md @@ -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 ' +``` + +### 响应字段说明 + +| 字段 | 类型 | 描述 | +|:----------------------------------------|:------|:----------------------------------------| +| 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等级名称枚举 | diff --git a/115doc/115开放平台/API列表/视频播放/提交视频转码.md b/115doc/115开放平台/API列表/视频播放/提交视频转码.md new file mode 100644 index 0000000..e068c1d --- /dev/null +++ b/115doc/115开放平台/API列表/视频播放/提交视频转码.md @@ -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 ' \ + -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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/视频播放/获取视频在线播放地址.md b/115doc/115开放平台/API列表/视频播放/获取视频在线播放地址.md new file mode 100644 index 0000000..db45c46 --- /dev/null +++ b/115doc/115开放平台/API列表/视频播放/获取视频在线播放地址.md @@ -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 ' \ + --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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/视频播放/获取视频播放进度.md b/115doc/115开放平台/API列表/视频播放/获取视频播放进度.md new file mode 100644 index 0000000..226cce3 --- /dev/null +++ b/115doc/115开放平台/API列表/视频播放/获取视频播放进度.md @@ -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 ' \ + --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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/视频播放/视频字幕列表.md b/115doc/115开放平台/API列表/视频播放/视频字幕列表.md new file mode 100644 index 0000000..8f705d5 --- /dev/null +++ b/115doc/115开放平台/API列表/视频播放/视频字幕列表.md @@ -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 ' \ + --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 | 创建文档 | diff --git a/115doc/115开放平台/API列表/视频播放/记忆视频播放进度.md b/115doc/115开放平台/API列表/视频播放/记忆视频播放进度.md new file mode 100644 index 0000000..726d7d7 --- /dev/null +++ b/115doc/115开放平台/API列表/视频播放/记忆视频播放进度.md @@ -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 ' \ + -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 | 创建文档 | diff --git a/115doc/115开放平台/接入指南/开发须知.md b/115doc/115开放平台/接入指南/开发须知.md new file mode 100644 index 0000000..f934920 --- /dev/null +++ b/115doc/115开放平台/接入指南/开发须知.md @@ -0,0 +1,30 @@ +# 开发须知 + +### 基本信息 + +| 属性 | 内容 | +|:-----------|:---------------------------------| +| 文档名称 | 开发须知 | +| 文档版本 | v1.0 | + +## 注意事项 + +为共同建设开放、共赢的合作生态并保障平台服务的可持续发展,开发者在接入平台服务前,须认真阅读并严格遵循《115生活开放平台开发者协议》的相关要求,确保合规运营、维护平台秩序。 + +基于平台与用户权益保护原则,平台会持续监测开发者的服务使用行为。如发现违反平台规范的行为,平台将视情节采取包括但不限于服务限流、接口冻结、资质回收等限制措施,并保留依法追责的权利。 + +开发者严禁实施包括但不限于以下行为: + +1. **数据隐私违规行为**:侵害用户数据隐私安全,包括未经用户授权或未明确用途,违规收集、下载、存储、传播、加工用户存储数据等。开发者需要确保用户数据的获取与使用全程透明、可追溯。 +2. **商业利益侵犯行为**:损害 115 科技的商业利益,包括多人共享开发者账号及会员权益、开展竞争关系业务、未经授权获取平台相关服务运营数据等。 +3. **不当使用行为**:违规或未按要求使用 API 服务,包括违反国家相关政策法规、侵犯第三方合法权益、调用非公开接口、实际用途与申请信息不符等。 + +## 限流说明 + +为确保系统安全并保障服务稳定运行,115生活开放平台对所有 API 实施频率控制策略。出于安全防护需要,相关策略细则暂不公开,平台会持续动态优化该机制。 + +### 修改历史 + +| 修改时间 | 修改说明 | +|:-----------------------------|:-----| +| 2025年04月01日(周二) 00:00:00 | 创建文档 | diff --git a/115doc/115开放平台/接入指南/授权错误码.md b/115doc/115开放平台/接入指南/授权错误码.md new file mode 100644 index 0000000..25218c8 --- /dev/null +++ b/115doc/115开放平台/接入指南/授权错误码.md @@ -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` 的永久失效判定说明 | diff --git a/115doc/115开放平台/接入指南/接入授权/刷新access_token.md b/115doc/115开放平台/接入指南/接入授权/刷新access_token.md new file mode 100644 index 0000000..c82a1ea --- /dev/null +++ b/115doc/115开放平台/接入指南/接入授权/刷新access_token.md @@ -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` 说明 | diff --git a/115doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取access_token.md b/115doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取access_token.md new file mode 100644 index 0000000..50fccef --- /dev/null +++ b/115doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取access_token.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取设备码和二维码内容.md b/115doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取设备码和二维码内容.md new file mode 100644 index 0000000..c9f3b2c --- /dev/null +++ b/115doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/获取设备码和二维码内容.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/轮询二维码状态.md b/115doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/轮询二维码状态.md new file mode 100644 index 0000000..0d1e821 --- /dev/null +++ b/115doc/115开放平台/接入指南/接入授权/手机扫码授权PKCE模式/轮询二维码状态.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/接入指南/接入授权/授权码模式/用授权码换取access_token.md b/115doc/115开放平台/接入指南/接入授权/授权码模式/用授权码换取access_token.md new file mode 100644 index 0000000..cedc6ca --- /dev/null +++ b/115doc/115开放平台/接入指南/接入授权/授权码模式/用授权码换取access_token.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/接入指南/接入授权/授权码模式/请求授权.md b/115doc/115开放平台/接入指南/接入授权/授权码模式/请求授权.md new file mode 100644 index 0000000..f152562 --- /dev/null +++ b/115doc/115开放平台/接入指南/接入授权/授权码模式/请求授权.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/接入指南/接入流程.md b/115doc/115开放平台/接入指南/接入流程.md new file mode 100644 index 0000000..493417d --- /dev/null +++ b/115doc/115开放平台/接入指南/接入流程.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/简介/更新记录.md b/115doc/115开放平台/简介/更新记录.md new file mode 100644 index 0000000..b575ec6 --- /dev/null +++ b/115doc/115开放平台/简介/更新记录.md @@ -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 | 创建文档 | diff --git a/115doc/115开放平台/简介/概述.md b/115doc/115开放平台/简介/概述.md new file mode 100644 index 0000000..b51058f --- /dev/null +++ b/115doc/115开放平台/简介/概述.md @@ -0,0 +1,30 @@ +# 概述 + +### 基本信息 + +| 属性 | 内容 | +|:-----------|:---------------------------------| +| 文档名称 | 概述 | +| 文档版本 | v1.0 | + +## 115生活简介 + +“115生活”是一款面向个人用户的数字生活平台,提供海量数据的安全存储、多端同步与快速访问。用户不仅可以便捷地管理和使用各类数字资源,还能使用多维社交、生活服务等多元化功能。 + +## 115生活开放平台能力说明 + +115生活开放平台提供“115生活”数据存储、同步、管理等功能的 API 服务。开发者通过对接 API,可以将“115生活”的存储能力集成到自己的应用中。 + +目前已开放以下能力: + +- **用户管理能力**:用户授权与信息查询等。 +- **文件管理能力**:获取文件列表,查看文件属性,以及文件上传、下载、搜索、移动、删除等。 +- **视频管理能力**:视频文件的在线转码与播放等。 +- **云下载服务**:获取云下载任务列表、配额信息,以及添加、删除下载任务等。 +- **商业价值转化**:开发者参与“推广产品得收益”计划,可基于用户实际购买的产品获取相应推广收益。 + +### 修改历史 + +| 修改时间 | 修改说明 | +|:-----------------------------|:-----| +| 2025年04月01日(周二) 00:00:00 | 创建文档 | diff --git a/115doc/README.md b/115doc/README.md new file mode 100644 index 0000000..aedbbe3 --- /dev/null +++ b/115doc/README.md @@ -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开放平台/接入指南/接入授权/` diff --git a/115doc/tree-index.json b/115doc/tree-index.json new file mode 100644 index 0000000..a796404 --- /dev/null +++ b/115doc/tree-index.json @@ -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" +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 5d484cd..79edaff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ # ============================================================================= # Multi-architecture build for MeBox. # -# Stage 1 (frontend) : Node 20 -> static SPA bundle +# Stage 1 (frontend) : Node 20.19+ -> static SPA bundle # Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0) # Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user # @@ -15,7 +15,7 @@ # ============================================================================= # ---- Stage 1: frontend (always build on the host architecture) ------------- -FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend +FROM --platform=$BUILDPLATFORM node:20.19-alpine AS frontend ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ WORKDIR /app/web COPY web/package*.json ./ diff --git a/README.md b/README.md index 1e2f780..b48e5d1 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

面向 NAS 与家庭影音场景的私人媒体中心

- 媒体库 · 刮削整理 · 网盘 STRM · Emby 协议 · 远程 Emby 挂载 · 多用户权限 · Docker 一键部署 + 媒体库 · 刮削整理 · 网盘 STRM · 兼容 Emby/Jellyfin 客户端 · 远程 Emby 挂载 · 多用户权限 · Docker 一键部署

@@ -17,7 +17,8 @@ 鸣谢 · 开发构建 · English · - 贡献规范 + 贡献规范 · + Telegram 群组

@@ -46,12 +47,12 @@ | **媒体库** | 电影、电视剧、动漫、综艺、音乐与自定义库;多根目录、扫库、海报墙、继续观看 | | **元数据刮削** | 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 投屏、系统设置与日志 | +| **运维能力** | 统一任务队列、存储统计、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,12 +193,17 @@ environment: **扫库或入库很慢?** 先确认路径映射与数据库档位。网盘扫描还受接口限速与目录规模影响;大库可考虑第二档 Redis 或第三档 OpenSearch。 -**qBittorrent 下载后无法整理?** -确认下载目录已通过 `volumes` 挂进容器,且 `MEBOX_DOWNLOAD_*` 环境变量对应正确。 +**下载目录文件没有被自动整理?** +确认下载目录已通过 `volumes` 挂进容器,且 `MEBOX_DOWNLOAD_*` 环境变量对应正确。MeBox 负责目录整理入库,qBittorrent 等下载器按普通软件自行部署即可。 **硬链接失败(cross-device link)?** 硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 btrfs 子卷或网盘挂载时请改用复制或软链接。 +**日志保留时间太短?** + +默认应用日志为 `20MB x 5`,容器 stdout 日志为 `20m x 3`。排障时可在 compose 中调大 +`MEBOX_LOGGING_MAX_SIZE_MB`、`MEBOX_LOGGING_MAX_BACKUPS` 与服务的 `logging.options.max-size/max-file`。 + **第三方播放器连不上?** 确认地址为 `http://IP:18080`,使用 MeBox 用户账号;反代部署需正确配置外部 URL 与 HTTPS 头。 @@ -204,6 +212,7 @@ environment: ## 开发构建 后端通过 `go:embed` 嵌入 `web/dist`,**编译前必须先构建前端**。 +前端构建要求 Node.js `20.19+` 或 `22.12+`。 ```bash npm --prefix web ci @@ -216,6 +225,14 @@ npm --prefix web run dev # http://127.0.0.1:3000 CI 会在 Release 中提供 Windows / Linux / macOS 的 amd64、arm64 单文件可执行程序。 +Windows 本地打包: + +```powershell +.\scripts\build-windows.ps1 -Version dev +``` + +Windows 可执行程序使用项目 Logo,不显示控制台窗口;启动后会常驻系统托盘。托盘菜单可打开 MeBox、切换开机自启、查看日志、重启或退出。 + --- ## 鸣谢 @@ -251,3 +268,18 @@ MeBox 在 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) 的基础 ## 许可证 本项目采用 [GPL-3.0](LICENSE) 许可证。 + +--- + +## 赞赏 + +如果 MeBox 帮你把家庭影音折腾明白了,欢迎请作者喝杯咖啡 ☕ + +

+ WhileTrue 的赞赏码 +

+ +

+ Telegram 交流群:https://t.me/MeBoxGroup
+ 使用问题、功能建议、更新动态,欢迎来群里聊 +

diff --git a/README_EN.md b/README_EN.md index 0afda28..18ab276 100644 --- a/README_EN.md +++ b/README_EN.md @@ -7,7 +7,7 @@

A self-hosted media center for NAS and home theater

- Libraries · Metadata · Cloud STRM · Emby protocol · Remote Emby mounts · Multi-user · Docker-first + Libraries · Metadata · Cloud STRM · Emby/Jellyfin client compatible · Remote Emby mounts · Multi-user · Docker-first

@@ -16,7 +16,8 @@ Quick Start · Deployment · Acknowledgements · - Development + Development · + Telegram

@@ -45,12 +46,12 @@ 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, recycle bin, storage stats, DLNA casting, settings and logs | +| **Operations** | Unified task queue, storage stats, DLNA casting, settings and logs | ### Tech stack @@ -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. @@ -196,6 +199,14 @@ npm --prefix web run dev Release builds ship single-file binaries for Windows, Linux, and macOS on amd64 and arm64. +Build the Windows executable locally: + +```powershell +.\scripts\build-windows.ps1 -Version dev +``` + +The Windows executable uses the project logo and runs without a console window. It stays in the notification area, with menu actions for opening MeBox, toggling auto-start, viewing logs, restarting, and exiting. + --- ## Acknowledgements @@ -227,3 +238,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 ☕ + +

+ WhileTrue donation QR +

+ +

+ Telegram group: https://t.me/MeBoxGroup
+ Questions, feature requests, and release news — come chat with us +

diff --git a/VERSION b/VERSION deleted file mode 100644 index 1a793f0..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.0.81 diff --git a/cmd/reader-smoke/main.go b/cmd/reader-smoke/main.go new file mode 100644 index 0000000..c6e6822 --- /dev/null +++ b/cmd/reader-smoke/main.go @@ -0,0 +1,197 @@ +// reader-smoke 是阅读书源兼容性冒烟工具: +// 对批量书源逐个跑「搜索 → 详情 → 目录 → 正文」全链路,输出兼容率报告。 +// +// 用法: +// +// go run ./cmd/reader-smoke -file sources.json -key 斗破苍穹 -c 8 +// go run ./cmd/reader-smoke -url https://example.com/sources.json -json > report.json +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "net/http" + "os" + "strings" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/helper" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service/reader" +) + +func main() { + file := flag.String("file", "", "书源文件路径(JSON 数组/对象/Base64/每行一个)") + urlFlag := flag.String("url", "", "书源网络地址(与 -file 二选一)") + key := flag.String("key", "斗破苍穹", "搜索关键词") + concurrency := flag.Int("c", 4, "并发数") + timeout := flag.Int("timeout", 90, "单源全链路超时(秒)") + jsonOut := flag.Bool("json", false, "输出完整 JSON 报告(追加在汇总后)") + flag.Parse() + + payload := "" + switch { + case *file != "": + data, err := os.ReadFile(*file) + if err != nil { + fatal("读取文件失败: %v", err) + } + payload = string(data) + case *urlFlag != "": + client := helper.NewSiteHTTPClient(30, true) + req, err := http.NewRequest("GET", *urlFlag, nil) + if err != nil { + fatal("构造请求失败: %v", err) + } + for k, v := range helper.HTTPHeaderPresets() { + req.Header.Set(k, v) + } + resp, err := client.Do(req) + if err != nil { + fatal("拉取书源失败: %v", err) + } + defer resp.Body.Close() + var sb strings.Builder + buf := make([]byte, 32*1024) + for { + n, err := resp.Body.Read(buf) + sb.Write(buf[:n]) + if err != nil { + break + } + } + payload = sb.String() + default: + fatal("需要 -file 或 -url 指定书源来源") + } + + sources := reader.ParseSourcePayload(payload) + if len(sources) == 0 { + fatal("未从输入中识别到书源") + } + + svc := reader.NewReaderService(&config.Config{}, zap.NewNop(), &repository.Container{}) + ctx := context.Background() + + results := make([]*reader.SmokeChainResult, len(sources)) + sem := make(chan struct{}, max(1, *concurrency)) + var wg sync.WaitGroup + for i, raw := range sources { + wg.Add(1) + sem <- struct{}{} + go func(i int, raw string) { + defer wg.Done() + defer func() { <-sem }() + ctxSrc, cancel := context.WithTimeout(ctx, time.Duration(*timeout)*time.Second) + defer cancel() + res := svc.SmokeSource(ctxSrc, raw, *key) + results[i] = res + status := "✓" + if !res.OK { + status = "✗" + } + fmt.Fprintf(os.Stderr, "%s %-24s [%s] hits=%d chapters=%d content=%d %s\n", + status, truncate(res.SourceName, 24), stageCN(res), res.SearchHits, res.Chapters, res.ContentLen, res.Error) + }(i, raw) + } + wg.Wait() + + // 汇总 + var searchOK, infoOK, tocOK, contentOK, allOK int + failedAt := map[string]int{} + for _, r := range results { + if r == nil { + continue + } + switch r.FailedAt { + case "": + allOK++ + searchOK++ + infoOK++ + tocOK++ + contentOK++ + case "search": + failedAt["search"]++ + case "info": + searchOK++ + failedAt["info"]++ + case "toc": + searchOK++ + infoOK++ + failedAt["toc"]++ + case "content": + searchOK++ + infoOK++ + tocOK++ + failedAt["content"]++ + } + } + n := len(results) + pct := func(v int) string { + if n == 0 { + return "0%" + } + return fmt.Sprintf("%.1f%%", float64(v)/float64(n)*100) + } + fmt.Printf("\n==== 冒烟报告 ====\n") + fmt.Printf("书源总数: %d 关键词: %s\n", n, *key) + fmt.Printf("搜索通过: %d (%s)\n", searchOK, pct(searchOK)) + fmt.Printf("详情通过: %d (%s)\n", infoOK, pct(infoOK)) + fmt.Printf("目录通过: %d (%s)\n", tocOK, pct(tocOK)) + fmt.Printf("正文通过: %d (%s)\n", contentOK, pct(contentOK)) + fmt.Printf("全链路通过: %d (%s)\n", allOK, pct(allOK)) + for _, stage := range []string{"search", "info", "toc", "content"} { + if failedAt[stage] > 0 { + fmt.Printf(" 失败于 %s: %d\n", stageCN(&reader.SmokeChainResult{FailedAt: stage}), failedAt[stage]) + } + } + + if *jsonOut { + out, err := json.MarshalIndent(results, "", " ") + if err != nil { + fatal("序列化报告失败: %v", err) + } + fmt.Println(string(out)) + } +} + +func stageCN(r *reader.SmokeChainResult) string { + switch r.FailedAt { + case "": + return "完成" + case "search": + return "搜索" + case "info": + return "详情" + case "toc": + return "目录" + case "content": + return "正文" + case "parse": + return "解析" + default: + return r.FailedAt + } +} + +func truncate(s string, n int) string { + rs := []rune(strings.TrimSpace(s)) + if len(rs) <= n { + if len(rs) == 0 { + return "(未命名)" + } + return string(rs) + } + return string(rs[:n]) + "…" +} + +func fatal(format string, args ...any) { + fmt.Fprintf(os.Stderr, "reader-smoke: "+format+"\n", args...) + os.Exit(1) +} diff --git a/cmd/server/application.go b/cmd/server/application.go new file mode 100644 index 0000000..2056c29 --- /dev/null +++ b/cmd/server/application.go @@ -0,0 +1,197 @@ +package main + +import ( + "context" + "fmt" + "os" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/database" + "github.com/truewhile/MeBox/internal/helper" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" +) + +// application owns the long-running MeBox server and all resources created +// during startup. Platform entry points decide how the application is +// controlled: a console signal loop or a Windows notification-area icon. +type application struct { + cfg *config.Config + logger *zap.Logger + embyCompatLogger *zap.Logger + serverManager *serverManager + services *service.Container + + closeMu sync.Mutex + closeFuncs []func() + shutdown sync.Once + shutdownErr error +} + +func newApplication() (*application, error) { + cfg, err := config.Load() + if err != nil { + return nil, fmt.Errorf("config load failed: %w", err) + } + + logger, closeLogger, err := newLoggerWithCloser(cfg) + if err != nil { + return nil, fmt.Errorf("logger init failed: %w", err) + } + + app := &application{ + cfg: cfg, + logger: logger, + closeFuncs: []func(){closeLogger}, + } + if err := app.start(); err != nil { + app.closeLoggers() + return nil, err + } + return app, nil +} + +func (a *application) start() error { + embyCompatLogger, closeEmbyCompatLogger, err := newEmbyCompatLogger(a.cfg) + if err != nil { + a.logger.Error("Emby compatibility logger init failed", zap.Error(err)) + return fmt.Errorf("Emby compatibility logger init failed: %w", err) + } + a.embyCompatLogger = embyCompatLogger + a.addCloser(closeEmbyCompatLogger) + + appVersion := effectiveVersion(version) + a.logger.Info("starting MeBox", + zap.String("version", appVersion), + zap.Int("port", a.cfg.App.Port), + zap.String("data_dir", a.cfg.App.DataDir), + zap.String("emby_compat_log", embyCompatLogPath(a.cfg)), + ) + + for _, dir := range []string{a.cfg.App.DataDir, a.cfg.Cache.CacheDir} { + if err := os.MkdirAll(dir, 0o750); err != nil { + a.logger.Error("create dir failed", zap.String("dir", dir), zap.Error(err)) + return fmt.Errorf("create dir %s failed: %w", dir, err) + } + } + + db, err := database.Open(a.cfg, a.logger) + if err != nil { + a.logger.Error("database open failed", zap.Error(err)) + return fmt.Errorf("database open failed: %w", err) + } + if err := waitForDatabase(db, a.logger); err != nil { + a.logger.Error("database not ready", zap.Error(err)) + return fmt.Errorf("database not ready: %w", err) + } + if err := database.AutoMigrate(db); err != nil { + a.logger.Error("auto-migrate failed", zap.Error(err)) + return fmt.Errorf("auto-migrate failed: %w", err) + } + if err := database.MigrateSQLiteToCurrentIfNeeded(a.cfg, db, a.logger); err != nil { + a.logger.Error("sqlite to postgres migration failed", zap.Error(err)) + return fmt.Errorf("sqlite to postgres migration failed: %w", err) + } + + repos := repository.New(db) + service.ApplyRuntimeSettings(context.Background(), a.cfg, repos, a.logger) + applyCPUThreadLimit(a.cfg, a.logger) + a.services = service.NewWithVersion(a.cfg, a.logger, repos, appVersion) + + // 一次性清洗历史脏数据: 老版本把单集 episode id / 单集名写进整剧字段, 导致 + // 同一部剧被拆成多张单集卡。清空被污染的字段并重置为 pending(借后续重刮修正)。 + if cleaned, err := a.services.NormalizePollutedEpisodeMetadata(context.Background()); err != nil { + a.logger.Warn("polluted episode metadata cleanup failed", zap.Error(err)) + } else if cleaned > 0 { + a.logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned)) + } + + if err := a.services.Auth.SeedAdmin(context.Background()); err != nil { + a.logger.Warn("seed admin failed", zap.Error(err)) + } + + router := buildRouter(a.cfg, a.logger, a.embyCompatLogger, a.services) + a.serverManager = newServerManager(a.cfg, a.logger, router) + a.services.ReloadHTTPServer = a.serverManager.Reload + if err := a.serverManager.Start(); err != nil { + a.logger.Error("listen failed", zap.Error(err)) + return fmt.Errorf("listen failed: %w", err) + } + + go func() { + scheme := "http" + if a.cfg.App.HTTPSEnabled { + scheme = "https" + } + if publicIP := getPublicIP(3 * time.Second); publicIP != "" { + a.logger.Info("server public endpoint", + zap.String("public", fmt.Sprintf("%s://%s:%d", scheme, publicIP, a.cfg.App.Port)), + ) + } + }() + helper.Go(a.logger, "services.boot", a.services.Boot) + return nil +} + +// Shutdown is idempotent and safe to call from both the tray handler and the +// systray exit callback. +func (a *application) Shutdown() error { + a.shutdown.Do(func() { + a.logger.Info("shutdown requested") + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + if a.serverManager != nil { + if err := a.serverManager.Shutdown(ctx); err != nil { + a.shutdownErr = err + a.logger.Error("graceful shutdown failed", zap.Error(err)) + } + } + cancel() + + if a.services != nil { + a.services.Close() + } + a.logger.Info("MeBox stopped") + _ = a.logger.Sync() + if a.embyCompatLogger != nil { + _ = a.embyCompatLogger.Sync() + } + a.closeLoggers() + }) + return a.shutdownErr +} + +func (a *application) addCloser(fn func()) { + if fn == nil { + return + } + a.closeMu.Lock() + a.closeFuncs = append(a.closeFuncs, fn) + a.closeMu.Unlock() +} + +func (a *application) closeLoggers() { + a.closeMu.Lock() + closers := append([]func(){}, a.closeFuncs...) + a.closeFuncs = nil + a.closeMu.Unlock() + + for i := len(closers) - 1; i >= 0; i-- { + closers[i]() + } +} + +func (a *application) localURL() string { + config.RuntimeMu.RLock() + scheme := "http" + if a.cfg.App.HTTPSEnabled { + scheme = "https" + } + port := a.cfg.App.Port + config.RuntimeMu.RUnlock() + return fmt.Sprintf("%s://127.0.0.1:%d", scheme, port) +} diff --git a/cmd/server/logging.go b/cmd/server/logging.go index 6d32fd3..f53ada8 100644 --- a/cmd/server/logging.go +++ b/cmd/server/logging.go @@ -98,3 +98,41 @@ func logFilePaths(cfg *config.Config) (string, string, string) { } return filepath.Join(out, "app.log"), filepath.Join(out, "warn.log"), filepath.Join(out, "error.log") } + +// newEmbyCompatLogger 构建只写入 Emby 兼容日志文件的独立 Zap 实例。 +// 它不参与 app.log 的日志级别过滤,始终记录 INFO 及以上,确保成功请求也能 +// 用于还原客户端的接口调用顺序;轮转参数沿用 logging 配置。 +func newEmbyCompatLogger(cfg *config.Config) (*zap.Logger, func(), error) { + encoderCfg := zap.NewProductionEncoderConfig() + encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder + var encoder zapcore.Encoder + if strings.EqualFold(strings.TrimSpace(cfg.Logging.Format), "console") { + encoder = zapcore.NewConsoleEncoder(encoderCfg) + } else { + encoder = zapcore.NewJSONEncoder(encoderCfg) + } + + writer, err := newRotatingFileWriter(embyCompatLogPath(cfg), cfg.Logging) + if err != nil { + return nil, nil, err + } + log := zap.New( + zapcore.NewCore(encoder, writer, zap.InfoLevel), + zap.AddCaller(), + zap.AddStacktrace(zapcore.ErrorLevel), + zap.ErrorOutput(zapcore.Lock(os.Stderr)), + ) + return log, func() { _ = writer.Close() }, nil +} + +func embyCompatLogPath(cfg *config.Config) string { + out := strings.TrimSpace(cfg.Logging.OutputPath) + if out == "" || strings.EqualFold(out, "stdout") || strings.EqualFold(out, "stderr") { + return filepath.Join(cfg.App.DataDir, "logs", "emby-compat.log") + } + if ext := filepath.Ext(out); ext != "" { + base := strings.TrimSuffix(out, ext) + return base + ".emby-compat" + ext + } + return filepath.Join(out, "emby-compat.log") +} diff --git a/cmd/server/logging_test.go b/cmd/server/logging_test.go index 1929f4d..99260cf 100644 --- a/cmd/server/logging_test.go +++ b/cmd/server/logging_test.go @@ -116,3 +116,34 @@ func TestRotatingFileWriterCapsFileSize(t *testing.T) { } _ = writer.Sync() } + +func TestEmbyCompatLoggerWritesDedicatedFile(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{} + cfg.App.DataDir = dir + cfg.Logging.Format = "json" + cfg.Logging.OutputPath = filepath.Join(dir, "logs") + cfg.Logging.EnableRotation = true + cfg.Logging.MaxSizeMB = 1 + cfg.Logging.MaxBackups = 2 + + log, closeFn, err := newEmbyCompatLogger(cfg) + if err != nil { + t.Fatal(err) + } + log.Info("emby request", zap.String("client", "Infuse")) + _ = log.Sync() + closeFn() + + path := filepath.Join(dir, "logs", "emby-compat.log") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "emby request") || !strings.Contains(string(data), "Infuse") { + t.Fatalf("dedicated Emby log missing request data: %s", data) + } + if _, err := os.Stat(filepath.Join(dir, "logs", "app.log")); !os.IsNotExist(err) { + t.Fatalf("Emby logger must not write app.log, stat err=%v", err) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 713f7a4..083d53e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -11,20 +11,8 @@ package main import ( - "context" - "fmt" "os" - "os/signal" "strings" - "syscall" - "time" - - "go.uber.org/zap" - - "github.com/truewhile/MeBox/internal/config" - "github.com/truewhile/MeBox/internal/database" - "github.com/truewhile/MeBox/internal/repository" - "github.com/truewhile/MeBox/internal/service" ) // version is overwritten at build time via -ldflags="-X main.version=...". @@ -45,95 +33,5 @@ func effectiveVersion(buildVersion string) string { } func main() { - cfg, err := config.Load() - if err != nil { - fmt.Fprintf(os.Stderr, "config load failed: %v\n", err) - os.Exit(1) - } - - logger, err := newLogger(cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "logger init failed: %v\n", err) - os.Exit(1) - } - defer func() { _ = logger.Sync() }() - - appVersion := effectiveVersion(version) - logger.Info("starting MeBox", - zap.String("version", appVersion), - zap.Int("port", cfg.App.Port), - zap.String("data_dir", cfg.App.DataDir), - ) - - // Ensure data / cache / web dirs exist. - for _, d := range []string{cfg.App.DataDir, cfg.Cache.CacheDir} { - if err := os.MkdirAll(d, 0o750); err != nil { - logger.Fatal("create dir failed", zap.String("dir", d), zap.Error(err)) - } - } - - db, err := database.Open(cfg, logger) - if err != nil { - logger.Fatal("database open failed", zap.Error(err)) - } - if err := waitForDatabase(db, logger); err != nil { - logger.Fatal("database not ready", zap.Error(err)) - } - if err := database.AutoMigrate(db); err != nil { - logger.Fatal("auto-migrate failed", zap.Error(err)) - } - if err := database.MigrateSQLiteToCurrentIfNeeded(cfg, db, logger); err != nil { - logger.Fatal("sqlite to postgres migration failed", zap.Error(err)) - } - - repos := repository.New(db) - service.ApplyRuntimeSettings(context.Background(), cfg, repos, logger) - applyCPUThreadLimit(cfg, logger) - services := service.NewWithVersion(cfg, logger, repos, appVersion) - - // 一次性清洗历史脏数据: 老版本把单集 episode id / 单集名写进整剧字段, 导致 - // 同一部剧被拆成多张单集卡。清空被污染的字段并重置为 pending(借后续重刮修正)。 - if cleaned, err := services.NormalizePollutedEpisodeMetadata(context.Background()); err != nil { - logger.Warn("polluted episode metadata cleanup failed", zap.Error(err)) - } else if cleaned > 0 { - logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned)) - } - - if err := services.Auth.SeedAdmin(context.Background()); err != nil { - logger.Warn("seed admin failed", zap.Error(err)) - } - - router := buildRouter(cfg, logger, services) - - serverMgr := newServerManager(cfg, logger, router) - services.ReloadHTTPServer = serverMgr.Reload - if err := serverMgr.Start(); err != nil { - logger.Fatal("listen failed", zap.Error(err)) - } - go func() { - scheme := "http" - if cfg.App.HTTPSEnabled { - scheme = "https" - } - if publicIP := getPublicIP(3 * time.Second); publicIP != "" { - logger.Info("server public endpoint", - zap.String("public", fmt.Sprintf("%s://%s:%d", scheme, publicIP, cfg.App.Port)), - ) - } - }() - go services.Boot() - - // Graceful shutdown. - stop := make(chan os.Signal, 1) - signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) - <-stop - logger.Info("shutdown requested") - - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - if err := serverMgr.Shutdown(ctx); err != nil { - logger.Error("graceful shutdown failed", zap.Error(err)) - } - services.Close() - logger.Info("MeBox stopped") + runProgram() } diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index 368e149..2038b8e 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -76,6 +76,9 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) { if err := os.MkdirAll(filepath.Join(webDir, "assets"), 0o755); err != nil { t.Fatal(err) } + if err := os.MkdirAll(filepath.Join(webDir, "fonts"), 0o755); err != nil { + t.Fatal(err) + } if err := os.MkdirAll(filepath.Join(webDir, "brand"), 0o755); err != nil { t.Fatal(err) } @@ -85,6 +88,9 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) { if err := os.WriteFile(filepath.Join(webDir, "assets", "app.js"), []byte("console.log('ok')"), 0o644); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(webDir, "fonts", "geist-400.woff2"), []byte("wOF2-test-font"), 0o644); err != nil { + t.Fatal(err) + } if err := os.WriteFile(filepath.Join(webDir, "brand", "mebox-logo.svg"), []byte(""), 0o644); err != nil { t.Fatal(err) } @@ -105,6 +111,29 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) { t.Fatalf("asset Cache-Control = %q, want immutable", got) } + fontReq := httptest.NewRequest(http.MethodGet, "/fonts/geist-400.woff2", nil) + fontResp := httptest.NewRecorder() + router.ServeHTTP(fontResp, fontReq) + if fontResp.Code != http.StatusOK { + t.Fatalf("font status = %d, want 200", fontResp.Code) + } + if got := fontResp.Header().Get("Cache-Control"); !strings.Contains(got, "max-age=86400") { + t.Fatalf("font Cache-Control = %q, want max-age=86400", got) + } + if got := fontResp.Body.String(); got != "wOF2-test-font" { + t.Fatalf("font body = %q, want wOF2-test-font", got) + } + + missingFontReq := httptest.NewRequest(http.MethodGet, "/fonts/missing.woff2", nil) + missingFontResp := httptest.NewRecorder() + router.ServeHTTP(missingFontResp, missingFontReq) + if missingFontResp.Code != http.StatusNotFound { + t.Fatalf("missing font status = %d, want 404", missingFontResp.Code) + } + if strings.Contains(missingFontResp.Body.String(), "index") { + t.Fatalf("missing font should not serve SPA index: %q", missingFontResp.Body.String()) + } + brandReq := httptest.NewRequest(http.MethodGet, "/brand/mebox-logo.svg", nil) brandResp := httptest.NewRecorder() router.ServeHTTP(brandResp, brandReq) diff --git a/cmd/server/program_other.go b/cmd/server/program_other.go new file mode 100644 index 0000000..43f3825 --- /dev/null +++ b/cmd/server/program_other.go @@ -0,0 +1,33 @@ +//go:build !windows + +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" +) + +func runProgram() { + app, err := newApplication() + if err != nil { + reportError("MeBox 启动失败", err) + os.Exit(1) + } + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + if err := app.Shutdown(); err != nil { + reportError("MeBox 退出失败", err) + } +} + +func reportError(title string, err error) { + if err == nil { + return + } + fmt.Fprintf(os.Stderr, "%s: %v\n", title, err) +} diff --git a/cmd/server/program_windows.go b/cmd/server/program_windows.go new file mode 100644 index 0000000..623b03a --- /dev/null +++ b/cmd/server/program_windows.go @@ -0,0 +1,330 @@ +//go:build windows + +package main + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "syscall" + + "fyne.io/systray" + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + + "github.com/truewhile/MeBox/internal/brand" +) + +const ( + runRegistryKey = `Software\Microsoft\Windows\CurrentVersion\Run` + runRegistryValue = "MeBox" + singleInstanceName = `Local\MeBox-Server` +) + +var errAlreadyRunning = errors.New("MeBox is already running") + +func runProgram() { + if err := prepareWorkingDirectory(); err != nil { + reportError("MeBox 启动失败", fmt.Errorf("切换工作目录失败: %w", err)) + return + } + + instance, err := acquireSingleInstance() + if errors.Is(err, errAlreadyRunning) { + reportError("MeBox", errors.New("MeBox 已在运行,请查看右下角托盘图标")) + return + } + if err != nil { + reportError("MeBox 启动失败", fmt.Errorf("创建单实例锁失败: %w", err)) + return + } + + app, err := newApplication() + if err != nil { + _ = instance.Close() + reportError("MeBox 启动失败", err) + return + } + + controller := &trayController{app: app} + systray.Run(controller.onReady, controller.onExit) + + // Release the mutex before starting the replacement process. The new + // process must be able to acquire it immediately after the old one exits. + _ = instance.Close() + + if !controller.readyClosed.Load() { + _ = app.Shutdown() + reportError("MeBox 启动失败", errors.New("系统托盘初始化失败")) + return + } + if controller.restartRequested.Load() { + if err := launchSelf(); err != nil { + reportError("MeBox 重启失败", err) + } + } +} + +type trayController struct { + app *application + + readyClosed atomic.Bool + restartRequested atomic.Bool + shutdownStarted atomic.Bool +} + +func (c *trayController) onReady() { + defer func() { + c.readyClosed.Store(true) + }() + + systray.SetIcon(brand.Icon) + systray.SetTooltip("MeBox") + + mOpen := systray.AddMenuItem("打开 MeBox", "在浏览器中打开 MeBox") + mAutoStart := systray.AddMenuItemCheckbox("开机自启", "登录 Windows 后自动启动 MeBox", autoStartEnabled()) + mLogs := systray.AddMenuItem("查看日志", "打开 MeBox 应用日志") + systray.AddSeparator() + mRestart := systray.AddMenuItem("重启 MeBox", "重启 MeBox 服务") + mQuit := systray.AddMenuItem("退出 MeBox", "停止服务并退出") + + initialAutoStart := mAutoStart.Checked() + go func() { + for { + select { + case <-mOpen.ClickedCh: + if err := openURL(c.app.localURL()); err != nil { + reportError("MeBox", fmt.Errorf("打开 MeBox 失败: %w", err)) + } + case <-mAutoStart.ClickedCh: + enable := !mAutoStart.Checked() + if err := setAutoStart(enable); err != nil { + if initialAutoStart { + mAutoStart.Check() + } else { + mAutoStart.Uncheck() + } + reportError("MeBox", fmt.Errorf("更新开机自启设置失败: %w", err)) + continue + } + if enable { + mAutoStart.Check() + } else { + mAutoStart.Uncheck() + } + initialAutoStart = enable + case <-mLogs.ClickedCh: + if err := c.app.openLog(); err != nil { + reportError("MeBox", fmt.Errorf("打开日志失败: %w", err)) + } + case <-mRestart.ClickedCh: + c.restart() + case <-mQuit.ClickedCh: + c.quit() + } + } + }() +} + +func (c *trayController) onExit() { + _ = c.app.Shutdown() +} + +func (c *trayController) quit() { + if !c.shutdownStarted.CompareAndSwap(false, true) { + return + } + go func() { + _ = c.app.Shutdown() + systray.Quit() + }() +} + +func (c *trayController) restart() { + if !c.shutdownStarted.CompareAndSwap(false, true) { + return + } + c.restartRequested.Store(true) + go func() { + _ = c.app.Shutdown() + systray.Quit() + }() +} + +func (a *application) openLog() error { + appLog, _, _ := logFilePaths(a.cfg) + if appLog != "" { + if _, err := os.Stat(appLog); err == nil { + return openPath(appLog) + } + _ = os.MkdirAll(filepath.Dir(appLog), 0o750) + return openPath(filepath.Dir(appLog)) + } + + logDir := filepath.Join(a.cfg.App.DataDir, "logs") + if err := os.MkdirAll(logDir, 0o750); err != nil { + return err + } + return openPath(logDir) +} + +func prepareWorkingDirectory() error { + exe, err := os.Executable() + if err != nil { + return err + } + exeDir := filepath.Dir(exe) + cwd, err := os.Getwd() + if err != nil { + return err + } + if samePath(exeDir, cwd) || looksLikeProjectDirectory(cwd) { + return nil + } + return os.Chdir(exeDir) +} + +func looksLikeProjectDirectory(dir string) bool { + for _, name := range []string{"go.mod", "config.yaml", "data", filepath.Join("web", "dist")} { + if _, err := os.Stat(filepath.Join(dir, name)); err == nil { + return true + } + } + return false +} + +func samePath(left, right string) bool { + return strings.EqualFold(filepath.Clean(left), filepath.Clean(right)) +} + +type singleInstance struct { + handle windows.Handle +} + +func acquireSingleInstance() (*singleInstance, error) { + name, err := windows.UTF16PtrFromString(singleInstanceName) + if err != nil { + return nil, err + } + handle, err := windows.CreateMutex(nil, false, name) + if errors.Is(err, windows.ERROR_ALREADY_EXISTS) { + if handle != 0 { + _ = windows.CloseHandle(handle) + } + return nil, errAlreadyRunning + } + if err != nil { + return nil, err + } + return &singleInstance{handle: handle}, nil +} + +func (s *singleInstance) Close() error { + if s == nil || s.handle == 0 { + return nil + } + err := windows.CloseHandle(s.handle) + s.handle = 0 + return err +} + +func launchSelf() error { + exe, err := os.Executable() + if err != nil { + return err + } + cwd, err := os.Getwd() + if err != nil { + return err + } + + cmd := exec.Command(exe, os.Args[1:]...) + cmd.Dir = cwd + cmd.Env = os.Environ() + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS, + } + if err := cmd.Start(); err != nil { + return err + } + return cmd.Process.Release() +} + +func autoStartEnabled() bool { + key, err := registry.OpenKey(registry.CURRENT_USER, runRegistryKey, registry.QUERY_VALUE) + if err != nil { + return false + } + defer key.Close() + + value, _, err := key.GetStringValue(runRegistryValue) + if err != nil { + return false + } + exe, err := os.Executable() + if err != nil { + return false + } + return strings.EqualFold(strings.TrimSpace(strings.Trim(value, `"`)), filepath.Clean(exe)) +} + +func setAutoStart(enabled bool) error { + key, _, err := registry.CreateKey(registry.CURRENT_USER, runRegistryKey, registry.SET_VALUE) + if err != nil { + return err + } + defer key.Close() + + if !enabled { + if err := key.DeleteValue(runRegistryValue); err != nil && !errors.Is(err, registry.ErrNotExist) { + return err + } + return nil + } + + exe, err := os.Executable() + if err != nil { + return err + } + return key.SetStringValue(runRegistryValue, syscall.EscapeArg(filepath.Clean(exe))) +} + +func openURL(url string) error { + return shellOpen(url) +} + +func openPath(path string) error { + return shellOpen(path) +} + +func shellOpen(target string) error { + targetPtr, err := windows.UTF16PtrFromString(target) + if err != nil { + return err + } + verbPtr, err := windows.UTF16PtrFromString("open") + if err != nil { + return err + } + return windows.ShellExecute(0, verbPtr, targetPtr, nil, nil, 1) +} + +func reportError(title string, err error) { + if err == nil { + return + } + text, textErr := windows.UTF16PtrFromString(title + "\r\n\r\n" + err.Error()) + if textErr != nil { + return + } + caption, captionErr := windows.UTF16PtrFromString("MeBox") + if captionErr != nil { + return + } + _, _ = windows.MessageBox(0, text, caption, windows.MB_OK|windows.MB_ICONERROR|windows.MB_SETFOREGROUND) +} diff --git a/cmd/server/resources_windows.go b/cmd/server/resources_windows.go new file mode 100644 index 0000000..d668f2c --- /dev/null +++ b/cmd/server/resources_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package main + +// Regenerate the linked Windows resources after changing the project logo or +// manifest: +// +// go generate ./cmd/server +// +//go:generate go run github.com/akavel/rsrc@v0.10.2 -arch amd64 -ico ../../internal/brand/logo.ico -manifest winres/mebox.manifest -o rsrc_windows_amd64.syso +//go:generate go run github.com/akavel/rsrc@v0.10.2 -arch arm64 -ico ../../internal/brand/logo.ico -manifest winres/mebox.manifest -o rsrc_windows_arm64.syso diff --git a/cmd/server/router.go b/cmd/server/router.go index 7c37bb5..fea078d 100644 --- a/cmd/server/router.go +++ b/cmd/server/router.go @@ -19,13 +19,16 @@ import ( "github.com/truewhile/MeBox/web" ) -func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) *gin.Engine { +func buildRouter(cfg *config.Config, logger *zap.Logger, embyCompatLogger *zap.Logger, svc *service.Container) *gin.Engine { if !cfg.App.Debug { gin.SetMode(gin.ReleaseMode) } r := gin.New() r.Use(gin.Recovery()) r.Use(middleware.RequestLogger(logger)) + r.Use(middleware.EmbyCompatLogger(embyCompatLogger, func(path string) bool { + return !isFrontendLibraryRoute(path) && handler.IsEmbyPath(path) + })) if !cfg.App.Debug && len(cfg.App.CORSOrigins) == 0 { logger.Warn("CORS: no origins configured in production — CORS headers will be omitted (same-origin enforced). Set app.cors_origins for cross-origin access.") } @@ -53,11 +56,19 @@ func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) // comes from root, which is either the compiled-in SPA or an on-disk web dir. func serveSPA(r *gin.Engine, root fs.FS) { assets := r.Group("/assets") + assets.Use(middleware.GzipStatic()) assets.Use(func(c *gin.Context) { c.Header("Cache-Control", "public, max-age=31536000, immutable") c.Next() }) assets.GET("/*filepath", serveFSDir(root, "assets")) + fonts := r.Group("/fonts") + fonts.Use(middleware.GzipStatic()) + fonts.Use(func(c *gin.Context) { + c.Header("Cache-Control", "public, max-age=86400") + c.Next() + }) + fonts.GET("/*filepath", serveFSDir(root, "fonts")) brand := r.Group("/brand") brand.Use(func(c *gin.Context) { setNoCacheHeaders(c) @@ -69,7 +80,10 @@ func serveSPA(r *gin.Engine, root fs.FS) { r.GET(rootFile, serveFSFile(root, name)) r.HEAD(rootFile, serveFSFile(root, name)) } - r.NoRoute(func(c *gin.Context) { + 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) diff --git a/cmd/server/rsrc_windows_amd64.syso b/cmd/server/rsrc_windows_amd64.syso new file mode 100644 index 0000000..d704fd4 Binary files /dev/null and b/cmd/server/rsrc_windows_amd64.syso differ diff --git a/cmd/server/rsrc_windows_arm64.syso b/cmd/server/rsrc_windows_arm64.syso new file mode 100644 index 0000000..2125468 Binary files /dev/null and b/cmd/server/rsrc_windows_arm64.syso differ diff --git a/cmd/server/server_manager.go b/cmd/server/server_manager.go index 02ff1da..abe2526 100644 --- a/cmd/server/server_manager.go +++ b/cmd/server/server_manager.go @@ -130,26 +130,35 @@ func (m *serverManager) Shutdown(ctx context.Context) error { // desiredPair 根据当前配置计算目标监听形态:nil 表示明文 HTTP,非 nil 表示 TLS。 // 证书/私钥按"路径优先、内容兜底"解析,并校验是否匹配。 func (m *serverManager) desiredPair() (*tlsPair, error) { - if m.cfg == nil || !m.cfg.App.HTTPSEnabled { + // 与 ApplyRuntimeSetting 的写锁配对:HTTPS 相关字段可能被运行时设置 + // 热更新,无锁读存在数据竞争(string 撕裂)。 + config.RuntimeMu.RLock() + httpsEnabled := m.cfg != nil && m.cfg.App.HTTPSEnabled + cert := m.cfg.App.SSLCert + certPath := m.cfg.App.SSLCertPath + key := m.cfg.App.SSLKey + keyPath := m.cfg.App.SSLKeyPath + config.RuntimeMu.RUnlock() + if !httpsEnabled { return nil, nil } - certPEM, err := service.ResolveSSLMaterial(m.cfg.App.SSLCert, m.cfg.App.SSLCertPath, "证书") + certPEM, err := service.ResolveSSLMaterial(cert, certPath, "证书") if err != nil { return nil, err } - keyPEM, err := service.ResolveSSLMaterial(m.cfg.App.SSLKey, m.cfg.App.SSLKeyPath, "私钥") + keyPEM, err := service.ResolveSSLMaterial(key, keyPath, "私钥") if err != nil { return nil, err } if err := service.ValidateSSLKeyPair(certPEM, keyPEM); err != nil { return nil, err } - cert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)) + pairCert, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)) if err != nil { return nil, fmt.Errorf("SSL 证书/私钥无效:%v", err) } return &tlsPair{ - cert: cert, + cert: pairCert, certPEM: certPEM, keyPEM: keyPEM, version: certPEM + "\x00" + keyPEM, @@ -171,6 +180,8 @@ func (m *serverManager) maybeStartAutoReloadLocked() { // pathBased 是否至少有一侧证书/私钥通过文件路径配置。 func (m *serverManager) pathBased() bool { + config.RuntimeMu.RLock() + defer config.RuntimeMu.RUnlock() return strings.TrimSpace(m.cfg.App.SSLCertPath) != "" || strings.TrimSpace(m.cfg.App.SSLKeyPath) != "" } diff --git a/cmd/server/winres/mebox.manifest b/cmd/server/winres/mebox.manifest new file mode 100644 index 0000000..12e1a90 --- /dev/null +++ b/cmd/server/winres/mebox.manifest @@ -0,0 +1,28 @@ + + + + MeBox media server + + + + + + + + + + + + + + + true/pm + true + + + diff --git a/dist/index.html b/dist/index.html new file mode 100644 index 0000000..232bbe8 --- /dev/null +++ b/dist/index.html @@ -0,0 +1,26 @@ + + + + + + + + + + + + MeBox + + + + + + + + + + +
+ + diff --git a/docker-compose.metatube.yml b/docker-compose.metatube.yml new file mode 100644 index 0000000..62e37c8 --- /dev/null +++ b/docker-compose.metatube.yml @@ -0,0 +1,216 @@ +# MeBox + MetaTube 一体部署 Docker Compose 部署文件 +# +# 组件: +# MeBox + PostgreSQL + Redis + MetaTube +# +# 与 docker-compose.standard.yml 的区别: +# 额外内置一个 MetaTube 后端(番号元数据刮削 / 封面人脸裁剪), +# 并与 MeBox 共用同一个 PostgreSQL 实例,不需要再单独起一套数据库。 +# +# 使用方式二选一: +# 1. 保存为 docker-compose.yml 后执行: +# docker compose up -d +# 2. 保留本文件名时执行: +# docker compose -f docker-compose.metatube.yml up -d +# +# 首次启动后需要做一次配置(只有这一处是手动的): +# 登录 MeBox → 设置 → 番号刮削: +# - 番号刮削引擎:MetaTube 后端服务,或 智能混合 +# - MetaTube 服务端地址:http://metatube:8080 +# - 访问 Token:留空(仅内部网络,未启用认证;如需认证两边填同一个值) +# +# 说明: +# - MetaTube 使用独立数据库 metatube,由 metatube-db-init 一次性容器自动创建; +# 和 MeBox 的 mebox 库共用同一个 PostgreSQL 实例,只是分成两个库。 +# - MetaTube 不向宿主机映射端口,只在本 Compose 内部网络提供服务。 +# - MeBox 故意不依赖 MetaTube:MetaTube 未就绪或挂掉时,智能混合模式会自动回退到内置刮削源。 +# - 更新全部组件: +# docker compose -f docker-compose.metatube.yml pull +# docker compose -f docker-compose.metatube.yml up -d +# 保留本文件名时,管理面板「系统更新」需要把「Compose 安装目录」指向本文件所在目录, +# 或把更新命令改成带 -f docker-compose.metatube.yml 的形式。 +# +# 默认账号: +# admin / admin123 + +services: + mebox: + image: ghcr.io/truewhile/mebox:latest + + restart: unless-stopped + init: true + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + ports: + - "18080:8080" + + extra_hosts: + - "host.docker.internal:host-gateway" + + volumes: + # 程序运行数据:JWT 密钥、运行配置、旧 SQLite 迁移源。 + - ./data:/data + + # 缓存目录:海报缓存、临时文件等。通常不用备份。 + - ./cache:/cache + + # 媒体库目录。自动整理/重命名/入库需要读写权限。 + # NAS 示例:source: /vol1/1000/Media + # Windows Docker Desktop 示例:source: D:/Media + # create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。 + - ./media: /media + + environment: + TZ: Asia/Shanghai + + PUID: "0" + PGID: "0" + + MEBOX_APP_HOST: 0.0.0.0 + MEBOX_APP_PORT: 8080 + MEBOX_APP_WEB_DIR: /app/web/dist + MEBOX_APP_DATA_DIR: /data + MEBOX_LOGGING_LEVEL: info + MEBOX_LOGGING_FORMAT: console + MEBOX_LOGGING_OUTPUT_PATH: /data/logs + MEBOX_LOGGING_MAX_SIZE_MB: "20" + MEBOX_LOGGING_MAX_BACKUPS: "5" + MEBOX_LOGGING_MAX_AGE_DAYS: "30" + + MEBOX_DATABASE_TYPE: postgres + MEBOX_DATABASE_DSN: postgres://mebox:mebox@postgres:5432/mebox?sslmode=disable + MEBOX_DATABASE_DB_PATH: /data/mebox.db + + # Redis 只做热缓存,源数据仍在 PostgreSQL;Redis 丢失可自动重建。 + MEBOX_CACHE_REDIS_URL: redis://redis:6379/0 + MEBOX_CACHE_CACHE_DIR: /cache + + MEBOX_UPDATE_IMAGE: ghcr.io/truewhile/mebox:latest + + # 路径换算配置。左边宿主机真实路径要和 volumes 左边保持一致。 + MEBOX_MEDIA_DIR: /media + MEBOX_MEDIA_CONTAINER_DIR: /media + MEBOX_DOWNLOAD_DIR: /downloads + MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads + + MEBOX_TRANSCODER_ENABLED: "true" + MEBOX_TRANSCODER_HARDWARE_ACCEL: "false" + MEBOX_TRANSCODER_REALTIME: "true" + MEBOX_TRANSCODER_THREADS: "2" + MEBOX_TRANSCODER_MAX_CONCURRENT: "1" + MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120" + + healthcheck: + test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + + postgres: + image: postgres:16-alpine + # 首次部署允许拉取;日常更新请只 pull mebox。 + # MetaTube 与 MeBox 共用本实例(不同数据库),无需第二套 PostgreSQL。 + pull_policy: missing + restart: unless-stopped + environment: + POSTGRES_DB: mebox + POSTGRES_USER: mebox + POSTGRES_PASSWORD: mebox + TZ: Asia/Shanghai + volumes: + # 同时存放 mebox 与 metatube 两个数据库,备份这一个目录即可。 + - ./postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mebox -d mebox"] + interval: 10s + timeout: 5s + retries: 10 + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + + # 一次性容器:确保 metatube 库存在。 + # MetaTube 自己的 -db-auto-migrate 只建表不建库,所以必须先建库。 + # 数据库已存在时它会直接退出,重复执行无副作用。 + metatube-db-init: + image: postgres:16-alpine + pull_policy: missing + restart: "no" + depends_on: + postgres: + condition: service_healthy + environment: + PGPASSWORD: mebox + entrypoint: ["/bin/sh", "-c"] + command: + - | + psql -h postgres -U mebox -d mebox -tAc "SELECT 1 FROM pg_database WHERE datname='metatube'" | grep -q 1 || psql -h postgres -U mebox -d mebox -c "CREATE DATABASE metatube OWNER mebox" + logging: + driver: json-file + options: + max-size: "5m" + max-file: "1" + + metatube: + image: ghcr.io/metatube-community/metatube-server:latest + restart: unless-stopped + depends_on: + metatube-db-init: + condition: service_completed_successfully + environment: + TZ: Asia/Shanghai + # 需要走代理时在这里填写,例如 http://192.168.1.2:7890 + HTTP_PROXY: "" + HTTPS_PROXY: "" + # 不需要 ports:MeBox 通过内部网络访问 http://metatube:8080。 + # 只有想让局域网内其他工具直连时才映射,且建议只映射到 127.0.0.1。 + command: + - -dsn + - postgres://mebox:mebox@postgres:5432/metatube?sslmode=disable + - -port + - "8080" + - -db-auto-migrate + - -db-prepared-stmt + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + + redis: + image: redis:7-alpine + pull_policy: missing + restart: unless-stopped + command: + - redis-server + - --appendonly + - "yes" + - --maxmemory + - 256mb + - --maxmemory-policy + - allkeys-lru + volumes: + - ./redis:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" diff --git a/docker-compose.search.yml b/docker-compose.search.yml index 0453233..5614ac2 100644 --- a/docker-compose.search.yml +++ b/docker-compose.search.yml @@ -55,7 +55,7 @@ services: bind: create_host_path: false - # 下载目录。需要和 qBittorrent 保存路径保持一致。 + # 下载目录。需要和下载器保存路径保持一致。 # NAS 示例:source: /vol1/1000/Downloads # Windows Docker Desktop 示例:source: D:/Downloads - type: bind @@ -81,8 +81,8 @@ services: MEBOX_LOGGING_LEVEL: info MEBOX_LOGGING_FORMAT: console MEBOX_LOGGING_OUTPUT_PATH: /data/logs - MEBOX_LOGGING_MAX_SIZE_MB: "50" - MEBOX_LOGGING_MAX_BACKUPS: "20" + MEBOX_LOGGING_MAX_SIZE_MB: "20" + MEBOX_LOGGING_MAX_BACKUPS: "5" MEBOX_LOGGING_MAX_AGE_DAYS: "30" MEBOX_DATABASE_TYPE: postgres @@ -124,8 +124,8 @@ services: logging: driver: json-file options: - max-size: "50m" - max-file: "10" + max-size: "20m" + max-file: "3" postgres: image: postgres:16-alpine @@ -147,8 +147,8 @@ services: logging: driver: json-file options: - max-size: "50m" - max-file: "10" + max-size: "20m" + max-file: "3" redis: image: redis:7-alpine @@ -172,8 +172,8 @@ services: logging: driver: json-file options: - max-size: "50m" - max-file: "10" + max-size: "20m" + max-file: "3" opensearch: image: opensearchproject/opensearch:2 @@ -196,5 +196,5 @@ services: logging: driver: json-file options: - max-size: "50m" - max-file: "10" + max-size: "20m" + max-file: "3" diff --git a/docker-compose.simple.yml b/docker-compose.simple.yml index 47d675d..1be9d65 100644 --- a/docker-compose.simple.yml +++ b/docker-compose.simple.yml @@ -64,12 +64,12 @@ services: MEBOX_LOGGING_LEVEL: info MEBOX_LOGGING_FORMAT: console MEBOX_LOGGING_OUTPUT_PATH: /data/logs - MEBOX_LOGGING_MAX_SIZE_MB: "50" - MEBOX_LOGGING_MAX_BACKUPS: "20" + MEBOX_LOGGING_MAX_SIZE_MB: "20" + MEBOX_LOGGING_MAX_BACKUPS: "5" 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: @@ -82,5 +82,5 @@ services: logging: driver: json-file options: - max-size: "50m" - max-file: "10" + max-size: "20m" + max-file: "3" diff --git a/docker-compose.standard.yml b/docker-compose.standard.yml index 0eba48f..e6c13a0 100644 --- a/docker-compose.standard.yml +++ b/docker-compose.standard.yml @@ -44,30 +44,13 @@ services: # NAS 示例:source: /vol1/1000/Media # Windows Docker Desktop 示例:source: D:/Media # create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。 - - type: bind - source: ./media - target: /media - bind: - create_host_path: false - - # 下载目录。需要和 qBittorrent 保存路径保持一致。 - # NAS 示例:source: /vol1/1000/Downloads - # Windows Docker Desktop 示例:source: D:/Downloads - - type: bind - source: ./downloads - target: /downloads - bind: - create_host_path: false - - # 管理面板「系统更新」需要访问 Docker 引擎。 - # 需要一键更新 Docker 镜像时取消下一行注释。 - # - /var/run/docker.sock:/var/run/docker.sock + - ./media: /media environment: TZ: Asia/Shanghai - PUID: "1000" - PGID: "1000" + PUID: "0" + PGID: "0" MEBOX_APP_HOST: 0.0.0.0 MEBOX_APP_PORT: 8080 @@ -76,8 +59,8 @@ services: MEBOX_LOGGING_LEVEL: info MEBOX_LOGGING_FORMAT: console MEBOX_LOGGING_OUTPUT_PATH: /data/logs - MEBOX_LOGGING_MAX_SIZE_MB: "50" - MEBOX_LOGGING_MAX_BACKUPS: "20" + MEBOX_LOGGING_MAX_SIZE_MB: "20" + MEBOX_LOGGING_MAX_BACKUPS: "5" MEBOX_LOGGING_MAX_AGE_DAYS: "30" MEBOX_DATABASE_TYPE: postgres @@ -113,8 +96,8 @@ services: logging: driver: json-file options: - max-size: "50m" - max-file: "10" + max-size: "20m" + max-file: "3" postgres: image: postgres:16-alpine @@ -136,8 +119,8 @@ services: logging: driver: json-file options: - max-size: "50m" - max-file: "10" + max-size: "20m" + max-file: "3" redis: image: redis:7-alpine @@ -161,5 +144,5 @@ services: logging: driver: json-file options: - max-size: "50m" - max-file: "10" + max-size: "20m" + max-file: "3" diff --git a/docker-compose.yml b/docker-compose.yml index b2d0c83..97bef74 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,7 +29,7 @@ services: ports: - "18080:8080" - # 让容器可以访问宿主机上的 qBittorrent。 + # 让容器可以访问宿主机上的服务(如下载器)。 # qB 地址通常可填:http://host.docker.internal:8085 extra_hosts: - "host.docker.internal:host-gateway" @@ -92,8 +92,8 @@ services: MEBOX_LOGGING_LEVEL: info MEBOX_LOGGING_FORMAT: console MEBOX_LOGGING_OUTPUT_PATH: /data/logs - MEBOX_LOGGING_MAX_SIZE_MB: "50" - MEBOX_LOGGING_MAX_BACKUPS: "20" + MEBOX_LOGGING_MAX_SIZE_MB: "20" + MEBOX_LOGGING_MAX_BACKUPS: "5" MEBOX_LOGGING_MAX_AGE_DAYS: "30" # 轻量模式默认只使用 PostgreSQL,适合大多数 NAS。 @@ -141,8 +141,8 @@ services: logging: driver: json-file options: - max-size: "50m" - max-file: "10" + max-size: "20m" + max-file: "3" postgres: image: postgres:16-alpine @@ -166,5 +166,5 @@ services: logging: driver: json-file options: - max-size: "50m" - max-file: "10" + max-size: "20m" + max-file: "3" diff --git a/docs/images/donation-qr.png b/docs/images/donation-qr.png new file mode 100644 index 0000000..9cc8089 Binary files /dev/null and b/docs/images/donation-qr.png differ diff --git a/docs/reader-plan.md b/docs/reader-plan.md new file mode 100644 index 0000000..b65621e --- /dev/null +++ b/docs/reader-plan.md @@ -0,0 +1,144 @@ +# MeBox 阅读功能实施计划(legado 书源兼容) + +> 分支:`feature/reading` +> 目标:在首页增加「影视 / 阅读」模式切换,阅读模式完整兼容阅读 3.0(legado)书源体系,覆盖 **文本(bookSourceType=0)、音频(=1)、漫画/图片(=2)** 三类源。 +> 实现方式(用户明确要求):**样式与逻辑全部仿造 refgd/legado 本体**,不参考其他重实现项目;相当于用 Go + React 18 + TypeScript 5 重写该项目。 +> 界面与交互的唯一规格:`docs/reader-ui-spec.md`(从 legado 源码逐屏调研产出)。 +> 规则引擎的唯一语义基准:legado `app/src/main/java/io/legado/app/model/analyzeRule/` 源码,Go 侧逐方法移植对拍(源码克隆在 `C:\MyProject\_ref\legado`,仅作对照,不进入构建)。 + +## 1. 范围 + +**做:** +- 首页「影视/阅读」切换,阅读模式下有独立首页(书架/搜索/发现/最近阅读) +- legado 书源导入与管理(URL 导入、文本/JSON 粘贴导入、启停、分组、排序) +- 三类书源的完整链路:搜索 → 详情 → 目录 → 正文/播放列表/图片列表 +- Go 侧规则引擎:CSS(jsoup 风格) / JSONPath / XPath / 正则 / 内嵌 JS 五种语法及其组合 +- 文本阅读器、音频播放器、漫画阅读器三套前端 UI +- 追更、阅读进度同步(服务端存储,多端一致)、换源、替换净化规则 + +**不做(本期明确排除):** +- `webView` 类规则(需要无头浏览器,识别后标记该源为不兼容并提示) +- `webView` 真人校验类登录(loginUrl 走 WebView 人工过验证码的场景; + 纯 JS / 表单类登录已支持,见 P4.5) +- RSS/订阅源、TTS 朗读、文件类型源(bookSourceType=3) +- 本地 TXT/EPUB 导入(列为后续可选) + +## 2. 总体架构 + +沿用 MeBox 现有分层,全部新增代码集中在: + +``` +internal/ + model/ # 新增 5 张表,注册进 AllModels() 自动迁移 + repository/ # reader 相关 GORM 封装 + service/reader/ # 规则引擎 + 书源业务(核心新增,预计占全部后端代码 70%) + handler/ # /api/reader/* 路由组 +web/src/ + pages/reader/ # 阅读端独立页面群(懒加载路由) + components/reader/ +``` + +**基建复用**:`internal/helper/http.go`(浏览器 UA + 代理回退 HTTP 客户端)、`internal/service/runtime_cache.go`(正文/目录/搜索缓存,内存+Redis)、`internal/service/image_proxy*`(封面与漫画图片代理)、`internal/handler/ws.go` 的 WSHub(搜索进度、追更任务推送,新增 `reader:*` topic)。 + +**数据流**:书源 JSON 存库 → 搜索/发现时按启用的源并发抓取(errgroup + 信号量限流,超时熔断)→ 结果聚合 → 前端。正文、播放地址、图片列表由服务端组装(含 `nextContentUrl` 翻页合并)后带 TTL 缓存下发;音频流与漫画图片按需经服务端代理补 UA/Referer 头。 + +## 3. 规则引擎(核心工作) + +语义基准:gedoor/legado `app/src/main/java/io/legado/app/model/analyzeRule/` 下的 AnalyzeRule / AnalyzeByJSoup / AnalyzeByJSonPath / AnalyzeByRegex / AnalyzeUrl,逐项对拍测试。 + +### 3.1 组件与选型 + +| 组件 | 选型 | 说明 | +|---|---|---| +| HTML/CSS | `PuerkitoBio/goquery` | jsoup 等价物;jsoup 特有语法(class.x / id.x / tag.x / text.x / children / @text / @textNodes / @html / 属性选择)自己包一层 | +| XPath | `antchfx/htmlquery` | 对齐 JsoupXpath 语义 | +| JSONPath | `PaesslerAG/jsonpath`(备选 ohler55/ojg) | Jayway 语义 + 自实现 `\|\|`/`&&` 合并层,选型阶段需验证 | +| 正则 | Go regexp(RE2) | legado 部分源用 Java 正则语法,回退换 `dlclark/regexp2` | +| JS | `dop251/goja` | ES2017+,跑书源内嵌 JS | +| 字符集 | `golang.org/x/text` | GBK/GB18030 解码 | + +### 3.2 引擎能力清单 + +- 规则模式识别:`@css:` / `$.`(JSONPath) / `@XPath:`或`//` / ``与`@js:` / `##` 正则替换段 +- 列表组合符 `&&` / `||` / `%%`,变量存取 `@put:{}` / `@get:{}`,内嵌 JS `{{ }}` +- jsoup 分析器、JSONPath 分析器、XPath 分析器、正则分析器,以及混合规则的链式解析(AnalyzeRule 的分段执行语义) +- AnalyzeUrl:`{{key}}`/`{{page}}` 变量、`` 生成 URL、URL 后 `,{...}` 选项(method/body/charset/headers/retry/timeout/type/proxy/js/webView) +- JS 沙箱:goja 运行时 + 执行超时中断 + 禁止直接 IO;上下文注入 `java`、`source`、`book`、`baseUrl`、`result` 等对象 +- `java.*` 桥接函数(按书源实际使用频率分批实现): + - 网络:ajax / ajaxAll / connect / get / post / head + - 编解码:base64Decode/Encode(含 URL-safe)、hexDecode、encodeURI/decodeURI、htmlDecode + - 加解密:md5(16/32)、sha1/sha256、AES/DES/3DES/RSA(CBC/ECB + 常见 padding/key 语义,legado 源里最常见的坑) + - 字符串与时间:replaceAll/substring/正则族、timeFormat 等 + - 规则回调:`java.getString/getElement` 等,桥回 Go 规则引擎(JS 与规则互相嵌套的关键) + +### 3.3 兼容策略 + +- 引擎按能力分层实现,每个能力配真实书源样本的单测(fixtures 放 `internal/service/reader/testdata/`) +- 提供 CLI 冒烟工具(如 `cmd/reader-smoke`):对批量导入的公开书源集跑 搜索/详情/目录/正文 全链路,输出成功率报告,作为每个阶段验收依据 +- 含 `webView` 选项的源直接判定不兼容并在书源管理页标注 + +## 4. 数据模型(新增表) + +| 表 | 关键字段 | +|---|---| +| book_sources | name, group, type(0/1/2), source_url, json(原文), enabled, custom_order, last_check_at, comment | +| books(书架) | source_url, book_url, name, author, cover_url, intro, kind, type(文本/音频/图片), latest_chapter, total_chapters, last_read_chapter_index, last_read_at | +| book_chapters | book_id, index, title, url, is_volume, update_time | +| read_progress | book_id(唯一), chapter_index, position(滚动/秒/图片序), updated_at | +| replace_rules | name, find, replace, scope, is_regex, enabled, order | + +阅读器显示设置(主题/字体/翻页方式)存前端 localStorage,不上服务端。 + +## 5. API 设计(/api/reader/*) + +- 书源:`GET/POST/DELETE /sources`、`POST /sources/import`(URL 或 JSON/base64 文本,自动识别格式与类型)、`PATCH /sources/:id`(启停/排序) +- 搜索:`POST /search {keyword}` → 后台聚合任务,结果经 WS `reader:search` 增量推送;结果可一键加入书架 +- 发现:`GET /explore?source=&group=`(解析 exploreUrl 的 `分组名::url` 结构) +- 书架:`GET/POST/DELETE /books`、`GET /books/:id/info`、`GET /books/:id/toc`、`POST /books/:id/refresh`(追更) +- 本地书籍:`POST /local/books`(multipart 上传 TXT/EPUB,导入即入书架) +- 内容:`GET /books/:id/chapters/:idx/content` —— 按书籍类型返回: + - 文本:`{type:"text", content:"..."}`(服务端已合并 nextContentUrl 翻页、已应用替换规则) + - 音频:`{type:"audio", tracks:[{url,title}]}`(含代理路径与所需请求头) + - 图片:`{type:"image", images:[{url, style}]}`(同样经代理) +- 进度:`PUT /books/:id/progress` +- 替换规则:`/replace-rules` CRUD +- 调试:`POST /debug {source_id, rule, url}`(书源调试器后端) +- 图片/流代理:复用现有 image_proxy / stream_proxy 模式,按源配置注入 UA/Referer + +## 6. 前端设计 + +- **首页切换**:`HomePage.tsx` 顶部加分段控件(仿 `LibraryTagBar` tab 模式),「阅读」切到阅读首页;选择持久化(zustand + localStorage) +- **阅读首页**:继续阅读横排 + 书架封面网格 + 搜索入口 + 追更提示 +- **页面群**(懒加载,仿 `appRoutes.tsx`):`/reader`(首页)、`/reader/search`(多源并发搜索 + 实时进度)、`/reader/explore`、`/reader/book/:id`(详情 + 目录 + 换源)、`/reader/sources`(书源管理 + 调试器) +- **三套阅读器**: + - 文本:滚动 + 分页双模式(CSS 分栏测量分页)、主题(含夜间)、字体/行距/边距、点击翻页区、章节预加载、进度上报 + - 音频:`hls.js`(已是依赖)+ `