mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 20:34:23 +08:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d137ab6db | |||
| e2133cd76f | |||
| 1c45483e1f | |||
| 40982f6675 | |||
| 27dcfc2fe9 | |||
| 984b98e61b | |||
| 5e496b35f1 | |||
| 91eb0dadf6 | |||
| 3d7d661199 | |||
| 8686d8cae6 | |||
| 43cfeabea1 | |||
| d112e8e4db | |||
| 6604691c19 |
@@ -40,6 +40,39 @@ jobs:
|
||||
- name: go test
|
||||
run: go test ./...
|
||||
|
||||
database-compatibility:
|
||||
name: SQLite / PostgreSQL ${{ matrix.postgres }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
postgres: [16, 17]
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:${{ matrix.postgres }}-alpine
|
||||
env:
|
||||
POSTGRES_USER: mebox
|
||||
POSTGRES_PASSWORD: mebox-test-only
|
||||
POSTGRES_DB: mebox_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U mebox -d mebox_test"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
env:
|
||||
MEBOX_TEST_POSTGRES_DSN: postgres://mebox:mebox-test-only@127.0.0.1:5432/mebox_test?sslmode=disable
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26'
|
||||
cache: true
|
||||
|
||||
- name: Database regression tests
|
||||
run: go test ./internal/database ./internal/repository -count=1
|
||||
|
||||
frontend:
|
||||
name: Frontend (Node)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -78,7 +111,7 @@ jobs:
|
||||
ci-success:
|
||||
name: CI Success
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend, smoke]
|
||||
needs: [backend, frontend, database-compatibility, smoke]
|
||||
if: success()
|
||||
steps:
|
||||
- run: echo "CI passed, ready for release"
|
||||
|
||||
@@ -84,6 +84,15 @@ go test ./internal/service -run "TestOrganize|TestSubscription|TestMTeam" -count
|
||||
go test ./cmd/server -count=1
|
||||
```
|
||||
|
||||
数据库兼容测试默认覆盖 SQLite。连接专用测试 PostgreSQL 后可运行真实的双数据库回归:
|
||||
|
||||
```bash
|
||||
MEBOX_TEST_POSTGRES_DSN='postgres://mebox:test-password@127.0.0.1:5432/mebox_test?sslmode=disable' \
|
||||
go test ./internal/database ./internal/repository -count=1
|
||||
```
|
||||
|
||||
测试账号需要创建、删除 schema 的权限;每个用例使用独立的临时 schema。CI 会分别在 PostgreSQL 16、17 上执行这些检查。
|
||||
|
||||
### 代码要求
|
||||
|
||||
- Go 代码使用 `gofmt`。
|
||||
|
||||
@@ -355,6 +355,9 @@ func (m *serverManager) drain(s *http.Server) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
// Stop reusing old connections before Reload returns. Asynchronous Shutdown
|
||||
// alone leaves a window where a client receives the previous HTTP/TLS mode.
|
||||
s.SetKeepAlivesEnabled(false)
|
||||
go func(s *http.Server) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# MeBox Mobile API 接入与部署说明
|
||||
|
||||
更新日期:2026-10-17
|
||||
适用版本:后端 `feature/android-mobile-api` 分支;App `0.1.0`(versionCode 1)。
|
||||
|
||||
## 1. 路径与版本
|
||||
|
||||
- 安卓契约挂载在 `/api/v1`,与既有 `/api`、Emby/Jellyfin 入口并行。
|
||||
- 健康检查:`GET /api/health`。
|
||||
- 实例身份:`GET /api/v1/server-info`(匿名,`Cache-Control: no-store`)。
|
||||
- 契约字段与错误码见同目录 `openapi.yaml`。
|
||||
|
||||
`server-info.capabilities` 必须反映真实运行能力。当前分支只有
|
||||
`device_sessions=true`;播放会话、阅读 v1 进度、批量正文与移动端转码仍为
|
||||
`false`,App 不得据此进入对应流程。
|
||||
|
||||
## 2. 连接与传输安全
|
||||
|
||||
- 公网必须使用 HTTPS;App 默认使用系统信任链,不提供全局跳过证书校验。
|
||||
- 局域网 HTTP 需要用户在 App 内明确确认风险;App 只对当前配置的服务器来源
|
||||
使用明文,不会把 MeBox `Authorization` 发往其它主机。
|
||||
- 首版只支持根路径部署。`https://host/mebox` 这类子路径会被 App 明确拒绝,
|
||||
不会静默截断为根路径。
|
||||
- 反代需要保留 `X-Request-ID`(可选);服务端会回传同值或新生成值,用于
|
||||
日志对账。
|
||||
|
||||
## 3. 反向代理检查项
|
||||
|
||||
以下项用于媒体阶段(M3/M4)回归,当前登录闭环不依赖它们,但仍建议提前配置:
|
||||
|
||||
| 项目 | 要求 |
|
||||
| --- | --- |
|
||||
| Range / 206 | 透传客户端 `Range`,不要缓存完整响应后改写状态码 |
|
||||
| 流式响应 | 关闭代理缓冲(如 nginx `proxy_buffering off`) |
|
||||
| HLS | `.m3u8` 与分片路径不要被改写或强制压缩 |
|
||||
| WebSocket | 升级头透传,读超时大于心跳间隔 |
|
||||
| 超时 | 登录/普通 API 30 秒内完成;转码起播超时单独配置 |
|
||||
|
||||
目前只验证了普通 JSON 端点;上表其余项尚未在本分支验收。
|
||||
|
||||
## 4. 认证与会话
|
||||
|
||||
- `POST /api/v1/auth/login` 创建 `auth_sessions` 记录,访问令牌 JWT 增加 `sid`。
|
||||
- 旧 `/api` 中间件同样校验 `sid`:设备被踢出或退出后,未过期的 JWT 也会立即
|
||||
返回 401,不能借旧入口绕过。
|
||||
- 没有 `sid` 的历史令牌继续按原逻辑访问旧 API;v1 认证端点拒绝这类令牌,
|
||||
App 需要重新登录。
|
||||
- `POST /api/v1/auth/logout` 幂等;会话撤销会同时撤销其名下刷新令牌。
|
||||
- 设备管理中的踢出操作会同步撤销该设备认证会话。
|
||||
|
||||
### 刷新轮换边界
|
||||
|
||||
- 同一个刷新令牌的并发请求共享一次轮换;60 秒宽限期内重复提交返回同一结果。
|
||||
- 轮换结果仍保存在进程内。若新令牌已签发、旧令牌已撤销,但客户端没收到响应
|
||||
且服务器随后重启,旧令牌可能无法恢复;此时 App 应要求重新登录。首版接受
|
||||
该边界,未实现持久化幂等轮换。
|
||||
- 会话被撤销时,刷新返回 `401 SESSION_REVOKED`,优先于宽限期复用。
|
||||
|
||||
## 5. 错误契约
|
||||
|
||||
统一响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "SESSION_REVOKED",
|
||||
"message": "auth session revoked or expired",
|
||||
"request_id": "8a3bade4-cbb3-485b-8e34-2471e4940fc3",
|
||||
"retryable": false
|
||||
}
|
||||
```
|
||||
|
||||
- 401 由 `code` 区分 `TOKEN_EXPIRED`、`SESSION_REVOKED`、`INVALID_CREDENTIALS`。
|
||||
- 403 表示权限或账号状态,不触发刷新。
|
||||
- 429 带 `Retry-After`,客户端必须等待。
|
||||
- 5xx / `SERVICE_UNAVAILABLE` / `retryable=true` 表示可安全重试。
|
||||
|
||||
## 6. 数据迁移与回滚
|
||||
|
||||
- 新表 `auth_sessions` 与 `refresh_tokens.auth_session_id` 由现有 AutoMigrate
|
||||
创建,属于增量变更,不清库。
|
||||
- SQLite 已验证;PostgreSQL 迁移需要在有实例的环境中单独执行
|
||||
`go test ./internal/database ./internal/repository -count=1` 后才能宣称通过。
|
||||
- 回滚到旧版本不会删除新表;旧程序忽略新增字段。回滚前应先确认不需要再
|
||||
依赖 `sid` 即时撤销能力。
|
||||
|
||||
## 7. 已知限制
|
||||
|
||||
- 单实例部署;未实现多副本共享会话。
|
||||
- 外部播放令牌尚未继承 `sid`(属于 M3 播放会话范围),当前不能按设备即时撤销。
|
||||
- 反代子路径、自签名证书信任、多服务器配置均未支持。
|
||||
- App 目前只开放连接、登录、会话恢复与退出;媒体与阅读入口未实现。
|
||||
@@ -0,0 +1,389 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: MeBox Mobile API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
MeBox 安卓客户端的稳定接入契约。所有路径以 `/api` 为前缀挂载,
|
||||
本文档描述 `/api/v1` 部分;既有 `/api` 与 Emby/Jellyfin 入口保持兼容。
|
||||
|
||||
约定:
|
||||
- JSON 字段使用 snake_case;
|
||||
- 时间点为 RFC 3339 UTC 字符串,播放位置与时长使用整数毫秒;
|
||||
- 集合为空时返回 `[]`,未知字段客户端必须忽略;
|
||||
- 错误响应统一为 `code`、`message`、`request_id`、`retryable`;
|
||||
- 能力开关只有在接口实现且当前可运行时才为 true。
|
||||
servers:
|
||||
- url: /api
|
||||
security:
|
||||
- bearerAuth: []
|
||||
tags:
|
||||
- name: server
|
||||
- name: auth
|
||||
paths:
|
||||
/v1/server-info:
|
||||
get:
|
||||
tags: [server]
|
||||
summary: 匿名读取实例身份与移动端能力
|
||||
security: []
|
||||
responses:
|
||||
'200':
|
||||
description: 实例可连接
|
||||
headers:
|
||||
Cache-Control:
|
||||
schema:
|
||||
type: string
|
||||
description: 固定为 no-store
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServerInfo'
|
||||
'503':
|
||||
description: 暂时无法读取实例身份
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MobileError'
|
||||
/v1/auth/login:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: 账号密码登录并创建认证会话
|
||||
security: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/RequestId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LoginRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: 登录成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/InvalidArgument'
|
||||
'401':
|
||||
$ref: '#/components/responses/InvalidCredentials'
|
||||
'403':
|
||||
description: 账号被禁用或已到期
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MobileError'
|
||||
'429':
|
||||
$ref: '#/components/responses/RateLimited'
|
||||
'503':
|
||||
$ref: '#/components/responses/ServiceUnavailable'
|
||||
/v1/auth/refresh:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: 使用刷新令牌轮换令牌对
|
||||
security: []
|
||||
description: |
|
||||
刷新令牌必须由 v1 登录签发并关联认证会话。同一 refresh token 的
|
||||
并发提交只会真正轮换一次;轮换后 60 秒内重复提交返回同一次结果。
|
||||
会话被撤销后刷新返回 SESSION_REVOKED。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RefreshRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: 刷新成功,新令牌继续归属同一 auth_session_id
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/InvalidArgument'
|
||||
'401':
|
||||
description: 刷新令牌无效、过期或会话已撤销
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MobileError'
|
||||
'503':
|
||||
$ref: '#/components/responses/ServiceUnavailable'
|
||||
/v1/auth/session:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: 读取当前用户与认证会话
|
||||
responses:
|
||||
'200':
|
||||
description: 会话有效
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SessionResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/SessionInvalid'
|
||||
'503':
|
||||
$ref: '#/components/responses/ServiceUnavailable'
|
||||
/v1/auth/logout:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: 撤销当前认证会话
|
||||
description: 幂等;重复退出同样返回 200。
|
||||
responses:
|
||||
'200':
|
||||
description: 已退出(本机与远端)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: ok
|
||||
'401':
|
||||
$ref: '#/components/responses/SessionInvalid'
|
||||
'503':
|
||||
$ref: '#/components/responses/ServiceUnavailable'
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
parameters:
|
||||
RequestId:
|
||||
name: X-Request-ID
|
||||
in: header
|
||||
required: false
|
||||
description: |
|
||||
可选的诊断 ID(8—128 位 ASCII 字母、数字或 `-_.:`)。服务器会在
|
||||
响应头与错误体回传同一个值;非法值会被替换为新 UUID。该值不参与鉴权。
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
InvalidArgument:
|
||||
description: 请求字段缺失或格式错误
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MobileError'
|
||||
InvalidCredentials:
|
||||
description: 用户名或密码错误
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MobileError'
|
||||
SessionInvalid:
|
||||
description: 访问令牌无效、过期或认证会话已撤销
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MobileError'
|
||||
RateLimited:
|
||||
description: 请求过于频繁
|
||||
headers:
|
||||
Retry-After:
|
||||
schema:
|
||||
type: integer
|
||||
description: 建议等待秒数
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MobileError'
|
||||
ServiceUnavailable:
|
||||
description: 暂时性服务/存储错误,可安全重试
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MobileError'
|
||||
schemas:
|
||||
ServerInfo:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- server_id
|
||||
- server_version
|
||||
- api_version
|
||||
- min_supported_app_version_code
|
||||
- capabilities
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
example: MeBox
|
||||
server_id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: 实例持久身份;备份克隆成独立实例时必须显式重置。
|
||||
server_version:
|
||||
type: string
|
||||
example: MeBox-v0.1.16
|
||||
api_version:
|
||||
type: string
|
||||
example: v1
|
||||
min_supported_app_version_code:
|
||||
type: integer
|
||||
minimum: 1
|
||||
capabilities:
|
||||
$ref: '#/components/schemas/ServerCapabilities'
|
||||
ServerCapabilities:
|
||||
type: object
|
||||
required:
|
||||
- device_sessions
|
||||
- playback_sessions
|
||||
- reader_progress_v1
|
||||
- reader_content_batch
|
||||
- local_transcode
|
||||
properties:
|
||||
device_sessions:
|
||||
type: boolean
|
||||
description: M1 起为 true
|
||||
playback_sessions:
|
||||
type: boolean
|
||||
description: 播放会话接口未实现前必须为 false
|
||||
reader_progress_v1:
|
||||
type: boolean
|
||||
description: v1 阅读锚点/版本冲突未实现前必须为 false
|
||||
reader_content_batch:
|
||||
type: boolean
|
||||
description: v1 批量正文与资源引用未实现前必须为 false
|
||||
local_transcode:
|
||||
type: boolean
|
||||
description: 移动端可用的本地转码会话未实现前必须为 false
|
||||
MobileUser:
|
||||
type: object
|
||||
required: [id, username, role, tier, is_active, force_password_reset]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
nickname:
|
||||
type: string
|
||||
role:
|
||||
type: string
|
||||
enum: [admin, user]
|
||||
tier:
|
||||
type: string
|
||||
example: free
|
||||
is_active:
|
||||
type: boolean
|
||||
force_password_reset:
|
||||
type: boolean
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
TokenPair:
|
||||
type: object
|
||||
required: [access_token, refresh_token, expires_in, token_type]
|
||||
properties:
|
||||
access_token:
|
||||
type: string
|
||||
refresh_token:
|
||||
type: string
|
||||
expires_in:
|
||||
type: integer
|
||||
description: 访问令牌有效秒数
|
||||
token_type:
|
||||
type: string
|
||||
example: Bearer
|
||||
LoginRequest:
|
||||
type: object
|
||||
required: [username, password, device_id]
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
device_id:
|
||||
type: string
|
||||
maxLength: 128
|
||||
description: App 安装随机生成,不使用 IMEI/MAC 等硬件标识。
|
||||
device_name:
|
||||
type: string
|
||||
maxLength: 128
|
||||
client_name:
|
||||
type: string
|
||||
maxLength: 64
|
||||
example: MeBox Android
|
||||
client_version:
|
||||
type: string
|
||||
maxLength: 64
|
||||
RefreshRequest:
|
||||
type: object
|
||||
required: [refresh_token]
|
||||
properties:
|
||||
refresh_token:
|
||||
type: string
|
||||
AuthResponse:
|
||||
type: object
|
||||
required: [auth_session_id, user, tokens]
|
||||
properties:
|
||||
auth_session_id:
|
||||
type: string
|
||||
format: uuid
|
||||
user:
|
||||
$ref: '#/components/schemas/MobileUser'
|
||||
tokens:
|
||||
$ref: '#/components/schemas/TokenPair'
|
||||
SessionDevice:
|
||||
type: object
|
||||
properties:
|
||||
device_id:
|
||||
type: string
|
||||
device_name:
|
||||
type: string
|
||||
client_name:
|
||||
type: string
|
||||
client_version:
|
||||
type: string
|
||||
last_used_at:
|
||||
type: string
|
||||
format: date-time
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
SessionResponse:
|
||||
type: object
|
||||
required: [auth_session_id, user]
|
||||
properties:
|
||||
auth_session_id:
|
||||
type: string
|
||||
format: uuid
|
||||
user:
|
||||
$ref: '#/components/schemas/MobileUser'
|
||||
session:
|
||||
$ref: '#/components/schemas/SessionDevice'
|
||||
MobileError:
|
||||
type: object
|
||||
required: [code, message, request_id, retryable]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
enum:
|
||||
- INVALID_ARGUMENT
|
||||
- INVALID_CREDENTIALS
|
||||
- TOKEN_EXPIRED
|
||||
- SESSION_REVOKED
|
||||
- ACCOUNT_DISABLED
|
||||
- ACCOUNT_EXPIRED
|
||||
- PERMISSION_DENIED
|
||||
- PROFILE_PIN_REQUIRED
|
||||
- RATE_LIMITED
|
||||
- UPSTREAM_UNAVAILABLE
|
||||
- TRANSCODE_BUSY
|
||||
- PLAYBACK_SESSION_NOT_FOUND
|
||||
- RESOURCE_EXPIRED
|
||||
- PROGRESS_CONFLICT
|
||||
- SERVICE_UNAVAILABLE
|
||||
message:
|
||||
type: string
|
||||
request_id:
|
||||
type: string
|
||||
retryable:
|
||||
type: boolean
|
||||
retry_after_sec:
|
||||
type: integer
|
||||
minimum: 0
|
||||
details:
|
||||
description: 可选的稳定结构化细节,未定义时客户端必须忽略。
|
||||
@@ -0,0 +1,238 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// PostgreSQL tests use a disposable schema, never the caller's existing tables.
|
||||
// Set MEBOX_TEST_POSTGRES_DSN to include a real PostgreSQL server in local runs.
|
||||
func compatibilityDB(t *testing.T, dialect string) *gorm.DB {
|
||||
t.Helper()
|
||||
var db *gorm.DB
|
||||
if dialect == "sqlite" {
|
||||
cfg := &config.Config{}
|
||||
cfg.Database.Type = dialect
|
||||
cfg.Database.DBPath = filepath.Join(t.TempDir(), "test.db")
|
||||
cfg.Database.WALMode = true
|
||||
cfg.Database.BusyTimeout = 5000
|
||||
cfg.Database.MaxOpenConns = 8
|
||||
var err error
|
||||
db, err = Open(cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
dsn := os.Getenv("MEBOX_TEST_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set MEBOX_TEST_POSTGRES_DSN to run PostgreSQL integration tests")
|
||||
}
|
||||
pgcfg, err := pgx.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
t.Fatal("invalid PostgreSQL test configuration")
|
||||
}
|
||||
admin := stdlib.OpenDB(*pgcfg)
|
||||
t.Cleanup(func() { _ = admin.Close() })
|
||||
schemaName := "mebox_test_" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
if _, err := admin.Exec("CREATE SCHEMA " + quoteIdent(schemaName)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if _, err := admin.Exec("DROP SCHEMA " + quoteIdent(schemaName) + " CASCADE"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
pgcfg.RuntimeParams["search_path"] = schemaName + ",public"
|
||||
conn := stdlib.OpenDB(*pgcfg)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
db, err = gorm.Open(postgres.New(postgres.Config{Conn: conn}), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
db.Logger = logger.Default.LogMode(logger.Silent)
|
||||
if conn, err := db.DB(); err == nil {
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
}
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestDatabaseCompatibility(t *testing.T) {
|
||||
for _, dialect := range []string{"sqlite", "postgres"} {
|
||||
t.Run(dialect, func(t *testing.T) {
|
||||
t.Run("long_paths_and_restart", func(t *testing.T) {
|
||||
db := compatibilityDB(t, dialect)
|
||||
// Distinct CJK characters prevent TOAST compression from hiding
|
||||
// the PostgreSQL btree entry size limit (> 2704 bytes).
|
||||
var path strings.Builder
|
||||
path.WriteString("/media/")
|
||||
for i := range 980 {
|
||||
path.WriteRune(rune(0x4e00 + i))
|
||||
}
|
||||
path.WriteString(".mkv")
|
||||
media := model.Media{Path: path.String(), Title: "Long path"}
|
||||
if err := db.Create(&media).Error; err != nil {
|
||||
t.Fatalf("insert long media path: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{Path: media.Path}).Error; err == nil {
|
||||
t.Fatal("duplicate path accepted")
|
||||
}
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("migrate existing database: %v", err)
|
||||
}
|
||||
var found model.Media
|
||||
if err := db.Where("path = ?", media.Path).First(&found).Error; err != nil {
|
||||
t.Fatalf("lookup long path: %v", err)
|
||||
}
|
||||
})
|
||||
if dialect == "postgres" {
|
||||
t.Run("upgrade_legacy_path_indexes", func(t *testing.T) {
|
||||
db := compatibilityDB(t, dialect)
|
||||
for _, unique := range []string{"UNIQUE ", ""} {
|
||||
if err := db.Exec("DROP INDEX idx_media_path").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("CREATE " + unique + "INDEX idx_media_path ON media(path)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 2 {
|
||||
if err := ensureMediaPathIndex(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
isUnique, method, found, err := postgresIndexProperties(db, "idx_media_path")
|
||||
if err != nil || !found || isUnique || method != "hash" {
|
||||
t.Fatalf("legacy index not upgraded: unique=%v method=%s found=%v err=%v", isUnique, method, found, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("search_ranking", func(t *testing.T) {
|
||||
db := compatibilityDB(t, dialect)
|
||||
// Exercise LIKE fallback in both engines.
|
||||
if dialect == "sqlite" {
|
||||
resetMediaSearchIndex(db)
|
||||
}
|
||||
for i, title := range []string{"The Alpha story", "Alpha sequel", "Alpha"} {
|
||||
row := model.Media{Base: model.Base{ID: fmt.Sprintf("m-%d", i), CreatedAt: time.Now().Add(time.Duration(i) * time.Minute)}, Title: title, Path: fmt.Sprintf("/media/%d.mkv", i)}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
items, total, err := repository.New(db).Media.SearchFilteredPage(t.Context(), "Alpha", 0, 10, repository.MediaQueryFilter{IncludeNSFW: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 3 || len(items) != 3 || items[0].Title != "Alpha" || items[1].Title != "Alpha sequel" {
|
||||
t.Fatalf("unexpected search ranking: total=%d, items=%v", total, items)
|
||||
}
|
||||
})
|
||||
t.Run("concurrent_media_upsert", func(t *testing.T) {
|
||||
db := compatibilityDB(t, dialect)
|
||||
repo := repository.New(db).Media
|
||||
const workers = 8
|
||||
// Synchronize the initial lookups to force the insert race.
|
||||
var lookups sync.WaitGroup
|
||||
lookups.Add(workers)
|
||||
if dialect == "postgres" {
|
||||
if err := db.Callback().Query().After("gorm:after_query").Register("test:insert_race", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "media" && tx.Error == gorm.ErrRecordNotFound {
|
||||
lookups.Done()
|
||||
lookups.Wait()
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
errs := make(chan error, workers)
|
||||
for range workers {
|
||||
go func() { errs <- repo.Upsert(ctx, &model.Media{Path: "/media/shared.mkv", Title: "Shared"}) }()
|
||||
}
|
||||
for range workers {
|
||||
if err := <-errs; err != nil {
|
||||
t.Errorf("concurrent upsert: %v", err)
|
||||
}
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.Media{}).Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatalf("media count=%d, err=%v", count, err)
|
||||
}
|
||||
})
|
||||
t.Run("search_filters", func(t *testing.T) {
|
||||
db := compatibilityDB(t, dialect)
|
||||
rows := []model.Media{
|
||||
{Base: model.Base{ID: "keep"}, LibraryID: "lib-1", Genres: "Action", Year: 2020, Rating: 8},
|
||||
{Base: model.Base{ID: "other-library"}, LibraryID: "lib-2", Genres: "Action", Year: 2020, Rating: 8},
|
||||
{Base: model.Base{ID: "other-genre"}, LibraryID: "lib-1", Genres: "Comedy", Year: 2020, Rating: 8},
|
||||
{Base: model.Base{ID: "too-old"}, LibraryID: "lib-1", Genres: "Action", Year: 1990, Rating: 8},
|
||||
{Base: model.Base{ID: "too-new"}, LibraryID: "lib-1", Genres: "Action", Year: 2026, Rating: 8},
|
||||
{Base: model.Base{ID: "low-rating"}, LibraryID: "lib-1", Genres: "Action", Year: 2020, Rating: 2},
|
||||
{Base: model.Base{ID: "watched"}, LibraryID: "lib-1", Genres: "Action", Year: 2020, Rating: 8},
|
||||
}
|
||||
for i := range rows {
|
||||
rows[i].Title = "Alpha " + rows[i].ID
|
||||
rows[i].Path = "/media/" + rows[i].ID + ".mkv"
|
||||
if err := db.Create(&rows[i]).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.Create(&model.PlaybackHistory{UserID: "viewer", MediaID: "watched", Completed: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, total, err := repository.New(db).Media.SearchFilteredPage(t.Context(), "alpha", 0, 10, repository.MediaQueryFilter{
|
||||
IncludeNSFW: true, LibraryID: "lib-1", Genres: []string{"Action"}, YearMin: 2000, YearMax: 2025, RatingMin: 5,
|
||||
UnwatchedOnly: true, UnwatchedUserID: "viewer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(items) != 1 || items[0].ID != "keep" {
|
||||
t.Fatalf("search ignored filters: total=%d, items=%v", total, items)
|
||||
}
|
||||
})
|
||||
t.Run("migration_preserves_zero_values", func(t *testing.T) {
|
||||
src := compatibilityDB(t, "sqlite")
|
||||
dst := compatibilityDB(t, dialect)
|
||||
user := model.User{Username: "disabled", PasswordHash: "hash"}
|
||||
if err := src.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := src.Model(&user).Updates(map[string]any{"is_active": false, "hide_adult": false, "player_volume": 0, "danmaku_enabled": false}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := copyModelTables(src, dst, 500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got model.User
|
||||
if err := dst.First(&got, "id = ?", user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.IsActive || got.HideAdult || got.PlayerVolume != 0 || got.DanmakuEnabled {
|
||||
t.Fatalf("migration replaced stored zero values: active=%v hide_adult=%v volume=%v danmaku=%v", got.IsActive, got.HideAdult, got.PlayerVolume, got.DanmakuEnabled)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// InsertPreservingValues copies persisted model rows without applying creation
|
||||
// hooks or defaults. Struct CreateInBatches treats false, 0 and empty strings as
|
||||
// missing values, which would reactivate disabled accounts during migration or
|
||||
// restore. Column maps preserve the exact values already stored in the source.
|
||||
func InsertPreservingValues(db *gorm.DB, rows any, batchSize int) error {
|
||||
stmt := &gorm.Statement{DB: db}
|
||||
if err := stmt.Parse(rows); err != nil {
|
||||
return err
|
||||
}
|
||||
slice := reflect.Indirect(reflect.ValueOf(rows))
|
||||
if slice.Kind() != reflect.Slice {
|
||||
return fmt.Errorf("persisted rows must be a slice, got %T", rows)
|
||||
}
|
||||
if len(stmt.Schema.DBNames) == 0 {
|
||||
return fmt.Errorf("persisted rows have no database columns")
|
||||
}
|
||||
values := make([]map[string]any, 0, slice.Len())
|
||||
for i := 0; i < slice.Len(); i++ {
|
||||
row := make(map[string]any, len(stmt.Schema.DBNames))
|
||||
for _, name := range stmt.Schema.DBNames {
|
||||
field := stmt.Schema.FieldsByDBName[name]
|
||||
if field.Creatable {
|
||||
row[name], _ = field.ValueOf(db.Statement.Context, slice.Index(i))
|
||||
}
|
||||
}
|
||||
values = append(values, row)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Bound batches by each engine's bind-parameter limit.
|
||||
parameterLimit := 65535
|
||||
if isSQLite(db) {
|
||||
parameterLimit = 32766
|
||||
}
|
||||
maxBatch := parameterLimit / len(stmt.Schema.DBNames)
|
||||
if batchSize <= 0 || batchSize > maxBatch {
|
||||
batchSize = maxBatch
|
||||
}
|
||||
return db.Table(stmt.Schema.Table).CreateInBatches(values, batchSize).Error
|
||||
}
|
||||
@@ -71,8 +71,8 @@ func AutoMigrateWithConfig(db *gorm.DB, cfg *config.Config, log *zap.Logger) err
|
||||
// index row size ... exceeds btree version 4 maximum 2704
|
||||
//
|
||||
// 所以 PostgreSQL 改用 md5(path) 的表达式唯一索引(定长 32 字节,永远塞得下),
|
||||
// 同时保留一条普通索引 idx_media_path 供 path = ? 等值查找(扫描与 upsert 的
|
||||
// 热路径)使用。SQLite 没有这个长度限制,沿用整列唯一索引。
|
||||
// 同时用 hash 索引 idx_media_path 加速 path = ? 等值查找;非唯一 btree
|
||||
// 也有索引项长度限制,不能继续对完整 path 建 btree。SQLite 沿用整列唯一索引。
|
||||
func ensureMediaPathIndex(db *gorm.DB) error {
|
||||
if db == nil || !db.Migrator().HasTable("media") {
|
||||
return nil
|
||||
@@ -84,37 +84,39 @@ func ensureMediaPathIndex(db *gorm.DB) error {
|
||||
if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_media_path_hash ON media(md5(path))`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
unique, found, err := postgresIndexIsUnique(db, "idx_media_path")
|
||||
unique, method, found, err := postgresIndexProperties(db, "idx_media_path")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found && unique {
|
||||
if found && (unique || method != "hash") {
|
||||
if err := db.Exec(`DROP INDEX IF EXISTS idx_media_path`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Exec(`CREATE INDEX IF NOT EXISTS idx_media_path ON media(path)`).Error
|
||||
return db.Exec(`CREATE INDEX IF NOT EXISTS idx_media_path ON media USING hash(path)`).Error
|
||||
}
|
||||
|
||||
// postgresIndexIsUnique 报告当前 schema 下是否存在该索引、以及它是否唯一。
|
||||
func postgresIndexIsUnique(db *gorm.DB, indexName string) (unique bool, found bool, err error) {
|
||||
// postgresIndexProperties 返回当前 schema 下索引的唯一性与访问方法。
|
||||
func postgresIndexProperties(db *gorm.DB, indexName string) (unique bool, method string, found bool, err error) {
|
||||
// 用指针接 bool:没有匹配行时 Scan 不报错,nil 即代表索引不存在。
|
||||
var row struct {
|
||||
IsUnique *bool
|
||||
Method string
|
||||
}
|
||||
if err := db.Raw(`
|
||||
SELECT i.indisunique AS is_unique
|
||||
SELECT i.indisunique AS is_unique, a.amname AS method
|
||||
FROM pg_class c
|
||||
JOIN pg_index i ON i.indexrelid = c.oid
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
JOIN pg_am a ON a.oid = c.relam
|
||||
WHERE n.nspname = current_schema() AND c.relname = ?
|
||||
`, indexName).Scan(&row).Error; err != nil {
|
||||
return false, false, err
|
||||
return false, "", false, err
|
||||
}
|
||||
if row.IsUnique == nil {
|
||||
return false, false, nil
|
||||
return false, "", false, nil
|
||||
}
|
||||
return *row.IsUnique, true, nil
|
||||
return *row.IsUnique, row.Method, true, nil
|
||||
}
|
||||
|
||||
// ensurePostgresSearchIndexes 给 PostgreSQL 补上 SQLite FTS5 的替代品。
|
||||
|
||||
@@ -78,7 +78,7 @@ func copyModelTables(src, target *gorm.DB, batchSize int) (map[string]int64, int
|
||||
if filtered.Len() > 0 {
|
||||
filteredPtr := reflect.New(filtered.Type())
|
||||
filteredPtr.Elem().Set(filtered)
|
||||
if err := target.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(filteredPtr.Interface(), batchSize).Error; err != nil {
|
||||
if err := InsertPreservingValues(target.Clauses(clause.OnConflict{DoNothing: true}), filteredPtr.Interface(), batchSize); err != nil {
|
||||
return tableCounts, totalCopied, fmt.Errorf("copy sqlite table %s: %w", table, err)
|
||||
}
|
||||
copiedForTable += int64(filtered.Len())
|
||||
|
||||
@@ -104,19 +104,18 @@ func (g *sqliteWriteGate) Lock(ctx context.Context) (*sqliteGateHolder, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
// ctx 取消时唤醒等待者(cond 无法感知 ctx,用旁路 goroutine 广播)。
|
||||
if done := ctx.Done(); done != nil {
|
||||
stop := make(chan struct{})
|
||||
defer close(stop)
|
||||
go func() {
|
||||
select {
|
||||
case <-done:
|
||||
g.cond.Broadcast()
|
||||
case <-stop:
|
||||
}
|
||||
}()
|
||||
// 广播也必须持有同一把锁,否则取消可能落在检查与 Wait 之间而丢失。
|
||||
wake := func() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.cond.Broadcast()
|
||||
}
|
||||
stopCancel := context.AfterFunc(ctx, wake)
|
||||
defer stopCancel()
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if g.owner == nil {
|
||||
holder := &sqliteGateHolder{
|
||||
id: sqliteGateHolderSeq.Add(1),
|
||||
@@ -125,16 +124,17 @@ func (g *sqliteWriteGate) Lock(ctx context.Context) (*sqliteGateHolder, error) {
|
||||
g.owner = holder
|
||||
return holder, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if time.Since(g.owner.acquired) > sqliteGateHoldLimit {
|
||||
remaining := sqliteGateHoldLimit - time.Since(g.owner.acquired)
|
||||
if remaining <= 0 {
|
||||
// 持有者疑似 panic 泄漏(After 回调未执行):强制回收。
|
||||
g.owner = nil
|
||||
g.cond.Broadcast()
|
||||
continue
|
||||
}
|
||||
// 无后续写入或取消时,也要在租约到期后主动唤醒等待者。
|
||||
timer := time.AfterFunc(remaining, wake)
|
||||
g.cond.Wait()
|
||||
timer.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSQLiteWriteGateRejectsCancelledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
gate := newSQLiteWriteGate()
|
||||
holder, err := gate.Lock(ctx)
|
||||
defer gate.Unlock(holder)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Lock error=%v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteWriteGateWakesForExpiredHolder(t *testing.T) {
|
||||
gate := newSQLiteWriteGate()
|
||||
holder, err := gate.Lock(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer gate.Unlock(holder)
|
||||
// Make the lease expire shortly, without waiting a full minute.
|
||||
gate.mu.Lock()
|
||||
holder.acquired = time.Now().Add(-sqliteGateHoldLimit + 20*time.Millisecond)
|
||||
gate.mu.Unlock()
|
||||
done := make(chan *sqliteGateHolder, 1)
|
||||
go func() {
|
||||
next, _ := gate.Lock(t.Context())
|
||||
done <- next
|
||||
}()
|
||||
select {
|
||||
case next := <-done:
|
||||
gate.Unlock(holder) // A stale holder must not unlock its successor.
|
||||
gate.mu.Lock()
|
||||
owner := gate.owner
|
||||
gate.mu.Unlock()
|
||||
gate.Unlock(next)
|
||||
if next == nil || owner != next {
|
||||
t.Fatal("expired holder released the new owner's lock")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
gate.Unlock(holder)
|
||||
gate.Unlock(<-done)
|
||||
t.Fatal("waiter was not woken when the holder expired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteWriteGateWakesForCancellation(t *testing.T) {
|
||||
gate := newSQLiteWriteGate()
|
||||
holder, err := gate.Lock(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer gate.Unlock(holder)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
next, err := gate.Lock(ctx)
|
||||
gate.Unlock(next)
|
||||
done <- err
|
||||
}()
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Lock error=%v, want context.Canceled", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
gate.Unlock(holder)
|
||||
<-done
|
||||
t.Fatal("waiter was not woken for cancellation")
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
||||
|
||||
registerPublicAuthRoutes(api, svc, log)
|
||||
|
||||
registerMobileRoutes(api, svc, log)
|
||||
|
||||
registerAuthenticatedRoutes(api, cfg, svc)
|
||||
|
||||
registerAdminRoutes(api, cfg, svc)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func TestSearchMediaSeriesKeepsRemoteLibrariesSeparate(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"TotalRecordCount": 1,
|
||||
"Items": []map[string]any{{
|
||||
"Id": "same-series", "Name": "龙珠改", "Type": "Series",
|
||||
"ProviderIds": map[string]string{"Tmdb": "61709"},
|
||||
}},
|
||||
})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.StrmAccount{}, &model.EmbyMount{}, &model.Setting{}, &model.User{}, &model.PlayProfile{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Name: "本地番剧", Path: "/media/anime", Type: "anime", Enabled: true}
|
||||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := []model.Media{
|
||||
{Base: model.Base{ID: "local-1"}, LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep1.mkv", SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709},
|
||||
{Base: model.Base{ID: "local-2"}, LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep2.mkv", SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709},
|
||||
}
|
||||
if err := db.Create(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawConfig, _ := json.Marshal(map[string]string{"url": upstream.URL, "token": "fake-token"})
|
||||
acct := model.StrmAccount{Base: model.Base{ID: "account"}, Name: "Emby", Provider: model.StrmProviderEmbyRemote, Config: string(rawConfig), Enabled: true}
|
||||
if err := repos.StrmAccount.Create(t.Context(), &acct); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedIDs := map[string]string{lib.ID: "local-1"}
|
||||
for _, suffix := range []string{"a", "b"} {
|
||||
mount := model.EmbyMount{Base: model.Base{ID: "mount-" + suffix}, AccountID: acct.ID, RemoteViewID: "view-" + suffix, RemoteViewName: "番剧" + suffix, CollectionType: "tvshows", Enabled: true}
|
||||
if err := repos.EmbyMount.Create(t.Context(), &mount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedIDs[service.EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)] = service.EncodeEmbyRemoteID(mount.ID, "same-series")
|
||||
}
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
|
||||
EmbyRemote: service.NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())),
|
||||
}
|
||||
|
||||
request := func(params string) ([]model.Media, int) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set(middleware.CtxUserID, "admin")
|
||||
c.Set(middleware.CtxUserRole, "admin")
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=龙珠改&group_series=1&"+params, nil)
|
||||
searchMediaHandler(svc)(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Items []model.Media `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return response.Items, response.Total
|
||||
}
|
||||
|
||||
items, _ := request("limit=8")
|
||||
if len(items) != len(expectedIDs) {
|
||||
t.Fatalf("got %d results, want one local series and two remote series: %#v", len(items), items)
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for page := 1; page <= len(expectedIDs); page++ {
|
||||
items, total := request(fmt.Sprintf("page=%d&page_size=1", page))
|
||||
if total != len(expectedIDs) || len(items) != 1 {
|
||||
t.Fatalf("page %d: total=%d items=%#v, want total=3 and one item", page, total, items)
|
||||
}
|
||||
item := items[0]
|
||||
if expectedIDs[item.DisplayLibraryID] != item.ID || seen[item.DisplayLibraryID] || item.DisplayLibraryName == "" {
|
||||
t.Fatalf("page %d lost or repeated a library's series identity: %#v", page, item)
|
||||
}
|
||||
seen[item.DisplayLibraryID] = true
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,9 @@ import (
|
||||
)
|
||||
|
||||
func TestQueueLibraryRootScanImportsNewLibraryMedia(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
// The scanner and polling loop use separate connections; :memory: gives
|
||||
// each connection its own empty database.
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "scan.db")+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
// Package handler — 移动端 /api/v1 认证端点。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// mobileUserPayload 是 v1 返回的用户摘要。这里显式挑选字段,避免把仅服务端
|
||||
// 使用的偏好和内部状态带进新契约。
|
||||
type mobileUserPayload struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Tier string `json:"tier"`
|
||||
IsActive bool `json:"is_active"`
|
||||
ForcePasswordReset bool `json:"force_password_reset"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
func toMobileUserPayload(u *model.User) *mobileUserPayload {
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
return &mobileUserPayload{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Nickname: u.Nickname,
|
||||
Role: u.Role,
|
||||
Tier: u.Tier,
|
||||
IsActive: u.IsActive,
|
||||
ForcePasswordReset: u.ForcePasswordReset,
|
||||
ExpiresAt: u.ExpiredAt,
|
||||
}
|
||||
}
|
||||
|
||||
type mobileSessionPayload struct {
|
||||
AuthSessionID string `json:"auth_session_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name,omitempty"`
|
||||
ClientName string `json:"client_name,omitempty"`
|
||||
ClientVersion string `json:"client_version,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt time.Time `json:"last_used_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func toMobileSessionPayload(s *model.AuthSession) *mobileSessionPayload {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return &mobileSessionPayload{
|
||||
AuthSessionID: s.ID,
|
||||
DeviceID: s.DeviceID,
|
||||
DeviceName: s.DeviceName,
|
||||
ClientName: s.ClientName,
|
||||
ClientVersion: s.ClientVersion,
|
||||
CreatedAt: s.CreatedAt,
|
||||
LastUsedAt: s.LastUsedAt,
|
||||
ExpiresAt: s.ExpiresAt,
|
||||
}
|
||||
}
|
||||
|
||||
type mobileLoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
DeviceID string `json:"device_id" binding:"required"`
|
||||
DeviceName string `json:"device_name"`
|
||||
ClientName string `json:"client_name"`
|
||||
ClientVersion string `json:"client_version"`
|
||||
}
|
||||
|
||||
type mobileRefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
// mobileAuthRequired 是 v1 专用认证中间件:要求有效 JWT + 有效设备会话,
|
||||
// 并统一输出 v1 错误契约。
|
||||
func mobileAuthRequired(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw := middleware.ExtractToken(c)
|
||||
if raw == "" {
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||
"access token required", false, 0)
|
||||
return
|
||||
}
|
||||
claims, err := svc.Token.ValidateAccessToken(raw)
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired,
|
||||
"access token expired", false, 0)
|
||||
return
|
||||
}
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||
"invalid access token", false, 0)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(claims.Purpose) != "" {
|
||||
writeMobileError(c, http.StatusForbidden, mobileCodePermissionDenied,
|
||||
"token scope denied for this endpoint", false, 0)
|
||||
return
|
||||
}
|
||||
sessionID := strings.TrimSpace(claims.Sid)
|
||||
if sessionID == "" {
|
||||
// v1 客户端必须使用登录时创建的会话;旧网页令牌不进入移动端契约。
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||
"auth session required; sign in again", false, 0)
|
||||
return
|
||||
}
|
||||
session, err := svc.AuthSessions.Validate(c.Request.Context(), sessionID, claims.UserID)
|
||||
if err != nil {
|
||||
if service.IsTransientDatabaseLock(err) {
|
||||
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||
"auth session check temporarily unavailable", true, 2)
|
||||
return
|
||||
}
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked,
|
||||
"auth session revoked or expired", false, 0)
|
||||
return
|
||||
}
|
||||
c.Set(middleware.CtxUserID, claims.UserID)
|
||||
c.Set(middleware.CtxUserRole, claims.Role)
|
||||
c.Set(middleware.CtxUserTier, claims.Tier)
|
||||
c.Set(middleware.CtxAuthSessionID, sessionID)
|
||||
c.Set(mobileCtxAuthSession, session)
|
||||
// 每个请求写一次 last_used_at 会在 SQLite 上造成不必要的写锁竞争;
|
||||
// 一分钟内的重复请求不更新,足以支撑设备列表的“最近活动”。
|
||||
if time.Since(session.LastUsedAt) > time.Minute {
|
||||
svc.AuthSessions.Touch(c.Request.Context(), sessionID)
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// mobileLogoutAuthRequired 只校验 JWT 与 sid,不要求会话仍然有效。
|
||||
// 退出必须幂等:同一个令牌重复调用 logout 也要返回成功,而不是在第二次
|
||||
// 被会话校验拦成 401。
|
||||
func mobileLogoutAuthRequired(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw := middleware.ExtractToken(c)
|
||||
if raw == "" {
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||
"access token required", false, 0)
|
||||
return
|
||||
}
|
||||
claims, err := svc.Token.ValidateAccessToken(raw)
|
||||
if err != nil {
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired,
|
||||
"access token expired", false, 0)
|
||||
return
|
||||
}
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||
"invalid access token", false, 0)
|
||||
return
|
||||
}
|
||||
sessionID := strings.TrimSpace(claims.Sid)
|
||||
if sessionID == "" {
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||
"auth session required; sign in again", false, 0)
|
||||
return
|
||||
}
|
||||
c.Set(middleware.CtxUserID, claims.UserID)
|
||||
c.Set(middleware.CtxAuthSessionID, sessionID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func mobileLoginHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req mobileLoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
|
||||
"username, password and device_id are required", false, 0)
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
req.DeviceID = strings.TrimSpace(req.DeviceID)
|
||||
if req.Username == "" || req.DeviceID == "" || len(req.DeviceID) > 128 {
|
||||
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
|
||||
"invalid username or device_id", false, 0)
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
user, err := svc.Auth.Authenticate(ctx, req.Username, req.Password)
|
||||
if err != nil {
|
||||
writeMobileAuthError(c, log, err)
|
||||
return
|
||||
}
|
||||
|
||||
deviceName := strings.TrimSpace(req.DeviceName)
|
||||
clientName := strings.TrimSpace(req.ClientName)
|
||||
if clientName == "" {
|
||||
clientName = "MeBox Android"
|
||||
}
|
||||
session, err := svc.AuthSessions.Create(ctx, service.CreateAuthSessionInput{
|
||||
UserID: user.ID,
|
||||
DeviceID: req.DeviceID,
|
||||
DeviceName: deviceName,
|
||||
ClientName: clientName,
|
||||
ClientVersion: req.ClientVersion,
|
||||
})
|
||||
if err != nil {
|
||||
if isTransientServiceError(err) {
|
||||
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||
"cannot create auth session right now", true, 2)
|
||||
return
|
||||
}
|
||||
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
|
||||
"invalid device information", false, 0)
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := svc.Token.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
|
||||
if err != nil {
|
||||
// 令牌签发失败时回收会话,避免留下永远无法使用的会话记录。
|
||||
_ = svc.AuthSessions.Revoke(ctx, session.ID)
|
||||
writeMobileAuthError(c, log, err)
|
||||
return
|
||||
}
|
||||
if svc.Device != nil {
|
||||
svc.Device.RecordLogin(ctx, user.ID, session.DeviceID, deviceName, clientName, c.ClientIP())
|
||||
}
|
||||
if svc.Audit != nil {
|
||||
svc.Audit.RecordBestEffort(user.ID, "auth.mobile.login", user.Username, c.ClientIP(), "")
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"auth_session_id": session.ID,
|
||||
"user": toMobileUserPayload(user),
|
||||
"tokens": tokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mobileRefreshHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req mobileRefreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeMobileError(c, http.StatusBadRequest, mobileCodeInvalidArgument,
|
||||
"refresh_token required", false, 0)
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
rt, err := svc.Repo.RefreshToken.FindByHash(ctx, repository.HashToken(req.RefreshToken))
|
||||
if err != nil {
|
||||
if isTransientServiceError(err) {
|
||||
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||
"token store temporarily unavailable", true, 2)
|
||||
return
|
||||
}
|
||||
writeMobileAuthError(c, log, err)
|
||||
return
|
||||
}
|
||||
// 只有 v1 登录签发的刷新令牌才带会话;旧网页令牌必须重新登录。
|
||||
// 注意这里不能在 rt.Revoked 时提前返回:轮换宽限期内的幂等复用
|
||||
// 由 TokenService 判定。
|
||||
if rt == nil || strings.TrimSpace(rt.AuthSessionID) == "" {
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||
"refresh token is not bound to a device session; sign in again", false, 0)
|
||||
return
|
||||
}
|
||||
if _, err := svc.AuthSessions.Validate(ctx, rt.AuthSessionID, rt.UserID); err != nil {
|
||||
if isTransientServiceError(err) {
|
||||
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||
"auth session check temporarily unavailable", true, 2)
|
||||
return
|
||||
}
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked,
|
||||
"auth session revoked or expired", false, 0)
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := svc.Auth.RefreshTokens(ctx, req.RefreshToken)
|
||||
if err != nil {
|
||||
writeMobileAuthError(c, log, err)
|
||||
return
|
||||
}
|
||||
user, err := svc.Repo.User.FindByID(ctx, rt.UserID)
|
||||
if err != nil {
|
||||
writeMobileAuthError(c, log, err)
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||
"user not found", false, 0)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"auth_session_id": rt.AuthSessionID,
|
||||
"user": toMobileUserPayload(user),
|
||||
"tokens": tokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mobileSessionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
user, err := svc.Repo.User.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
writeMobileAuthError(c, svc.Log, err)
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||
"user not found", false, 0)
|
||||
return
|
||||
}
|
||||
session, _ := c.Get(mobileCtxAuthSession)
|
||||
sessionModel, _ := session.(*model.AuthSession)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"auth_session_id": c.GetString(middleware.CtxAuthSessionID),
|
||||
"user": toMobileUserPayload(user),
|
||||
"session": toMobileSessionPayload(sessionModel),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mobileLogoutHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
sessionID := c.GetString(middleware.CtxAuthSessionID)
|
||||
if err := svc.AuthSessions.Revoke(c.Request.Context(), sessionID); err != nil {
|
||||
if isTransientServiceError(err) {
|
||||
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||
"cannot revoke session right now", true, 2)
|
||||
return
|
||||
}
|
||||
writeMobileError(c, http.StatusInternalServerError, mobileCodeServiceUnavailable,
|
||||
"logout failed", false, 0)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
}
|
||||
|
||||
// writeMobileAuthError 把 service 层错误映射为稳定错误码。
|
||||
// 临时性存储/基础设施错误必须返回可重试的 503,不能伪装成凭证失效。
|
||||
func writeMobileAuthError(c *gin.Context, log *zap.Logger, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrInvalidCredentials), errors.Is(err, service.ErrInvalidRefreshToken):
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeInvalidCredentials,
|
||||
"invalid username, password or refresh token", false, 0)
|
||||
case errors.Is(err, service.ErrTokenExpired):
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeTokenExpired,
|
||||
"refresh token expired", false, 0)
|
||||
case errors.Is(err, service.ErrTokenRevoked),
|
||||
errors.Is(err, service.ErrAuthSessionRevoked),
|
||||
errors.Is(err, service.ErrAuthSessionExpired),
|
||||
errors.Is(err, service.ErrAuthSessionRequired):
|
||||
writeMobileError(c, http.StatusUnauthorized, mobileCodeSessionRevoked,
|
||||
"auth session revoked or expired", false, 0)
|
||||
case errors.Is(err, service.ErrUserInactive):
|
||||
writeMobileError(c, http.StatusForbidden, mobileCodeAccountDisabled,
|
||||
"account is disabled", false, 0)
|
||||
case errors.Is(err, service.ErrUserExpired):
|
||||
writeMobileError(c, http.StatusForbidden, mobileCodeAccountExpired,
|
||||
"account has expired", false, 0)
|
||||
case isTransientServiceError(err):
|
||||
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||
"service temporarily unavailable, please retry", true, 2)
|
||||
default:
|
||||
if log != nil {
|
||||
log.Warn("mobile auth request failed", zap.Error(err))
|
||||
}
|
||||
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||
"service temporarily unavailable, please retry", true, 2)
|
||||
}
|
||||
}
|
||||
|
||||
func isTransientServiceError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if service.IsTransientDatabaseLock(err) {
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
const mobileTestSecret = "mobile-test-secret"
|
||||
|
||||
func newMobileTestEnv(t *testing.T) (*gin.Engine, *service.Container) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// :memory: 只有单连接能看到同一份数据;handler 测试足够,且避免
|
||||
// shared cache 名字在并行测试间串库。
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&model.User{}, &model.RefreshToken{}, &model.AuthSession{}, &model.Setting{},
|
||||
&model.UserDevice{}, &model.UserPermission{},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{Secrets: config.SecretsConfig{JWTSecret: mobileTestSecret}}
|
||||
svc := service.NewWithVersion(cfg, zap.NewNop(), repos, "MeBox-test")
|
||||
|
||||
router := gin.New()
|
||||
Register(router, cfg, zap.NewNop(), svc)
|
||||
return router, svc
|
||||
}
|
||||
|
||||
func seedMobileUser(t *testing.T, svc *service.Container, username, password string) *model.User {
|
||||
t.Helper()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := &model.User{Username: username, PasswordHash: string(hash), Role: "user", Tier: "free", IsActive: true}
|
||||
if err := svc.Repo.User.Create(t.Context(), u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func mobileJSONRequest(t *testing.T, router *gin.Engine, method, path, token string, body any, headers map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var reader *bytes.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader = bytes.NewReader(raw)
|
||||
} else {
|
||||
reader = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reader)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func decodeMobileJSON(t *testing.T, w *httptest.ResponseRecorder, out any) {
|
||||
t.Helper()
|
||||
if err := json.Unmarshal(w.Body.Bytes(), out); err != nil {
|
||||
t.Fatalf("decode body %q: %v", w.Body.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileServerInfoAnonymousAndCacheable(t *testing.T) {
|
||||
router, _ := newMobileTestEnv(t)
|
||||
w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/server-info", "", nil, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
var info service.ServerInfo
|
||||
decodeMobileJSON(t, w, &info)
|
||||
if info.ServerID == "" || info.ServerVersion != "MeBox-test" || info.APIVersion != "v1" {
|
||||
t.Fatalf("unexpected server info: %+v", info)
|
||||
}
|
||||
if !info.Capabilities.DeviceSessions {
|
||||
t.Fatalf("device_sessions capability should be true: %+v", info.Capabilities)
|
||||
}
|
||||
if info.Capabilities.PlaybackSessions || info.Capabilities.ReaderProgressV1 {
|
||||
t.Fatalf("unimplemented capabilities must stay false: %+v", info.Capabilities)
|
||||
}
|
||||
if info.MinSupportedAppVersionCode < 1 {
|
||||
t.Fatalf("min_supported_app_version_code = %d", info.MinSupportedAppVersionCode)
|
||||
}
|
||||
|
||||
// 匿名访问不应要求 Authorization,也不应记录 cookie。
|
||||
if len(w.Result().Cookies()) != 0 {
|
||||
t.Fatalf("server-info should not set cookies")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileAuthLifecycle(t *testing.T) {
|
||||
router, svc := newMobileTestEnv(t)
|
||||
user := seedMobileUser(t, svc, "alice", "secret-123")
|
||||
|
||||
// 1. 错误密码:401 + 稳定错误码 + request_id。
|
||||
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"username": "alice", "password": "wrong", "device_id": "device-1",
|
||||
}, map[string]string{"X-Request-ID": "mobile-test-0001"})
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong password status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var authErr mobileErrorBody
|
||||
decodeMobileJSON(t, w, &authErr)
|
||||
if authErr.Code != mobileCodeInvalidCredentials || authErr.RequestID != "mobile-test-0001" {
|
||||
t.Fatalf("unexpected auth error: %+v", authErr)
|
||||
}
|
||||
if authErr.Retryable {
|
||||
t.Fatalf("invalid credentials must not be retryable: %+v", authErr)
|
||||
}
|
||||
|
||||
// 2. 缺少 device_id:400 INVALID_ARGUMENT。
|
||||
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"username": "alice", "password": "secret-123",
|
||||
}, nil)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing device id status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
decodeMobileJSON(t, w, &authErr)
|
||||
if authErr.Code != mobileCodeInvalidArgument {
|
||||
t.Fatalf("code = %q, want %q", authErr.Code, mobileCodeInvalidArgument)
|
||||
}
|
||||
|
||||
// 3. 正常登录。
|
||||
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"username": "alice", "password": "secret-123", "device_id": "device-1",
|
||||
"device_name": "Pixel 9", "client_name": "MeBox Android", "client_version": "1.0.0",
|
||||
}, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var loginResp struct {
|
||||
AuthSessionID string `json:"auth_session_id"`
|
||||
User mobileUserPayload `json:"user"`
|
||||
Tokens service.TokenPair `json:"tokens"`
|
||||
}
|
||||
decodeMobileJSON(t, w, &loginResp)
|
||||
if loginResp.AuthSessionID == "" || loginResp.Tokens.AccessToken == "" || loginResp.Tokens.RefreshToken == "" {
|
||||
t.Fatalf("incomplete login response: %+v", loginResp)
|
||||
}
|
||||
if loginResp.User.ID != user.ID || loginResp.User.Username != "alice" {
|
||||
t.Fatalf("unexpected user payload: %+v", loginResp.User)
|
||||
}
|
||||
claims, err := svc.Token.ValidateAccessToken(loginResp.Tokens.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("validate access token: %v", err)
|
||||
}
|
||||
if claims.Sid != loginResp.AuthSessionID {
|
||||
t.Fatalf("token sid = %q, want %q", claims.Sid, loginResp.AuthSessionID)
|
||||
}
|
||||
rt, err := svc.Repo.RefreshToken.FindByHash(t.Context(), repository.HashToken(loginResp.Tokens.RefreshToken))
|
||||
if err != nil || rt == nil || rt.AuthSessionID != loginResp.AuthSessionID {
|
||||
t.Fatalf("refresh token not bound to session: %+v err=%v", rt, err)
|
||||
}
|
||||
|
||||
// 4. 会话读取。
|
||||
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", loginResp.Tokens.AccessToken, nil, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("session status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var sessionResp struct {
|
||||
AuthSessionID string `json:"auth_session_id"`
|
||||
User mobileUserPayload `json:"user"`
|
||||
Session *mobileSessionPayload `json:"session"`
|
||||
}
|
||||
decodeMobileJSON(t, w, &sessionResp)
|
||||
if sessionResp.AuthSessionID != loginResp.AuthSessionID || sessionResp.Session == nil {
|
||||
t.Fatalf("unexpected session response: %+v", sessionResp)
|
||||
}
|
||||
if sessionResp.Session.DeviceID != "device-1" || sessionResp.Session.DeviceName != "Pixel 9" {
|
||||
t.Fatalf("unexpected session payload: %+v", sessionResp.Session)
|
||||
}
|
||||
|
||||
// 5. 刷新:新令牌继续归属同一会话,旧刷新令牌被撤销。
|
||||
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{
|
||||
"refresh_token": loginResp.Tokens.RefreshToken,
|
||||
}, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("refresh status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var refreshResp struct {
|
||||
AuthSessionID string `json:"auth_session_id"`
|
||||
Tokens service.TokenPair `json:"tokens"`
|
||||
}
|
||||
decodeMobileJSON(t, w, &refreshResp)
|
||||
if refreshResp.AuthSessionID != loginResp.AuthSessionID || refreshResp.Tokens.AccessToken == "" {
|
||||
t.Fatalf("unexpected refresh response: %+v", refreshResp)
|
||||
}
|
||||
if refreshResp.Tokens.RefreshToken == loginResp.Tokens.RefreshToken {
|
||||
t.Fatal("refresh token should rotate")
|
||||
}
|
||||
oldRT, err := svc.Repo.RefreshToken.FindByHash(t.Context(), repository.HashToken(loginResp.Tokens.RefreshToken))
|
||||
if err != nil || oldRT == nil || !oldRT.Revoked {
|
||||
t.Fatalf("old refresh token should be revoked: %+v err=%v", oldRT, err)
|
||||
}
|
||||
|
||||
// 6. 用刷新后的访问令牌访问旧 API,确认 sid 会话校验在旧入口同样生效。
|
||||
w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", refreshResp.Tokens.AccessToken, nil, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("old api with fresh token status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 7. 退出:成功且可重复。
|
||||
for i := 0; i < 2; i++ {
|
||||
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/logout", refreshResp.Tokens.AccessToken, nil, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("logout #%d status = %d body=%s", i+1, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 会话撤销后:v1 会话接口 401 SESSION_REVOKED。
|
||||
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", refreshResp.Tokens.AccessToken, nil, nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("session after logout status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
decodeMobileJSON(t, w, &authErr)
|
||||
if authErr.Code != mobileCodeSessionRevoked {
|
||||
t.Fatalf("session after logout code = %q, want %q", authErr.Code, mobileCodeSessionRevoked)
|
||||
}
|
||||
|
||||
// 9. 被踢后的未过期 JWT 不能绕过旧 API。
|
||||
w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", refreshResp.Tokens.AccessToken, nil, nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("old api after logout status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 10. 刷新令牌随会话一起撤销。
|
||||
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{
|
||||
"refresh_token": refreshResp.Tokens.RefreshToken,
|
||||
}, nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("refresh after logout status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
decodeMobileJSON(t, w, &authErr)
|
||||
if authErr.Code != mobileCodeSessionRevoked {
|
||||
t.Fatalf("refresh after logout code = %q, want %q", authErr.Code, mobileCodeSessionRevoked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileV1RejectsLegacyTokenWithoutSid(t *testing.T) {
|
||||
router, svc := newMobileTestEnv(t)
|
||||
user := seedMobileUser(t, svc, "legacy", "secret-123")
|
||||
pair, err := svc.Token.IssuePair(t.Context(), user.ID, user.Role, user.Tier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims, err := svc.Token.ValidateAccessToken(pair.AccessToken); err != nil || claims.Sid != "" {
|
||||
t.Fatalf("legacy token unexpectedly has sid: %+v err=%v", claims, err)
|
||||
}
|
||||
|
||||
w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", pair.AccessToken, nil, nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("legacy token status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var body mobileErrorBody
|
||||
decodeMobileJSON(t, w, &body)
|
||||
if body.Code != mobileCodeInvalidCredentials {
|
||||
t.Fatalf("legacy token code = %q, want %q", body.Code, mobileCodeInvalidCredentials)
|
||||
}
|
||||
|
||||
// 旧令牌仍可访问旧 API(兼容路径不能被新协议破坏)。
|
||||
w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", pair.AccessToken, nil, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("legacy token on old api status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileRefreshRejectsUnboundLegacyToken(t *testing.T) {
|
||||
router, svc := newMobileTestEnv(t)
|
||||
user := seedMobileUser(t, svc, "bob", "secret-123")
|
||||
pair, err := svc.Token.IssuePair(t.Context(), user.ID, user.Role, user.Tier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{
|
||||
"refresh_token": pair.RefreshToken,
|
||||
}, nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("legacy refresh status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var body mobileErrorBody
|
||||
decodeMobileJSON(t, w, &body)
|
||||
if body.Code != mobileCodeInvalidCredentials {
|
||||
t.Fatalf("legacy refresh code = %q, want %q", body.Code, mobileCodeInvalidCredentials)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileDeviceKickRevokesSession(t *testing.T) {
|
||||
router, svc := newMobileTestEnv(t)
|
||||
user := seedMobileUser(t, svc, "carol", "secret-123")
|
||||
|
||||
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"username": "carol", "password": "secret-123", "device_id": "device-kick",
|
||||
"device_name": "Kick Me", "client_name": "MeBox Android",
|
||||
}, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var loginResp struct {
|
||||
AuthSessionID string `json:"auth_session_id"`
|
||||
Tokens service.TokenPair `json:"tokens"`
|
||||
}
|
||||
decodeMobileJSON(t, w, &loginResp)
|
||||
|
||||
if err := svc.Device.KickDevice(t.Context(), user.ID, "device-kick"); err != nil {
|
||||
t.Fatalf("kick device: %v", err)
|
||||
}
|
||||
session, err := svc.Repo.AuthSession.FindByID(t.Context(), loginResp.AuthSessionID)
|
||||
if err != nil || session == nil || !session.Revoked {
|
||||
t.Fatalf("session should be revoked after device kick: %+v err=%v", session, err)
|
||||
}
|
||||
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", loginResp.Tokens.AccessToken, nil, nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("session after kick status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMobileKickOneDeviceKeepsOtherSessions(t *testing.T) {
|
||||
router, svc := newMobileTestEnv(t)
|
||||
seedMobileUser(t, svc, "erin", "secret-123")
|
||||
|
||||
login := func(deviceID string) string {
|
||||
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"username": "erin", "password": "secret-123", "device_id": deviceID,
|
||||
"device_name": deviceID, "client_name": "MeBox Android",
|
||||
}, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("login %s status = %d body=%s", deviceID, w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Tokens service.TokenPair `json:"tokens"`
|
||||
}
|
||||
decodeMobileJSON(t, w, &resp)
|
||||
return resp.Tokens.AccessToken
|
||||
}
|
||||
|
||||
tokenA := login("device-a")
|
||||
tokenB := login("device-b")
|
||||
|
||||
if err := svc.Device.KickDevice(t.Context(), svcUserID(t, svc, "erin"), "device-a"); err != nil {
|
||||
t.Fatalf("kick device a: %v", err)
|
||||
}
|
||||
|
||||
w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", tokenA, nil, nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("kicked session status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", tokenB, nil, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("other device status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func svcUserID(t *testing.T, svc *service.Container, username string) string {
|
||||
t.Helper()
|
||||
u, err := svc.Repo.User.FindByUsername(t.Context(), username)
|
||||
if err != nil || u == nil {
|
||||
t.Fatalf("find user %s: %v", username, err)
|
||||
}
|
||||
return u.ID
|
||||
}
|
||||
|
||||
func TestMobileAuthDefaultsAndSessionExpiryShape(t *testing.T) {
|
||||
router, svc := newMobileTestEnv(t)
|
||||
seedMobileUser(t, svc, "dave", "secret-123")
|
||||
|
||||
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"username": "dave", "password": "secret-123", "device_id": "device-default",
|
||||
}, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var loginResp struct {
|
||||
AuthSessionID string `json:"auth_session_id"`
|
||||
Tokens service.TokenPair `json:"tokens"`
|
||||
}
|
||||
decodeMobileJSON(t, w, &loginResp)
|
||||
session, err := svc.Repo.AuthSession.FindByID(t.Context(), loginResp.AuthSessionID)
|
||||
if err != nil || session == nil {
|
||||
t.Fatalf("load session: %v", err)
|
||||
}
|
||||
if session.ClientName != "MeBox Android" {
|
||||
t.Fatalf("client_name default = %q", session.ClientName)
|
||||
}
|
||||
if session.ExpiresAt.Before(time.Now()) {
|
||||
t.Fatalf("session expiry in the past: %v", session.ExpiresAt)
|
||||
}
|
||||
if loginResp.Tokens.TokenType != "Bearer" || loginResp.Tokens.ExpiresIn <= 0 {
|
||||
t.Fatalf("unexpected token metadata: %+v", loginResp.Tokens)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
)
|
||||
|
||||
// 移动端 v1 错误码。新增错误码必须同步 OpenAPI 与 App 的 ApiErrorMapper。
|
||||
const (
|
||||
mobileCodeInvalidArgument = "INVALID_ARGUMENT"
|
||||
mobileCodeInvalidCredentials = "INVALID_CREDENTIALS"
|
||||
mobileCodeTokenExpired = "TOKEN_EXPIRED"
|
||||
mobileCodeSessionRevoked = "SESSION_REVOKED"
|
||||
mobileCodeAccountDisabled = "ACCOUNT_DISABLED"
|
||||
mobileCodeAccountExpired = "ACCOUNT_EXPIRED"
|
||||
mobileCodePermissionDenied = "PERMISSION_DENIED"
|
||||
mobileCodeProfilePINRequired = "PROFILE_PIN_REQUIRED"
|
||||
mobileCodeRateLimited = "RATE_LIMITED"
|
||||
mobileCodeUpstreamUnavailable = "UPSTREAM_UNAVAILABLE"
|
||||
mobileCodeTranscodeBusy = "TRANSCODE_BUSY"
|
||||
mobileCodePlaybackSessionAbsent = "PLAYBACK_SESSION_NOT_FOUND"
|
||||
mobileCodeResourceExpired = "RESOURCE_EXPIRED"
|
||||
mobileCodeProgressConflict = "PROGRESS_CONFLICT"
|
||||
mobileCodeServiceUnavailable = "SERVICE_UNAVAILABLE"
|
||||
)
|
||||
|
||||
const (
|
||||
mobileCtxRequestID = "mobile_request_id"
|
||||
mobileCtxAuthSession = "mobile_auth_session"
|
||||
mobileRequestIDHeader = "X-Request-ID"
|
||||
)
|
||||
|
||||
// mobileErrorBody 是 v1 统一错误响应。字段直接位于顶层,便于客户端按
|
||||
// code 分流,不需要再判断后端历史包装层。
|
||||
type mobileErrorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"request_id"`
|
||||
Retryable bool `json:"retryable"`
|
||||
RetryAfterSec int `json:"retry_after_sec,omitempty"`
|
||||
Details any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// mobileRequestIDMiddleware 为每个 v1 请求绑定可追踪的请求 ID。
|
||||
// 合法客户端值会沿用(便于 App 与服务端日志对齐),非法或过长则由服务器
|
||||
// 重新生成;该值仅用于诊断,绝不参与鉴权。
|
||||
func mobileRequestIDMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.GetHeader(mobileRequestIDHeader))
|
||||
if !validRequestID(id) {
|
||||
id = uuid.NewString()
|
||||
}
|
||||
c.Set(mobileCtxRequestID, id)
|
||||
c.Header(mobileRequestIDHeader, id)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func validRequestID(id string) bool {
|
||||
if len(id) < 8 || len(id) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, r := range id {
|
||||
if r > unicode.MaxASCII {
|
||||
return false
|
||||
}
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
continue
|
||||
}
|
||||
switch r {
|
||||
case '-', '_', '.', ':':
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func currentMobileRequestID(c *gin.Context) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := c.Get(mobileCtxRequestID); ok {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// writeMobileError 输出统一错误体。request_id 缺失时现场生成,保证所有
|
||||
// v1 错误都可用于对账,而不是只在注册了中间件的路由上才有。
|
||||
func writeMobileError(c *gin.Context, status int, code, message string, retryable bool, retryAfterSec int) {
|
||||
requestID := currentMobileRequestID(c)
|
||||
if requestID == "" {
|
||||
requestID = uuid.NewString()
|
||||
c.Header(mobileRequestIDHeader, requestID)
|
||||
}
|
||||
if retryAfterSec > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfterSec))
|
||||
}
|
||||
c.AbortWithStatusJSON(status, mobileErrorBody{
|
||||
Code: code,
|
||||
Message: message,
|
||||
RequestID: requestID,
|
||||
Retryable: retryable,
|
||||
RetryAfterSec: retryAfterSec,
|
||||
})
|
||||
}
|
||||
|
||||
// mobileRateLimit 与旧端点共用 RateLimiter,但返回 v1 错误契约和 Retry-After。
|
||||
func mobileRateLimit(limiter *middleware.RateLimiter) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !limiter.Allow(c.ClientIP()) {
|
||||
writeMobileError(c, http.StatusTooManyRequests, mobileCodeRateLimited,
|
||||
"too many requests, please try again later", true, 60)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// registerMobileRoutes 挂载 /api/v1 移动端稳定契约。
|
||||
//
|
||||
// 既有 /api 与 Emby 入口保持不变;v1 只做适配和会话语义补强,不复制媒体
|
||||
// 或书源业务逻辑。
|
||||
func registerMobileRoutes(api *gin.RouterGroup, svc *service.Container, log *zap.Logger) {
|
||||
if svc == nil || svc.ServerIdentity == nil || svc.AuthSessions == nil {
|
||||
// 最小容器(例如旧测试桩)没有身份/会话服务时,不暴露不可用的 v1。
|
||||
return
|
||||
}
|
||||
wireAuthSessionValidator(svc)
|
||||
|
||||
v1 := api.Group("/v1")
|
||||
v1.Use(mobileRequestIDMiddleware())
|
||||
|
||||
v1.GET("/server-info", mobileServerInfoHandler(svc, log))
|
||||
|
||||
authLimiter := middleware.NewRateLimiter(30, time.Minute)
|
||||
v1.POST("/auth/login", mobileRateLimit(authLimiter), mobileLoginHandler(svc, log))
|
||||
v1.POST("/auth/refresh", mobileRefreshHandler(svc, log))
|
||||
|
||||
authed := v1.Group("/auth")
|
||||
{
|
||||
authed.GET("/session", mobileAuthRequired(svc), mobileSessionHandler(svc))
|
||||
authed.POST("/logout", mobileLogoutAuthRequired(svc), mobileLogoutHandler(svc))
|
||||
}
|
||||
}
|
||||
|
||||
// wireAuthSessionValidator 让旧 /api 中间件也能校验新令牌的 sid。
|
||||
// service 层错误在这里映射为 middleware 的两个哨兵,避免包依赖反转。
|
||||
func wireAuthSessionValidator(svc *service.Container) {
|
||||
middleware.SetSessionValidator(func(ctx context.Context, sessionID, userID string) error {
|
||||
if svc == nil || svc.AuthSessions == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := svc.AuthSessions.Validate(ctx, sessionID, userID)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, service.ErrAuthSessionRevoked) ||
|
||||
errors.Is(err, service.ErrAuthSessionExpired) ||
|
||||
errors.Is(err, service.ErrAuthSessionRequired) {
|
||||
return middleware.ErrSessionRevoked
|
||||
}
|
||||
return fmt.Errorf("%w: %v", middleware.ErrSessionUnavailable, err)
|
||||
})
|
||||
}
|
||||
|
||||
func mobileServerInfoHandler(svc *service.Container, log *zap.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
info, err := svc.ServerIdentity.Info(ctx, service.CurrentServerCapabilities(svc))
|
||||
if err != nil {
|
||||
if log != nil {
|
||||
log.Warn("mobile server info failed", zap.Error(err))
|
||||
}
|
||||
writeMobileError(c, http.StatusServiceUnavailable, mobileCodeServiceUnavailable,
|
||||
"server identity temporarily unavailable", true, 2)
|
||||
return
|
||||
}
|
||||
// 身份信息随配置变化且不应被中间缓存:同一地址可能切换部署实例。
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ type Claims struct {
|
||||
Tier string `json:"tier,omitempty"`
|
||||
Purpose string `json:"purpose,omitempty"`
|
||||
MediaID string `json:"media_id,omitempty"`
|
||||
// Sid 是设备认证会话 ID;旧令牌为空,继续走兼容路径。
|
||||
Sid string `json:"sid,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -44,6 +46,19 @@ func AuthRequired(secret string) gin.HandlerFunc {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": 40304, "message": "token scope denied"})
|
||||
return
|
||||
}
|
||||
// 新协议令牌携带 sid:旧 API 也必须检查会话是否已被撤销,否则
|
||||
// 设备被踢后仍能用未过期的 JWT 访问 /api/*。
|
||||
if sid := strings.TrimSpace(claims.Sid); sid != "" {
|
||||
if err := ValidateAuthSession(c.Request.Context(), sid, claims.UserID); err != nil {
|
||||
if errors.Is(err, ErrSessionUnavailable) {
|
||||
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"code": 50303, "message": "auth session unavailable"})
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 40104, "message": "auth session revoked"})
|
||||
return
|
||||
}
|
||||
c.Set(CtxAuthSessionID, sid)
|
||||
}
|
||||
syncAccessTokenCookie(c, raw, claims)
|
||||
c.Set(CtxUserID, claims.UserID)
|
||||
c.Set(CtxUserRole, claims.Role)
|
||||
@@ -151,3 +166,8 @@ func extractToken(c *gin.Context) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ExtractToken 暴露令牌提取逻辑给需要自定义错误契约的移动端中间件。
|
||||
func ExtractToken(c *gin.Context) string {
|
||||
return extractToken(c)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 会话校验错误。service 层把自己的错误映射到这两个哨兵,避免 middleware
|
||||
// 依赖 service 包(auth 服务已经依赖 middleware 的上下文键)。
|
||||
var (
|
||||
// ErrSessionRevoked 表示会话已撤销、已过期或不存在:客户端应重新登录。
|
||||
ErrSessionRevoked = errors.New("auth session revoked")
|
||||
// ErrSessionUnavailable 表示暂时无法完成会话校验:客户端应稍后重试。
|
||||
ErrSessionUnavailable = errors.New("auth session unavailable")
|
||||
)
|
||||
|
||||
// SessionValidator 校验认证会话是否仍属于该用户。
|
||||
type SessionValidator func(ctx context.Context, sessionID, userID string) error
|
||||
|
||||
var (
|
||||
sessionValidatorMu sync.RWMutex
|
||||
sessionValidator SessionValidator
|
||||
)
|
||||
|
||||
// SetSessionValidator 注册全局会话校验器;传 nil 表示关闭校验(旧装配路径)。
|
||||
func SetSessionValidator(fn SessionValidator) {
|
||||
sessionValidatorMu.Lock()
|
||||
defer sessionValidatorMu.Unlock()
|
||||
sessionValidator = fn
|
||||
}
|
||||
|
||||
// ValidateAuthSession 使用已注册的校验器检查 sid。未注册校验器时放行,
|
||||
// 以保持旧测试和嵌入式调用的兼容行为。
|
||||
func ValidateAuthSession(ctx context.Context, sessionID, userID string) error {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
return nil
|
||||
}
|
||||
sessionValidatorMu.RLock()
|
||||
fn := sessionValidator
|
||||
sessionValidatorMu.RUnlock()
|
||||
if fn == nil {
|
||||
return nil
|
||||
}
|
||||
return fn(ctx, sessionID, userID)
|
||||
}
|
||||
@@ -9,6 +9,8 @@ const (
|
||||
CtxUserTier = "ctx_user_tier"
|
||||
CtxTokenPurpose = "ctx_token_purpose"
|
||||
CtxTokenMediaID = "ctx_token_media_id"
|
||||
// CtxAuthSessionID 保存已校验的设备认证会话 ID(新协议令牌才有)。
|
||||
CtxAuthSessionID = "ctx_auth_session_id"
|
||||
|
||||
// AccessTokenCookieName carries the web access token for browser-managed
|
||||
// resource requests such as <img>, which cannot attach Authorization.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AuthSession 是可撤销的设备认证会话。
|
||||
//
|
||||
// 会话与刷新令牌的关系是 1:N:一次登录创建一个会话,之后每次轮换刷新
|
||||
// 令牌都继续归属同一会话。访问令牌通过 JWT 的 sid 字段引用会话,因此
|
||||
// 服务端可以在 JWT 尚未过期时立即撤销某台设备的访问能力。
|
||||
//
|
||||
// 旧客户端没有会话关联(sid 为空),继续走既有兼容路径;本模型不强制
|
||||
// 把历史刷新令牌绑定到任何设备。
|
||||
type AuthSession struct {
|
||||
ID string `gorm:"primaryKey;size:36" json:"id"`
|
||||
UserID string `gorm:"index;size:36;not null" json:"user_id"`
|
||||
DeviceID string `gorm:"index:idx_auth_session_device,priority:1;size:128;not null" json:"device_id"`
|
||||
DeviceName string `gorm:"size:128" json:"device_name,omitempty"`
|
||||
ClientName string `gorm:"size:64" json:"client_name,omitempty"`
|
||||
ClientVersion string `gorm:"size:64" json:"client_version,omitempty"`
|
||||
Revoked bool `gorm:"not null;default:false" json:"revoked"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
LastUsedAt time.Time `json:"last_used_at"`
|
||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// BeforeCreate 生成 UUID 主键。
|
||||
func (s *AuthSession) BeforeCreate(_ *gorm.DB) error {
|
||||
if s.ID == "" {
|
||||
s.ID = uuid.NewString()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsExpired 判断会话是否超过最长有效期。
|
||||
func (s *AuthSession) IsExpired() bool {
|
||||
return s != nil && !s.ExpiresAt.IsZero() && time.Now().After(s.ExpiresAt)
|
||||
}
|
||||
|
||||
// IsValid 判断会话当前是否可继续使用。
|
||||
func (s *AuthSession) IsValid() bool {
|
||||
return s != nil && !s.Revoked && !s.IsExpired()
|
||||
}
|
||||
@@ -49,6 +49,7 @@ func AllModels() []interface{} {
|
||||
&APIConfig{},
|
||||
&UserPermission{},
|
||||
&RefreshToken{},
|
||||
&AuthSession{},
|
||||
&PlayProfile{},
|
||||
&RegistrationCode{},
|
||||
&SignIn{},
|
||||
|
||||
@@ -11,12 +11,14 @@ import (
|
||||
// RefreshToken 用于双令牌认证机制中的刷新令牌。
|
||||
// 存储时使用 SHA256 哈希,原始令牌不存储。
|
||||
type RefreshToken struct {
|
||||
ID string `gorm:"primaryKey;size:36" json:"id"`
|
||||
UserID string `gorm:"index;size:36;not null" json:"user_id"`
|
||||
TokenHash string `gorm:"uniqueIndex;size:128;not null" json:"-"`
|
||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Revoked bool `gorm:"default:false" json:"revoked"`
|
||||
ID string `gorm:"primaryKey;size:36" json:"id"`
|
||||
UserID string `gorm:"index;size:36;not null" json:"user_id"`
|
||||
TokenHash string `gorm:"uniqueIndex;size:128;not null" json:"-"`
|
||||
// AuthSessionID 关联移动端设备会话。旧数据为空,继续按既有兼容路径使用。
|
||||
AuthSessionID string `gorm:"index;size:36" json:"auth_session_id,omitempty"`
|
||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Revoked bool `gorm:"default:false" json:"revoked"`
|
||||
}
|
||||
|
||||
// BeforeCreate 生成 UUID。
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// AuthSessionRepository persists model.AuthSession records.
|
||||
type AuthSessionRepository struct{ db *gorm.DB }
|
||||
|
||||
// Create inserts a new authentication session.
|
||||
func (r *AuthSessionRepository) Create(ctx context.Context, s *model.AuthSession) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Create(s).Error
|
||||
})
|
||||
}
|
||||
|
||||
// FindByID returns the session or (nil, nil) when absent.
|
||||
func (r *AuthSessionRepository) FindByID(ctx context.Context, id string) (*model.AuthSession, error) {
|
||||
if id == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var s model.AuthSession
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
s = model.AuthSession{}
|
||||
return r.db.WithContext(ctx).Where("id = ?", id).First(&s).Error
|
||||
})
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// TouchLastUsed 更新会话最近使用时间。
|
||||
func (r *AuthSessionRepository) TouchLastUsed(ctx context.Context, id string, at time.Time) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Model(&model.AuthSession{}).
|
||||
Where("id = ? AND revoked = ?", id, false).
|
||||
Update("last_used_at", at).Error
|
||||
})
|
||||
}
|
||||
|
||||
// Revoke 撤销单个会话(幂等)。
|
||||
func (r *AuthSessionRepository) Revoke(ctx context.Context, id string, at time.Time) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Model(&model.AuthSession{}).
|
||||
Where("id = ? AND revoked = ?", id, false).
|
||||
Updates(map[string]any{"revoked": true, "revoked_at": at}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeByUser 撤销某用户的全部会话(幂等)。
|
||||
func (r *AuthSessionRepository) RevokeByUser(ctx context.Context, userID string, at time.Time) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Model(&model.AuthSession{}).
|
||||
Where("user_id = ? AND revoked = ?", userID, false).
|
||||
Updates(map[string]any{"revoked": true, "revoked_at": at}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeByDevice 撤销某用户某设备关联的全部会话(幂等)。
|
||||
func (r *AuthSessionRepository) RevokeByDevice(ctx context.Context, userID, deviceID string, at time.Time) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Model(&model.AuthSession{}).
|
||||
Where("user_id = ? AND device_id = ? AND revoked = ?", userID, deviceID, false).
|
||||
Updates(map[string]any{"revoked": true, "revoked_at": at}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ListActiveByUser 返回某用户当前有效(未撤销、未过期)的会话。
|
||||
func (r *AuthSessionRepository) ListActiveByUser(ctx context.Context, userID string) ([]model.AuthSession, error) {
|
||||
var rows []model.AuthSession
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
rows = nil
|
||||
return r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()).
|
||||
Order("last_used_at desc, created_at desc").
|
||||
Find(&rows).Error
|
||||
})
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListByDevice 返回某用户某设备的全部会话,用于踢出设备时逐个撤销。
|
||||
func (r *AuthSessionRepository) ListByDevice(ctx context.Context, userID, deviceID string) ([]model.AuthSession, error) {
|
||||
var rows []model.AuthSession
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
rows = nil
|
||||
return r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND device_id = ?", userID, deviceID).
|
||||
Find(&rows).Error
|
||||
})
|
||||
return rows, err
|
||||
}
|
||||
@@ -63,38 +63,38 @@ type MediaQueryFilter struct {
|
||||
|
||||
func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
|
||||
if !filter.IncludeNSFW {
|
||||
q = q.Where("nsfw = ?", false)
|
||||
q = q.Where("media.nsfw = ?", false)
|
||||
}
|
||||
if len(filter.HiddenLibraryIDs) > 0 {
|
||||
q = q.Where("library_id NOT IN ?", filter.HiddenLibraryIDs)
|
||||
q = q.Where("media.library_id NOT IN ?", filter.HiddenLibraryIDs)
|
||||
}
|
||||
if len(filter.AllowedLibraryIDs) > 0 {
|
||||
q = q.Where("library_id IN ?", filter.AllowedLibraryIDs)
|
||||
q = q.Where("media.library_id IN ?", filter.AllowedLibraryIDs)
|
||||
}
|
||||
if libraryID := strings.TrimSpace(filter.LibraryID); libraryID != "" {
|
||||
q = q.Where("library_id = ?", libraryID)
|
||||
q = q.Where("media.library_id = ?", libraryID)
|
||||
}
|
||||
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
|
||||
q = q.Where("series_id = ?", seriesID)
|
||||
q = q.Where("media.series_id = ?", seriesID)
|
||||
}
|
||||
if len(filter.Genres) > 0 {
|
||||
q = q.Where(genreMatchClause(filter.Genres), genreMatchArgs(filter.Genres)...)
|
||||
}
|
||||
if filter.YearMin > 0 {
|
||||
q = q.Where("year >= ?", filter.YearMin)
|
||||
q = q.Where("media.year >= ?", filter.YearMin)
|
||||
}
|
||||
if filter.YearMax > 0 {
|
||||
q = q.Where("year <= ?", filter.YearMax)
|
||||
q = q.Where("media.year <= ?", filter.YearMax)
|
||||
}
|
||||
if filter.RatingMin > 0 {
|
||||
q = q.Where("rating >= ?", filter.RatingMin)
|
||||
q = q.Where("media.rating >= ?", filter.RatingMin)
|
||||
}
|
||||
if filter.UnwatchedOnly {
|
||||
userID := strings.TrimSpace(filter.UnwatchedUserID)
|
||||
// 没有用户上下文时忽略该条件:否则会把整个库筛成空,看起来像「坏了」。
|
||||
if userID != "" {
|
||||
q = q.Where(
|
||||
"id NOT IN (SELECT media_id FROM playback_histories WHERE user_id = ? AND completed = ?)",
|
||||
"media.id NOT IN (SELECT media_id FROM playback_histories WHERE user_id = ? AND completed = ?)",
|
||||
userID, true,
|
||||
)
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
|
||||
func genreMatchClause(genres []string) string {
|
||||
clauses := make([]string, 0, len(genres))
|
||||
for range genres {
|
||||
clauses = append(clauses, "',' || REPLACE(REPLACE(TRIM(genres), ' ', ''), ',', ',') || ',' LIKE ?")
|
||||
clauses = append(clauses, "',' || REPLACE(REPLACE(TRIM(media.genres), ' ', ''), ',', ',') || ',' LIKE ?")
|
||||
}
|
||||
return "(" + strings.Join(clauses, " OR ") + ")"
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func genreMatchClause(genres []string) string {
|
||||
//
|
||||
// 必须与 genreMatchClause 的列端处理完全对称:
|
||||
// - TRIM → TrimSpace
|
||||
// - REPLACE(…, ' ', '') → ReplaceAll(…, " ", "") ← 多词类型(如 "Science Fiction")
|
||||
// - REPLACE(…, ' ', ”) → ReplaceAll(…, " ", "") ← 多词类型(如 "Science Fiction")
|
||||
// - REPLACE(…, ',', ',') → ReplaceAll(…, ",", ",")
|
||||
func genreMatchArgs(genres []string) []any {
|
||||
args := make([]any, 0, len(genres))
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
@@ -95,6 +96,9 @@ func (r *MediaRepository) UpsertBatchWithAliases(ctx context.Context, items []Me
|
||||
|
||||
// indexByIDBestEffort 在事务提交后按 ID 回读最新行并同步搜索索引。
|
||||
func (r *MediaRepository) indexByIDBestEffort(ctx context.Context, ids []string) {
|
||||
if _, ok := r.searchBackend.(MediaSearchSyncBackend); !ok {
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id == "" {
|
||||
continue
|
||||
@@ -193,14 +197,19 @@ func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.
|
||||
if m.ScrapeStatus == "" {
|
||||
m.ScrapeStatus = "pending"
|
||||
}
|
||||
if createErr := db.WithContext(ctx).Create(m).Error; createErr == nil {
|
||||
return *m, true, false, nil
|
||||
} else if retryErr := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; retryErr != nil {
|
||||
return model.Media{}, false, false, createErr
|
||||
} else {
|
||||
// 并发插入竞态:重查已命中既有行,直接走更新分支。
|
||||
return existing, false, false, nil
|
||||
// PostgreSQL 唯一键异常会中止整个事务,之后重查也会失败。
|
||||
// DO NOTHING 保持事务可用,并兼容 path 的表达式唯一索引。
|
||||
result := db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(m)
|
||||
if result.Error != nil {
|
||||
return model.Media{}, false, false, result.Error
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
return *m, true, false, nil
|
||||
}
|
||||
if err := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; err != nil {
|
||||
return model.Media{}, false, false, err
|
||||
}
|
||||
return existing, false, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return model.Media{}, false, false, err
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
@@ -86,7 +87,7 @@ func (r *MediaRepository) searchFilteredFTS(ctx context.Context, query string, o
|
||||
Joins("JOIN media_search_fts ON media_search_fts.rowid = media.rowid").
|
||||
Where("media.deleted_at IS NULL").
|
||||
Where("media_search_fts MATCH ?", ftsQuery)
|
||||
q = applyQualifiedMediaQueryFilter(q, filter)
|
||||
q = applyMediaQueryFilter(q, filter)
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, false
|
||||
}
|
||||
@@ -122,10 +123,10 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string,
|
||||
if query != "" {
|
||||
prefix := escapeLike(query) + "%"
|
||||
exact := query
|
||||
q = q.Order(gorm.Expr(
|
||||
q = q.Order(clause.OrderBy{Expression: gorm.Expr(
|
||||
"CASE WHEN title = ? THEN 0 WHEN original_name = ? THEN 1 WHEN title "+likeOp+" ? ESCAPE '\\' THEN 2 WHEN original_name "+likeOp+" ? ESCAPE '\\' THEN 3 ELSE 4 END, created_at desc",
|
||||
exact, exact, prefix, prefix,
|
||||
))
|
||||
)})
|
||||
} else {
|
||||
q = q.Order("created_at desc")
|
||||
}
|
||||
@@ -133,22 +134,6 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string,
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
func applyQualifiedMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
|
||||
if !filter.IncludeNSFW {
|
||||
q = q.Where("media.nsfw = ?", false)
|
||||
}
|
||||
if len(filter.HiddenLibraryIDs) > 0 {
|
||||
q = q.Where("media.library_id NOT IN ?", filter.HiddenLibraryIDs)
|
||||
}
|
||||
if len(filter.AllowedLibraryIDs) > 0 {
|
||||
q = q.Where("media.library_id IN ?", filter.AllowedLibraryIDs)
|
||||
}
|
||||
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
|
||||
q = q.Where("media.series_id = ?", seriesID)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func mediaFTSQuery(query string) string {
|
||||
terms := mediaSearchTerms(query)
|
||||
if len(terms) == 0 {
|
||||
|
||||
@@ -79,7 +79,7 @@ func (r *RefreshTokenRepository) DeleteExpired(ctx context.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Revoke revokes a specific refresh token.
|
||||
// Revoke 撤销某个刷新令牌。
|
||||
func (r *RefreshTokenRepository) Revoke(ctx context.Context, hash string) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Model(&model.RefreshToken{}).
|
||||
@@ -87,6 +87,18 @@ func (r *RefreshTokenRepository) Revoke(ctx context.Context, hash string) error
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeByAuthSessionID 撤销某个认证会话名下的全部刷新令牌。
|
||||
// 设备被踢或主动退出后,旧刷新令牌不能再换出新令牌。
|
||||
func (r *RefreshTokenRepository) RevokeByAuthSessionID(ctx context.Context, sessionID string) error {
|
||||
if sessionID == "" {
|
||||
return nil
|
||||
}
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Model(&model.RefreshToken{}).
|
||||
Where("auth_session_id = ?", sessionID).Update("revoked", true).Error
|
||||
})
|
||||
}
|
||||
|
||||
// HashToken returns the SHA256 hash of a token.
|
||||
func HashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
|
||||
@@ -23,6 +23,7 @@ type Container struct {
|
||||
Log *AccessLogRepository
|
||||
Permission *PermissionRepository
|
||||
RefreshToken *RefreshTokenRepository
|
||||
AuthSession *AuthSessionRepository
|
||||
ApiConfig *ApiConfigRepository
|
||||
PlayProfile *PlayProfileRepository
|
||||
RegCode *RegistrationCodeRepository
|
||||
@@ -56,6 +57,7 @@ func New(db *gorm.DB) *Container {
|
||||
Log: &AccessLogRepository{db: db},
|
||||
Permission: &PermissionRepository{db: db},
|
||||
RefreshToken: &RefreshTokenRepository{db: db},
|
||||
AuthSession: &AuthSessionRepository{db: db},
|
||||
ApiConfig: &ApiConfigRepository{db: db},
|
||||
PlayProfile: &PlayProfileRepository{db: db},
|
||||
RegCode: &RegistrationCodeRepository{db: db},
|
||||
|
||||
@@ -34,6 +34,7 @@ type AuthService struct {
|
||||
repo *repository.Container
|
||||
tokenSvc *TokenService
|
||||
permissionSvc *PermissionService
|
||||
sessionSvc *AuthSessionService
|
||||
|
||||
tempPassMu sync.RWMutex
|
||||
tempPasswords map[string]temporaryPasswordEntry
|
||||
@@ -51,6 +52,11 @@ func NewAuthService(cfg *config.Config, log *zap.Logger, repo *repository.Contai
|
||||
}
|
||||
}
|
||||
|
||||
// SetAuthSessionService 注入设备会话服务;旧登出路径会同时撤销全部会话。
|
||||
func (s *AuthService) SetAuthSessionService(sessions *AuthSessionService) {
|
||||
s.sessionSvc = sessions
|
||||
}
|
||||
|
||||
// Common service-level errors.
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid username or password")
|
||||
@@ -162,6 +168,23 @@ type LoginResponse struct {
|
||||
|
||||
// Login validates credentials and returns the user + a fresh JWT token pair.
|
||||
func (s *AuthService) Login(ctx context.Context, username, password string) (*LoginResponse, error) {
|
||||
u, err := s.Authenticate(ctx, username, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 签发令牌对
|
||||
tokens, err := s.tokenSvc.IssuePairBestEffort(ctx, u.ID, u.Role, u.Tier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.touchLoginBestEffort(u.ID)
|
||||
return &LoginResponse{User: u, Tokens: tokens}, nil
|
||||
}
|
||||
|
||||
// Authenticate 只校验账号与密码,不签发任何令牌。
|
||||
// 移动端 v1 登录需要先确认账号有效,再创建设备会话并签发带 sid 的令牌;
|
||||
// 分离这一步可以避免先签发一对无会话的令牌再作废。
|
||||
func (s *AuthService) Authenticate(ctx context.Context, username, password string) (*model.User, error) {
|
||||
u, err := s.repo.User.FindByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -180,13 +203,7 @@ func (s *AuthService) Login(ctx context.Context, username, password string) (*Lo
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
// 签发令牌对
|
||||
tokens, err := s.tokenSvc.IssuePairBestEffort(ctx, u.ID, u.Role, u.Tier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.touchLoginBestEffort(u.ID)
|
||||
return &LoginResponse{User: u, Tokens: tokens}, nil
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) touchLoginBestEffort(userID string) {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// Package service — 设备认证会话。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// 认证会话错误。middleware 与 handler 依据这些错误区分「重新登录」和
|
||||
// 「临时基础设施故障」。
|
||||
var (
|
||||
ErrAuthSessionRequired = errors.New("auth session required")
|
||||
ErrAuthSessionRevoked = errors.New("auth session revoked")
|
||||
ErrAuthSessionExpired = errors.New("auth session expired")
|
||||
)
|
||||
|
||||
// AuthSessionService 管理设备会话的创建、校验和撤销。
|
||||
type AuthSessionService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewAuthSessionService 创建会话服务。
|
||||
func NewAuthSessionService(log *zap.Logger, repo *repository.Container) *AuthSessionService {
|
||||
return &AuthSessionService{log: log, repo: repo, now: time.Now}
|
||||
}
|
||||
|
||||
// CreateAuthSessionInput 是创建会话所需的最小设备信息。
|
||||
type CreateAuthSessionInput struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
DeviceName string
|
||||
ClientName string
|
||||
ClientVersion string
|
||||
}
|
||||
|
||||
// Create 创建一次登录对应的认证会话。
|
||||
//
|
||||
// 设备 ID 由 App 安装随机生成,不接收 IMEI/MAC 等硬件标识;这里只做长度
|
||||
// 与字符白名单校验,避免任意字符串进入索引和日志。
|
||||
func (s *AuthSessionService) Create(ctx context.Context, in CreateAuthSessionInput) (*model.AuthSession, error) {
|
||||
userID := strings.TrimSpace(in.UserID)
|
||||
deviceID := strings.TrimSpace(in.DeviceID)
|
||||
if userID == "" || deviceID == "" {
|
||||
return nil, errors.New("user id and device id required")
|
||||
}
|
||||
if len(deviceID) > 128 || !validDeviceID(deviceID) {
|
||||
return nil, errors.New("invalid device id")
|
||||
}
|
||||
now := s.currentTime()
|
||||
session := &model.AuthSession{
|
||||
UserID: userID,
|
||||
DeviceID: deviceID,
|
||||
DeviceName: truncateRunes(strings.TrimSpace(in.DeviceName), 128),
|
||||
ClientName: truncateRunes(strings.TrimSpace(in.ClientName), 64),
|
||||
ClientVersion: truncateRunes(strings.TrimSpace(in.ClientVersion), 64),
|
||||
LastUsedAt: now,
|
||||
ExpiresAt: now.Add(RefreshTokenDuration),
|
||||
}
|
||||
if err := s.repo.AuthSession.Create(ctx, session); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// Validate 校验会话仍可用于给定用户。
|
||||
//
|
||||
// 未找到的会话按「已撤销」处理:App 收到 SESSION_REVOKED 后应重新登录,
|
||||
// 而不是无限刷新。数据库错误保持原样返回,由上层映射成可重试的 503。
|
||||
func (s *AuthSessionService) Validate(ctx context.Context, sessionID, userID string) (*model.AuthSession, error) {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
return nil, ErrAuthSessionRequired
|
||||
}
|
||||
session, err := s.repo.AuthSession.FindByID(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if session == nil || session.UserID != userID {
|
||||
return nil, ErrAuthSessionRevoked
|
||||
}
|
||||
if session.Revoked {
|
||||
return nil, ErrAuthSessionRevoked
|
||||
}
|
||||
if session.IsExpired() {
|
||||
return nil, ErrAuthSessionExpired
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// Touch 更新会话最近使用时间,失败只记日志,不影响请求。
|
||||
func (s *AuthSessionService) Touch(ctx context.Context, sessionID string) {
|
||||
if s == nil || sessionID == "" {
|
||||
return
|
||||
}
|
||||
if err := s.repo.AuthSession.TouchLastUsed(ctx, sessionID, s.currentTime()); err != nil && s.log != nil {
|
||||
s.log.Debug("touch auth session failed", zap.String("session_id", sessionID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke 撤销单个会话及其名下刷新令牌(幂等)。
|
||||
func (s *AuthSessionService) Revoke(ctx context.Context, sessionID string) error {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
return nil
|
||||
}
|
||||
if err := s.repo.AuthSession.Revoke(ctx, sessionID, s.currentTime()); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.RefreshToken.RevokeByAuthSessionID(ctx, sessionID)
|
||||
}
|
||||
|
||||
// RevokeByUser 撤销某用户的全部会话(幂等)。
|
||||
func (s *AuthSessionService) RevokeByUser(ctx context.Context, userID string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return nil
|
||||
}
|
||||
sessions, err := s.repo.AuthSession.ListActiveByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range sessions {
|
||||
if err := s.Revoke(ctx, sessions[i].ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeDevice 撤销某用户某设备的全部会话(幂等)。
|
||||
func (s *AuthSessionService) RevokeDevice(ctx context.Context, userID, deviceID string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
deviceID = strings.TrimSpace(deviceID)
|
||||
if userID == "" || deviceID == "" {
|
||||
return nil
|
||||
}
|
||||
sessions, err := s.repo.AuthSession.ListByDevice(ctx, userID, deviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range sessions {
|
||||
if sessions[i].Revoked {
|
||||
continue
|
||||
}
|
||||
if err := s.Revoke(ctx, sessions[i].ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthSessionService) currentTime() time.Time {
|
||||
if s == nil || s.now == nil {
|
||||
return time.Now()
|
||||
}
|
||||
return s.now()
|
||||
}
|
||||
|
||||
func validDeviceID(id string) bool {
|
||||
for _, r := range id {
|
||||
if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' || r == '.' || r == ':') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func truncateRunes(s string, max int) string {
|
||||
if max <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
return string(runes[:max])
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func newAuthSessionTestEnv(t *testing.T) (*AuthSessionService, *TokenService, *repository.Container) {
|
||||
t.Helper()
|
||||
db := newServiceTestDB(t, &model.AuthSession{}, &model.RefreshToken{}, &model.User{}, &model.Setting{})
|
||||
repos := repository.New(db)
|
||||
sessions := NewAuthSessionService(zap.NewNop(), repos)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
tokens := NewTokenService(cfg, zap.NewNop(), repos)
|
||||
tokens.SetSessionValidator(func(ctx context.Context, sessionID, userID string) error {
|
||||
_, err := sessions.Validate(ctx, sessionID, userID)
|
||||
return err
|
||||
})
|
||||
return sessions, tokens, repos
|
||||
}
|
||||
|
||||
func seedSessionUser(t *testing.T, repos *repository.Container) *model.User {
|
||||
t.Helper()
|
||||
u := &model.User{Username: "mobile", PasswordHash: "x", Role: "user", Tier: "free", IsActive: true}
|
||||
if err := repos.User.Create(t.Context(), u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func TestAuthSessionLifecycle(t *testing.T) {
|
||||
sessions, tokens, repos := newAuthSessionTestEnv(t)
|
||||
ctx := context.Background()
|
||||
user := seedSessionUser(t, repos)
|
||||
|
||||
session, err := sessions.Create(ctx, CreateAuthSessionInput{
|
||||
UserID: user.ID,
|
||||
DeviceID: "device-abc",
|
||||
DeviceName: "Pixel",
|
||||
ClientName: "MeBox Android",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
if session.ID == "" || session.ExpiresAt.IsZero() {
|
||||
t.Fatal("session missing id or expiry")
|
||||
}
|
||||
if _, err := sessions.Validate(ctx, session.ID, user.ID); err != nil {
|
||||
t.Fatalf("validate active session: %v", err)
|
||||
}
|
||||
if _, err := sessions.Validate(ctx, session.ID, "other-user"); !errors.Is(err, ErrAuthSessionRevoked) {
|
||||
t.Fatalf("cross-user validate err = %v, want ErrAuthSessionRevoked", err)
|
||||
}
|
||||
|
||||
pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("issue pair: %v", err)
|
||||
}
|
||||
claims, err := tokens.ValidateAccessToken(pair.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("validate access token: %v", err)
|
||||
}
|
||||
if claims.Sid != session.ID {
|
||||
t.Fatalf("access token sid = %q, want %q", claims.Sid, session.ID)
|
||||
}
|
||||
rt, err := repos.RefreshToken.FindByHash(ctx, repository.HashToken(pair.RefreshToken))
|
||||
if err != nil || rt == nil {
|
||||
t.Fatalf("refresh token row = %v, err = %v", rt, err)
|
||||
}
|
||||
if rt.AuthSessionID != session.ID {
|
||||
t.Fatalf("refresh token session = %q, want %q", rt.AuthSessionID, session.ID)
|
||||
}
|
||||
|
||||
if err := sessions.Revoke(ctx, session.ID); err != nil {
|
||||
t.Fatalf("revoke session: %v", err)
|
||||
}
|
||||
if _, err := sessions.Validate(ctx, session.ID, user.ID); !errors.Is(err, ErrAuthSessionRevoked) {
|
||||
t.Fatalf("validate revoked err = %v, want ErrAuthSessionRevoked", err)
|
||||
}
|
||||
rt, err = repos.RefreshToken.FindByHash(ctx, repository.HashToken(pair.RefreshToken))
|
||||
if err != nil || rt == nil {
|
||||
t.Fatalf("reload refresh token: %v", err)
|
||||
}
|
||||
if !rt.Revoked {
|
||||
t.Fatal("refresh token should be revoked with the session")
|
||||
}
|
||||
|
||||
// 幂等:重复撤销不报错。
|
||||
if err := sessions.Revoke(ctx, session.ID); err != nil {
|
||||
t.Fatalf("second revoke: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRejectsRevokedSession(t *testing.T) {
|
||||
sessions, tokens, repos := newAuthSessionTestEnv(t)
|
||||
ctx := context.Background()
|
||||
user := seedSessionUser(t, repos)
|
||||
|
||||
session, err := sessions.Create(ctx, CreateAuthSessionInput{UserID: user.ID, DeviceID: "device-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sessions.Revoke(ctx, session.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tokens.Refresh(ctx, pair.RefreshToken); !errors.Is(err, ErrAuthSessionRevoked) {
|
||||
t.Fatalf("refresh revoked session err = %v, want ErrAuthSessionRevoked", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshKeepsSessionAssociation(t *testing.T) {
|
||||
sessions, tokens, repos := newAuthSessionTestEnv(t)
|
||||
ctx := context.Background()
|
||||
user := seedSessionUser(t, repos)
|
||||
|
||||
session, err := sessions.Create(ctx, CreateAuthSessionInput{UserID: user.ID, DeviceID: "device-2"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rotated, err := tokens.Refresh(ctx, pair.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("refresh: %v", err)
|
||||
}
|
||||
claims, err := tokens.ValidateAccessToken(rotated.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims.Sid != session.ID {
|
||||
t.Fatalf("rotated access sid = %q, want %q", claims.Sid, session.ID)
|
||||
}
|
||||
// 刷新路径使用 best-effort 落库;轮换结果可能稍后才写入。
|
||||
var rt *model.RefreshToken
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
rt, err = repos.RefreshToken.FindByHash(ctx, repository.HashToken(rotated.RefreshToken))
|
||||
if err == nil && rt != nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if err != nil || rt == nil {
|
||||
t.Fatalf("rotated refresh row: %v err=%v", rt, err)
|
||||
}
|
||||
if rt.AuthSessionID != session.ID {
|
||||
t.Fatalf("rotated refresh session = %q, want %q", rt.AuthSessionID, session.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerIdentityPersistsAcrossInstances(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Setting{})
|
||||
repos := repository.New(db)
|
||||
ctx := context.Background()
|
||||
|
||||
first := NewServerIdentityService(repos, "MeBox-v1.2.3")
|
||||
id1, err := first.ServerID(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id1 == "" {
|
||||
t.Fatal("empty server id")
|
||||
}
|
||||
|
||||
// 独立服务实例(模拟重启)读到同一个持久身份。
|
||||
second := NewServerIdentityService(repos, "MeBox-v1.2.3")
|
||||
id2, err := second.ServerID(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id2 != id1 {
|
||||
t.Fatalf("server id changed across instances: %q != %q", id2, id1)
|
||||
}
|
||||
|
||||
info, err := second.Info(ctx, ServerCapabilities{DeviceSessions: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.ServerID != id1 || info.ServerVersion != "MeBox-v1.2.3" || info.APIVersion != "v1" {
|
||||
t.Fatalf("unexpected info: %+v", info)
|
||||
}
|
||||
if !info.Capabilities.DeviceSessions || info.Capabilities.PlaybackSessions {
|
||||
t.Fatalf("unexpected capabilities: %+v", info.Capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSessionExpiry(t *testing.T) {
|
||||
sessions, _, repos := newAuthSessionTestEnv(t)
|
||||
user := seedSessionUser(t, repos)
|
||||
sessions.now = func() time.Time { return time.Now().Add(-40 * 24 * time.Hour) }
|
||||
session, err := sessions.Create(context.Background(), CreateAuthSessionInput{UserID: user.ID, DeviceID: "device-old"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessions.now = time.Now
|
||||
if _, err := sessions.Validate(context.Background(), session.ID, user.ID); !errors.Is(err, ErrAuthSessionExpired) {
|
||||
t.Fatalf("expired validate err = %v, want ErrAuthSessionExpired", err)
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,13 @@ func (s *AuthService) RefreshTokens(ctx context.Context, refreshToken string) (*
|
||||
return s.tokenSvc.Refresh(ctx, refreshToken)
|
||||
}
|
||||
|
||||
// Logout 撤销用户的所有刷新令牌。
|
||||
// Logout 撤销用户的所有刷新令牌与认证会话。
|
||||
func (s *AuthService) Logout(ctx context.Context, userID string) error {
|
||||
return s.tokenSvc.RevokeAll(ctx, userID)
|
||||
if err := s.tokenSvc.RevokeAll(ctx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.sessionSvc != nil {
|
||||
return s.sessionSvc.RevokeByUser(ctx, userID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/database"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
@@ -271,7 +272,7 @@ func (b *BackupService) restorePostgresBackup(ctx context.Context, src string) e
|
||||
if err != nil {
|
||||
return fmt.Errorf("表 %s 解码失败: %w", chunk.Table, err)
|
||||
}
|
||||
if err := tx.Unscoped().CreateInBatches(rows, backupRestoreBatchSize).Error; err != nil {
|
||||
if err := database.InsertPreservingValues(tx.Unscoped(), rows, backupRestoreBatchSize); err != nil {
|
||||
return fmt.Errorf("恢复表 %s 失败: %w", chunk.Table, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func TestPostgresLogicalBackupPreservesZeroValues(t *testing.T) {
|
||||
svc, _ := newBackupTestService(t)
|
||||
user := model.User{Username: "disabled", PasswordHash: "hash"}
|
||||
if err := svc.db.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.db.Model(&user).Updates(map[string]any{
|
||||
"is_active": false, "hide_adult": false, "player_volume": 0, "danmaku_enabled": false,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dump, err := svc.createPostgresBackup(t.Context(), "zero-values")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.restorePostgresBackup(t.Context(), dump); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got model.User
|
||||
if err := svc.db.First(&got, "id = ?", user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.IsActive || got.HideAdult || got.PlayerVolume != 0 || got.DanmakuEnabled {
|
||||
t.Fatalf("restore replaced zero values: active=%v hide_adult=%v volume=%v danmaku=%v", got.IsActive, got.HideAdult, got.PlayerVolume, got.DanmakuEnabled)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// newDanmakuRemoteResolverTest 起一个假的远程 Emby(账号 + 挂载齐备),返回接好
|
||||
// 弹幕解析器的 builder。
|
||||
func newDanmakuRemoteResolverTest(t *testing.T, handler http.Handler) *serviceContainerBuilder {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
remote := NewEmbyRemoteService(cfg, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
remote.SetRuntimeCache(NewRuntimeCacheService(cfg, zap.NewNop()))
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": server.URL,
|
||||
"api_key": "test-api-key",
|
||||
"remote_user_id": "uid-1",
|
||||
})
|
||||
if err := repos.StrmAccount.Create(t.Context(), &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-1"},
|
||||
Name: "远程 Emby",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), &model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-1"},
|
||||
AccountID: "acct-1",
|
||||
RemoteViewID: "view-1",
|
||||
RemoteViewName: "新番连载",
|
||||
CollectionType: "tvshows",
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create mount: %v", err)
|
||||
}
|
||||
return &serviceContainerBuilder{
|
||||
cfg: cfg,
|
||||
log: zap.NewNop(),
|
||||
repos: repos,
|
||||
c: &Container{Cfg: cfg, Log: zap.NewNop(), Repo: repos, EmbyRemote: remote},
|
||||
}
|
||||
}
|
||||
|
||||
func danmakuResolverTestEpisode(id string) map[string]any {
|
||||
return map[string]any{
|
||||
"Id": id,
|
||||
"Name": "第 1 集",
|
||||
"Type": "Episode",
|
||||
"Path": "/media/anime/PSYREN/" + id + ".mkv",
|
||||
"MediaSources": []any{
|
||||
map[string]any{"Id": id + "-src", "Container": "mkv", "Size": int64(1 << 20)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 目录类条目(整部剧/季)自身没有媒体源,拿它的 ID 去请求远程 /Videos/{id}/stream 只会
|
||||
// 回 500,哈希层于是每次都白白跳过。这条用例保证弹幕解析器跟随 /api/stream 的规则先
|
||||
// 落到第一集:哈希算的是真正要播的那条流。
|
||||
func TestResolveDanmakuRemoteMediaUsesPlayableChildForContainer(t *testing.T) {
|
||||
var requested []string
|
||||
b := newDanmakuRemoteResolverTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requested = append(requested, r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/emby/Users/uid-1/Items/series-100":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"Id": "series-100", "Name": "PSYREN", "Type": "Series", "IsFolder": true,
|
||||
})
|
||||
case "/emby/Users/uid-1/Items":
|
||||
if got := r.URL.Query().Get("ParentId"); got != "series-100" {
|
||||
t.Errorf("ParentId = %q, want series-100", got)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"Items": []map[string]any{
|
||||
{"Id": "season-1", "Type": "Season", "IndexNumber": 1},
|
||||
danmakuResolverTestEpisode("ep-1"),
|
||||
},
|
||||
})
|
||||
case "/emby/Users/uid-1/Items/ep-1":
|
||||
_ = json.NewEncoder(w).Encode(danmakuResolverTestEpisode("ep-1"))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
|
||||
media, streamURL, err := b.resolveDanmakuRemoteMedia(
|
||||
context.Background(), EncodeEmbyRemoteID("mount-1", "series-100"))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
if media == nil {
|
||||
t.Fatal("resolve returned no media metadata")
|
||||
}
|
||||
if !strings.Contains(streamURL, "/Videos/ep-1/stream") {
|
||||
t.Fatalf("stream url = %q, want the playable child ep-1", streamURL)
|
||||
}
|
||||
if strings.Contains(streamURL, "series-100") {
|
||||
t.Fatalf("stream url = %q, must not point at the container id (remote Emby answers 500)", streamURL)
|
||||
}
|
||||
if !strings.Contains(strings.Join(requested, " "), "/Items/ep-1") {
|
||||
t.Fatalf("metadata must come from the playable child, requests = %v", requested)
|
||||
}
|
||||
}
|
||||
|
||||
// 本身可播的条目(单集/电影)不能被改写:直接取它自己的流地址。
|
||||
func TestResolveDanmakuRemoteMediaKeepsPlayableItem(t *testing.T) {
|
||||
b := newDanmakuRemoteResolverTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path != "/emby/Users/uid-1/Items/ep-2" {
|
||||
t.Errorf("unexpected upstream path %q", r.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(danmakuResolverTestEpisode("ep-2"))
|
||||
}))
|
||||
|
||||
media, streamURL, err := b.resolveDanmakuRemoteMedia(
|
||||
context.Background(), EncodeEmbyRemoteID("mount-1", "ep-2"))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve failed: %v", err)
|
||||
}
|
||||
if media == nil {
|
||||
t.Fatal("resolve returned no media metadata")
|
||||
}
|
||||
if !strings.Contains(streamURL, "/Videos/ep-2/stream") {
|
||||
t.Fatalf("stream url = %q, want /Videos/ep-2/stream", streamURL)
|
||||
}
|
||||
}
|
||||
|
||||
// 不是远程伪装 ID 时直接报错,不去猜一个可以拉取的地址。
|
||||
func TestResolveDanmakuRemoteMediaRejectsPlainID(t *testing.T) {
|
||||
b := newDanmakuRemoteResolverTest(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
t.Error("plain local ids must not reach the remote Emby")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
|
||||
if _, _, err := b.resolveDanmakuRemoteMedia(context.Background(), "local-media-1"); err == nil {
|
||||
t.Fatal("expected an error for a non remote-emby id")
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
@@ -28,6 +30,14 @@ func (s *DeviceService) KickDevice(ctx context.Context, userID, deviceID string)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 立即撤销该设备的认证会话:只标记 UserDevice 不足以让已签发的 JWT
|
||||
// 失效,新协议令牌必须同时被会话校验拒绝。
|
||||
if s.authSessions != nil {
|
||||
if err := s.authSessions.RevokeDevice(ctx, userID, d.DeviceID); err != nil && s.log != nil {
|
||||
s.log.Warn("revoke auth sessions for kicked device failed",
|
||||
zap.String("user_id", userID), zap.String("device_id", d.DeviceID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
s.notify(ctx, userID, fmt.Sprintf("🔌 设备已下线:<b>%s</b>\n该终端需要重新登录后才能继续使用。", deviceLabel(d.DeviceName, d.Client)))
|
||||
return nil
|
||||
}
|
||||
@@ -37,6 +47,12 @@ func (s *DeviceService) KickAllDevices(ctx context.Context, userID string) error
|
||||
if err := s.repo.UserDevice.SetKickedByUser(ctx, userID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.authSessions != nil {
|
||||
if err := s.authSessions.RevokeByUser(ctx, userID); err != nil && s.log != nil {
|
||||
s.log.Warn("revoke auth sessions for kicked user failed",
|
||||
zap.String("user_id", userID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
s.notify(ctx, userID, "🔌 你名下的全部设备已下线,需要重新登录后才能继续使用。")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ type DeviceService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
sessions *SessionTrackerService
|
||||
// authSessions 用于踢出设备时立即撤销其认证会话;nil 表示旧装配路径。
|
||||
authSessions *AuthSessionService
|
||||
|
||||
// notifyUser sends a Telegram message to the local user (resolved to their
|
||||
// Telegram binding). Wired by the bot service; nil disables notifications.
|
||||
@@ -60,6 +62,11 @@ func (s *DeviceService) SetSessionTracker(tracker *SessionTrackerService) {
|
||||
s.sessions = tracker
|
||||
}
|
||||
|
||||
// SetAuthSessionService 注入认证会话服务,使踢出设备同时撤销其新协议令牌。
|
||||
func (s *DeviceService) SetAuthSessionService(sessions *AuthSessionService) {
|
||||
s.authSessions = sessions
|
||||
}
|
||||
|
||||
// fingerprint derives a stable terminal hash from the device name. Client/app
|
||||
// names are deliberately ignored so one phone/TV/PC using multiple apps is
|
||||
// still counted as one terminal device; Client remains a login channel label.
|
||||
|
||||
@@ -35,16 +35,28 @@ func IsUserFavorite(ctx context.Context, repo *repository.Container, userID, med
|
||||
func setLocalFavorite(ctx context.Context, repo *repository.Container, userID, mediaID string, favorite bool) error {
|
||||
if favorite {
|
||||
var existing model.Favorite
|
||||
err := repo.DB.WithContext(ctx).
|
||||
// 收藏行的删除是软删除;重新收藏时必须连已软删除的行一起查出来,
|
||||
// 否则会再次插入同一 (user_id, media_id),撞上唯一索引后稳定返回
|
||||
// 500,用户表现为“取消收藏后无法再收藏”。
|
||||
err := repo.DB.WithContext(ctx).Unscoped().
|
||||
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||
First(&existing).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
return repo.DB.WithContext(ctx).Create(&model.Favorite{
|
||||
UserID: userID,
|
||||
MediaID: mediaID,
|
||||
}).Error
|
||||
case err != nil:
|
||||
return err
|
||||
case existing.DeletedAt.Valid:
|
||||
return repo.DB.WithContext(ctx).Unscoped().
|
||||
Model(&model.Favorite{}).
|
||||
Where("id = ?", existing.ID).
|
||||
Update("deleted_at", nil).Error
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return repo.DB.WithContext(ctx).
|
||||
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||
|
||||
@@ -44,6 +44,19 @@ func TestSyncUserFavoriteWritesLocalForRemoteID(t *testing.T) {
|
||||
if favorite {
|
||||
t.Fatal("expected remote favourite to be removed locally")
|
||||
}
|
||||
|
||||
// 软删除后重新收藏必须恢复原行:唯一索引 (user_id, media_id) 不允许
|
||||
// 再插入一条,旧的实现会在这里返回约束错误。
|
||||
if err := SyncUserFavorite(context.Background(), repos, nil, userID, remoteMediaID, true); err != nil {
|
||||
t.Fatalf("SyncUserFavorite re-favorite: %v", err)
|
||||
}
|
||||
favorite, err = IsUserFavorite(context.Background(), repos, userID, remoteMediaID)
|
||||
if err != nil {
|
||||
t.Fatalf("IsUserFavorite after re-favorite: %v", err)
|
||||
}
|
||||
if !favorite {
|
||||
t.Fatal("expected re-favourite to restore the soft-deleted row")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavoriteItemsIncludesRemoteFavourites(t *testing.T) {
|
||||
|
||||
@@ -49,6 +49,8 @@ func TestSafeZipTargetRejectsTraversal(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFFmpegToolsStatusNoPanic(t *testing.T) {
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
t.Chdir(t.TempDir())
|
||||
svc := NewFFmpegToolsService(&config.Config{}, zap.NewNop(), nil)
|
||||
st := svc.Status(context.Background())
|
||||
for _, key := range []string{"installing", "message", "error", "install_dir", "target", "ffmpeg", "ffprobe"} {
|
||||
|
||||
@@ -14,14 +14,12 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -103,53 +101,14 @@ func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
|
||||
// from image.tmdb.org via their HTTP proxy without extra config. On
|
||||
// Windows we also honor the current user's system proxy settings.
|
||||
transport := NewExternalTransport()
|
||||
if proxyConfiguredForImageFetch() {
|
||||
// 走本地代理(如 127.0.0.1:7890)时,拨号目标是代理本身,
|
||||
// 连接层 SSRF 校验会误杀本地回环代理;此时沿用 URL 级校验。
|
||||
log.Info("image proxy: outbound proxy detected, connection-level SSRF guard disabled")
|
||||
} else {
|
||||
// 仅 URL 解析层的 isPrivateHost 可被十进制/十六进制 IP、解析到
|
||||
// 私网的域名与 DNS rebinding 绕过;在拨号层对最终连接 IP 做二次
|
||||
// 校验(含重定向后的每条连接)堵住该旁路。
|
||||
// 用户明确配置的远程挂载源(如内网 Emby)豁免该私网限制。
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 15 * time.Second,
|
||||
Control: func(_, address string, _ syscall.RawConn) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if proxy.isAllowedRemoteHost(host) {
|
||||
return nil
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return errors.New("image proxy: refusing non-IP dial target")
|
||||
}
|
||||
if isPrivateIP(ip) {
|
||||
return errors.New("image proxy: requests to private/internal hosts are not allowed")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
transport.DialContext = dialer.DialContext
|
||||
}
|
||||
|
||||
proxy.guardTransport(transport)
|
||||
directTransport := NewInternalTransport()
|
||||
proxy.guardTransport(directTransport)
|
||||
proxy.client = &http.Client{Timeout: 30 * time.Second, Transport: transport}
|
||||
proxy.directClient = &http.Client{Timeout: 30 * time.Second, Transport: NewInternalTransport()}
|
||||
proxy.directClient = &http.Client{Timeout: 30 * time.Second, Transport: directTransport}
|
||||
return proxy
|
||||
}
|
||||
|
||||
// proxyConfiguredForImageFetch 探测环境变量或系统代理是否会影响图片抓取。
|
||||
func proxyConfiguredForImageFetch() bool {
|
||||
req, err := http.NewRequest(http.MethodGet, "https://image.tmdb.org/", nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
proxy, err := ProxyFromEnvironmentOrSystem(req)
|
||||
return err == nil && proxy != nil
|
||||
}
|
||||
|
||||
// SetLibraryRootsProvider injects a callback that returns the current set of
|
||||
// media library root directories. Sidecar posters live under these roots
|
||||
// (which are arbitrary, user-defined, and not necessarily under the
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// 显式配置的服务地址也要进入图片代理的私网放行名单。
|
||||
//
|
||||
// 背景(现场 2026-10-16):MetaTube 刮削后端被配置成 docker 服务名
|
||||
// `http://mebox-metatube-1:8081`,刮削结果里的 poster_url 就是这个地址。
|
||||
// 图片代理的连接层 SSRF 校验只放行远程 Emby 挂载主机,于是每一次封面回源都
|
||||
// 被拒(日志 warn: requests to private/internal hosts are not allowed),
|
||||
// 前端长期只拿到占位图。
|
||||
//
|
||||
// 这里的取舍与 isPrivateHost 的注释一致:私网校验防的是「用户可控 URL 打到
|
||||
// 内网」,而管理员显式配置的服务地址属于受信输入,和远程 Emby 挂载同级。
|
||||
func configuredServiceImageHosts(values ...string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, raw := range values {
|
||||
host := imageHostFromBaseURL(raw)
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[host]; ok {
|
||||
continue
|
||||
}
|
||||
seen[host] = struct{}{}
|
||||
out = append(out, host)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// internalServiceImageHosts 只保留指向内网/局域网的服务地址。
|
||||
//
|
||||
// 公网域名(image.tmdb.org、api.themoviedb.org 等)必须留在名单之外:放行后
|
||||
// 图片代理会优先走直连客户端,而 GFW 环境恰恰需要先用 HTTP(S)_PROXY。把它们
|
||||
// 加进来会让每张海报都白付一次直连失败。
|
||||
func internalServiceImageHosts(values ...string) []string {
|
||||
hosts := configuredServiceImageHosts(values...)
|
||||
out := make([]string, 0, len(hosts))
|
||||
for _, host := range hosts {
|
||||
if isInternalServiceHost(host) {
|
||||
out = append(out, host)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isInternalServiceHost 判断主机名是否只在内网可达:私网/回环/链路本地 IP、
|
||||
// 单标签服务名(docker compose 服务名如 mebox-metatube-1),或常见的内部域名后缀。
|
||||
func isInternalServiceHost(host string) bool {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return isPrivateIP(ip)
|
||||
}
|
||||
// 单标签名(不含点)不是公网可解析的 FQDN,只可能是容器名/主机名。
|
||||
if !strings.Contains(host, ".") {
|
||||
return true
|
||||
}
|
||||
for _, suffix := range []string{".local", ".internal", ".lan", ".home.arpa", ".localhost"} {
|
||||
if strings.HasSuffix(host, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// imageHostFromBaseURL 从配置里的 base URL 取出小写主机名(不含端口)。
|
||||
// 容忍缺少 scheme 的写法(`mebox-metatube-1:8081`),并拒绝非 http/https。
|
||||
func imageHostFromBaseURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.Contains(raw, "://") {
|
||||
raw = "http://" + raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "http", "https":
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(u.Hostname()))
|
||||
}
|
||||
|
||||
// mergeImageProxyAllowedHosts 合并多组放行主机(远程 Emby 挂载 + 显式配置的
|
||||
// 服务地址),去重时忽略大小写与端口。
|
||||
func mergeImageProxyAllowedHosts(groups ...[]string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]string, 0)
|
||||
for _, group := range groups {
|
||||
for _, raw := range group {
|
||||
host := strings.ToLower(strings.TrimSpace(raw))
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = strings.ToLower(strings.TrimSpace(h))
|
||||
}
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[host]; ok {
|
||||
continue
|
||||
}
|
||||
seen[host] = struct{}{}
|
||||
out = append(out, host)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// imageMintingSettingKeys 是「其取值会被拼进图片 URL」的设置项。
|
||||
//
|
||||
// 只收录这类设置:放行名单每多一条就是多一处 SSRF 例外,因此 app.server_url、
|
||||
// strm.base_url 这些只出现在播放地址里的设置不在此列(它们由 streamURL 使用,
|
||||
// 与 /api/img 无关)。
|
||||
var imageMintingSettingKeys = []string{
|
||||
"adult.scraper.metatube_server",
|
||||
"adult.scraper.builtin_javdb_url",
|
||||
"adult.scraper.builtin_javbus_url",
|
||||
}
|
||||
|
||||
// imageMintingAPIProviders 是「其 base_url 会被拼进图片 URL」的 api_configs 行。
|
||||
//
|
||||
// 同样刻意收窄:api_configs 里每个 provider 都有一行出厂默认值,其中 metatube
|
||||
// 默认指向 http://127.0.0.1:7700。把整张表都放行会让每次安装都无条件放开回环
|
||||
// 地址,而只有真正用 MetaTube 刮削的部署才需要这个例外。
|
||||
var imageMintingAPIProviders = []string{"metatube"}
|
||||
|
||||
// configuredServiceImageHostsFromRepo 从持久化配置里收集「会被写进图片 URL」的
|
||||
// 内网服务地址。只在构造阶段调用一次,所以直接读库、忽略错误——读不到只是少
|
||||
// 一条放行,不影响启动。
|
||||
func configuredServiceImageHostsFromRepo(ctx context.Context, repos *repository.Container) []string {
|
||||
if repos == nil {
|
||||
return nil
|
||||
}
|
||||
values := make([]string, 0, 4)
|
||||
|
||||
if repos.Setting != nil {
|
||||
for _, key := range imageMintingSettingKeys {
|
||||
if val, err := repos.Setting.Get(ctx, key); err == nil && strings.TrimSpace(val) != "" {
|
||||
values = append(values, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if repos.ApiConfig != nil {
|
||||
// 用 List 而不是 FindByProvider:后者走 First(),缺行时 gorm 会打一条
|
||||
// "record not found" 日志,而「没配置 MetaTube」是完全正常的状态。
|
||||
if rows, err := repos.ApiConfig.List(ctx); err == nil {
|
||||
for _, row := range rows {
|
||||
if !imageMintingAPIProvider(row.Provider) {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(row.BaseURL) != "" {
|
||||
values = append(values, row.BaseURL)
|
||||
}
|
||||
// Extra 是逗号/分号分隔的备用源列表(见 api_config.go 的 adult 默认值)。
|
||||
values = append(values, adultConfiguredBases(row.Extra)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return internalServiceImageHosts(values...)
|
||||
}
|
||||
|
||||
func imageMintingAPIProvider(provider string) bool {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
for _, allowed := range imageMintingAPIProviders {
|
||||
if provider == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// 现场(2026-10-16):13 条媒体记录的 poster_url 指向
|
||||
// http://mebox-metatube-1:8081/v1/images/primary/... ,而图片代理只放行
|
||||
// 远程 Emby 挂载主机。MetaTube 是用户显式配置的刮削后端(settings 里的
|
||||
// adult.scraper.metatube_server),它跑在 docker 私网里,于是每一次封面
|
||||
// 回源都被连接层 SSRF 校验拒掉,日志刷 warn,前端长期只拿到占位图。
|
||||
//
|
||||
// 结论:显式配置的服务地址必须和远程 Emby 一样被加入放行名单。
|
||||
func TestConfiguredServiceImageHostsExtractsHostnames(t *testing.T) {
|
||||
got := configuredServiceImageHosts(
|
||||
"http://mebox-metatube-1:8081",
|
||||
"https://api.themoviedb.org/3",
|
||||
"http://192.168.1.50:8096/emby",
|
||||
)
|
||||
want := []string{"192.168.1.50", "api.themoviedb.org", "mebox-metatube-1"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("hosts = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("hosts = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredServiceImageHostsNormalizesAndDedupes(t *testing.T) {
|
||||
got := configuredServiceImageHosts(
|
||||
" HTTP://MeBox-MetaTube-1:8081 ",
|
||||
"http://mebox-metatube-1:8081/other/path",
|
||||
"mebox-metatube-1:8081",
|
||||
)
|
||||
if len(got) != 1 || got[0] != "mebox-metatube-1" {
|
||||
t.Fatalf("hosts = %v, want exactly [mebox-metatube-1]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredServiceImageHostsSkipsUnusableValues(t *testing.T) {
|
||||
got := configuredServiceImageHosts(
|
||||
"",
|
||||
" ",
|
||||
"not a url at all",
|
||||
"http://",
|
||||
"://missing-scheme",
|
||||
"/relative/path",
|
||||
"ftp://files.example.com",
|
||||
)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("hosts = %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredServiceImageHostsPreservesIPv6(t *testing.T) {
|
||||
got := configuredServiceImageHosts("http://[fd00::5]:8081")
|
||||
if len(got) != 1 || got[0] != "fd00::5" {
|
||||
t.Fatalf("hosts = %v, want [fd00::5]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageProxyAllowlistCombinesMountsAndConfiguredServices(t *testing.T) {
|
||||
// 回归:合并后的名单必须同时覆盖远程 Emby 挂载与显式配置的服务,
|
||||
// 否则两组图片里必有一组被 SSRF 校验挡住。
|
||||
proxy := newUpstreamImageProxy(t)
|
||||
proxy.SetAllowedRemoteHostsProvider(func() []string {
|
||||
return mergeImageProxyAllowedHosts(
|
||||
[]string{"192.168.1.50"},
|
||||
configuredServiceImageHosts("http://mebox-metatube-1:8081"),
|
||||
)
|
||||
})
|
||||
|
||||
for _, host := range []string{"192.168.1.50", "mebox-metatube-1"} {
|
||||
if !proxy.isAllowedRemoteHost(host) {
|
||||
t.Fatalf("host %q must bypass the private-IP guard", host)
|
||||
}
|
||||
}
|
||||
if proxy.isAllowedRemoteHost("evil.example.com") {
|
||||
t.Fatal("unrelated hosts must stay blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeImageProxyAllowedHostsDedupesCaseInsensitively(t *testing.T) {
|
||||
got := mergeImageProxyAllowedHosts(
|
||||
[]string{"192.168.1.50:8096", "MeBox-MetaTube-1"},
|
||||
[]string{"mebox-metatube-1", "", " ", "192.168.1.50"},
|
||||
)
|
||||
sort.Strings(got)
|
||||
want := []string{"192.168.1.50", "mebox-metatube-1"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("merged = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("merged = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 公网上游必须留在名单之外:放行后图片代理会优先走直连,而 GFW 环境恰恰要靠
|
||||
// HTTP(S)_PROXY 才能取到 image.tmdb.org 这类海报。只有内网地址才需要豁免。
|
||||
func TestInternalServiceImageHostsKeepsOnlyInternalAddresses(t *testing.T) {
|
||||
got := internalServiceImageHosts(
|
||||
"http://mebox-metatube-1:8081",
|
||||
"http://127.0.0.1:7700",
|
||||
"http://192.168.1.50:8096",
|
||||
"https://api.themoviedb.org/3",
|
||||
"https://image.tmdb.org",
|
||||
"https://api.bgm.tv",
|
||||
"https://javdb.com",
|
||||
"http://nas.local:7700",
|
||||
"http://media.internal",
|
||||
)
|
||||
want := []string{
|
||||
"127.0.0.1",
|
||||
"192.168.1.50",
|
||||
"mebox-metatube-1",
|
||||
"media.internal",
|
||||
"nas.local",
|
||||
}
|
||||
sort.Strings(want)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("hosts = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("hosts = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInternalServiceHost(t *testing.T) {
|
||||
internal := []string{
|
||||
"mebox-metatube-1", "127.0.0.1", "10.1.2.3", "172.18.0.5",
|
||||
"192.168.1.50", "fd00::5", "nas.local", "media.internal",
|
||||
"box.lan", "db.home.arpa",
|
||||
}
|
||||
for _, host := range internal {
|
||||
if !isInternalServiceHost(host) {
|
||||
t.Errorf("isInternalServiceHost(%q) = false, want true", host)
|
||||
}
|
||||
}
|
||||
external := []string{
|
||||
"", "api.themoviedb.org", "image.tmdb.org", "www.javbus.com",
|
||||
"pics.dmm.co.jp", "8.8.8.8", "example.com",
|
||||
}
|
||||
for _, host := range external {
|
||||
if isInternalServiceHost(host) {
|
||||
t.Errorf("isInternalServiceHost(%q) = true, want false", host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端装配:从真实的 settings / api_configs 里读出内网服务地址。
|
||||
// 现场数据是 settings.adult.scraper.metatube_server = http://mebox-metatube-1:8081,
|
||||
// 而 api_configs.metatube.base_url 还是出厂默认的 http://127.0.0.1:7700。
|
||||
func TestConfiguredServiceImageHostsFromRepoReadsSettingsAndAPIConfigs(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Setting{}, &model.APIConfig{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := repos.Setting.Set(ctx, "adult.scraper.metatube_server", "http://mebox-metatube-1:8081"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Setting.Set(ctx, "adult.scraper.builtin_javdb_url", "https://javdb.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, row := range []model.APIConfig{
|
||||
{Provider: "metatube", BaseURL: "http://127.0.0.1:7700", Enabled: true},
|
||||
{Provider: "tmdb", BaseURL: "https://api.themoviedb.org/3", Enabled: true},
|
||||
{Provider: "openai", BaseURL: "http://192.168.1.77:11434/v1", Enabled: true},
|
||||
} {
|
||||
if err := repos.ApiConfig.Create(ctx, &row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
got := configuredServiceImageHostsFromRepo(ctx, repos)
|
||||
// javdb.com / api.themoviedb.org 是公网地址,必须留在名单之外(否则图片
|
||||
// 代理会改走直连,GFW 环境下反而取不到图);openai 的 base_url 虽然指向
|
||||
// 内网,但它从不产出图片 URL,同样不应成为 SSRF 例外。
|
||||
want := []string{"127.0.0.1", "mebox-metatube-1"}
|
||||
sort.Strings(want)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("hosts = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("hosts = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 放行名单是 SSRF 例外,必须收窄:只有「会产出图片 URL」的配置才入选。
|
||||
func TestConfiguredServiceImageHostsFromRepoIgnoresUnrelatedInternalServices(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Setting{}, &model.APIConfig{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// 只配置了内网的 OpenAI 兼容网关与内网下载器,没有任何图片来源。
|
||||
if err := repos.Setting.Set(ctx, "app.server_url", "http://10.0.0.2:1314"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Setting.Set(ctx, "strm.base_url", "http://10.0.0.2:1314"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, row := range []model.APIConfig{
|
||||
{Provider: "openai", BaseURL: "http://192.168.1.77:11434/v1", Enabled: true},
|
||||
{Provider: "tmdb", BaseURL: "https://api.themoviedb.org/3", Enabled: true},
|
||||
} {
|
||||
if err := repos.ApiConfig.Create(ctx, &row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if got := configuredServiceImageHostsFromRepo(ctx, repos); len(got) != 0 {
|
||||
t.Fatalf("hosts = %v, want none: unrelated internal services must not become SSRF exceptions", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredServiceImageHostsFromRepoToleratesMissingRepos(t *testing.T) {
|
||||
if got := configuredServiceImageHostsFromRepo(context.Background(), nil); len(got) != 0 {
|
||||
t.Fatalf("hosts = %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
)
|
||||
|
||||
// hostAndPortOf 从测试服务器的 URL 里取出主机名与端口。
|
||||
func hostAndPortOf(t *testing.T, raw string) (string, string) {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse upstream url: %v", err)
|
||||
}
|
||||
return u.Hostname(), u.Port()
|
||||
}
|
||||
|
||||
// 现场(2026-10-16,生产容器内实测):放行名单里明明有 mebox-metatube-1,
|
||||
// 回源仍然失败于
|
||||
//
|
||||
// image proxy: requests to private/internal hosts are not allowed
|
||||
//
|
||||
// 原因:连接层校验(dialer 的 Control 回调)拿到的 address 是**解析后的 IP**,
|
||||
// 而名单里存的是主机名(ConfiguredRemoteHosts 与
|
||||
// configuredServiceImageHostsFromRepo 都返回 u.Hostname()),两者永远不相等:
|
||||
// 那段 isAllowedRemoteHost 分支对主机名条目是死代码。
|
||||
//
|
||||
// 远程 Emby 挂载用 IP 配置时看不出问题,用主机名(NAS 名、docker 服务名)配置
|
||||
// 时同样中招。这个用例锁住「按主机名放行」这条路径。
|
||||
func TestImageProxyDialsAllowlistedHostname(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(testJPEG)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy := NewImageProxy(
|
||||
&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}},
|
||||
zap.NewNop(),
|
||||
)
|
||||
// 名单里放「主机名」而不是 IP —— 这正是生产里 mebox-metatube-1 的形态。
|
||||
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{"localhost"} })
|
||||
|
||||
// 直接用该代理自己的 transport 拨号,才能命中连接层校验。
|
||||
_, port := hostAndPortOf(t, upstream.URL)
|
||||
resp, err := proxy.client.Get("http://localhost:" + port + "/poster.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("allowlisted hostname must be dialable, got: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// 反向约束:未放行的主机名解析到私网地址时仍必须被拒。
|
||||
// 这是连接层校验存在的理由(DNS rebinding),不能为了修上面那条而拆掉。
|
||||
func TestImageProxyStillBlocksUnlistedHostnameResolvingToPrivateIP(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(testJPEG)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxy := NewImageProxy(
|
||||
&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}},
|
||||
zap.NewNop(),
|
||||
)
|
||||
// 名单为空:localhost 解析到回环地址,必须被拦。
|
||||
proxy.SetAllowedRemoteHostsProvider(func() []string { return nil })
|
||||
|
||||
_, port := hostAndPortOf(t, upstream.URL)
|
||||
for _, client := range []*http.Client{proxy.client, proxy.directClient} {
|
||||
resp, err := client.Get("http://localhost:" + port + "/poster.jpg")
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
t.Fatal("a non-allowlisted hostname resolving to a private IP must be refused")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageProxyAllowsSelectedLocalProxy(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(testJPEG)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
proxyURL, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := NewImageProxy(&config.Config{}, zap.NewNop())
|
||||
transport := NewExternalTransport()
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
p.guardTransport(transport)
|
||||
t.Cleanup(transport.CloseIdleConnections)
|
||||
client := &http.Client{Transport: transport}
|
||||
resp, err := client.Get("http://images.example.com/poster.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("configured local proxy must remain usable: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status=%d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// 在真实 docker 网络里验证图片代理的私网放行名单修复。
|
||||
//
|
||||
// 现场(修复前):MetaTube 刮削结果里的 poster_url 指向
|
||||
// http://mebox-metatube-1:8081/... ,而放行名单只含远程 Emby 挂载主机,
|
||||
// 于是每一次封面回源都被连接层 SSRF 校验拒掉,前端长期只拿到占位图。
|
||||
//
|
||||
// 本文件用与 service_builder.initImageProxy 相同的装配方式复现那条路径。
|
||||
// 真实回源那一步只在能解析到 MetaTube 服务名的环境里跑(生产容器网络);
|
||||
// 开发机上会 Skip,因为那属于环境缺失,不是回归。
|
||||
//
|
||||
// 在生产机上运行的姿势(无需 Go 工具链):
|
||||
//
|
||||
// GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go test -c -o /tmp/verify.test ./internal/service
|
||||
// scp /tmp/verify.test root@HOST:/tmp/ && ssh root@HOST \
|
||||
// 'docker cp /tmp/verify.test mebox:/tmp/ && docker exec mebox /tmp/verify.test \
|
||||
// -test.run TestImageProxyFetchesFromMetaTubeService -test.v'
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// 与生产环境一致的两个取值(取自 10.0.0.2 上真实的 settings / api_configs)。
|
||||
const (
|
||||
metaTubeServer = "http://mebox-metatube-1:8081"
|
||||
metaTubeLegacy = "http://127.0.0.1:7700"
|
||||
sampleImageURL = "http://mebox-metatube-1:8081/v1/images/primary/JAV321/dsam-15"
|
||||
)
|
||||
|
||||
// newMetaTubeVerificationRepos 建一个带现场取值的临时库(不碰生产数据)。
|
||||
func newMetaTubeVerificationRepos(t *testing.T) (*repository.Container, *ImageProxy) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Setting{}, &model.APIConfig{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
ctx := context.Background()
|
||||
if err := repos.Setting.Set(ctx, "adult.scraper.metatube_server", metaTubeServer); err != nil {
|
||||
t.Fatalf("seed setting: %v", err)
|
||||
}
|
||||
if err := repos.ApiConfig.Create(ctx, &model.APIConfig{
|
||||
Provider: "metatube", BaseURL: metaTubeLegacy, Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed metatube api config: %v", err)
|
||||
}
|
||||
// 一个公网上游,用来确认它没有混进放行名单。
|
||||
if err := repos.ApiConfig.Create(ctx, &model.APIConfig{
|
||||
Provider: "tmdb", BaseURL: "https://api.themoviedb.org/3", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed tmdb api config: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}
|
||||
cfg.Secrets.JWTSecret = "verify-secret"
|
||||
proxy := NewImageProxy(cfg, zap.NewNop())
|
||||
// 与 service_builder.initImageProxy 相同的装配方式(无远程 Emby 挂载:
|
||||
// 本次只验证「显式配置的服务地址」这条路径)。
|
||||
proxy.SetAllowedRemoteHostsProvider(func() []string {
|
||||
return mergeImageProxyAllowedHosts(
|
||||
nil,
|
||||
configuredServiceImageHostsFromRepo(ctx, repos),
|
||||
)
|
||||
})
|
||||
return repos, proxy
|
||||
}
|
||||
|
||||
// TestImageProxyAllowlistIncludesConfiguredMetaTube 确认内网服务地址被放行、
|
||||
// 公网上游没有被放行。
|
||||
func TestImageProxyAllowlistIncludesConfiguredMetaTube(t *testing.T) {
|
||||
repos, proxy := newMetaTubeVerificationRepos(t)
|
||||
t.Logf("allowlist from config: %v",
|
||||
configuredServiceImageHostsFromRepo(context.Background(), repos))
|
||||
|
||||
for _, host := range []string{"mebox-metatube-1", "127.0.0.1"} {
|
||||
if !proxy.isAllowedRemoteHost(host) {
|
||||
t.Fatalf("host %q must be allowlisted (it is an explicitly configured service)", host)
|
||||
}
|
||||
}
|
||||
if proxy.isAllowedRemoteHost("api.themoviedb.org") {
|
||||
t.Fatal("public upstreams must stay off the allowlist so image fetches keep using the proxy")
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageProxyReachesMetaTubeService 证明放行名单真的解除了私网拦截。
|
||||
//
|
||||
// 现场(修复前)回源必然失败于
|
||||
//
|
||||
// dial tcp 172.18.0.5:8081: image proxy: requests to private/internal hosts are not allowed
|
||||
//
|
||||
// 也就是连接根本没发生。修复后连接成功,失败只可能来自上游自己的 HTTP 状态
|
||||
// (MetaTube 冷缓存时对未刮削过的番号返回 500)。
|
||||
//
|
||||
// 因此这个用例断言的不是「拿到 200」,而是**「不再是被私网校验拒绝」**:
|
||||
// 后者才是本次修复的对象,前者取决于 MetaTube 缓存里有没有那条记录。
|
||||
func TestImageProxyReachesMetaTubeService(t *testing.T) {
|
||||
_, proxy := newMetaTubeVerificationRepos(t)
|
||||
|
||||
data, ctype, err := proxy.Fetch(context.Background(), sampleImageURL)
|
||||
if err != nil {
|
||||
if isMetaTubeUnreachable(err) {
|
||||
t.Skipf("mebox-metatube-1 is not reachable from this environment: %v", err)
|
||||
}
|
||||
if isPrivateHostRejection(err) {
|
||||
t.Fatalf("the allowlist did not take effect: %v", err)
|
||||
}
|
||||
// 连接已经建立,只是上游自己返回了错误状态(MetaTube 冷缓存)。
|
||||
t.Logf("reached metaTube; upstream returned its own error: %v", err)
|
||||
return
|
||||
}
|
||||
t.Logf("fetched %d bytes (content-type=%s)", len(data), ctype)
|
||||
if len(data) == 0 {
|
||||
t.Fatal("fetched an empty image body")
|
||||
}
|
||||
}
|
||||
|
||||
// isPrivateHostRejection 报告错误是否来自连接层的私网校验。
|
||||
// 这正是本次修复必须消除的那一类失败。
|
||||
func isPrivateHostRejection(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "requests to private/internal hosts are not allowed")
|
||||
}
|
||||
|
||||
// isMetaTubeUnreachable 区分「环境里没有这个服务」与「放行名单没生效」。
|
||||
func isMetaTubeUnreachable(err error) bool {
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
return true
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
for _, needle := range []string{"no such host", "server misbehaving", "i/o timeout", "connection refused"} {
|
||||
if strings.Contains(msg, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -32,7 +32,9 @@ func (p *ImageProxy) remoteImageFetchClients(host string) []remoteImageFetchClie
|
||||
}
|
||||
direct := p.directClient
|
||||
if direct == nil {
|
||||
direct = &http.Client{Timeout: timeout, Transport: NewInternalTransport()}
|
||||
transport := NewInternalTransport()
|
||||
p.guardTransport(transport)
|
||||
direct = &http.Client{Timeout: timeout, Transport: transport}
|
||||
}
|
||||
directCandidate := remoteImageFetchClient{name: "direct", client: direct}
|
||||
defaultCandidate := remoteImageFetchClient{name: "default", client: client}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// guardTransport checks resolved destination addresses even when a deployment
|
||||
// has an outbound proxy. Only actual proxy endpoints and explicitly configured
|
||||
// image services may use private addresses; NO_PROXY and direct fallback
|
||||
// requests still need the connection-level check against DNS rebinding.
|
||||
func (p *ImageProxy) guardTransport(transport *http.Transport) {
|
||||
var proxyEndpoints sync.Map
|
||||
if selectProxy := transport.Proxy; selectProxy != nil {
|
||||
transport.Proxy = func(req *http.Request) (*url.URL, error) {
|
||||
proxyURL, err := selectProxy(req)
|
||||
if err == nil && proxyURL != nil {
|
||||
port := proxyURL.Port()
|
||||
if port == "" {
|
||||
switch strings.ToLower(proxyURL.Scheme) {
|
||||
case "https":
|
||||
port = "443"
|
||||
case "socks5", "socks5h":
|
||||
port = "1080"
|
||||
default:
|
||||
port = "80"
|
||||
}
|
||||
}
|
||||
proxyEndpoints.Store(net.JoinHostPort(strings.ToLower(proxyURL.Hostname()), port), true)
|
||||
}
|
||||
return proxyURL, err
|
||||
}
|
||||
}
|
||||
guardDialer := &net.Dialer{
|
||||
Timeout: 15 * time.Second,
|
||||
Control: func(_, address string, _ syscall.RawConn) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil || isPrivateIP(ip) {
|
||||
return errors.New("image proxy: requests to private/internal hosts are not allowed")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
trustedDialer := &net.Dialer{Timeout: 15 * time.Second}
|
||||
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, isProxyEndpoint := proxyEndpoints.Load(strings.ToLower(address))
|
||||
if isProxyEndpoint || p.isAllowedRemoteHost(host) {
|
||||
return trustedDialer.DialContext(ctx, network, address)
|
||||
}
|
||||
return guardDialer.DialContext(ctx, network, address)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func TestGroupMediaSeriesItemsKeepsLibrariesSeparate(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
media model.Media
|
||||
}{
|
||||
{"remote series with TMDb ID", model.Media{TMDbID: 61709}},
|
||||
{"remote series with Bangumi ID", model.Media{BangumiID: 61709}},
|
||||
{"flat episode with TMDb ID", model.Media{SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709}},
|
||||
{"flat episode with Bangumi ID", model.Media{SeasonNum: 1, EpisodeNum: 1, BangumiID: 61709}},
|
||||
{"flat episode with Douban ID", model.Media{SeasonNum: 1, EpisodeNum: 1, DoubanID: "61709"}},
|
||||
{"flat episode with TVDB ID", model.Media{SeasonNum: 1, EpisodeNum: 1, TheTVDBID: "61709"}},
|
||||
{"shared series ID", model.Media{SeriesID: "same-series"}},
|
||||
{"same title and directory", model.Media{Path: "/media/anime/龙珠改/Season 1/ep1.mkv", SeasonNum: 1, EpisodeNum: 1}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
first, second := tc.media, tc.media
|
||||
first.ID, first.LibraryID, first.LibraryName, first.Title = "first", "anime-a", "番剧库甲", "龙珠改"
|
||||
second.ID, second.LibraryID, second.LibraryName, second.Title = "second", "anime-b", "番剧库乙", "龙珠改"
|
||||
if strings.HasPrefix(tc.name, "remote") {
|
||||
first.ID = EncodeEmbyRemoteID("mount-a", "same-id")
|
||||
second.ID = EncodeEmbyRemoteID("mount-b", "same-id")
|
||||
}
|
||||
|
||||
items := GroupMediaSeriesItems([]model.Media{first, second})
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("got %d search results, want one per library: %#v", len(items), items)
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, item := range items {
|
||||
seen[item.LibraryID] = true
|
||||
if item.Title != "龙珠改" || item.LibraryName == "" {
|
||||
t.Fatalf("search result lost title or library metadata: %#v", item)
|
||||
}
|
||||
}
|
||||
if !seen[first.LibraryID] || !seen[second.LibraryID] {
|
||||
t.Fatalf("search results lost a library: %#v", items)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaSeriesItemsFoldsEpisodesWithinDisplayLibrary(t *testing.T) {
|
||||
items := []model.Media{
|
||||
{Base: model.Base{ID: "a-2"}, LibraryID: "source-a", DisplayLibraryID: "anime-a", Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep2.mkv", SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709},
|
||||
{Base: model.Base{ID: "a-1"}, LibraryID: "source-b", DisplayLibraryID: "anime-a", Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep1.mkv", SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709},
|
||||
{Base: model.Base{ID: "b-2"}, LibraryID: "source-a", DisplayLibraryID: "anime-b", Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep2.mkv", SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709},
|
||||
{Base: model.Base{ID: "b-1"}, LibraryID: "source-a", DisplayLibraryID: "anime-b", Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep1.mkv", SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709},
|
||||
}
|
||||
grouped := GroupMediaSeriesItems(items)
|
||||
if len(grouped) != 2 {
|
||||
t.Fatalf("got %d results, want one series per display library: %#v", len(grouped), grouped)
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, item := range grouped {
|
||||
if item.EpisodeNum != 1 || seen[item.DisplayLibraryID] {
|
||||
t.Fatalf("expected the first episode from each display library: %#v", grouped)
|
||||
}
|
||||
seen[item.DisplayLibraryID] = true
|
||||
}
|
||||
}
|
||||
@@ -524,9 +524,16 @@ func groupMediaSeriesCardsByKeys(items []model.Media, keys []string) []SeriesCar
|
||||
// GroupMediaSeriesItems folds episode-level rows into one representative media
|
||||
// row per series. It is intended for search surfaces where applying a small
|
||||
// limit before series grouping would otherwise return several episodes from
|
||||
// the same show.
|
||||
// the same show. Each display library keeps its own search result.
|
||||
func GroupMediaSeriesItems(items []model.Media) []model.Media {
|
||||
cards := groupMediaSeriesCards(items)
|
||||
_, keys := resolveMediaSeriesKeys(items)
|
||||
for i, item := range items {
|
||||
// External IDs and SeriesID can be shared by different libraries. Scope
|
||||
// search grouping to the same library used by the result's label/link,
|
||||
// while preserving episode folding within merged display libraries.
|
||||
keys[i] = seriesFingerprint(mediaTargetLibraryID(item), keys[i])
|
||||
}
|
||||
cards := groupMediaSeriesCardsByKeys(items, keys)
|
||||
if len(cards) == 0 {
|
||||
return []model.Media{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Package service — 服务器身份与移动端能力声明。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// 服务器身份与移动端契约常量。
|
||||
const (
|
||||
// ServerIDSettingKey 是持久化实例身份的设置键。普通重启、升级和同实例
|
||||
// 数据恢复都沿用同一个值;把备份克隆成独立服务时需要显式重置。
|
||||
ServerIDSettingKey = "mobile.server_id"
|
||||
|
||||
// MobileAPIVersion 是 /api/v1 契约版本。
|
||||
MobileAPIVersion = "v1"
|
||||
|
||||
// MinSupportedAppVersionCode 是首个移动端契约要求的最低 App versionCode。
|
||||
MinSupportedAppVersionCode = 1
|
||||
|
||||
// MobileServerName 是 server-info 暴露的实例名称。
|
||||
MobileServerName = "MeBox"
|
||||
)
|
||||
|
||||
// ServerCapabilities 是 server-info 的能力开关。
|
||||
//
|
||||
// 每项都由「接口已实现且当前可运行」共同决定;仅注册了 handler 但功能
|
||||
// 不可用时必须返回 false,避免 App 依据虚假能力进入不可用流程。
|
||||
type ServerCapabilities struct {
|
||||
DeviceSessions bool `json:"device_sessions"`
|
||||
PlaybackSessions bool `json:"playback_sessions"`
|
||||
ReaderProgressV1 bool `json:"reader_progress_v1"`
|
||||
ReaderContentBatch bool `json:"reader_content_batch"`
|
||||
LocalTranscode bool `json:"local_transcode"`
|
||||
}
|
||||
|
||||
// ServerInfo 是 GET /api/v1/server-info 的响应。
|
||||
type ServerInfo struct {
|
||||
Name string `json:"name"`
|
||||
ServerID string `json:"server_id"`
|
||||
ServerVersion string `json:"server_version"`
|
||||
APIVersion string `json:"api_version"`
|
||||
MinSupportedAppVersionCode int `json:"min_supported_app_version_code"`
|
||||
Capabilities ServerCapabilities `json:"capabilities"`
|
||||
}
|
||||
|
||||
// ServerIdentityService 负责生成并持久化实例身份。
|
||||
type ServerIdentityService struct {
|
||||
repo *repository.Container
|
||||
version string
|
||||
|
||||
mu sync.Mutex
|
||||
cached string
|
||||
}
|
||||
|
||||
// NewServerIdentityService 创建实例身份服务。
|
||||
func NewServerIdentityService(repo *repository.Container, version string) *ServerIdentityService {
|
||||
return &ServerIdentityService{repo: repo, version: strings.TrimSpace(version)}
|
||||
}
|
||||
|
||||
// ServerID 返回持久化的实例 ID,首次调用时生成并写入设置表。
|
||||
func (s *ServerIdentityService) ServerID(ctx context.Context) (string, error) {
|
||||
if s == nil || s.repo == nil || s.repo.Setting == nil {
|
||||
return "", errors.New("server identity unavailable")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.cached != "" {
|
||||
return s.cached, nil
|
||||
}
|
||||
stored, err := s.repo.Setting.Get(ctx, ServerIDSettingKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
stored = strings.TrimSpace(stored)
|
||||
if stored != "" {
|
||||
s.cached = stored
|
||||
return stored, nil
|
||||
}
|
||||
generated := uuid.NewString()
|
||||
if err := s.repo.Setting.Set(ctx, ServerIDSettingKey, generated); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.cached = generated
|
||||
return generated, nil
|
||||
}
|
||||
|
||||
// Info 组装 server-info 响应。capabilities 由调用方按真实可用服务推导。
|
||||
func (s *ServerIdentityService) Info(ctx context.Context, capabilities ServerCapabilities) (ServerInfo, error) {
|
||||
serverID, err := s.ServerID(ctx)
|
||||
if err != nil {
|
||||
return ServerInfo{}, err
|
||||
}
|
||||
version := strings.TrimSpace(s.version)
|
||||
if version == "" {
|
||||
version = "dev"
|
||||
}
|
||||
return ServerInfo{
|
||||
Name: MobileServerName,
|
||||
ServerID: serverID,
|
||||
ServerVersion: version,
|
||||
APIVersion: MobileAPIVersion,
|
||||
MinSupportedAppVersionCode: MinSupportedAppVersionCode,
|
||||
Capabilities: capabilities,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CurrentServerCapabilities 依据服务容器推导移动端能力。
|
||||
//
|
||||
// M1 只启用了设备会话;播放会话、阅读 v1 进度与批量正文属于后续阶段,
|
||||
// 在对应任务真正实现并通过契约测试前保持 false。
|
||||
func CurrentServerCapabilities(c *Container) ServerCapabilities {
|
||||
return ServerCapabilities{
|
||||
DeviceSessions: c != nil && c.AuthSessions != nil,
|
||||
PlaybackSessions: false,
|
||||
ReaderProgressV1: false,
|
||||
ReaderContentBatch: false,
|
||||
LocalTranscode: false,
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,8 @@ type Container struct {
|
||||
OrganizePipeline *OrganizePipelineService
|
||||
Douban *DoubanProvider
|
||||
Token *TokenService
|
||||
AuthSessions *AuthSessionService
|
||||
ServerIdentity *ServerIdentityService
|
||||
ApiConfig *ApiConfigService
|
||||
Device *DeviceService
|
||||
Telegram *TelegramService
|
||||
|
||||
@@ -159,28 +159,41 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
b.c.Stream.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTargetWithUA)
|
||||
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
|
||||
if b.c.EmbyRemote != nil {
|
||||
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
|
||||
mountID, remoteID, ok := DecodeEmbyRemoteID(encodedID)
|
||||
if !ok {
|
||||
return nil, "", errors.New("invalid emby remote id")
|
||||
}
|
||||
mount, acct, err := b.c.EmbyRemote.ResolveMount(ctx, mountID)
|
||||
if err != nil || mount == nil || acct == nil {
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return nil, "", errors.New("emby mount or account not found")
|
||||
}
|
||||
m, err := b.c.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID)
|
||||
if err != nil || m == nil {
|
||||
return nil, "", err
|
||||
}
|
||||
streamURL, _ := b.c.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
|
||||
return m, streamURL, nil
|
||||
})
|
||||
b.c.Danmaku.SetRemoteMediaResolver(b.resolveDanmakuRemoteMedia)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveDanmakuRemoteMedia 把远程 Emby 伪 ID 解析成弹幕识别要用的 Media 与直链 URL。
|
||||
//
|
||||
// 目录类条目(整部剧/季/目录)自身没有媒体源,拿它的 ID 去请求远程
|
||||
// /Videos/{id}/stream 只会回 500(Unable to cast object of type 'Series' to type
|
||||
// 'IHasMediaSources'),哈希层于是每次都在这里白白失败、退回按文件名搜索。所以先按
|
||||
// 与 /api/stream 相同的规则回落到第一集:算出来的哈希对得上真正播放的那条流,元数据
|
||||
// (时长、文件名)也才是播放中那一集的。
|
||||
func (b *serviceContainerBuilder) resolveDanmakuRemoteMedia(ctx context.Context, encodedID string) (*model.Media, string, error) {
|
||||
mountID, remoteID, ok := DecodeEmbyRemoteID(encodedID)
|
||||
if !ok {
|
||||
return nil, "", errors.New("invalid emby remote id")
|
||||
}
|
||||
mount, acct, err := b.c.EmbyRemote.ResolveMount(ctx, mountID)
|
||||
if err != nil || mount == nil || acct == nil {
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return nil, "", errors.New("emby mount or account not found")
|
||||
}
|
||||
playable := b.c.EmbyRemote.PlayableRemoteID(ctx, acct, remoteID)
|
||||
if strings.TrimSpace(playable) == "" {
|
||||
playable = remoteID
|
||||
}
|
||||
m, err := b.c.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, playable)
|
||||
if err != nil || m == nil {
|
||||
return nil, "", err
|
||||
}
|
||||
streamURL, _ := b.c.EmbyRemote.WebStreamURL(ctx, acct, playable)
|
||||
return m, streamURL, nil
|
||||
}
|
||||
|
||||
func (b *serviceContainerBuilder) initAccessAndStorageServices() {
|
||||
b.c.PlayProfiles = NewPlayProfileService(b.log, b.repos)
|
||||
b.c.Permissions = NewPermissionService(b.log, b.repos)
|
||||
@@ -221,10 +234,20 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
|
||||
|
||||
func (b *serviceContainerBuilder) initIdentityServices() {
|
||||
b.c.Token = NewTokenService(b.cfg, b.log, b.repos)
|
||||
b.c.AuthSessions = NewAuthSessionService(b.log, b.repos)
|
||||
b.c.ServerIdentity = NewServerIdentityService(b.repos, b.version)
|
||||
// 刷新令牌轮换时必须校验所属会话仍然有效,否则被踢设备可以用旧刷新
|
||||
// 令牌换出新令牌。
|
||||
b.c.Token.SetSessionValidator(func(ctx context.Context, sessionID, userID string) error {
|
||||
_, err := b.c.AuthSessions.Validate(ctx, sessionID, userID)
|
||||
return err
|
||||
})
|
||||
b.c.Auth = NewAuthService(b.cfg, b.log, b.repos, b.c.Token, b.c.Permissions)
|
||||
b.c.Auth.SetAuthSessionService(b.c.AuthSessions)
|
||||
b.c.Sessions = NewSessionTrackerService(b.log)
|
||||
b.c.Device = NewDeviceService(b.log, b.repos)
|
||||
b.c.Device.SetSessionTracker(b.c.Sessions)
|
||||
b.c.Device.SetAuthSessionService(b.c.AuthSessions)
|
||||
// Telegram 通知:未启用时 Start 不会占用 goroutine,SendToUser 静默跳过。
|
||||
b.c.Telegram = NewTelegramService(b.log, b.repos)
|
||||
b.c.Device.SetNotifier(b.c.Telegram.SendToUser)
|
||||
@@ -240,10 +263,21 @@ func (b *serviceContainerBuilder) initIdentityServices() {
|
||||
func (b *serviceContainerBuilder) initImageProxy() {
|
||||
b.c.ImageProxy = NewImageProxy(b.cfg, b.log)
|
||||
b.c.ImageProxy.SetLibraryRootsProvider(b.libraryRoots)
|
||||
// 放行名单 = 远程 Emby 挂载主机 + 用户显式配置的内网服务地址。
|
||||
//
|
||||
// 只放行挂载主机是不够的:MetaTube 这类刮削后端会把「容器服务名」写进
|
||||
// poster_url(现场:http://mebox-metatube-1:8081),连接层 SSRF 校验会把
|
||||
// 每一次封面回源都拒掉,前端长期只拿到占位图。名单本身有 30s 缓存,
|
||||
// 所以管理员改完设置无需重启即可生效。
|
||||
b.c.ImageProxy.SetAllowedRemoteHostsProvider(func() []string {
|
||||
var mounts []string
|
||||
if b.c.EmbyRemote != nil {
|
||||
mounts = b.c.EmbyRemote.ConfiguredRemoteHosts(context.Background())
|
||||
}
|
||||
services := configuredServiceImageHostsFromRepo(context.Background(), b.repos)
|
||||
return mergeImageProxyAllowedHosts(mounts, services)
|
||||
})
|
||||
if b.c.EmbyRemote != nil {
|
||||
b.c.ImageProxy.SetAllowedRemoteHostsProvider(func() []string {
|
||||
return b.c.EmbyRemote.ConfiguredRemoteHosts(context.Background())
|
||||
})
|
||||
// 远程 Emby 的图片 URL 会带着签发时的 api_key 长期缓存/下发;轮换后
|
||||
// URL 里的旧值必然过期。回源时改用账号当前 token,并在上游 401/403 时
|
||||
// 重新登录一次再重试,封面图不再依赖人工改账号。
|
||||
|
||||
@@ -33,6 +33,8 @@ type Claims struct {
|
||||
Tier string `json:"tier,omitempty"`
|
||||
Purpose string `json:"purpose,omitempty"`
|
||||
MediaID string `json:"media_id,omitempty"`
|
||||
// Sid 是设备认证会话 ID。旧令牌没有该字段,继续走兼容路径。
|
||||
Sid string `json:"sid,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -60,6 +62,10 @@ type TokenService struct {
|
||||
// rotations 记录刚轮换过的 refresh token 及其新令牌对,供宽限期内
|
||||
// 幂等复用(见 token_refresh_rotation.go)。
|
||||
rotations map[string]rotatedRefreshToken
|
||||
|
||||
// sessionValidator 校验刷新令牌所属认证会话是否仍有效;由服务构建器
|
||||
// 注入,nil 表示不做会话校验(测试或旧配置)。
|
||||
sessionValidator func(ctx context.Context, sessionID, userID string) error
|
||||
}
|
||||
|
||||
// NewTokenService 创建令牌服务实例。
|
||||
@@ -92,19 +98,35 @@ var (
|
||||
|
||||
// IssuePair 为用户签发新的令牌对。
|
||||
func (s *TokenService) IssuePair(ctx context.Context, userID, role, tier string) (*TokenPair, error) {
|
||||
return s.issuePair(ctx, userID, role, tier, false)
|
||||
return s.issuePair(ctx, userID, role, tier, "", false)
|
||||
}
|
||||
|
||||
// IssuePairBestEffort 为登录签发令牌。SQLite 被后台扫描长期写锁占用时,
|
||||
// 登录不能因为 refresh token 暂时无法落库而失败:先返回可用 access token,
|
||||
// 再在后台把 refresh token 补写进库。
|
||||
func (s *TokenService) IssuePairBestEffort(ctx context.Context, userID, role, tier string) (*TokenPair, error) {
|
||||
return s.issuePair(ctx, userID, role, tier, true)
|
||||
return s.issuePair(ctx, userID, role, tier, "", true)
|
||||
}
|
||||
|
||||
func (s *TokenService) issuePair(ctx context.Context, userID, role, tier string, bestEffort bool) (*TokenPair, error) {
|
||||
// IssuePairForSession 为指定认证会话签发令牌对,访问令牌携带 sid,
|
||||
// 刷新令牌记录关联该会话。
|
||||
func (s *TokenService) IssuePairForSession(ctx context.Context, userID, role, tier, sessionID string) (*TokenPair, error) {
|
||||
return s.issuePair(ctx, userID, role, tier, sessionID, false)
|
||||
}
|
||||
|
||||
// IssuePairBestEffortForSession 与 IssuePairBestEffort 相同,但绑定会话。
|
||||
func (s *TokenService) IssuePairBestEffortForSession(ctx context.Context, userID, role, tier, sessionID string) (*TokenPair, error) {
|
||||
return s.issuePair(ctx, userID, role, tier, sessionID, true)
|
||||
}
|
||||
|
||||
// SetSessionValidator 注入认证会话校验器。
|
||||
func (s *TokenService) SetSessionValidator(fn func(ctx context.Context, sessionID, userID string) error) {
|
||||
s.sessionValidator = fn
|
||||
}
|
||||
|
||||
func (s *TokenService) issuePair(ctx context.Context, userID, role, tier, sessionID string, bestEffort bool) (*TokenPair, error) {
|
||||
// 生成 Access Token
|
||||
accessToken, err := s.issueAccessToken(userID, role, tier)
|
||||
accessToken, err := s.issueAccessToken(userID, role, tier, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -118,12 +140,13 @@ func (s *TokenService) issuePair(ctx context.Context, userID, role, tier string,
|
||||
// 存储 Refresh Token 哈希
|
||||
tokenHash := repository.HashToken(refreshToken)
|
||||
rt := &model.RefreshToken{
|
||||
UserID: userID,
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: time.Now().Add(RefreshTokenDuration),
|
||||
UserID: userID,
|
||||
TokenHash: tokenHash,
|
||||
AuthSessionID: sessionID,
|
||||
ExpiresAt: time.Now().Add(RefreshTokenDuration),
|
||||
}
|
||||
if bestEffort {
|
||||
s.storeRefreshTokenBestEffort(userID, tokenHash, rt.ExpiresAt)
|
||||
s.storeRefreshTokenBestEffort(userID, tokenHash, sessionID, rt.ExpiresAt)
|
||||
return &TokenPair{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
@@ -162,11 +185,12 @@ func (s *TokenService) maxActiveRefreshTokens(ctx context.Context) int {
|
||||
}
|
||||
|
||||
// issueAccessToken 签发 JWT Access Token(HS256,60分钟有效期)。
|
||||
func (s *TokenService) issueAccessToken(userID, role, tier string) (string, error) {
|
||||
func (s *TokenService) issueAccessToken(userID, role, tier, sessionID string) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Role: role,
|
||||
Tier: tier,
|
||||
Sid: sessionID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)),
|
||||
@@ -232,14 +256,28 @@ func (s *TokenService) refreshOnce(ctx context.Context, tokenHash string) (*Toke
|
||||
return nil, ErrInvalidRefreshToken
|
||||
}
|
||||
rt = &model.RefreshToken{
|
||||
UserID: pending.UserID,
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: pending.ExpiresAt,
|
||||
UserID: pending.UserID,
|
||||
TokenHash: tokenHash,
|
||||
AuthSessionID: pending.SessionID,
|
||||
ExpiresAt: pending.ExpiresAt,
|
||||
}
|
||||
// 尚未落库的令牌同样受会话撤销约束。
|
||||
if rt.AuthSessionID != "" && s.sessionValidator != nil {
|
||||
if err := s.sessionValidator(ctx, rt.AuthSessionID, rt.UserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否已撤销
|
||||
if rt.Revoked {
|
||||
// 会话被撤销时必须在宽限期复用之前拦截:否则登出/被踢后的 60 秒
|
||||
// 内,旧刷新令牌仍能从内存复用表换回一对有效令牌。
|
||||
if rt.AuthSessionID != "" && s.sessionValidator != nil {
|
||||
if err := s.sessionValidator(ctx, rt.AuthSessionID, rt.UserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// 刚被轮换过的 token 在宽限期内允许幂等复用,避免并发/重试的
|
||||
// 客户端拿到 revoked 401 后清空整个会话。
|
||||
if pair, ok := s.reusedRotation(tokenHash, rt.UserID); ok {
|
||||
@@ -268,14 +306,22 @@ func (s *TokenService) refreshOnce(ctx context.Context, tokenHash string) (*Toke
|
||||
return nil, ErrUserExpired
|
||||
}
|
||||
|
||||
// 会话被踢出/撤销后,旧刷新令牌不能再换出新会话。检查必须发生在撤销
|
||||
// 旧令牌之前,否则一次失败的重试会把仍然有效的令牌标记成已撤销。
|
||||
if rt.AuthSessionID != "" && s.sessionValidator != nil {
|
||||
if err := s.sessionValidator(ctx, rt.AuthSessionID, user.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 撤销旧的 Refresh Token(包括可能仍在后台补写队列里的副本)。
|
||||
if err := s.repo.RefreshToken.Revoke(ctx, tokenHash); err != nil {
|
||||
s.log.Warn("failed to revoke old refresh token", zap.Error(err))
|
||||
}
|
||||
s.untrackDelayedStore(rt.UserID, tokenHash)
|
||||
|
||||
// 签发新的令牌对
|
||||
pair, err := s.IssuePairBestEffort(ctx, user.ID, user.Role, user.Tier)
|
||||
// 签发新的令牌对;轮换后的令牌继续归属同一个认证会话。
|
||||
pair, err := s.IssuePairBestEffortForSession(ctx, user.ID, user.Role, user.Tier, rt.AuthSessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -16,11 +16,12 @@ const loginRefreshTokenStoreTimeout = 750 * time.Millisecond
|
||||
|
||||
type pendingRefreshToken struct {
|
||||
UserID string
|
||||
SessionID string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash string, expiresAt time.Time) {
|
||||
if !s.trackDelayedStore(userID, tokenHash, expiresAt) {
|
||||
func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash, sessionID string, expiresAt time.Time) {
|
||||
if !s.trackDelayedStore(userID, tokenHash, sessionID, expiresAt) {
|
||||
return
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
@@ -29,16 +30,17 @@ func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash string, exp
|
||||
defer cancel()
|
||||
err := helper.Recover(s.log, "token.storeRefreshToken", func() error {
|
||||
return s.storeRefreshToken(ctx, &model.RefreshToken{
|
||||
UserID: userID,
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: expiresAt,
|
||||
UserID: userID,
|
||||
TokenHash: tokenHash,
|
||||
AuthSessionID: sessionID,
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
})
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
s.finishBestEffortRefreshTokenStore(userID, tokenHash, expiresAt, err)
|
||||
s.finishBestEffortRefreshTokenStore(userID, tokenHash, sessionID, expiresAt, err)
|
||||
case <-time.After(loginRefreshTokenStoreTimeout):
|
||||
if s.log != nil {
|
||||
s.log.Warn("refresh token store delayed; login will continue",
|
||||
@@ -47,12 +49,12 @@ func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash string, exp
|
||||
}
|
||||
go func() {
|
||||
err := <-done
|
||||
s.finishBestEffortRefreshTokenStore(userID, tokenHash, expiresAt, err)
|
||||
s.finishBestEffortRefreshTokenStore(userID, tokenHash, sessionID, expiresAt, err)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TokenService) finishBestEffortRefreshTokenStore(userID, tokenHash string, expiresAt time.Time, err error) {
|
||||
func (s *TokenService) finishBestEffortRefreshTokenStore(userID, tokenHash, sessionID string, expiresAt time.Time, err error) {
|
||||
if err == nil {
|
||||
s.untrackDelayedStore(userID, tokenHash)
|
||||
return
|
||||
@@ -63,7 +65,7 @@ func (s *TokenService) finishBestEffortRefreshTokenStore(userID, tokenHash strin
|
||||
zap.String("user_id", userID),
|
||||
zap.Error(err))
|
||||
}
|
||||
s.storeRefreshTokenEventually(userID, tokenHash, expiresAt)
|
||||
s.storeRefreshTokenEventually(userID, tokenHash, sessionID, expiresAt)
|
||||
return
|
||||
}
|
||||
s.untrackDelayedStore(userID, tokenHash)
|
||||
@@ -72,22 +74,23 @@ func (s *TokenService) finishBestEffortRefreshTokenStore(userID, tokenHash strin
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TokenService) storeRefreshTokenEventually(userID, tokenHash string, expiresAt time.Time) {
|
||||
func (s *TokenService) storeRefreshTokenEventually(userID, tokenHash, sessionID string, expiresAt time.Time) {
|
||||
defer s.untrackDelayedStore(userID, tokenHash)
|
||||
delay := time.Second
|
||||
for attempt := 1; attempt <= 8; attempt++ {
|
||||
timer := time.NewTimer(delay)
|
||||
<-timer.C
|
||||
// 令牌可能已在等待期间被轮换/登出(从 pending 表移除),
|
||||
// 此时绝不能再写库,否则会复活一个已被替换的旧令牌。
|
||||
// ?????????????/???? pending ?????
|
||||
// ?????????????????????????
|
||||
if _, stillPending := s.pendingDelayedStore(tokenHash); !stillPending {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
err := s.storeRefreshToken(ctx, &model.RefreshToken{
|
||||
UserID: userID,
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: expiresAt,
|
||||
UserID: userID,
|
||||
TokenHash: tokenHash,
|
||||
AuthSessionID: sessionID,
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
cancel()
|
||||
if err == nil {
|
||||
@@ -114,7 +117,7 @@ func (s *TokenService) storeRefreshTokenEventually(userID, tokenHash string, exp
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TokenService) trackDelayedStore(userID, tokenHash string, expiresAt time.Time) bool {
|
||||
func (s *TokenService) trackDelayedStore(userID, tokenHash, sessionID string, expiresAt time.Time) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
@@ -126,7 +129,7 @@ func (s *TokenService) trackDelayedStore(userID, tokenHash string, expiresAt tim
|
||||
if _, ok := s.delayedStores[tokenHash]; ok {
|
||||
return false
|
||||
}
|
||||
s.delayedStores[tokenHash] = pendingRefreshToken{UserID: userID, ExpiresAt: expiresAt}
|
||||
s.delayedStores[tokenHash] = pendingRefreshToken{UserID: userID, SessionID: sessionID, ExpiresAt: expiresAt}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -139,7 +142,7 @@ func (s *TokenService) untrackDelayedStore(userID, tokenHash string) {
|
||||
s.delayedStoreMu.Unlock()
|
||||
}
|
||||
|
||||
// pendingDelayedStore 返回尚未落库的 refresh token 信息(如果存在)。
|
||||
// pendingDelayedStore ??????? refresh token ?????????
|
||||
func (s *TokenService) pendingDelayedStore(tokenHash string) (pendingRefreshToken, bool) {
|
||||
if s == nil {
|
||||
return pendingRefreshToken{}, false
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestRefreshAcceptsPendingDelayedToken(t *testing.T) {
|
||||
|
||||
refreshToken := "pending-token-value"
|
||||
hash := repository.HashToken(refreshToken)
|
||||
if !svc.trackDelayedStore(u.ID, hash, time.Now().Add(time.Hour)) {
|
||||
if !svc.trackDelayedStore(u.ID, hash, "", time.Now().Add(time.Hour)) {
|
||||
t.Fatal("trackDelayedStore returned false")
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestRefreshRejectsExpiredPendingToken(t *testing.T) {
|
||||
|
||||
refreshToken := "expired-pending"
|
||||
hash := repository.HashToken(refreshToken)
|
||||
svc.trackDelayedStore("user-x", hash, time.Now().Add(-time.Minute))
|
||||
svc.trackDelayedStore("user-x", hash, "", time.Now().Add(-time.Minute))
|
||||
|
||||
if _, err := svc.Refresh(t.Context(), refreshToken); err == nil {
|
||||
t.Fatal("expired pending token should be rejected")
|
||||
|
||||
@@ -59,6 +59,9 @@ type TranscoderService struct {
|
||||
availMu sync.Mutex
|
||||
availCheckedAt time.Time
|
||||
availValue bool
|
||||
availKey string
|
||||
ffmpegMu sync.Mutex
|
||||
ffmpegCache ffmpegValidationCache
|
||||
}
|
||||
|
||||
// hlsJob holds the live state of one ffmpeg run.
|
||||
@@ -83,6 +86,11 @@ type hlsJob struct {
|
||||
quality string
|
||||
// done is closed when the ffmpeg goroutine fully exits (after process death).
|
||||
done chan struct{}
|
||||
// Completed outputs stay reusable until idle eviction without occupying a
|
||||
// running-process slot. The binary and config are fixed for this generation.
|
||||
completed bool
|
||||
ffmpegPath string
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -117,24 +125,37 @@ func (t *TranscoderService) Available() bool {
|
||||
if t == nil || t.cfg == nil {
|
||||
return false
|
||||
}
|
||||
if !t.cfg.Transcoder.Enabled {
|
||||
cfg := t.transcodeConfig()
|
||||
if !cfg.Transcoder.Enabled {
|
||||
return false
|
||||
}
|
||||
encoder := ffmpegArgSettingsFromConfig(cfg, nil).encoder
|
||||
key := cfg.App.FFmpegPath + "\x00" + encoder
|
||||
t.availMu.Lock()
|
||||
defer t.availMu.Unlock()
|
||||
if !t.availCheckedAt.IsZero() && time.Since(t.availCheckedAt) < transcodeAvailabilityTTL {
|
||||
return t.availValue
|
||||
if !t.availValue && t.availKey == key && !t.availCheckedAt.IsZero() && time.Since(t.availCheckedAt) < transcodeAvailabilityTTL {
|
||||
return false
|
||||
}
|
||||
_, err := t.resolveFFmpegPath()
|
||||
_, err := t.resolveFFmpegPathFor(context.Background(), cfg.App.FFmpegPath, encoder)
|
||||
available := err == nil
|
||||
if !available && t.log != nil {
|
||||
t.log.Debug("local HLS renditions unavailable", zap.Error(err))
|
||||
}
|
||||
t.availValue = available
|
||||
t.availCheckedAt = time.Now()
|
||||
t.availKey = key
|
||||
return available
|
||||
}
|
||||
|
||||
func (t *TranscoderService) transcodeConfig() *config.Config {
|
||||
config.RuntimeMu.RLock()
|
||||
defer config.RuntimeMu.RUnlock()
|
||||
return &config.Config{
|
||||
App: config.AppConfig{FFmpegPath: t.cfg.App.FFmpegPath, VAAPIDevice: t.cfg.App.VAAPIDevice},
|
||||
Transcoder: t.cfg.Transcoder,
|
||||
}
|
||||
}
|
||||
|
||||
// HLSDir is the per-media directory that holds index.m3u8 + segment files.
|
||||
func (t *TranscoderService) HLSDir(mediaID string) string {
|
||||
return filepath.Join(t.cfg.Cache.CacheDir, "hls", mediaID)
|
||||
@@ -174,7 +195,8 @@ func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID s
|
||||
// EnsureJobFromSubtitleQuality is EnsureJobFromSubtitle with an explicit local
|
||||
// HLS quality profile. Empty qualityID keeps the global transcoder config.
|
||||
func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, mediaID string, startSec float64, seekGen int64, subtitleStream int, qualityID string) (string, error) {
|
||||
if !t.cfg.Transcoder.Enabled {
|
||||
cfg := t.transcodeConfig()
|
||||
if !cfg.Transcoder.Enabled {
|
||||
return "", ErrTranscodeDisabled
|
||||
}
|
||||
if startSec < 0 {
|
||||
@@ -203,12 +225,17 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
|
||||
|
||||
t.mu.Lock()
|
||||
if existing, ok := t.jobs[mediaID]; ok {
|
||||
if sameHLSConfiguration(existing, startSec, subtitleStream, qualityID) {
|
||||
reusable := true
|
||||
if existing.completed {
|
||||
info, err := os.Stat(t.PlaylistPath(mediaID))
|
||||
reusable = err == nil && info.Size() > 0
|
||||
}
|
||||
if reusable && sameHLSConfiguration(existing, startSec, subtitleStream, qualityID) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
if !shouldReplaceHLSJobConfigurationQuality(existing, startSec, seekGen, subtitleStream, qualityID) {
|
||||
if reusable && !shouldReplaceHLSJobConfigurationQuality(existing, startSec, seekGen, subtitleStream, qualityID) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
@@ -230,7 +257,13 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
|
||||
}
|
||||
input.Quality = quality
|
||||
t.maybeFillDuration(ctx, m, input)
|
||||
if _, err := t.resolveFFmpegPath(); err != nil {
|
||||
settings := ffmpegArgSettingsFromConfig(cfg, quality)
|
||||
jobEncoder := settings.encoder
|
||||
if settings.noScale || subtitleStream >= 0 {
|
||||
jobEncoder = ""
|
||||
}
|
||||
bin, err := t.resolveFFmpegPathFor(ctx, cfg.App.FFmpegPath, jobEncoder)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -242,29 +275,15 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
if existing, ok := t.jobs[mediaID]; ok {
|
||||
if sameHLSConfiguration(existing, startSec, subtitleStream, qualityID) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
if !shouldReplaceHLSJobConfigurationQuality(existing, startSec, seekGen, subtitleStream, qualityID) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
prev := t.detachJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
waitJobExit(prev, 12*time.Second)
|
||||
t.mu.Lock()
|
||||
}
|
||||
if max := t.maxConcurrent(); max > 0 && len(t.jobs) >= max {
|
||||
if max := t.maxConcurrent(); max > 0 && t.runningJobCountLocked() >= max {
|
||||
t.mu.Unlock()
|
||||
return "", ErrTranscodeBusy
|
||||
}
|
||||
|
||||
jobCtx, cancel := context.WithCancel(context.Background())
|
||||
jobEncoder := t.effectiveEncoder()
|
||||
if jobEncoder == "" {
|
||||
jobEncoder = "software"
|
||||
}
|
||||
if subtitleStream >= 0 {
|
||||
jobEncoder = "software (subtitle burn-in)"
|
||||
}
|
||||
@@ -280,6 +299,8 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
|
||||
subtitleStream: subtitleStream,
|
||||
quality: qualityID,
|
||||
done: make(chan struct{}),
|
||||
ffmpegPath: bin,
|
||||
config: cfg,
|
||||
}
|
||||
t.jobs[mediaID] = job
|
||||
t.mu.Unlock()
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
// be false both when transcoding is disabled by configuration and when no usable
|
||||
// ffmpeg can be resolved.
|
||||
func TestTranscoderAvailableGatesOnConfigAndBinary(t *testing.T) {
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
t.Chdir(t.TempDir())
|
||||
cases := []struct {
|
||||
name string
|
||||
enabled bool
|
||||
|
||||
@@ -11,29 +11,17 @@ import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
)
|
||||
|
||||
func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, input transcodeInput) {
|
||||
bin, err := t.resolveFFmpegPath()
|
||||
if err != nil {
|
||||
t.log.Warn("ffmpeg unavailable", zap.String("media_id", job.mediaID), zap.Error(err))
|
||||
t.mu.Lock()
|
||||
delete(t.jobs, job.mediaID)
|
||||
t.mu.Unlock()
|
||||
t.hub.Publish("transcode", map[string]any{
|
||||
"media_id": job.mediaID,
|
||||
"status": "error",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
playlist := filepath.Join(job.outputDir, "index.m3u8")
|
||||
segments := filepath.Join(job.outputDir, "seg_%05d.ts")
|
||||
|
||||
args := buildFFmpegArgsForInput(t.cfg, input, playlist, segments)
|
||||
args := buildFFmpegArgsForInput(job.config, input, playlist, segments)
|
||||
|
||||
cmd := exec.CommandContext(ctx, bin, args...) // #nosec G204 -- bin is resolved by resolveFFmpegPath and args are passed without a shell.
|
||||
cmd := exec.CommandContext(ctx, job.ffmpegPath, args...) // #nosec G204 -- the binary was validated before creating this job.
|
||||
setFFmpegSysProcAttr(cmd)
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
@@ -50,7 +38,8 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, input tr
|
||||
"start_sec": input.StartSec,
|
||||
})
|
||||
|
||||
if err := cmd.Run(); err != nil && !errors.Is(ctx.Err(), context.Canceled) {
|
||||
err := cmd.Run()
|
||||
if err != nil && !errors.Is(ctx.Err(), context.Canceled) {
|
||||
t.log.Warn("ffmpeg exited",
|
||||
zap.String("media_id", job.mediaID),
|
||||
zap.Float64("start_sec", input.StartSec),
|
||||
@@ -59,11 +48,18 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, input tr
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
// Only drop the map entry if we are still the registered generation.
|
||||
// Only update the map entry if we are still the registered generation.
|
||||
if cur, ok := t.jobs[job.mediaID]; ok && cur == job {
|
||||
delete(t.jobs, job.mediaID)
|
||||
if err == nil && ctx.Err() == nil {
|
||||
job.completed = true
|
||||
} else {
|
||||
delete(t.jobs, job.mediaID)
|
||||
}
|
||||
}
|
||||
t.mu.Unlock()
|
||||
if err != nil || ctx.Err() != nil {
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
t.hub.Publish("transcode", map[string]any{
|
||||
"media_id": job.mediaID,
|
||||
@@ -73,19 +69,53 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, input tr
|
||||
}
|
||||
|
||||
func (t *TranscoderService) resolveFFmpegPath() (string, error) {
|
||||
t.mu.Lock()
|
||||
configuredPath := strings.TrimSpace(t.cfg.App.FFmpegPath)
|
||||
t.mu.Unlock()
|
||||
cfg := t.transcodeConfig()
|
||||
return t.resolveFFmpegPathFor(context.Background(), cfg.App.FFmpegPath, ffmpegArgSettingsFromConfig(cfg, nil).encoder)
|
||||
}
|
||||
|
||||
type ffmpegValidationCache struct {
|
||||
configuredPath string
|
||||
path string
|
||||
encoder string
|
||||
size int64
|
||||
modTime time.Time
|
||||
checkedAt time.Time
|
||||
}
|
||||
|
||||
func (t *TranscoderService) resolveFFmpegPathFor(ctx context.Context, configuredPath, encoder string) (string, error) {
|
||||
t.ffmpegMu.Lock()
|
||||
defer t.ffmpegMu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
configuredPath = strings.TrimSpace(configuredPath)
|
||||
cached := t.ffmpegCache
|
||||
if cached.path != "" && cached.encoder == encoder &&
|
||||
(configuredPath == cached.configuredPath || configuredPath == cached.path) &&
|
||||
time.Since(cached.checkedAt) < transcodeAvailabilityTTL {
|
||||
if info, err := os.Stat(cached.path); err == nil && info.Size() == cached.size && info.ModTime().Equal(cached.modTime) {
|
||||
return cached.path, nil
|
||||
}
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, bin := range executableCandidates(configuredPath, "ffmpeg") {
|
||||
if err := validateFFmpegForTranscode(context.Background(), bin, t.effectiveEncoder()); err != nil {
|
||||
if err := validateFFmpegForTranscode(ctx, bin, encoder); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.cfg.App.FFmpegPath = bin
|
||||
t.mu.Unlock()
|
||||
if info, err := os.Stat(bin); err == nil {
|
||||
t.ffmpegCache = ffmpegValidationCache{configuredPath: configuredPath, path: bin, encoder: encoder,
|
||||
size: info.Size(), modTime: info.ModTime(), checkedAt: time.Now()}
|
||||
}
|
||||
config.RuntimeMu.Lock()
|
||||
if strings.TrimSpace(t.cfg.App.FFmpegPath) == configuredPath {
|
||||
t.cfg.App.FFmpegPath = bin
|
||||
}
|
||||
config.RuntimeMu.Unlock()
|
||||
return bin, nil
|
||||
}
|
||||
if lastErr != nil {
|
||||
@@ -95,10 +125,11 @@ func (t *TranscoderService) resolveFFmpegPath() (string, error) {
|
||||
}
|
||||
|
||||
func (t *TranscoderService) effectiveEncoder() string {
|
||||
if !t.cfg.Transcoder.HardwareAccel {
|
||||
cfg := t.transcodeConfig()
|
||||
if !cfg.Transcoder.HardwareAccel {
|
||||
return ""
|
||||
}
|
||||
return normalizedHardwareEncoder(t.cfg.Transcoder.Encoder)
|
||||
return normalizedHardwareEncoder(cfg.Transcoder.Encoder)
|
||||
}
|
||||
|
||||
func normalizedHardwareEncoder(encoder string) string {
|
||||
@@ -160,6 +191,7 @@ func hasFFmpegListEntry(output, name string) bool {
|
||||
|
||||
// HumanFFmpegProfile is exposed for the admin UI / settings view.
|
||||
func (t *TranscoderService) HumanFFmpegProfile() string {
|
||||
cfg := t.transcodeConfig()
|
||||
return fmt.Sprintf("ffmpeg=%s, encoder=%s, output=%s",
|
||||
t.cfg.App.FFmpegPath, t.cfg.Transcoder.Encoder, filepath.Join(t.cfg.Cache.CacheDir, "hls"))
|
||||
cfg.App.FFmpegPath, cfg.Transcoder.Encoder, filepath.Join(t.cfg.Cache.CacheDir, "hls"))
|
||||
}
|
||||
|
||||
@@ -6,41 +6,58 @@ import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
)
|
||||
|
||||
// WaitReady blocks (with a deadline) until the playlist file shows up on
|
||||
// disk for the *current* job generation. Stale playlists left behind by a
|
||||
// failed RemoveAll / still-exiting ffmpeg must not unblock a mid-file restart.
|
||||
func (t *TranscoderService) WaitReady(ctx context.Context, mediaID string, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
ticker := time.NewTicker(250 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
t.mu.Lock()
|
||||
job, ok := t.jobs[mediaID]
|
||||
var started time.Time
|
||||
var completed bool
|
||||
if ok {
|
||||
started = job.startedAt
|
||||
completed = job.completed
|
||||
}
|
||||
t.mu.Unlock()
|
||||
if !ok {
|
||||
return false // A failed/exited job cannot become ready later.
|
||||
}
|
||||
if ok {
|
||||
if info, err := os.Stat(t.PlaylistPath(mediaID)); err == nil {
|
||||
// Allow a small clock skew; reject anything older than this job.
|
||||
if !info.ModTime().Before(started.Add(-2 * time.Second)) {
|
||||
t.mu.Lock()
|
||||
if j, exists := t.jobs[mediaID]; exists {
|
||||
if j, exists := t.jobs[mediaID]; exists && j == job {
|
||||
j.playlistOK = true
|
||||
t.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
t.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) || ctx.Err() != nil {
|
||||
return false
|
||||
if completed {
|
||||
return false // A completed job cannot regenerate a missing playlist.
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-time.After(250 * time.Millisecond):
|
||||
case <-timer.C:
|
||||
return false
|
||||
case <-ticker.C:
|
||||
case <-job.done:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +118,9 @@ func (t *TranscoderService) Active() []ActiveJob {
|
||||
defer t.mu.Unlock()
|
||||
out := make([]ActiveJob, 0, len(t.jobs))
|
||||
for _, j := range t.jobs {
|
||||
if j.completed {
|
||||
continue
|
||||
}
|
||||
out = append(out, ActiveJob{
|
||||
MediaID: j.mediaID,
|
||||
Encoder: j.encoder,
|
||||
@@ -111,7 +131,19 @@ func (t *TranscoderService) Active() []ActiveJob {
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *TranscoderService) runningJobCountLocked() int {
|
||||
n := 0
|
||||
for _, job := range t.jobs {
|
||||
if !job.completed {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (t *TranscoderService) maxConcurrent() int {
|
||||
config.RuntimeMu.RLock()
|
||||
defer config.RuntimeMu.RUnlock()
|
||||
if t.cfg.Transcoder.MaxConcurrent <= 0 {
|
||||
return 1
|
||||
}
|
||||
@@ -119,6 +151,8 @@ func (t *TranscoderService) maxConcurrent() int {
|
||||
}
|
||||
|
||||
func (t *TranscoderService) idleTimeout() time.Duration {
|
||||
config.RuntimeMu.RLock()
|
||||
defer config.RuntimeMu.RUnlock()
|
||||
if t.cfg.Transcoder.IdleTimeoutSeconds <= 0 {
|
||||
return 120 * time.Second
|
||||
}
|
||||
@@ -137,21 +171,38 @@ func (t *TranscoderService) monitorIdle(ctx context.Context, job *hlsJob) {
|
||||
case <-ticker.C:
|
||||
t.mu.Lock()
|
||||
current, ok := t.jobs[job.mediaID]
|
||||
if !ok {
|
||||
if !ok || current != job {
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
idleFor := time.Since(current.lastAccess)
|
||||
t.mu.Unlock()
|
||||
if idleFor >= timeout {
|
||||
t.log.Info("transcode idle timeout",
|
||||
zap.String("media_id", job.mediaID),
|
||||
zap.Duration("idle_for", idleFor),
|
||||
zap.Duration("timeout", timeout),
|
||||
)
|
||||
t.StopJob(job.mediaID)
|
||||
if idleFor >= timeout && t.stopIdleJob(job, timeout) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TranscoderService) stopIdleJob(job *hlsJob, timeout time.Duration) bool {
|
||||
gate := t.mediaStartGate(job.mediaID)
|
||||
gate.Lock()
|
||||
defer gate.Unlock()
|
||||
t.mu.Lock()
|
||||
current := t.jobs[job.mediaID]
|
||||
if current != job {
|
||||
t.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
idleFor := time.Since(job.lastAccess)
|
||||
if idleFor < timeout {
|
||||
t.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
t.detachJobLocked(job.mediaID)
|
||||
t.mu.Unlock()
|
||||
waitJobExit(job, 12*time.Second)
|
||||
t.log.Info("transcode idle timeout", zap.String("media_id", job.mediaID),
|
||||
zap.Duration("idle_for", idleFor), zap.Duration("timeout", timeout))
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestHLSCompletedTranscodeStaysReadyAndIsReused(t *testing.T) {
|
||||
bin, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
t.Skip("ffmpeg is required for the real HLS lifecycle test")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
source := filepath.Join(t.TempDir(), "short.mp4")
|
||||
cmd := exec.CommandContext(ctx, bin, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=size=64x64:rate=10", "-t", "1", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-threads", "1", source)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("generate source: %v: %s", err, output)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.App.FFmpegPath = bin
|
||||
cfg.Cache.CacheDir = t.TempDir()
|
||||
cfg.Transcoder.Enabled = true
|
||||
cfg.Transcoder.MaxConcurrent = 1
|
||||
cfg.Transcoder.MaxHeight = 64
|
||||
cfg.Transcoder.SegmentSeconds = 1
|
||||
cfg.Transcoder.Threads = 1
|
||||
db := newServiceTestDB(t)
|
||||
media := model.Media{Path: source, DurationSec: 1}
|
||||
if err := db.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewTranscoderService(cfg, zap.NewNop(), repository.New(db), NewHub(zap.NewNop()))
|
||||
t.Cleanup(svc.StopAll)
|
||||
playlist, err := svc.EnsureJob(ctx, media.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc.mu.Lock()
|
||||
job := svc.jobs[media.ID]
|
||||
svc.mu.Unlock()
|
||||
if job == nil {
|
||||
t.Fatal("transcode lost its job before the playlist could be read")
|
||||
}
|
||||
select {
|
||||
case <-job.done:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("short transcode did not finish")
|
||||
}
|
||||
data, err := os.ReadFile(playlist)
|
||||
if err != nil || !strings.Contains(string(data), "#EXT-X-ENDLIST") {
|
||||
t.Fatalf("transcode did not produce a complete playlist: err=%v, data=%s", err, data)
|
||||
}
|
||||
if !svc.WaitReady(ctx, media.ID, 100*time.Millisecond) {
|
||||
t.Fatal("completed HLS playlist is incorrectly reported as not ready")
|
||||
}
|
||||
if len(svc.Active()) != 0 {
|
||||
t.Fatal("completed transcode still occupies an active job slot")
|
||||
}
|
||||
if _, err := svc.EnsureJob(ctx, media.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc.mu.Lock()
|
||||
reused := svc.jobs[media.ID] == job
|
||||
svc.mu.Unlock()
|
||||
if !reused {
|
||||
t.Fatal("refreshing a completed playlist started a second transcode")
|
||||
}
|
||||
other := model.Media{Path: source + ".second", DurationSec: 1}
|
||||
if err := os.WriteFile(other.Path, mustReadTestFile(t, source), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&other).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.EnsureJob(ctx, other.ID); err != nil {
|
||||
t.Fatalf("completed cache must not consume MaxConcurrent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustReadTestFile(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestHLSWaitReadyReturnsImmediatelyForMissingJob(t *testing.T) {
|
||||
svc := NewTranscoderService(&config.Config{}, zap.NewNop(), nil, NewHub(zap.NewNop()))
|
||||
started := time.Now()
|
||||
if svc.WaitReady(t.Context(), "failed", time.Second) {
|
||||
t.Fatal("missing job must not be ready")
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed >= 500*time.Millisecond {
|
||||
t.Fatalf("missing/failed job waited for the readiness deadline: %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func countingFFmpeg(t *testing.T) string {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test ffmpeg uses a POSIX shell")
|
||||
}
|
||||
bin := filepath.Join(t.TempDir(), "ffmpeg")
|
||||
script := `#!/bin/sh
|
||||
printf '%s\n' "$*" >> "$0.calls"
|
||||
case " $* " in
|
||||
*" -encoders "*) printf ' V..... libx264\n V..... h264_nvenc\n' ;;
|
||||
*" -muxers "*) printf ' E hls\n' ;;
|
||||
esac
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return bin
|
||||
}
|
||||
|
||||
func TestFFmpegValidationIsCachedAndInvalidatedByEncoder(t *testing.T) {
|
||||
bin := countingFFmpeg(t)
|
||||
cfg := &config.Config{}
|
||||
cfg.App.FFmpegPath = bin
|
||||
svc := NewTranscoderService(cfg, zap.NewNop(), nil, NewHub(zap.NewNop()))
|
||||
for range 3 {
|
||||
if _, err := svc.resolveFFmpegPath(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
calls := strings.Count(string(mustReadTestFile(t, bin+".calls")), "\n")
|
||||
if calls != 2 {
|
||||
t.Fatalf("repeated resolution spawned ffmpeg %d times, want one encoder/muxer probe pair", calls)
|
||||
}
|
||||
ApplyRuntimeSetting(cfg, "transcoder.hardware_accel", "true")
|
||||
ApplyRuntimeSetting(cfg, "transcoder.encoder", "nvenc")
|
||||
if _, err := svc.resolveFFmpegPath(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls = strings.Count(string(mustReadTestFile(t, bin+".calls")), "\n")
|
||||
if calls != 4 {
|
||||
t.Fatalf("encoder change must revalidate capabilities, calls=%d", calls)
|
||||
}
|
||||
changed := time.Now().Add(time.Second)
|
||||
if err := os.Chtimes(bin, changed, changed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.resolveFFmpegPath(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls = strings.Count(string(mustReadTestFile(t, bin+".calls")), "\n")
|
||||
if calls != 6 {
|
||||
t.Fatalf("binary replacement must invalidate validation, calls=%d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHLSIdleCleanupPreservesReplacementAndRecentActivity(t *testing.T) {
|
||||
svc := NewTranscoderService(&config.Config{}, zap.NewNop(), nil, NewHub(zap.NewNop()))
|
||||
done := make(chan struct{})
|
||||
close(done)
|
||||
cancelled := false
|
||||
old := &hlsJob{mediaID: "m", lastAccess: time.Now().Add(-time.Hour)}
|
||||
current := &hlsJob{mediaID: "m", lastAccess: time.Now(), done: done, cancel: func() { cancelled = true }}
|
||||
svc.jobs["m"] = current
|
||||
if !svc.stopIdleJob(old, time.Minute) || cancelled || svc.jobs["m"] != current {
|
||||
t.Fatal("an old watchdog must leave the replacement generation intact")
|
||||
}
|
||||
if svc.stopIdleJob(current, time.Minute) || cancelled {
|
||||
t.Fatal("recent segment access must prevent idle cancellation")
|
||||
}
|
||||
current.lastAccess = time.Now().Add(-time.Hour)
|
||||
if !svc.stopIdleJob(current, time.Minute) || !cancelled || svc.jobs["m"] != nil {
|
||||
t.Fatal("an idle generation must release its resources")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHLSOriginalQualityValidatesSoftwareEncoder(t *testing.T) {
|
||||
bin := countingFFmpeg(t)
|
||||
softwareOnly := strings.ReplaceAll(string(mustReadTestFile(t, bin)), ` V..... h264_nvenc\n`, "")
|
||||
if err := os.WriteFile(bin, []byte(softwareOnly), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
t.Chdir(t.TempDir())
|
||||
cfg := &config.Config{}
|
||||
cfg.App.FFmpegPath = bin
|
||||
cfg.Transcoder.Enabled = true
|
||||
cfg.Transcoder.HardwareAccel = true
|
||||
cfg.Transcoder.Encoder = "nvenc"
|
||||
cfg.Cache.CacheDir = t.TempDir()
|
||||
db := newServiceTestDB(t)
|
||||
source := filepath.Join(t.TempDir(), "movie.mkv")
|
||||
if err := os.WriteFile(source, []byte("source"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media := model.Media{Path: source, DurationSec: 60}
|
||||
if err := db.Create(&media).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewTranscoderService(cfg, zap.NewNop(), repository.New(db), NewHub(zap.NewNop()))
|
||||
t.Cleanup(svc.StopAll)
|
||||
if _, err := svc.EnsureJobFromSubtitleQuality(t.Context(), media.ID, 0, 1, -1, "source"); err != nil {
|
||||
t.Fatalf("source quality uses software and must not require NVENC: %v", err)
|
||||
}
|
||||
svc.mu.Lock()
|
||||
job := svc.jobs[media.ID]
|
||||
svc.mu.Unlock()
|
||||
select {
|
||||
case <-job.done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("fake ffmpeg did not finish")
|
||||
}
|
||||
calls := string(mustReadTestFile(t, bin+".calls"))
|
||||
if strings.Count(calls, "\n") != 3 || !strings.Contains(calls, "-c:v libx264") {
|
||||
t.Fatalf("startup must probe once and encode with libx264, calls=%s", calls)
|
||||
}
|
||||
}
|
||||
+21
-2
@@ -2,7 +2,7 @@ import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { getActivePlayProfileId, getActivePlayProfilePinToken } from '../stores/playProfile'
|
||||
import type { RefreshOutcome } from '../utils/authRefresh'
|
||||
import { createTokenPreflight, type RefreshOutcome } from '../utils/authRefresh'
|
||||
|
||||
// Single axios instance used by every API helper. Adds the JWT to outgoing
|
||||
// requests and refreshes it on 401. Only an explicit server-side rejection of
|
||||
@@ -48,8 +48,27 @@ function isRefreshRequest(config?: InternalAxiosRequestConfig | null): boolean {
|
||||
return Boolean(config?.url?.includes('/auth/refresh'))
|
||||
}
|
||||
|
||||
// 请求发出前的令牌预检。本地持久化的 token 往往已经过期,而首屏有多个各自
|
||||
// 独立的 useEffect 会并发发请求;不预检的话它们会一起拿到 401,控制台刷出
|
||||
// 一整屏红字(现场:2026-10-16 12:49,7 个请求同时 401),用户还要等拦截器
|
||||
// 刷新完才看到内容。预检把刷新提到请求之前,且刷新协调器本身是单飞的。
|
||||
const preflightAccessToken = createTokenPreflight({
|
||||
readAccessToken: () => useAuthStore.getState().token,
|
||||
refreshSession: () => useAuthStore.getState().refreshSession(),
|
||||
})
|
||||
|
||||
// Add auth token to requests
|
||||
api.interceptors.request.use((config) => {
|
||||
api.interceptors.request.use(async (config) => {
|
||||
// 刷新请求自己绝不能走预检,否则会递归刷新。
|
||||
if (!isRefreshRequest(config)) {
|
||||
try {
|
||||
await preflightAccessToken()
|
||||
} catch {
|
||||
// 会话已被服务端判定失效:直接失败,不发无意义的 401 请求。
|
||||
// 清空会话与跳转登录页由刷新协调器和 401 拦截器负责,保持一致。
|
||||
throw new axios.Cancel('session expired')
|
||||
}
|
||||
}
|
||||
const token = useAuthStore.getState().token
|
||||
if (token) {
|
||||
config.headers = config.headers ?? {}
|
||||
|
||||
@@ -102,7 +102,10 @@ export const danmakuAPI = {
|
||||
api
|
||||
.get<DanmakuFetchResult>(`/danmaku/${encodeURIComponent(mediaId)}`, {
|
||||
params: { kw: options.kw || undefined, episodeId: options.episodeId || undefined },
|
||||
timeout: 20_000,
|
||||
// 服务端依次做「16MB 前缀哈希 → 上游匹配 → 评论库」三段串行请求,每段的上游
|
||||
// 超时都是 20 秒。客户端要是也卡在 20 秒,就会在服务端正要降级返回(哈希超时
|
||||
// 后改按文件名搜索)时把请求撤掉:用户看不到弹幕,服务端还记一条 404。
|
||||
timeout: 45_000,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
|
||||
@@ -42,9 +42,9 @@ function publicOriginHeader() {
|
||||
}
|
||||
|
||||
export const playbackAPI = {
|
||||
getResume: (mediaId: string) =>
|
||||
getResume: (mediaId: string, signal?: AbortSignal) =>
|
||||
api
|
||||
.get<{ position_ms: number; duration_ms: number; completed: boolean }>(`/playback/${mediaId}/resume`)
|
||||
.get<{ position_ms: number; duration_ms: number; completed: boolean }>(`/playback/${encodeURIComponent(mediaId)}/resume`, { signal })
|
||||
.then((r) => r.data),
|
||||
|
||||
// 片头/片尾片段:播放开始后再调用,服务端可能需要几秒去外部数据库取数,
|
||||
|
||||
@@ -6,6 +6,7 @@ const HomePageLoader = () => import('./pages/HomePage').then((m) => ({ default:
|
||||
const LibraryPageLoader = () => import('./pages/LibraryPage').then((m) => ({ default: m.LibraryPage }))
|
||||
const LibrariesPageLoader = () => import('./pages/LibrariesPage').then((m) => ({ default: m.LibrariesPage }))
|
||||
const FavouritesPageLoader = () => import('./pages/FavouritesPage').then((m) => ({ default: m.FavouritesPage }))
|
||||
const MediaSearchPageLoader = () => import('./pages/MediaSearchPage').then((m) => ({ default: m.MediaSearchPage }))
|
||||
const PlaylistsPageLoader = () => import('./pages/PlaylistsPage').then((m) => ({ default: m.PlaylistsPage }))
|
||||
const PlaylistDetailPageLoader = () =>
|
||||
import('./pages/PlaylistDetailPage').then((m) => ({ default: m.PlaylistDetailPage }))
|
||||
@@ -21,6 +22,7 @@ const HomePage = lazy(HomePageLoader)
|
||||
const LibraryPage = lazy(LibraryPageLoader)
|
||||
const LibrariesPage = lazy(LibrariesPageLoader)
|
||||
const FavouritesPage = lazy(FavouritesPageLoader)
|
||||
const MediaSearchPage = lazy(MediaSearchPageLoader)
|
||||
const PlaylistsPage = lazy(PlaylistsPageLoader)
|
||||
const PlaylistDetailPage = lazy(PlaylistDetailPageLoader)
|
||||
const MediaDetailPage = lazy(MediaDetailPageLoader)
|
||||
@@ -73,6 +75,7 @@ export function prefetchCommonRouteChunks() {
|
||||
LibrariesPageLoader,
|
||||
LibraryPageLoader,
|
||||
MediaDetailPageLoader,
|
||||
MediaSearchPageLoader,
|
||||
FavouritesPageLoader,
|
||||
WatchHistoryPageLoader,
|
||||
PlaylistsPageLoader,
|
||||
@@ -98,6 +101,7 @@ export type AppRoute = {
|
||||
|
||||
export const appRoutes: AppRoute[] = [
|
||||
{ index: true, element: <HomePage /> },
|
||||
{ path: 'search', element: <MediaSearchPage /> },
|
||||
{ path: 'libraries', element: <LibrariesPage /> },
|
||||
{ path: 'library/:id', element: <LibraryPage /> },
|
||||
{ path: 'favourites', element: <FavouritesPage /> },
|
||||
|
||||
@@ -25,6 +25,7 @@ function isMediaView(pathname: string, search: string): boolean {
|
||||
|
||||
return (
|
||||
pathname === '/' ||
|
||||
pathname === '/search' ||
|
||||
pathname === '/libraries' ||
|
||||
pathname.startsWith('/library') ||
|
||||
pathname.startsWith('/media') ||
|
||||
|
||||
@@ -7,7 +7,9 @@ import { ARTWORK, imageURL } from '../api/client'
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { readerAPI, type ReaderBook } from '../api/reader'
|
||||
import type { Media, PlayProfile, User } from '../types'
|
||||
import { favouriteMediaLink } from '../utils/mediaNavigation'
|
||||
import { favouriteMediaLink, mediaSearchLink, mediaSearchReturnPath } from '../utils/mediaNavigation'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePlayProfileStore } from '../stores/playProfile'
|
||||
import { resolveHeaderBack } from './layoutNavigation'
|
||||
import { LayoutThemeToggle } from './LayoutThemeToggle'
|
||||
import { LayoutUserMenu } from './LayoutUserMenu'
|
||||
@@ -56,7 +58,11 @@ export function LayoutHeader({
|
||||
pathname = '',
|
||||
}: LayoutHeaderProps) {
|
||||
const navigate = useNavigate()
|
||||
const headerBack = resolveHeaderBack(pathname)
|
||||
const location = useLocation()
|
||||
const searchFrom = mediaSearchReturnPath(location.state)
|
||||
const headerBack = searchFrom && (pathname.startsWith('/library/') || pathname.startsWith('/media/'))
|
||||
? { to: searchFrom, label: '搜索结果' }
|
||||
: resolveHeaderBack(pathname)
|
||||
|
||||
return (
|
||||
<header className="relative z-30 flex h-16 shrink-0 items-center justify-between gap-2 border-b border-[var(--app-border)] bg-[var(--app-header-bg)] px-3 backdrop-blur-md sm:h-20 sm:gap-4 sm:px-4 md:px-8">
|
||||
@@ -295,7 +301,12 @@ function LayoutHeaderBookSearch() {
|
||||
const SEARCH_PAGE_SIZE = 8
|
||||
|
||||
function LayoutHeaderSearch() {
|
||||
const [query, setQuery] = useState('')
|
||||
const location = useLocation()
|
||||
const userId = useAuthStore((s) => s.user?.id)
|
||||
const profileId = usePlayProfileStore((s) => s.activeProfileId)
|
||||
const [query, setQuery] = useState(() =>
|
||||
location.pathname === '/search' ? new URLSearchParams(location.search).get('q') ?? '' : '',
|
||||
)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
@@ -312,7 +323,6 @@ function LayoutHeaderSearch() {
|
||||
const resultsRef = useRef<Media[]>([])
|
||||
const hasMoreRef = useRef(false)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const locationKey = `${location.pathname}${location.search}`
|
||||
const prevLocationKeyRef = useRef(locationKey)
|
||||
|
||||
@@ -392,14 +402,21 @@ function LayoutHeaderSearch() {
|
||||
}
|
||||
}, 250)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [query])
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
// 卸载或关键词变化时,已发出的旧请求也不能覆盖当前结果。
|
||||
searchSeqRef.current += 1
|
||||
}
|
||||
}, [query, userId, profileId])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (prevLocationKeyRef.current === locationKey) return
|
||||
prevLocationKeyRef.current = locationKey
|
||||
dismissSearchRef.current(true)
|
||||
}, [locationKey])
|
||||
dismissSearchRef.current(false)
|
||||
if (location.pathname === '/search') {
|
||||
setQuery(new URLSearchParams(location.search).get('q') ?? '')
|
||||
}
|
||||
}, [locationKey, location.pathname, location.search])
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
@@ -446,28 +463,34 @@ function LayoutHeaderSearch() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (item: Media) => {
|
||||
const openSearchPage = () => {
|
||||
if (suppressInputRef.current) return
|
||||
const to = favouriteMediaLink(item)
|
||||
dismissSearch(true)
|
||||
navigate(to)
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return
|
||||
dismissSearch(false)
|
||||
navigate(mediaSearchLink(trimmed))
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
dismissSearch(false)
|
||||
} else if (e.key === 'Enter' && results.length > 0) {
|
||||
handleSelect(results[0])
|
||||
} else if (e.key === 'Enter' && !e.nativeEvent.isComposing && e.keyCode !== 229) {
|
||||
e.preventDefault()
|
||||
openSearchPage()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full">
|
||||
<div className="relative flex items-center">
|
||||
<Search
|
||||
size={15}
|
||||
className="absolute left-3 text-[var(--app-muted)] pointer-events-none transition-colors group-focus-within:text-brand-500 sm:left-3.5 sm:text-[16px]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openSearchPage}
|
||||
aria-label="搜索媒体"
|
||||
className="absolute left-3 rounded text-[var(--app-muted)] transition-colors hover:text-brand-500 sm:left-3.5"
|
||||
>
|
||||
<Search size={15} />
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
@@ -488,6 +511,7 @@ function LayoutHeaderSearch() {
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="搜索媒体…"
|
||||
aria-label="搜索媒体关键词"
|
||||
className="w-full h-9 sm:h-10 pl-8 sm:pl-10 pr-8 sm:pr-9 rounded-xl sm:rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] text-xs sm:text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] shadow-sm outline-none transition-all duration-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:bg-[var(--app-panel-elevated)]"
|
||||
/>
|
||||
{loading ? (
|
||||
@@ -498,6 +522,7 @@ function LayoutHeaderSearch() {
|
||||
onClick={() => {
|
||||
dismissSearch(true)
|
||||
}}
|
||||
aria-label="清空媒体搜索"
|
||||
className="absolute right-3 text-[var(--app-muted)] hover:text-[var(--app-text)] p-0.5 rounded-lg"
|
||||
>
|
||||
<X size={15} />
|
||||
@@ -538,15 +563,11 @@ function LayoutHeaderSearch() {
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{results.map((item) => (
|
||||
<button
|
||||
<Link
|
||||
key={item.id}
|
||||
type="button"
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.preventDefault()
|
||||
handleSelect(item)
|
||||
}}
|
||||
onClick={() => handleSelect(item)}
|
||||
to={favouriteMediaLink(item)}
|
||||
state={{ from: locationKey }}
|
||||
onClick={() => dismissSearch(false)}
|
||||
className="flex w-full items-center gap-3 rounded-xl p-2 text-left transition-colors hover:bg-[var(--app-hover)] group"
|
||||
>
|
||||
<div className="relative h-12 w-9 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
|
||||
@@ -590,7 +611,7 @@ function LayoutHeaderSearch() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</Link>
|
||||
))}
|
||||
{loadingMore && (
|
||||
<div className="flex items-center justify-center gap-2 py-3 text-[10px] text-[var(--app-muted)]">
|
||||
@@ -605,6 +626,15 @@ function LayoutHeaderSearch() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
to={mediaSearchLink(query)}
|
||||
onClick={() => dismissSearch(false)}
|
||||
className="sticky -bottom-2 flex items-center justify-center gap-2 border-t border-[var(--app-border)] bg-[var(--app-panel)] px-3 py-3 text-xs font-bold text-brand-500 hover:bg-[var(--app-hover)]"
|
||||
>
|
||||
<Search size={14} />
|
||||
查看全部搜索结果
|
||||
<span className="hidden text-[10px] font-normal text-[var(--app-muted)] sm:inline">按回车搜索</span>
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ const ACTION_OVERLAY_CLASS =
|
||||
// memo:父级状态变化(如轮播切图、其它卡片操作)不再级联重渲染所有卡片。
|
||||
// 注意 actions/renderActions 必须引用稳定(用 renderActions 传函数)memo 才生效。
|
||||
export const MediaCard = memo(function MediaCard({
|
||||
media, progress, count, rating, linkTo, linkState, onClick, actions, renderActions, compact,
|
||||
media, progress, count, rating, linkTo, linkState, onClick, actions, renderActions, compact, showLibrary,
|
||||
}: {
|
||||
media: Media
|
||||
progress?: number
|
||||
@@ -22,6 +22,7 @@ export const MediaCard = memo(function MediaCard({
|
||||
actions?: ReactNode
|
||||
renderActions?: (media: Media) => ReactNode
|
||||
compact?: boolean
|
||||
showLibrary?: boolean
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const href = linkTo ?? `/media/${media.id}`
|
||||
@@ -31,6 +32,7 @@ export const MediaCard = memo(function MediaCard({
|
||||
const blurredPosterSrc = imageURL(media.poster_url, media.updated_at, ARTWORK.posterTiny)
|
||||
const displayRating = rating ?? media.rating
|
||||
const versionCount = media.versions?.length ?? 0
|
||||
const libraryName = media.display_library_name || media.library_name
|
||||
// renderActions 延迟到卡片自身渲染时才调用,保证 memo 生效
|
||||
const actionContent = actions ?? renderActions?.(media)
|
||||
|
||||
@@ -131,6 +133,11 @@ export const MediaCard = memo(function MediaCard({
|
||||
}`}>
|
||||
{media.title}
|
||||
</p>
|
||||
{showLibrary && libraryName && (
|
||||
<p title={libraryName} className="truncate text-[10px] text-[var(--app-muted)] sm:text-xs">
|
||||
{libraryName}
|
||||
</p>
|
||||
)}
|
||||
{/* 单行、不折行:年份可截断,徽标永不压缩。折行会让同一行里各卡片的
|
||||
徽标落位高低不一,看起来就是「错位」,所以宁可极窄屏上截断年份。
|
||||
min-h 统一行高,避免有/无徽标的卡片年份基线差几像素。 */}
|
||||
|
||||
@@ -74,7 +74,7 @@ export const MOBILE_BOTTOM_NAV_ITEMS: MobileBottomNavItem[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const ROOT_MEDIA_PATHS = new Set(['/', '/libraries', '/favourites', '/playlists', '/history'])
|
||||
const ROOT_MEDIA_PATHS = new Set(['/', '/search', '/libraries', '/favourites', '/playlists', '/history'])
|
||||
|
||||
/** True only for the fullscreen player route (`/play` or `/play/:id`), not `/playlists`. */
|
||||
export function isPlayerRoute(pathname: string): boolean {
|
||||
@@ -131,7 +131,7 @@ export function resolveHeaderBack(pathname: string): HeaderBackTarget | null {
|
||||
if (pathname === '/history/stats') {
|
||||
return { to: '/history', label: '观看历史' }
|
||||
}
|
||||
if (pathname === '/playlists' || pathname === '/favourites' || pathname === '/history') {
|
||||
if (pathname === '/search' || pathname === '/playlists' || pathname === '/favourites' || pathname === '/history') {
|
||||
return { to: '/', label: '首页' }
|
||||
}
|
||||
if (pathname.startsWith('/playlist/')) {
|
||||
@@ -171,6 +171,7 @@ export function shouldShowMobileBottomNav(pathname: string): boolean {
|
||||
if (isPlayerRoute(pathname)) return false
|
||||
return (
|
||||
pathname === '/' ||
|
||||
pathname === '/search' ||
|
||||
pathname === '/libraries' ||
|
||||
pathname.startsWith('/library') ||
|
||||
pathname.startsWith('/media') ||
|
||||
|
||||
@@ -11,6 +11,7 @@ function check(name: string, condition: boolean) {
|
||||
}
|
||||
|
||||
check('home remembers scroll', shouldRememberScroll('/'))
|
||||
check('media search remembers scroll', shouldRememberScroll('/search'))
|
||||
check('library list remembers scroll', shouldRememberScroll('/libraries'))
|
||||
check('library detail remembers scroll', shouldRememberScroll('/library/lib-1'))
|
||||
check('library series panel remembers scroll', shouldRememberScroll('/library/lib-1'))
|
||||
|
||||
@@ -63,6 +63,7 @@ function writeScrollPosition(key: string, value: number): void {
|
||||
export function shouldRememberScroll(pathname: string): boolean {
|
||||
return (
|
||||
pathname === '/' ||
|
||||
pathname === '/search' ||
|
||||
pathname === '/libraries' ||
|
||||
pathname.startsWith('/library/') ||
|
||||
pathname.startsWith('/media/')
|
||||
|
||||
@@ -1,38 +1,13 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { isAccessTokenStale } from '../utils/authRefresh'
|
||||
|
||||
// 前 5 次沿用原快速退避间隔;之后进入 60s 慢速重试并不再放弃,
|
||||
// 服务重启或网络恢复后仍能自动重连(清理函数可随时取消定时器)。
|
||||
const FAST_RECONNECT_ATTEMPTS = 5
|
||||
const SLOW_RECONNECT_INTERVAL = 60_000
|
||||
|
||||
// token 过期前 30 秒即视为失效,给刷新留出余量。
|
||||
const TOKEN_EXPIRY_SKEW_MS = 30_000
|
||||
|
||||
// decodeJwtExpiryMs 解析 JWT 的 exp 声明并换算成毫秒时间戳。
|
||||
// 解析失败(非 JWT / 结构异常)返回 null,调用方据此放行重连。
|
||||
function decodeJwtExpiryMs(token: string): number | null {
|
||||
const segment = token.split('.')[1]
|
||||
if (!segment) return null
|
||||
try {
|
||||
// JWT 使用 base64url 编码,需先还原字符集并补齐 padding。
|
||||
const normalized = segment.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
const claims = JSON.parse(atob(padded)) as { exp?: unknown }
|
||||
return typeof claims.exp === 'number' ? claims.exp * 1000 : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// isTokenExpired 判断 token 是否已过期或即将过期。
|
||||
function isTokenExpired(token: string): boolean {
|
||||
const expiry = decodeJwtExpiryMs(token)
|
||||
if (expiry === null) return false
|
||||
return Date.now() >= expiry - TOKEN_EXPIRY_SKEW_MS
|
||||
}
|
||||
|
||||
// useWebSocket opens a single connection to /api/ws and dispatches every
|
||||
// message to the supplied handler. Auto-reconnects with back-off while the
|
||||
// auth token is present; after the fast retries are exhausted it keeps a
|
||||
@@ -68,7 +43,7 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
|
||||
const open = () => {
|
||||
if (closed) return
|
||||
if (isTokenExpired(token)) {
|
||||
if (isAccessTokenStale(token)) {
|
||||
refreshExpiredToken()
|
||||
return
|
||||
}
|
||||
@@ -92,7 +67,7 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
ws.onclose = () => {
|
||||
if (closed) return
|
||||
// token 过期时不要继续退避重试,交给刷新流程处理。
|
||||
if (isTokenExpired(token)) {
|
||||
if (isAccessTokenStale(token)) {
|
||||
refreshExpiredToken()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -32,12 +32,14 @@ import { useLibrarySeriesSelection } from './useLibrarySeriesSelection'
|
||||
import { useLibraryAdminActions } from './useLibraryAdminActions'
|
||||
import { usePermission } from '../hooks/usePermission'
|
||||
import { useFavourites } from '../hooks/useFavourites'
|
||||
import { mediaSearchReturnPath } from '../utils/mediaNavigation'
|
||||
|
||||
export function LibraryPage() {
|
||||
const { id = '' } = useParams()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const searchFrom = mediaSearchReturnPath(location.state)
|
||||
const role = useAuthStore((s) => s.user?.role)
|
||||
const canFavorite = usePermission('can_favorite')
|
||||
const { isFavourite, toggleFavourite } = useFavourites()
|
||||
@@ -358,7 +360,7 @@ export function LibraryPage() {
|
||||
void handleToggleFavourite(selectedSeries.rep.id)
|
||||
}}
|
||||
seriesToolBusy={seriesToolBusy}
|
||||
onBack={clearSelectedSeries}
|
||||
onBack={() => searchFrom ? navigate(searchFrom) : clearSelectedSeries()}
|
||||
onSmartScrape={handleSeriesSmartScrape}
|
||||
onManualScrape={() => setManualSeriesScrapeOpen(true)}
|
||||
onMetadataEdit={() => setSeriesMetadataEditOpen(true)}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ChevronLeft, ChevronRight, LoaderCircle, RefreshCw, Search } from 'lucide-react'
|
||||
|
||||
import { mediaAPI, type MediaSearchPage as SearchPageData } from '../api/library'
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
import { PageBackButton } from '../components/PageBackButton'
|
||||
import { normalizeListPosition } from '../hooks/useListPositionMemory'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePlayProfileStore } from '../stores/playProfile'
|
||||
import { favouriteMediaLink, mediaSearchLink } from '../utils/mediaNavigation'
|
||||
|
||||
const PAGE_SIZE = 48
|
||||
const CACHE_TTL_MS = 5 * 60_000
|
||||
// 返回详情前的结果可立即显示;按账号、播放档案、关键词和页码隔离。
|
||||
const pageCache = new Map<string, { at: number; data: SearchPageData }>()
|
||||
|
||||
function readCachedPage(key: string): SearchPageData | null {
|
||||
const cached = pageCache.get(key)
|
||||
if (!cached) return null
|
||||
if (Date.now() - cached.at >= CACHE_TTL_MS) {
|
||||
pageCache.delete(key)
|
||||
return null
|
||||
}
|
||||
return cached.data
|
||||
}
|
||||
|
||||
export function MediaSearchPage() {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const query = (params.get('q') ?? '').trim()
|
||||
const page = normalizeListPosition(params.get('page'))
|
||||
const userId = useAuthStore((s) => s.user?.id)
|
||||
const profileId = usePlayProfileStore((s) => s.activeProfileId)
|
||||
const cacheKey = JSON.stringify([userId, profileId, query, page])
|
||||
|
||||
const changePage = (next: number) => {
|
||||
const nextParams = new URLSearchParams({ q: query })
|
||||
if (next > 1) nextParams.set('page', String(next))
|
||||
setParams(nextParams)
|
||||
}
|
||||
|
||||
return (
|
||||
<MediaSearchResults
|
||||
key={cacheKey}
|
||||
cacheKey={cacheKey}
|
||||
query={query}
|
||||
page={page}
|
||||
onPageChange={changePage}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MediaSearchResults({ cacheKey, query, page, onPageChange }: {
|
||||
cacheKey: string
|
||||
query: string
|
||||
page: number
|
||||
onPageChange: (page: number) => void
|
||||
}) {
|
||||
const [data, setData] = useState(() => readCachedPage(cacheKey))
|
||||
const [loading, setLoading] = useState(() => !!query && !readCachedPage(cacheKey))
|
||||
const [error, setError] = useState('')
|
||||
const [revision, setRevision] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!query) return
|
||||
const cached = readCachedPage(cacheKey)
|
||||
if (cached) {
|
||||
setData(cached)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError('')
|
||||
mediaAPI.searchPage(query, page, PAGE_SIZE, { groupSeries: true })
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
// 限制缓存大小,避免长时间搜索大量关键词后占用过多内存。
|
||||
if (pageCache.size >= 20) pageCache.delete(pageCache.keys().next().value!)
|
||||
pageCache.set(cacheKey, { at: Date.now(), data: result })
|
||||
setData(result)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError('搜索失败,请稍后重试')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [cacheKey, query, page, revision])
|
||||
|
||||
const refresh = () => {
|
||||
pageCache.delete(cacheKey)
|
||||
setRevision((value) => value + 1)
|
||||
}
|
||||
|
||||
const items = data?.items ?? []
|
||||
const total = data?.total ?? items.length
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
const from = mediaSearchLink(query, page)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageBackButton to="/" label="返回首页" compact />
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<h1 className="font-display text-2xl font-bold text-[var(--app-text)] sm:text-3xl">搜索结果</h1>
|
||||
<p className="break-words text-sm text-[var(--app-muted)]" aria-live="polite">
|
||||
{query
|
||||
? <>“{query}”{data && !loading ? ` · 共 ${total} 条结果` : ''}</>
|
||||
: '在顶部搜索框输入媒体名称,按回车开始搜索'}
|
||||
</p>
|
||||
</div>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-3 py-2 text-xs text-[var(--app-subtle)] hover:bg-[var(--app-hover)] disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
刷新结果
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div role="status" className="flex items-center justify-center gap-2 py-20 text-sm text-[var(--app-muted)]">
|
||||
<LoaderCircle size={18} className="animate-spin text-brand-500" />
|
||||
正在搜索…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div role="alert" className="rounded-2xl border border-red-400/30 bg-[var(--app-panel)] p-5 text-sm text-red-400">
|
||||
{error}
|
||||
<button type="button" onClick={refresh} className="ml-3 underline">重试</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && query && items.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] px-6 py-16 text-center text-[var(--app-muted)]">
|
||||
<Search size={28} />
|
||||
<p className="break-all text-sm">未搜索到与 “{query}” 相关的媒体内容</p>
|
||||
<p className="text-xs">试试其他名称或更简短的关键词</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && items.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{items.map((item) => (
|
||||
<MediaCard key={item.id} media={item} showLibrary linkTo={favouriteMediaLink(item)} linkState={{ from }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && query && (totalPages > 1 || page > 1) && (
|
||||
<nav aria-label="搜索结果分页" className="flex items-center justify-center gap-4 py-4 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-3 py-2 hover:bg-[var(--app-hover)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft size={16} />上一页
|
||||
</button>
|
||||
<span className="text-[var(--app-muted)]">第 {page} / {totalPages} 页</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-3 py-2 hover:bg-[var(--app-hover)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
下一页<ChevronRight size={16} />
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+132
-73
@@ -37,8 +37,10 @@ import {
|
||||
type SubtitleStylePreset,
|
||||
} from '../utils/subtitleDisplay'
|
||||
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, isStrmMedia, type PlayerMode } from './playerPageModel'
|
||||
import { classifyDirectPlayError, DIRECT_SEEK_GRACE_MS } from './directPlayError'
|
||||
import { classifyDirectPlayError, directPlayFailureCopy, directStallTimeoutMs, DIRECT_SEEK_GRACE_MS } from './directPlayError'
|
||||
import type { DirectPlayFailureReason } from './directPlayError'
|
||||
import { canSeekDirectNow, DIRECT_SEEK_WAIT_MS } from './directPlaySeek'
|
||||
import { applyPendingHlsSeek, attachNativeHls, bindPendingHlsSeek, hlsManifestTimeoutMs, playbackPositionSec } from './hlsPlayback'
|
||||
import { apiErrorMessage } from './StrmManagePage'
|
||||
import { PlayerTopBar } from './PlayerTopBar'
|
||||
import { PlayerVideoStage } from './PlayerVideoStage'
|
||||
@@ -200,6 +202,13 @@ export function PlayerPage() {
|
||||
// 必须在切换前先记下来,等直连 loadedmetadata 后再跳。
|
||||
const pendingDirectStartRef = useRef(0)
|
||||
const hlsSourceRef = useRef<'cloud' | 'local'>('local')
|
||||
// 起播看门狗要等到 20 秒后才判定,判定时用 ref 读最新的「直连解码」开关,避免
|
||||
// 把回调塞进播放 effect 的依赖里反复重建。
|
||||
const directOnlyRef = useRef(false)
|
||||
// 当前挂在画面上的「播放失败」横幅文案。这类提示描述的是「刚才那一下没起来」,
|
||||
// 画面真正播起来之后必须撤掉:上游冷启动慢 10 秒就会先误报一次,而播放随后
|
||||
// 是正常的,留在画面上就成了「编码不兼容」的假结论。
|
||||
const transientPlayErrorRef = useRef('')
|
||||
|
||||
// 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。
|
||||
const [danmakuOpen, setDanmakuOpen] = useState(false)
|
||||
@@ -519,6 +528,7 @@ export function PlayerPage() {
|
||||
directRetryRef.current = false
|
||||
retryingDirectRef.current = false
|
||||
directSeekGraceUntilRef.current = 0
|
||||
transientPlayErrorRef.current = ''
|
||||
pendingDirectSeekRef.current = null
|
||||
directSeekWaitCleanupRef.current?.()
|
||||
directSeekWaitCleanupRef.current = null
|
||||
@@ -553,6 +563,24 @@ export function PlayerPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 挂一条「播放起不来」的横幅。走这个入口的提示都会记下来,一旦画面真的开始
|
||||
* 播放(playing)就被撤掉;切媒体、切线路时也会清空。信息类提示(例如「可能
|
||||
* 只有画面没有声音」)不走这里,它们播起来之后依然成立。
|
||||
*/
|
||||
const showPlayFailure = useCallback((message: string) => {
|
||||
transientPlayErrorRef.current = message
|
||||
setPlayerError(message)
|
||||
}, [])
|
||||
|
||||
const clearPlayFailure = useCallback(() => {
|
||||
const stale = transientPlayErrorRef.current
|
||||
if (!stale) return
|
||||
transientPlayErrorRef.current = ''
|
||||
// 只撤掉自己挂的那条:期间可能已经换了别的提示。
|
||||
setPlayerError((prev) => (prev === stale ? '' : prev))
|
||||
}, [])
|
||||
|
||||
const clearDirectSeekWait = useCallback(() => {
|
||||
directSeekWaitCleanupRef.current?.()
|
||||
directSeekWaitCleanupRef.current = null
|
||||
@@ -742,6 +770,7 @@ export function PlayerPage() {
|
||||
if (cancelled) return
|
||||
setMedia(m)
|
||||
setPlayerError('')
|
||||
transientPlayErrorRef.current = ''
|
||||
setLoadError('')
|
||||
const isDirect = isDirectStreamMedia(m)
|
||||
const auto = pickPlayerMode(m)
|
||||
@@ -875,6 +904,13 @@ export function PlayerPage() {
|
||||
// 加载统一播放能力:115 云端清晰度 + 本地 HLS 清晰度。
|
||||
useEffect(() => {
|
||||
if (!mediaId) return
|
||||
// 远程 Emby 条目只存在于远程库里,本地 media 表没有它的伪 ID,问这个接口只会
|
||||
// 拿到 404(控制台里就是一条红字);而它的播放方式恒为「远程直连、不转码」,
|
||||
// 也没有任何可切换的档位。播放地址由 /api/stream/:id 负责解析。
|
||||
if (isRemoteEmbyID(mediaId)) {
|
||||
setPlaybackInfo(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
mediaAPI
|
||||
.playbackInfo(mediaId)
|
||||
@@ -957,14 +993,15 @@ export function PlayerPage() {
|
||||
}, [])
|
||||
|
||||
const switchToLocalHLS = useCallback(
|
||||
(position = 0) => {
|
||||
(position?: number) => {
|
||||
pendingSeekRef.current = null
|
||||
setHlsSource('local')
|
||||
setCloudWaiting(false)
|
||||
setSelectedQuality((prev) => {
|
||||
if (playbackInfo?.local_qualities?.some((quality) => quality.id === prev)) return prev
|
||||
return defaultLocalQualityId(playbackInfo)
|
||||
})
|
||||
if (position > 2) setHlsStartSec(position)
|
||||
if (position !== undefined) setHlsStartSec(Math.max(0, position))
|
||||
setPlaybackMode('hls')
|
||||
},
|
||||
[playbackInfo, setPlaybackMode],
|
||||
@@ -1041,10 +1078,10 @@ export function PlayerPage() {
|
||||
: undefined
|
||||
if (preferred?.source === 'cloud') {
|
||||
setHlsSource('cloud')
|
||||
// 云 HLS 是整段时间轴,等待云转码期间也要保留目标位置。
|
||||
pendingSeekRef.current = position > 2 ? position : null
|
||||
if (preferred.available) {
|
||||
setCloudWaiting(false)
|
||||
// 云 HLS 是整段时间轴:切过去后按当前位置跳一下,别让用户从头看。
|
||||
pendingSeekRef.current = position > 2 ? position : null
|
||||
} else {
|
||||
setCloudWaiting(true)
|
||||
setCloudWaitMessage(preferred.note || `正在等待 115 转码 ${preferred.label}…`)
|
||||
@@ -1109,8 +1146,9 @@ export function PlayerPage() {
|
||||
if (!playbackInfo || !initialHlsRequestRef.current) return
|
||||
if (directOnly || !mediaId) return
|
||||
initialHlsRequestRef.current = false
|
||||
enterHlsPlayback(0, playbackInfo)
|
||||
}, [directOnly, enterHlsPlayback, mediaId, playbackInfo])
|
||||
const position = Math.max(resumePosition, playbackPositionSec(mode, hlsSource, hlsStartSec, ref.current?.currentTime || 0))
|
||||
enterHlsPlayback(position, playbackInfo)
|
||||
}, [directOnly, enterHlsPlayback, hlsSource, hlsStartSec, mediaId, mode, playbackInfo, resumePosition])
|
||||
|
||||
// 直连播放只发现外挂字幕;只有 HLS 模式需要探测可烧录的内嵌字幕。
|
||||
useEffect(() => {
|
||||
@@ -1145,24 +1183,30 @@ export function PlayerPage() {
|
||||
const mediaRef = useRef(media)
|
||||
useEffect(() => {
|
||||
mediaRef.current = media
|
||||
}, [media])
|
||||
directOnlyRef.current = directOnly
|
||||
}, [directOnly, media])
|
||||
|
||||
/**
|
||||
* 直连失败后的统一回退:按当前源与设置决定切 115 云转码 / 本地转码 / 明确报错。
|
||||
* error 分支与起播看门狗(armDirectStallWatchdog)共用同一套判定。
|
||||
*
|
||||
* reason:'stall' = 看门狗等到期还没数据;'error' = 媒体元素报了 error(重试过
|
||||
* 一次仍然失败)。两者文案必须分开——超时的真实原因多半是上游慢,不是编码不兼容。
|
||||
*/
|
||||
const fallbackDirectPlay = useCallback(() => {
|
||||
const fallbackDirectPlay = useCallback((reason: DirectPlayFailureReason = 'error') => {
|
||||
if (modeRef.current !== 'direct') return
|
||||
// 宽限期内又触发的迟到 fallback 定时器:直接丢掉。
|
||||
if (Date.now() < directSeekGraceUntilRef.current) return
|
||||
const current = ref.current
|
||||
if (current && current.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return
|
||||
if (isRemoteEmbyID(mediaRef.current?.id) || isDirectStreamMedia(mediaRef.current)) {
|
||||
setPlayerError('直接播放失败。该媒体为远程 Emby 挂载直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器(如 PotPlayer / VLC / IINA)播放。')
|
||||
toast.error('直接播放失败,建议使用外部播放器')
|
||||
const copy = directPlayFailureCopy('remote-emby', reason)
|
||||
showPlayFailure(copy.banner)
|
||||
toast.error(copy.toast)
|
||||
} else if (directOnly) {
|
||||
setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。')
|
||||
toast.error('直接播放失败(客户端直连解码模式)')
|
||||
const copy = directPlayFailureCopy('direct-only', reason)
|
||||
showPlayFailure(copy.banner)
|
||||
toast.error(copy.toast)
|
||||
} else if (playbackInfo?.provider === 'cloud115') {
|
||||
const preferred =
|
||||
findPlaybackQualityById(playbackInfo, selectedQuality) ??
|
||||
@@ -1180,7 +1224,7 @@ export function PlayerPage() {
|
||||
switchToLocalHLS(current?.currentTime || 0)
|
||||
}
|
||||
} else if (hlsUnavailable) {
|
||||
setPlayerError('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。')
|
||||
showPlayFailure('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。')
|
||||
toast.error('直接播放失败,HLS 转码不可用')
|
||||
} else {
|
||||
toast.error('直接播放失败,切换到 HLS 转码')
|
||||
@@ -1192,23 +1236,25 @@ export function PlayerPage() {
|
||||
playbackInfo,
|
||||
selectedQuality,
|
||||
setPlaybackMode,
|
||||
showPlayFailure,
|
||||
startCloudTranscode,
|
||||
switchToLocalHLS,
|
||||
])
|
||||
|
||||
/**
|
||||
* 安排一次直连回退判定。STRM/115 直链冷启动比本地文件慢,给足缓冲时间再判失败。
|
||||
* 只由 handleVideoError(媒体元素真的报了 error)触发,所以原因按 'error' 记。
|
||||
*/
|
||||
const scheduleDirectFallback = useCallback(() => {
|
||||
clearFallbackTimer()
|
||||
const fallbackDelayMs = isStrmMedia(mediaRef.current) ? 4000 : 1500
|
||||
fallbackTimerRef.current = setTimeout(fallbackDirectPlay, fallbackDelayMs)
|
||||
fallbackTimerRef.current = setTimeout(() => fallbackDirectPlay('error'), fallbackDelayMs)
|
||||
}, [clearFallbackTimer, fallbackDirectPlay])
|
||||
|
||||
// 看门狗定时器可能比一次渲染活得更久:始终调用最新一版回退逻辑,避免用
|
||||
// 「起播那一刻」捕获的 playbackInfo(那时可能还是 null)去判定,把本该走
|
||||
// 115 云转码的条目误判成本地转码。
|
||||
const fallbackDirectPlayRef = useRef<() => void>(() => {})
|
||||
const fallbackDirectPlayRef = useRef<(reason?: DirectPlayFailureReason) => void>(() => {})
|
||||
useEffect(() => {
|
||||
fallbackDirectPlayRef.current = fallbackDirectPlay
|
||||
}, [fallbackDirectPlay])
|
||||
@@ -1222,17 +1268,27 @@ export function PlayerPage() {
|
||||
* 只能退出重进或手动切播放方式)。这里给每次直连起播挂一个兜底定时器:
|
||||
* 到点仍是 readyState=0(连元数据都没解析到)就按失败处理;只要已经拿到
|
||||
* metadata 就说明链路是通的,只是慢,交给 playing 事件收尾。
|
||||
*
|
||||
* 期限本身要按「失败之后还有没有补救动作」来定:能切 HLS 的源早点换线路对
|
||||
* 用户有利;远程 Emby / 直连解码没有可退的线路,等不到数据时唯一的产出是
|
||||
* 一条提示,所以必须容忍跨网上游的冷启动(见 directStallTimeoutMs)。
|
||||
*/
|
||||
const armDirectStallWatchdog = useCallback(() => {
|
||||
clearFallbackTimer()
|
||||
const timeoutMs = isStrmMedia(mediaRef.current) ? 12000 : 6000
|
||||
const currentMedia = mediaRef.current
|
||||
const canFallback = !isDirectStreamMedia(currentMedia) && !directOnlyRef.current
|
||||
const timeoutMs = directStallTimeoutMs({
|
||||
isStrm: isStrmMedia(currentMedia),
|
||||
canFallback,
|
||||
})
|
||||
fallbackTimerRef.current = setTimeout(() => {
|
||||
fallbackTimerRef.current = null
|
||||
if (modeRef.current !== 'direct') return
|
||||
if (Date.now() < directSeekGraceUntilRef.current) return
|
||||
const current = ref.current
|
||||
if (current && current.readyState >= HTMLMediaElement.HAVE_METADATA) return
|
||||
fallbackDirectPlayRef.current()
|
||||
// 这里只可能是「一直没数据」,按超时文案报,不要说成编码不兼容。
|
||||
fallbackDirectPlayRef.current('stall')
|
||||
}, timeoutMs)
|
||||
}, [clearFallbackTimer])
|
||||
|
||||
@@ -1241,6 +1297,8 @@ export function PlayerPage() {
|
||||
const currentMedia = mediaRef.current
|
||||
if (!currentMedia) return
|
||||
let cancelled = false
|
||||
let cleanupHlsResume: (() => void) | undefined
|
||||
let onDirectMetadata: (() => void) | undefined
|
||||
teardownHls()
|
||||
|
||||
const video = ref.current
|
||||
@@ -1257,13 +1315,14 @@ export function PlayerPage() {
|
||||
void import('hls.js').then(({ default: HlsCtor }) => {
|
||||
if (cancelled || !ref.current) return
|
||||
if (HlsCtor.isSupported()) {
|
||||
cleanupHlsResume = bindPendingHlsSeek(video, pendingSeekRef)
|
||||
const hls = new HlsCtor({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
// The server waits up to 45s for the first segment. Slow two-core
|
||||
// hosts and remote STRM sources regularly need more than hls.js's
|
||||
// 10s default, which otherwise aborts a healthy transcode.
|
||||
manifestLoadingTimeOut: 60_000,
|
||||
manifestLoadingTimeOut: hlsManifestTimeoutMs(hlsSource, hlsStartSec),
|
||||
// 115 云 HLS 的 master.m3u8 每次都要现调 115 接口(实测 0.5–1.3s),
|
||||
// 还可能撞上 409「正在转码」。只重试 1 次的话,一次抖动就判死刑,
|
||||
// 直接掉到本地转码。
|
||||
@@ -1277,15 +1336,7 @@ export function PlayerPage() {
|
||||
hls.loadSource(url)
|
||||
hls.attachMedia(video)
|
||||
hls.on(HlsCtor.Events.MANIFEST_PARSED, () => {
|
||||
if (pendingSeekRef.current !== null) {
|
||||
const target = pendingSeekRef.current
|
||||
pendingSeekRef.current = null
|
||||
try {
|
||||
video.currentTime = target
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
applyPendingHlsSeek(video, pendingSeekRef)
|
||||
void video.play().catch(() => undefined)
|
||||
// .strm 入库时常缺 duration;转码启动时会补探测,这里刷新一次给进度条总时长。
|
||||
if (durationSec > 0) return
|
||||
@@ -1313,10 +1364,12 @@ export function PlayerPage() {
|
||||
}
|
||||
setHlsUnavailable(true)
|
||||
if (playbackProvider === 'cloud115') {
|
||||
setPlayerError('115 云端和本地转码均不可用,请检查账号授权或稍后重试。')
|
||||
showPlayFailure('115 云端和本地转码均不可用,请检查账号授权或稍后重试。')
|
||||
toast.error('115 云端和本地转码均不可用')
|
||||
return
|
||||
}
|
||||
// 这条同时是「为什么可能没声音」的说明,播起来之后依然成立,
|
||||
// 所以不走 showPlayFailure(不随 playing 撤掉)。
|
||||
setPlayerError('HLS 转码不可用,正在尝试直接播放原始文件。若出现有画面无声音,通常是 MKV/AC3/EAC3 音轨需要配置本机 ffmpeg 转码为 AAC。')
|
||||
toast.error('HLS 转码失败,尝试切换到直接播放')
|
||||
setPlaybackMode('direct')
|
||||
@@ -1333,18 +1386,17 @@ export function PlayerPage() {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
video.src = url
|
||||
void video.play().catch(() => undefined)
|
||||
cleanupHlsResume = attachNativeHls(video, url, pendingSeekRef)
|
||||
} else {
|
||||
setHlsUnavailable(true)
|
||||
setPlayerError('当前浏览器不支持 HLS,正在尝试直接播放。')
|
||||
showPlayFailure('当前浏览器不支持 HLS,正在尝试直接播放。')
|
||||
toast.error('当前浏览器不支持 HLS,降级到直接播放')
|
||||
setPlaybackMode('direct')
|
||||
}
|
||||
}).catch(() => {
|
||||
if (cancelled) return
|
||||
setHlsUnavailable(true)
|
||||
setPlayerError('HLS 播放组件加载失败,正在尝试直接播放。')
|
||||
showPlayFailure('HLS 播放组件加载失败,正在尝试直接播放。')
|
||||
setPlaybackMode('direct')
|
||||
})
|
||||
} else {
|
||||
@@ -1366,23 +1418,20 @@ export function PlayerPage() {
|
||||
)
|
||||
pendingDirectStartRef.current = 0
|
||||
if (resumeAt > 0) {
|
||||
video.addEventListener(
|
||||
'loadedmetadata',
|
||||
() => {
|
||||
// STRM/115 直链要等 seekable 覆盖目标位置再写 currentTime(否则会被
|
||||
// 钳回 0),并顺带打开误报宽限期,避免这次跳转被当成播放失败又弹回 HLS。
|
||||
if (isStrmMedia(currentMedia)) {
|
||||
seekStrmDirectTo(resumeAt)
|
||||
return
|
||||
}
|
||||
try {
|
||||
video.currentTime = resumeAt
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
onDirectMetadata = () => {
|
||||
// STRM/115 直链要等 seekable 覆盖目标位置再写 currentTime(否则会被
|
||||
// 钳回 0),并顺带打开误报宽限期,避免这次跳转被当成播放失败又弹回 HLS。
|
||||
if (isStrmMedia(currentMedia)) {
|
||||
seekStrmDirectTo(resumeAt)
|
||||
return
|
||||
}
|
||||
try {
|
||||
video.currentTime = resumeAt
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
video.addEventListener('loadedmetadata', onDirectMetadata, { once: true })
|
||||
}
|
||||
video.src = url
|
||||
void video.play().catch(() => undefined)
|
||||
@@ -1393,17 +1442,24 @@ export function PlayerPage() {
|
||||
setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。')
|
||||
}
|
||||
}
|
||||
const onPlaying = () => clearFallbackTimer()
|
||||
const onPlaying = () => {
|
||||
clearFallbackTimer()
|
||||
// 画面真的动了:之前挂的「起不来」提示一律作废。
|
||||
clearPlayFailure()
|
||||
}
|
||||
video.addEventListener('playing', onPlaying)
|
||||
return () => {
|
||||
cancelled = true
|
||||
video.removeEventListener('playing', onPlaying)
|
||||
if (onDirectMetadata) video.removeEventListener('loadedmetadata', onDirectMetadata)
|
||||
cleanupHlsResume?.()
|
||||
teardownHls()
|
||||
}
|
||||
}, [
|
||||
activeBurnedSubtitleStream,
|
||||
armDirectStallWatchdog,
|
||||
clearFallbackTimer,
|
||||
clearPlayFailure,
|
||||
cloudReloadNonce,
|
||||
cloudWaiting,
|
||||
hlsSource,
|
||||
@@ -1416,6 +1472,7 @@ export function PlayerPage() {
|
||||
seekStrmDirectTo,
|
||||
selectedQuality,
|
||||
setPlaybackMode,
|
||||
showPlayFailure,
|
||||
switchToLocalHLS,
|
||||
teardownHls,
|
||||
vr360DirectProxy,
|
||||
@@ -1444,19 +1501,22 @@ export function PlayerPage() {
|
||||
if (!id) return
|
||||
setResumePosition(0)
|
||||
setInitialSeekDone(false)
|
||||
const controller = new AbortController()
|
||||
playbackAPI
|
||||
.getResume(id)
|
||||
.getResume(id, controller.signal)
|
||||
.then((progress) => {
|
||||
if (controller.signal.aborted) return
|
||||
if (progress.position_ms > 2000 && !progress.completed) {
|
||||
setResumePosition(progress.position_ms / 1000)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => controller.abort()
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!resumePosition || initialSeekDone) return
|
||||
if (mode === 'hls') {
|
||||
if (mode === 'hls' && hlsSource === 'local') {
|
||||
// Restart transcode near the resume point instead of seeking a short partial playlist.
|
||||
if (Math.abs(hlsStartSec - resumePosition) > 2) {
|
||||
setHlsStartSec(resumePosition)
|
||||
@@ -1475,6 +1535,7 @@ export function PlayerPage() {
|
||||
let seekStarted = false
|
||||
let onPlaying: (() => void) | undefined
|
||||
let onCanPlay: (() => void) | undefined
|
||||
let onSeeked: (() => void) | undefined
|
||||
|
||||
const notifyResume = () => {
|
||||
const m = Math.floor(resumePosition / 60)
|
||||
@@ -1491,7 +1552,7 @@ export function PlayerPage() {
|
||||
return
|
||||
}
|
||||
seekStarted = true
|
||||
if (isStrmMedia(mediaRef.current)) {
|
||||
if (mode === 'direct' && isStrmMedia(mediaRef.current)) {
|
||||
seekStrmDirectTo(resumePosition, {
|
||||
timeoutMs: 6000,
|
||||
onSettled: () => {
|
||||
@@ -1518,7 +1579,8 @@ export function PlayerPage() {
|
||||
directSeekGraceUntilRef.current = Date.now() + DIRECT_SEEK_GRACE_MS
|
||||
directRetryRef.current = false
|
||||
clearFallbackTimer()
|
||||
const onSeeked = () => {
|
||||
onSeeked = () => {
|
||||
if (cancelled) return
|
||||
setInitialSeekDone(true)
|
||||
notifyResume()
|
||||
}
|
||||
@@ -1531,7 +1593,7 @@ export function PlayerPage() {
|
||||
}
|
||||
|
||||
// STRM/云盘:等真正起播后再续播跳转,避免 302 未完成就 seek。
|
||||
const deferForStrm = isStrmMedia(mediaRef.current)
|
||||
const deferForStrm = mode === 'direct' && isStrmMedia(mediaRef.current)
|
||||
if (deferForStrm) {
|
||||
onPlaying = () => {
|
||||
performResumeSeek()
|
||||
@@ -1554,8 +1616,9 @@ export function PlayerPage() {
|
||||
cancelled = true
|
||||
if (onPlaying) video.removeEventListener('playing', onPlaying)
|
||||
if (onCanPlay) video.removeEventListener('canplay', onCanPlay)
|
||||
if (onSeeked) video.removeEventListener('seeked', onSeeked)
|
||||
}
|
||||
}, [resumePosition, initialSeekDone, mode, hlsStartSec, clearFallbackTimer, seekStrmDirectTo])
|
||||
}, [resumePosition, initialSeekDone, mode, hlsSource, hlsStartSec, clearFallbackTimer, seekStrmDirectTo])
|
||||
|
||||
// 使用 ref 实时同步进度计算所需的状态,避免每次 hlsStartSec 改变都触发 cleanup 并误上报旧进度
|
||||
const hlsStartSecRef = useRef(hlsStartSec)
|
||||
@@ -2162,28 +2225,22 @@ export function PlayerPage() {
|
||||
toast.error('图片字幕需要开启 HLS 转码后才能显示')
|
||||
return
|
||||
}
|
||||
const position = playbackPositionSec(mode, hlsSource, hlsStartSec, ref.current?.currentTime || 0)
|
||||
if (nextTrack?.delivery === 'burn') {
|
||||
// PGS 等图片字幕必须走本地 FFmpeg 烧录,不能交给云端 HLS。
|
||||
switchToLocalHLS(ref.current?.currentTime || 0)
|
||||
}
|
||||
const burnChanged =
|
||||
oldTrack?.delivery === 'burn' || nextTrack?.delivery === 'burn'
|
||||
if (burnChanged && mode === 'hls' && ref.current) {
|
||||
setHlsStartSec(hlsStartSec + (ref.current.currentTime || 0))
|
||||
switchToLocalHLS(position)
|
||||
} else if (oldTrack?.delivery === 'burn' && mode === 'hls') {
|
||||
setHlsStartSec(position)
|
||||
}
|
||||
setSubtitleIndex(index)
|
||||
if (nextTrack?.delivery === 'burn' && mode !== 'hls' && !directOnly && !isDirectStream) {
|
||||
setHlsStartSec(ref.current?.currentTime || 0)
|
||||
setPlaybackMode('hls')
|
||||
}
|
||||
}, [directOnly, hlsStartSec, isDirectStream, mode, setPlaybackMode, subs, subtitleIndex, switchToLocalHLS])
|
||||
}, [directOnly, hlsSource, hlsStartSec, isDirectStream, mode, subs, subtitleIndex, switchToLocalHLS])
|
||||
|
||||
const selectPlaybackQuality = useCallback(
|
||||
(quality: PlaybackQuality) => {
|
||||
const video = ref.current
|
||||
const localTime = video?.currentTime || 0
|
||||
// 云 HLS 的 currentTime 本来就在绝对时间轴上;本地 HLS 的 t=0 对应 hlsStartSec。
|
||||
const position = mode === 'hls' && hlsSource === 'local' ? hlsStartSec + localTime : localTime
|
||||
const position = playbackPositionSec(mode, hlsSource, hlsStartSec, localTime)
|
||||
if (quality.source === 'original') {
|
||||
setCloudWaiting(false)
|
||||
setHlsSource('local')
|
||||
@@ -2195,12 +2252,13 @@ export function PlayerPage() {
|
||||
if (quality.source === 'cloud') {
|
||||
setSelectedQuality(quality.id)
|
||||
setHlsSource('cloud')
|
||||
setHlsStartSec(position)
|
||||
pendingSeekRef.current = position > 2 ? position : null
|
||||
// 用户手动选了云端档位:这是一次明确的重新尝试,重置自动重连的次数窗口。
|
||||
cloudReloadsRef.current = []
|
||||
if (quality.available) {
|
||||
setCloudWaiting(false)
|
||||
setCloudWaitMessage('')
|
||||
if (position > 2) pendingSeekRef.current = position
|
||||
setPlaybackMode('hls')
|
||||
} else {
|
||||
setCloudWaiting(true)
|
||||
@@ -2220,7 +2278,8 @@ export function PlayerPage() {
|
||||
toast.error(quality.note || '当前无法进行 HLS 转码,请在「系统设置 → 常规」安装 ffmpeg 后重试')
|
||||
return
|
||||
}
|
||||
if (position > 2) setHlsStartSec(position)
|
||||
pendingSeekRef.current = null
|
||||
setHlsStartSec(position)
|
||||
setPlaybackMode('hls')
|
||||
},
|
||||
[hlsSource, hlsStartSec, mode, setPlaybackMode, startCloudTranscode],
|
||||
@@ -2253,7 +2312,7 @@ export function PlayerPage() {
|
||||
const handleVideoError = useCallback(() => {
|
||||
const video = ref.current
|
||||
if (mode !== 'direct') {
|
||||
setPlayerError('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
|
||||
showPlayFailure('视频播放失败,请检查文件是否存在,或确认 ffmpeg 已正确配置。')
|
||||
toast.error('视频播放失败,请检查文件是否存在')
|
||||
return
|
||||
}
|
||||
@@ -2300,7 +2359,7 @@ export function PlayerPage() {
|
||||
}
|
||||
|
||||
scheduleDirectFallback()
|
||||
}, [mediaId, mode, scheduleDirectFallback, seekStrmDirectTo])
|
||||
}, [mediaId, mode, scheduleDirectFallback, seekStrmDirectTo, showPlayFailure])
|
||||
|
||||
const changeSubtitlePosition = useCallback((nextPosition: SubtitlePosition) => {
|
||||
setSubtitlePosition(nextPosition)
|
||||
|
||||
@@ -6,6 +6,65 @@ const HAVE_CURRENT_DATA = 2
|
||||
/** 程序化续播/跳转后忽略直连误报的宽限期(毫秒)。 */
|
||||
export const DIRECT_SEEK_GRACE_MS = 12_000
|
||||
|
||||
/** 直连起播看门狗的等待上限(毫秒)。 */
|
||||
export const DIRECT_STALL_TIMEOUT_MS = 6_000
|
||||
/** STRM / 云盘直链要等 302 换链,冷启动比本地文件慢,给长一点的期限。 */
|
||||
export const DIRECT_STALL_TIMEOUT_STRM_MS = 12_000
|
||||
/**
|
||||
* 没有可退线路(远程 Emby 挂载、客户端直连解码)时的等待上限。
|
||||
*
|
||||
* 这两种源都不在宿主机转码,等不到数据时没有任何补救动作——唯一的输出是给用户
|
||||
* 一条提示,所以宁可多等:跨网上游的冷启动实测要 8–11 秒才吐第一段数据,6 秒
|
||||
* 就判失败只会把「上游慢」写成「浏览器不支持这个编码」,而画面其实几秒后就播上了。
|
||||
*/
|
||||
export const DIRECT_STALL_TIMEOUT_NO_FALLBACK_MS = 20_000
|
||||
|
||||
/**
|
||||
* 起播看门狗该等多久才把「还没起来」当成「起不来」。
|
||||
*
|
||||
* canFallback=false:直连失败没有可切的线路(远程 Emby / 直连解码),失败提示
|
||||
* 是唯一产出,所以要容忍慢启动。canFallback=true:还能切 HLS,早点换线路对用户
|
||||
* 更有利,保持较短期限。
|
||||
*/
|
||||
export function directStallTimeoutMs(input: { isStrm?: boolean; canFallback: boolean }): number {
|
||||
if (!input.canFallback) return DIRECT_STALL_TIMEOUT_NO_FALLBACK_MS
|
||||
return input.isStrm ? DIRECT_STALL_TIMEOUT_STRM_MS : DIRECT_STALL_TIMEOUT_MS
|
||||
}
|
||||
|
||||
/** 直连起播失败的两类起因:看门狗等超时(上游一直没数据)/ 媒体元素真的报了 error。 */
|
||||
export type DirectPlayFailureReason = 'stall' | 'error'
|
||||
|
||||
/** 直连失败时用户看到的来源:远程 Emby 挂载 / 客户端直连解码。 */
|
||||
export type DirectPlayFailureKind = 'remote-emby' | 'direct-only'
|
||||
|
||||
/**
|
||||
* 直连失败的横幅与 toast 文案。
|
||||
*
|
||||
* 超时和真报错的文案必须分开:超时的真实原因是上游慢,不该断言「浏览器不支持这个
|
||||
* 编码」(实测最常见的情形就是慢,画面随后自己就播上了)。
|
||||
*/
|
||||
export function directPlayFailureCopy(
|
||||
kind: DirectPlayFailureKind,
|
||||
reason: DirectPlayFailureReason = 'error',
|
||||
): { banner: string; toast: string } {
|
||||
if (reason === 'stall') {
|
||||
return {
|
||||
banner:
|
||||
kind === 'remote-emby'
|
||||
? '直接播放失败:远程 Emby 挂载的流长时间没有返回数据(该线路不转码,只能直连)。可稍后重试,或用外部播放器(如 PotPlayer / VLC / IINA)播放。'
|
||||
: '直接播放失败:客户端直连解码模式下宿主机不转码,流长时间没有返回数据。可稍后重试,或关闭直连解码模式改用转码播放。',
|
||||
toast: '直连流长时间没有响应',
|
||||
}
|
||||
}
|
||||
return {
|
||||
banner:
|
||||
kind === 'remote-emby'
|
||||
? '直接播放失败。该媒体为远程 Emby 挂载直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器(如 PotPlayer / VLC / IINA)播放。'
|
||||
: '直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。',
|
||||
toast: '直接播放失败,建议使用外部播放器',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chromium 常把被中断的直连(换 src、插入 track、302 未完成就 seek)
|
||||
* 报成 error。真正不兼容应 fallback;瞬时中断应忽略或静默重试一次。
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { applyPendingHlsSeek, attachNativeHls, bindPendingHlsSeek, hlsManifestTimeoutMs, playbackPositionSec } from './hlsPlayback.ts'
|
||||
|
||||
class FakeVideo extends EventTarget {
|
||||
src = ''
|
||||
rejectSeek = false
|
||||
position = 0
|
||||
playCalls = 0
|
||||
get currentTime() { return this.position }
|
||||
set currentTime(value: number) {
|
||||
if (this.rejectSeek) throw new Error('metadata not ready')
|
||||
this.position = value
|
||||
}
|
||||
play() { this.playCalls += 1; return Promise.resolve() }
|
||||
}
|
||||
|
||||
const video = new FakeVideo()
|
||||
const pending = { current: 420 as number | null }
|
||||
const detach = attachNativeHls(video, '/cloud/master.m3u8', pending)
|
||||
assert.equal(video.src, '/cloud/master.m3u8')
|
||||
assert.equal(video.playCalls, 1)
|
||||
assert.equal(video.currentTime, 0, 'seek must wait for the new source metadata')
|
||||
video.rejectSeek = true
|
||||
video.dispatchEvent(new Event('loadedmetadata'))
|
||||
assert.equal(pending.current, 420, 'a rejected seek must survive until canplay')
|
||||
video.rejectSeek = false
|
||||
video.dispatchEvent(new Event('canplay'))
|
||||
assert.equal(video.currentTime, 420, 'native HLS must restore the absolute cloud position')
|
||||
assert.equal(pending.current, null)
|
||||
pending.current = 600
|
||||
detach()
|
||||
video.dispatchEvent(new Event('loadedmetadata'))
|
||||
assert.equal(video.currentTime, 420, 'a detached source must not apply late metadata events')
|
||||
assert.equal(pending.current, 600)
|
||||
|
||||
const msePending = { current: 120 as number | null }
|
||||
video.rejectSeek = true
|
||||
assert.equal(applyPendingHlsSeek(video, msePending), false)
|
||||
assert.equal(msePending.current, 120)
|
||||
video.rejectSeek = false
|
||||
assert.equal(applyPendingHlsSeek(video, msePending), true)
|
||||
assert.equal(msePending.current, null)
|
||||
const lateMsePending = { current: 900 as number | null }
|
||||
video.rejectSeek = true
|
||||
const detachMse = bindPendingHlsSeek(video, lateMsePending)
|
||||
assert.equal(applyPendingHlsSeek(video, lateMsePending), false)
|
||||
video.rejectSeek = false
|
||||
video.dispatchEvent(new Event('loadedmetadata'))
|
||||
assert.equal(video.currentTime, 900, 'hls.js seeks rejected during manifest parsing must retry after metadata')
|
||||
assert.equal(lateMsePending.current, null)
|
||||
detachMse()
|
||||
assert.ok(hlsManifestTimeoutMs('local', 1800) > 120_000, 'deep seeks must outlast the server readiness deadline')
|
||||
assert.equal(hlsManifestTimeoutMs('local', 0), 60_000)
|
||||
assert.equal(hlsManifestTimeoutMs('cloud', 1800), 60_000)
|
||||
assert.equal(playbackPositionSec('hls', 'cloud', 420, 600), 600, 'cloud subtitle/quality switches must not add an old local offset')
|
||||
assert.equal(playbackPositionSec('hls', 'local', 420, 60), 480, 'local subtitle switches preserve the source position')
|
||||
assert.equal(playbackPositionSec('direct', 'local', 420, 60), 60)
|
||||
console.log('hlsPlayback.test.ts ok')
|
||||
@@ -0,0 +1,43 @@
|
||||
type HlsVideo = Pick<HTMLVideoElement, 'src' | 'currentTime' | 'addEventListener' | 'removeEventListener' | 'play'>
|
||||
type PendingSeek = { current: number | null }
|
||||
|
||||
/** Local mid-file restarts may wait 120 seconds on the server before a playlist exists. */
|
||||
export function hlsManifestTimeoutMs(source: 'cloud' | 'local', startSec: number): number {
|
||||
return source === 'local' && startSec > 0.05 ? 135_000 : 60_000
|
||||
}
|
||||
|
||||
/** Keep a pending seek until the media element accepts it; metadata can arrive later. */
|
||||
export function applyPendingHlsSeek(video: Pick<HlsVideo, 'currentTime'>, pending: PendingSeek): boolean {
|
||||
if (pending.current === null) return true
|
||||
if (!Number.isFinite(pending.current) || pending.current < 0) return false
|
||||
try {
|
||||
video.currentTime = pending.current
|
||||
pending.current = null
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function bindPendingHlsSeek(video: HlsVideo, pending: PendingSeek): () => void {
|
||||
const restore = () => { applyPendingHlsSeek(video, pending) }
|
||||
video.addEventListener('loadedmetadata', restore)
|
||||
video.addEventListener('canplay', restore)
|
||||
return () => {
|
||||
video.removeEventListener('loadedmetadata', restore)
|
||||
video.removeEventListener('canplay', restore)
|
||||
}
|
||||
}
|
||||
|
||||
/** Native HLS needs the same seek restoration as hls.js, with removable listeners. */
|
||||
export function attachNativeHls(video: HlsVideo, url: string, pending: PendingSeek): () => void {
|
||||
const detach = bindPendingHlsSeek(video, pending)
|
||||
video.src = url
|
||||
void video.play().catch(() => undefined)
|
||||
return detach
|
||||
}
|
||||
|
||||
/** Only locally transcoded HLS uses a relative timeline starting at startSec. */
|
||||
export function playbackPositionSec(mode: 'direct' | 'hls', source: 'cloud' | 'local', startSec: number, currentTime: number): number {
|
||||
return mode === 'hls' && source === 'local' ? startSec + currentTime : currentTime
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import { classifyDirectPlayError } from './directPlayError.ts'
|
||||
import {
|
||||
classifyDirectPlayError,
|
||||
directPlayFailureCopy,
|
||||
directStallTimeoutMs,
|
||||
DIRECT_STALL_TIMEOUT_MS,
|
||||
DIRECT_STALL_TIMEOUT_NO_FALLBACK_MS,
|
||||
DIRECT_STALL_TIMEOUT_STRM_MS,
|
||||
} from './directPlayError.ts'
|
||||
|
||||
function check(name: string, cond: boolean) {
|
||||
if (!cond) throw new Error(`classifyDirectPlayError: ${name}`)
|
||||
@@ -96,4 +103,36 @@ check(
|
||||
}) === 'ignore',
|
||||
)
|
||||
|
||||
// 起播看门狗的期限:没有可退线路(远程 Emby / 直连解码)时必须容忍慢启动,
|
||||
// 否则上游冷启动的 8–11 秒会被误报成「编码不兼容」。
|
||||
check('local direct stall waits 6s', directStallTimeoutMs({ canFallback: true }) === DIRECT_STALL_TIMEOUT_MS)
|
||||
check(
|
||||
'strm direct stall waits 12s',
|
||||
directStallTimeoutMs({ isStrm: true, canFallback: true }) === DIRECT_STALL_TIMEOUT_STRM_MS,
|
||||
)
|
||||
check(
|
||||
'no fallback line waits 20s',
|
||||
directStallTimeoutMs({ canFallback: false }) === DIRECT_STALL_TIMEOUT_NO_FALLBACK_MS,
|
||||
)
|
||||
check(
|
||||
'no fallback line ignores strm tuning',
|
||||
directStallTimeoutMs({ isStrm: true, canFallback: false }) === DIRECT_STALL_TIMEOUT_NO_FALLBACK_MS,
|
||||
)
|
||||
|
||||
const stallCopy = directPlayFailureCopy('remote-emby', 'stall')
|
||||
check('stall copy blames the upstream, not the codec', !stallCopy.banner.includes('不支持'))
|
||||
check('stall copy keeps the failure prefix', stallCopy.banner.startsWith('直接播放失败'))
|
||||
const decodeCopy = directPlayFailureCopy('remote-emby', 'error')
|
||||
check('decode copy still mentions unsupported codec', decodeCopy.banner.includes('不支持'))
|
||||
check(
|
||||
'copy defaults to the decode wording',
|
||||
directPlayFailureCopy('remote-emby').banner === decodeCopy.banner,
|
||||
)
|
||||
check(
|
||||
'direct-only copy names the client-decode mode',
|
||||
directPlayFailureCopy('direct-only', 'stall').banner.includes('客户端直连解码') &&
|
||||
directPlayFailureCopy('direct-only', 'error').banner.includes('客户端直连解码'),
|
||||
)
|
||||
check('stall toast is not the external-player advice', stallCopy.toast !== decodeCopy.toast)
|
||||
|
||||
console.log('playerPageModel.test.ts ok')
|
||||
|
||||
@@ -35,7 +35,6 @@ export default function ReaderReplacePage() {
|
||||
.then(setRules)
|
||||
.catch(() => toast.error('加载替换规则失败'))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(load, [])
|
||||
|
||||
const save = async () => {
|
||||
|
||||
@@ -49,7 +49,6 @@ export default function ReaderSourcesPage() {
|
||||
.then(setSources)
|
||||
.catch(() => toast.error('加载书源失败'))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(load, [])
|
||||
|
||||
// 读取书源文件(选择或拖入),走与粘贴相同的导入链路
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
@@ -266,6 +266,19 @@ function settleTextDrag(dragEl: HTMLDivElement, turn: 1 | -1, dx: number, stride
|
||||
})
|
||||
}
|
||||
|
||||
function ReaderMenuButton({ icon, label, onClick }: { icon: ReactNode; label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex flex-1 flex-col items-center gap-1 py-1 opacity-80 hover:opacity-100"
|
||||
>
|
||||
{icon}
|
||||
<span className="text-2xs">{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ReaderViewPage() {
|
||||
const { bookId = '' } = useParams()
|
||||
const settings = useReaderSettingsStore()
|
||||
@@ -316,6 +329,7 @@ export default function ReaderViewPage() {
|
||||
const [page, setPage] = useState(0)
|
||||
const [pageCount, setPageCount] = useState(1)
|
||||
const [vw, setVw] = useState(0)
|
||||
const [scrollProgress, setScrollProgress] = useState(0)
|
||||
// 文本翻页:位移与页脚先走 DOM,React 的 page state 等到过渡结束后再同步。
|
||||
// 若在同一次点击/触摸里同步 setPage,整页重渲染会堵住下一次绘制,
|
||||
// 命令式 transform 也要等卡完才开始——点按和滑动都会顿一下。
|
||||
@@ -338,8 +352,13 @@ export default function ReaderViewPage() {
|
||||
const paragraphWarmRef = useRef<Uint8Array | null>(null)
|
||||
/** 当前窗口中心页;-1 表示窗口失效(映射刚重算过),下次应用时重建。 */
|
||||
const warmCenterRef = useRef(-1)
|
||||
vwRef.current = vw
|
||||
pageCountRef.current = pageCount
|
||||
const currentChapter = chapterIndex !== null ? chapters[chapterIndex] : null
|
||||
const chapterTitle = currentChapter?.title ?? ''
|
||||
useLayoutEffect(() => {
|
||||
vwRef.current = vw
|
||||
pageCountRef.current = pageCount
|
||||
chapterTitleRef.current = chapterTitle
|
||||
}, [vw, pageCount, chapterTitle])
|
||||
// 音频/漫画媒体状态
|
||||
const [media, setMedia] = useState<ReaderChapterContent | null>(null)
|
||||
const [restorePos, setRestorePos] = useState(0) // 音频秒数 / 漫画图片序号
|
||||
@@ -448,6 +467,7 @@ export default function ReaderViewPage() {
|
||||
// ── 加载书籍与章节 ──
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const cache = contentCache.current
|
||||
;(async () => {
|
||||
try {
|
||||
setLoadingStage('book')
|
||||
@@ -510,7 +530,7 @@ export default function ReaderViewPage() {
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
contentCache.current.clear()
|
||||
cache.clear()
|
||||
}
|
||||
}, [bookId, reloadKey])
|
||||
|
||||
@@ -910,6 +930,16 @@ export default function ReaderViewPage() {
|
||||
if (menuOpen) flushTextPageReactSync()
|
||||
}, [menuOpen, flushTextPageReactSync])
|
||||
|
||||
// Only refresh the scroll slider while its menu is visible, keeping scroll
|
||||
// events on the reading path free of whole-page React renders.
|
||||
useLayoutEffect(() => {
|
||||
if (!menuOpen || settings.pageMode !== 'scroll') return
|
||||
const el = scrollRef.current
|
||||
if (el) {
|
||||
setScrollProgress(Math.round((el.scrollTop / Math.max(1, el.scrollHeight - el.clientHeight)) * 1000))
|
||||
}
|
||||
}, [menuOpen, settings.pageMode, content])
|
||||
|
||||
// 滚动模式恢复进度。
|
||||
//
|
||||
// 同时清掉 dataset.last:它是「上次保存的滚动百分比」,换章后必须重算。
|
||||
@@ -1355,9 +1385,6 @@ export default function ReaderViewPage() {
|
||||
return () => el.removeEventListener('wheel', onWheel)
|
||||
}, [settings.pageMode, menuOpen, contentType, goPrev, goNext])
|
||||
|
||||
const currentChapter = chapterIndex !== null ? chapters[chapterIndex] : null
|
||||
chapterTitleRef.current = currentChapter?.title ?? ''
|
||||
|
||||
// 顶栏高度量一次:菜单打开时正文下移,开头几行不被顶栏压住。
|
||||
// 用 transform 而不是 padding,避免改变视口高度触发重新分页。
|
||||
const menuShiftStyle = useMemo(
|
||||
@@ -1397,11 +1424,7 @@ export default function ReaderViewPage() {
|
||||
value:
|
||||
settings.pageMode === 'page'
|
||||
? page + 1
|
||||
: Math.round(
|
||||
((scrollRef.current?.scrollTop ?? 0) /
|
||||
Math.max(1, (scrollRef.current?.scrollHeight ?? 1) - (scrollRef.current?.clientHeight ?? 1))) *
|
||||
1000,
|
||||
),
|
||||
: scrollProgress,
|
||||
onChange: (v: number) => {
|
||||
if (settings.pageMode === 'page') {
|
||||
commitTextPage(v - 1)
|
||||
@@ -1569,6 +1592,7 @@ export default function ReaderViewPage() {
|
||||
const max = el.scrollHeight - el.clientHeight
|
||||
if (max > 0) {
|
||||
const pct = el.scrollTop / max
|
||||
if (menuOpen) setScrollProgress(Math.round(pct * 1000))
|
||||
if (shouldSaveScrollProgress(pct, el.dataset.last)) {
|
||||
el.dataset.last = formatScrollPct(pct)
|
||||
savePos(Math.round(el.scrollTop))
|
||||
@@ -1753,62 +1777,35 @@ export default function ReaderViewPage() {
|
||||
用 flex 等分而不是 grid-cols-N:动作数量随内容类型和书源能力变化,
|
||||
grid 列数得写死成字面量类名,多一个按钮就要再加一档。 */}
|
||||
<div className="flex pt-1" style={{ color: theme.text }}>
|
||||
{([
|
||||
{ icon: <LayoutList size={18} />, label: '目录', action: () => openPanel('toc') },
|
||||
{ icon: <BookOpen size={18} />, label: '界面', action: () => openPanel('style') },
|
||||
{
|
||||
icon: settings.night ? <Sun size={18} /> : <Moon size={18} />,
|
||||
label: settings.night ? '日间' : '夜间',
|
||||
action: () => settings.toggleNight(),
|
||||
},
|
||||
{
|
||||
icon: settings.pageMode === 'page' ? <ScrollText size={18} /> : <ListEnd size={18} />,
|
||||
label: settings.pageMode === 'page' ? '滚动' : '翻页',
|
||||
action: () => settings.setPageMode(settings.pageMode === 'page' ? 'scroll' : 'page'),
|
||||
},
|
||||
// 双页铺开只对漫画的翻页模式有意义;窗口太窄时并排两页没法看,
|
||||
// 干脆不显示入口(不是灰掉,避免用户以为坏了)。
|
||||
...(contentType === 'image' && settings.pageMode === 'page' && windowWidth >= COMIC_SPREAD_MIN_WIDTH
|
||||
? [
|
||||
{
|
||||
icon: <Columns2 size={18} />,
|
||||
label: settings.comicDoublePage ? '单页' : '双页',
|
||||
action: () => settings.setComicDoublePage(!settings.comicDoublePage),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// 全屏:只在浏览器支持元素全屏时出现(iOS Safari 不支持,见上方注释)。
|
||||
...(fullscreenSupported
|
||||
? [
|
||||
{
|
||||
icon: fullscreen ? <Minimize size={18} /> : <Maximize size={18} />,
|
||||
label: fullscreen ? '还原' : '全屏',
|
||||
action: toggleFullscreen,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canSwitchSource
|
||||
? [
|
||||
{
|
||||
icon: <ArrowLeftRight size={18} />,
|
||||
label: '换源',
|
||||
action: () => {
|
||||
void openSourcePicker()
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]).map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
onClick={item.action}
|
||||
className="flex flex-1 flex-col items-center gap-1 py-1 opacity-80 hover:opacity-100"
|
||||
>
|
||||
{item.icon}
|
||||
<span className="text-2xs">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<ReaderMenuButton icon={<LayoutList size={18} />} label="目录" onClick={() => openPanel('toc')} />
|
||||
<ReaderMenuButton icon={<BookOpen size={18} />} label="界面" onClick={() => openPanel('style')} />
|
||||
<ReaderMenuButton
|
||||
icon={settings.night ? <Sun size={18} /> : <Moon size={18} />}
|
||||
label={settings.night ? '日间' : '夜间'}
|
||||
onClick={() => settings.toggleNight()}
|
||||
/>
|
||||
<ReaderMenuButton
|
||||
icon={settings.pageMode === 'page' ? <ScrollText size={18} /> : <ListEnd size={18} />}
|
||||
label={settings.pageMode === 'page' ? '滚动' : '翻页'}
|
||||
onClick={() => settings.setPageMode(settings.pageMode === 'page' ? 'scroll' : 'page')}
|
||||
/>
|
||||
{contentType === 'image' && settings.pageMode === 'page' && windowWidth >= COMIC_SPREAD_MIN_WIDTH && (
|
||||
<ReaderMenuButton
|
||||
icon={<Columns2 size={18} />}
|
||||
label={settings.comicDoublePage ? '单页' : '双页'}
|
||||
onClick={() => settings.setComicDoublePage(!settings.comicDoublePage)}
|
||||
/>
|
||||
)}
|
||||
{fullscreenSupported && (
|
||||
<ReaderMenuButton
|
||||
icon={fullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||
label={fullscreen ? '还原' : '全屏'}
|
||||
onClick={toggleFullscreen}
|
||||
/>
|
||||
)}
|
||||
{canSwitchSource && (
|
||||
<ReaderMenuButton icon={<ArrowLeftRight size={18} />} label="换源" onClick={() => { void openSourcePicker() }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 界面设置面板(主题 / 字号 / 行距 / 段距 / 漫画图片尺寸) */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReaderBook } from '../../api/reader'
|
||||
import { formatRelativeTime, sortShelfBooks, unreadChapters } from './bookshelfModel'
|
||||
import type { ReaderBook } from '../../api/reader.ts'
|
||||
import { formatRelativeTime, sortShelfBooks, unreadChapters } from './bookshelfModel.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`bookshelfModel: ${name}`)
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
RefreshRequestError,
|
||||
createRefreshCoordinator,
|
||||
createSingleFlightRunner,
|
||||
createTokenPreflight,
|
||||
decodeJwtExpiryMs,
|
||||
isAccessTokenStale,
|
||||
refreshFailureOutcome,
|
||||
type RefreshOutcome,
|
||||
type RefreshTokens,
|
||||
@@ -30,6 +33,149 @@ check('timeout is transient', refreshFailureOutcome(408) === 'transient')
|
||||
check('429 is transient', refreshFailureOutcome(429) === 'transient')
|
||||
check('unexpected status is transient', refreshFailureOutcome(418) === 'transient')
|
||||
|
||||
// ── JWT 过期判定:首屏放行前必须能识别出「已经过期的 access token」 ────────
|
||||
// 现场(2026-10-16 12:49):刷新页面时本地 token 已过期,React 首屏并发发出
|
||||
// 7 个受保护请求,全部 401,控制台一次性刷出 7 行红字;约 2 秒后才由拦截器
|
||||
// 刷新成功并重放。修复要求首屏就认得过期 token,先刷新再放行。
|
||||
function jwtWithExp(exp: number | string | null): string {
|
||||
const encode = (value: unknown) =>
|
||||
Buffer.from(JSON.stringify(value)).toString('base64url')
|
||||
const claims: Record<string, unknown> = { uid: 'u1' }
|
||||
if (exp !== null) claims.exp = exp
|
||||
return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(claims)}.signature`
|
||||
}
|
||||
|
||||
const NOW = 1_700_000_000_000
|
||||
|
||||
check('jwt expiry is decoded to milliseconds',
|
||||
decodeJwtExpiryMs(jwtWithExp(NOW / 1000 + 3600)) === NOW + 3600_000)
|
||||
check('jwt without exp decodes to null', decodeJwtExpiryMs(jwtWithExp(null)) === null)
|
||||
check('jwt with non-numeric exp decodes to null', decodeJwtExpiryMs(jwtWithExp('soon')) === null)
|
||||
check('opaque token decodes to null', decodeJwtExpiryMs('not-a-jwt') === null)
|
||||
check('empty token decodes to null', decodeJwtExpiryMs('') === null)
|
||||
check('malformed payload decodes to null', decodeJwtExpiryMs('aaa.!!!.ccc') === null)
|
||||
|
||||
// 已过期 / 即将过期的 token 必须在首屏就被拦下。
|
||||
check('expired token is stale', isAccessTokenStale(jwtWithExp(NOW / 1000 - 1), NOW))
|
||||
check('token expiring inside the skew is stale',
|
||||
isAccessTokenStale(jwtWithExp(NOW / 1000 + 20), NOW))
|
||||
check('token outside the skew is fresh',
|
||||
isAccessTokenStale(jwtWithExp(NOW / 1000 + 3600), NOW) === false)
|
||||
// 边界:正好落在 skew 上算过期(留出余量,避免刷新途中再次过期)。
|
||||
check('token exactly at the skew boundary is stale',
|
||||
isAccessTokenStale(jwtWithExp(NOW / 1000 + 30), NOW))
|
||||
|
||||
// 没有 token 或 token 无法解析时不能判定为「过期」:
|
||||
// 前者由路由守卫处理,后者强行刷新会变成无休止的刷新循环。
|
||||
check('missing token is not stale', isAccessTokenStale(null, NOW) === false)
|
||||
check('opaque token is not stale', isAccessTokenStale('opaque', NOW) === false)
|
||||
|
||||
// ── 发送前预检:本地 token 已过期就先刷新,再让请求带新令牌出去 ────────────
|
||||
// 现场(2026-10-16 12:49):刷新页面时本地 token 已过期,React 首屏并发发出
|
||||
// 7 个受保护请求,全部 401,控制台一次刷出 7 行红字;约 2 秒后才由 401 拦截器
|
||||
// 刷新成功并重放。修复要求请求在发出前就认得过期 token,先刷新再发送。
|
||||
async function expectPreflight(
|
||||
name: string,
|
||||
run: Promise<void>,
|
||||
want: 'resolved' | 'rejected',
|
||||
) {
|
||||
let outcome: 'resolved' | 'rejected' = 'resolved'
|
||||
try {
|
||||
await run
|
||||
} catch {
|
||||
outcome = 'rejected'
|
||||
}
|
||||
if (outcome !== want) throw new Error(`authRefresh: ${name} = ${outcome}, want ${want}`)
|
||||
}
|
||||
|
||||
function preflightHarness(overrides: {
|
||||
accessToken?: string | null
|
||||
refresh?: () => Promise<RefreshOutcome>
|
||||
}) {
|
||||
const state = { token: overrides.accessToken === undefined ? 'at-1' : overrides.accessToken }
|
||||
const calls = { refresh: 0 }
|
||||
const ensure = createTokenPreflight({
|
||||
readAccessToken: () => state.token,
|
||||
refreshSession: async () => {
|
||||
calls.refresh += 1
|
||||
if (overrides.refresh) return overrides.refresh()
|
||||
state.token = jwtWithExp(Date.now() / 1000 + 3600)
|
||||
return 'refreshed'
|
||||
},
|
||||
now: () => NOW,
|
||||
})
|
||||
return { state, calls, ensure }
|
||||
}
|
||||
|
||||
{
|
||||
// 令牌新鲜:不得产生任何刷新请求。
|
||||
const { calls, ensure } = preflightHarness({ accessToken: jwtWithExp(NOW / 1000 + 3600) })
|
||||
await expectPreflight('fresh token passes straight through', ensure(), 'resolved')
|
||||
check('fresh token skips the refresh', calls.refresh === 0)
|
||||
}
|
||||
|
||||
{
|
||||
// 核心回归:过期令牌必须先刷新,且刷新完成后才返回(调用方据此发请求)。
|
||||
const { state, calls, ensure } = preflightHarness({ accessToken: jwtWithExp(NOW / 1000 - 60) })
|
||||
await expectPreflight('stale token refreshes before sending', ensure(), 'resolved')
|
||||
check('stale token refreshed exactly once', calls.refresh === 1)
|
||||
check('preflight handed out a fresh token',
|
||||
!isAccessTokenStale(state.token, NOW))
|
||||
}
|
||||
|
||||
{
|
||||
// 并发首屏:7 个请求同时预检,只允许刷新一次(协调器本身是单飞,
|
||||
// 这里确认预检不会绕过它)。
|
||||
const { calls, ensure } = preflightHarness({ accessToken: jwtWithExp(NOW / 1000 - 60) })
|
||||
await Promise.all([ensure(), ensure(), ensure(), ensure(), ensure(), ensure(), ensure()])
|
||||
check('concurrent preflight refreshes once', calls.refresh === 1)
|
||||
}
|
||||
|
||||
{
|
||||
// 瞬时故障(部署/重启窗口):保留会话,照常发出请求——
|
||||
// 页面自己会重试,不该把人挡在门外。
|
||||
const { ensure } = preflightHarness({
|
||||
accessToken: jwtWithExp(NOW / 1000 - 60),
|
||||
refresh: async () => 'transient',
|
||||
})
|
||||
await expectPreflight('transient failure still sends the request', ensure(), 'resolved')
|
||||
}
|
||||
|
||||
{
|
||||
// 服务端明确判定凭证失效:不发请求,直接失败,避免刷出无意义的 401。
|
||||
const { ensure } = preflightHarness({
|
||||
accessToken: jwtWithExp(NOW / 1000 - 60),
|
||||
refresh: async () => 'invalid',
|
||||
})
|
||||
await expectPreflight('revoked session refuses to send', ensure(), 'rejected')
|
||||
}
|
||||
|
||||
{
|
||||
// 未登录:交给路由守卫和 401 路径处理,预检不替它们做决定。
|
||||
const { calls, ensure } = preflightHarness({ accessToken: null })
|
||||
await expectPreflight('anonymous request is not preflighted', ensure(), 'resolved')
|
||||
check('anonymous request does not refresh', calls.refresh === 0)
|
||||
}
|
||||
|
||||
{
|
||||
// 非 JWT 令牌(Emby 等外部客户端签发):读不出 exp,按新鲜处理,
|
||||
// 绝不能因此陷入刷新循环。
|
||||
const { calls, ensure } = preflightHarness({ accessToken: 'opaque-token' })
|
||||
await expectPreflight('opaque token passes through', ensure(), 'resolved')
|
||||
check('opaque token does not refresh', calls.refresh === 0)
|
||||
}
|
||||
|
||||
{
|
||||
// 刷新抛出异常(网络不可达):按瞬时故障处理,请求照发。
|
||||
const { ensure } = preflightHarness({
|
||||
accessToken: jwtWithExp(NOW / 1000 - 60),
|
||||
refresh: async () => {
|
||||
throw new Error('network down')
|
||||
},
|
||||
})
|
||||
await expectPreflight('throwing refresh still sends the request', ensure(), 'resolved')
|
||||
}
|
||||
|
||||
// ── 单飞:并发调用共享同一次执行,结束后可重新执行 ────────────────────────
|
||||
{
|
||||
let calls = 0
|
||||
|
||||
@@ -15,6 +15,46 @@
|
||||
/** 一次刷新尝试的结果。 */
|
||||
export type RefreshOutcome = 'refreshed' | 'invalid' | 'transient'
|
||||
|
||||
/**
|
||||
* access token 过期前多久就视为失效。刷新要留出余量:刚好卡在 exp 上
|
||||
* 刷新,重放的原请求可能在服务端又已经过期。
|
||||
*/
|
||||
export const TOKEN_EXPIRY_SKEW_MS = 30_000
|
||||
|
||||
/**
|
||||
* 解析 JWT 的 exp 声明并换算成毫秒时间戳。
|
||||
*
|
||||
* 解析失败(非 JWT、base64url 损坏、缺少 exp、exp 不是数字)一律返回 null:
|
||||
* 调用方据此放行,绝不能把「读不懂」当成「已过期」,否则不透明的令牌会导致
|
||||
* 无休止的刷新循环。
|
||||
*/
|
||||
export function decodeJwtExpiryMs(token: string | null | undefined): number | null {
|
||||
if (!token) return null
|
||||
const segment = token.split('.')[1]
|
||||
if (!segment) return null
|
||||
try {
|
||||
// JWT 使用 base64url 编码,需先还原字符集并补齐 padding。
|
||||
const normalized = segment.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
const claims = JSON.parse(atob(padded)) as { exp?: unknown }
|
||||
return typeof claims.exp === 'number' ? claims.exp * 1000 : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* token 是否已过期或即将过期。
|
||||
*
|
||||
* 首屏放行前用它做一次判断:本地持久化的 token 往往已经过期,直接发请求只
|
||||
* 会一次性刷出一屏 401。读不出 exp 的令牌按「新鲜」处理(见 decodeJwtExpiryMs)。
|
||||
*/
|
||||
export function isAccessTokenStale(token: string | null | undefined, nowMs = Date.now()): boolean {
|
||||
const expiry = decodeJwtExpiryMs(token)
|
||||
if (expiry === null) return false
|
||||
return nowMs >= expiry - TOKEN_EXPIRY_SKEW_MS
|
||||
}
|
||||
|
||||
/** 刷新接口返回的令牌对。 */
|
||||
export interface RefreshTokens {
|
||||
token: string
|
||||
@@ -101,6 +141,44 @@ export function createBrowserExclusiveRunner(
|
||||
}
|
||||
}
|
||||
|
||||
export interface SessionGateDeps {
|
||||
/** 读取当前 access token(可能来自持久化存储,往往已经过期)。 */
|
||||
readAccessToken: () => string | null
|
||||
/** 刷新会话;语义与 useAuthStore.refreshSession 一致。 */
|
||||
refreshSession: () => Promise<RefreshOutcome>
|
||||
/** 可注入的时间源,便于测试。 */
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造请求前预检:每个请求发出前调用,必要时先把过期的令牌换新。
|
||||
*
|
||||
* 本地持久化的 access token 常常已经过期。此时若直接发请求,首屏那些各自
|
||||
* 独立的 useEffect 会在同一帧里并发打出受保护请求,全部拿到 401,控制台
|
||||
* 一次刷出一整屏红字,用户还要等 401 拦截器刷新完才能看到内容
|
||||
* (现场:2026-10-16 12:49,7 个请求同时 401)。
|
||||
*
|
||||
* 预检把「先刷新、再发送」放在请求发出之前:
|
||||
* - token 新鲜(或无法解析 exp)→ 立即返回;
|
||||
* - token 已过期 → 等待刷新(刷新协调器自带单飞,并发请求只发一次);
|
||||
* - 刷新遇到瞬时故障 → 照常发送(页面自己会重试);
|
||||
* - 服务端明确判定凭证失效 → 抛出,请求根本不发出去。
|
||||
*
|
||||
* 未登录(没有 token)直接返回:跳登录页是路由守卫的职责。
|
||||
*/
|
||||
export function createTokenPreflight(deps: SessionGateDeps): () => Promise<void> {
|
||||
return async () => {
|
||||
const token = deps.readAccessToken()
|
||||
if (!token) return
|
||||
const now = deps.now ?? Date.now
|
||||
if (!isAccessTokenStale(token, now())) return
|
||||
const outcome = await deps.refreshSession().catch((): RefreshOutcome => 'transient')
|
||||
if (outcome === 'invalid') {
|
||||
throw new Error('session expired')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface RefreshCoordinatorDeps {
|
||||
readAccessToken: () => string | null
|
||||
readRefreshToken: () => string | null
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CHAPTER_GROUP_SIZE, buildChapterGroups, chapterGroupIndexOf } from './chapterGroups'
|
||||
import { CHAPTER_GROUP_SIZE, buildChapterGroups, chapterGroupIndexOf } from './chapterGroups.ts'
|
||||
|
||||
// 目录分段分组的纯逻辑回归测试(对齐 utils/readerBookGroups.test.ts 的写法)。
|
||||
|
||||
|
||||
@@ -2,6 +2,17 @@ import type { Media } from '../types'
|
||||
import { isRemoteEmbyID } from './remoteEmby'
|
||||
import { getSeriesKey, isEpisodeLike, pathLooksEpisodic } from './groupSeries'
|
||||
|
||||
export function mediaSearchLink(query: string, page = 1): string {
|
||||
const params = new URLSearchParams({ q: query.trim() })
|
||||
if (page > 1) params.set('page', String(page))
|
||||
return `/search?${params}`
|
||||
}
|
||||
|
||||
export function mediaSearchReturnPath(state: unknown): string | undefined {
|
||||
if (!state || typeof state !== 'object' || !('from' in state)) return undefined
|
||||
return typeof state.from === 'string' && state.from.startsWith('/search?') ? state.from : undefined
|
||||
}
|
||||
|
||||
function pathLooksLikeMovie(media: Media): boolean {
|
||||
const path = (media.path || media.display_library_path || media.library_path || '').toLowerCase()
|
||||
return /[\\/](?:movie|movies|film|films|电影)[\\/]/i.test(path)
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
normalizeBookGroups,
|
||||
reorderBookGroups,
|
||||
resolveSelectedGroupId,
|
||||
} from './readerBookGroups'
|
||||
} from './readerBookGroups.ts'
|
||||
|
||||
// 书架分组的纯逻辑回归测试(对齐 utils/libraryTags.test.ts 的写法)。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user