Compare commits

...

24 Commits

Author SHA1 Message Date
truewhile a7b24e1bf1 Merge pull request #47 from truewhile/main
拉齐代码
2026-10-20 11:33:06 +08:00
truewhile 0b68397265 Merge pull request #46 from truewhile/fix/introdb-api-v4
迁移 TheIntroDB 接口到 v4
2026-10-19 22:26:33 +08:00
truewhile 90bfac3b09 迁移 TheIntroDB 接口到 v4
v1 已删除、v2/v3 已弃用,仅保留只读的 GET /media。本次把 base URL 切到 v4,并适配响应以 tvdb_id 为身份(tmdb_id 字段已移除)。请求仍用 tmdb_id 寻址,v4 仍接受并由服务端解析季集映射。
2026-10-19 22:23:06 +08:00
truewhile f1974a179e 优化 2026-10-19 21:38:55 +08:00
truewhile f2357200be 优化 2026-10-19 21:11:35 +08:00
truewhile ca9a80b862 优化 2026-10-18 22:30:02 +08:00
truewhile 4f19e2210e feat: 字幕轨名称可读化
外挂字幕原来直接拿文件名的语言后缀当名称(chs、chs[BilibiliID_danmu]),
内嵌字幕没有 title 标签时也是裸语言码(chi)。改成统一映射成中文名:
chi/zh -> 中文,chs -> 简体中文,jpn -> 日本語,并把弹幕转存的
[BilibiliID_danmu] 文件标注为「弹幕」,避免和对白字幕混淆。

认不出来的标记保持原样(含 und),Emby 客户端的「字幕 (ASS)」兜底不变。
2026-10-18 17:01:08 +08:00
truewhile 91667c400c bug处理 2026-10-18 15:37:31 +08:00
truewhile 0b8438d798 Merge pull request #45 from truewhile/feature/android-mobile-api
优化
2026-10-18 10:45:05 +08:00
truewhile 2faedae0bf 优化 2026-10-18 10:43:43 +08:00
truewhile fd57d2e59b Merge pull request #43 from truewhile/feature/android-mobile-api
优化
2026-10-18 02:20:26 +08:00
truewhile 8356f5c3c7 优化 2026-10-18 02:19:33 +08:00
truewhile 5d137ab6db Merge pull request #42 from truewhile/feature/android-mobile-api
Feature/android mobile api
2026-10-17 23:25:30 +08:00
truewhile e2133cd76f app兼容接口开发 2026-10-17 23:24:48 +08:00
truewhile 1c45483e1f feat(mobile): add v1 server info and device auth sessions 2026-10-17 12:27:28 +08:00
truewhile 40982f6675 Merge pull request #41 from truewhile/fix/database-playback-stability
修复数据库兼容性与 HLS 播放生命周期问题
2026-10-17 09:31:04 +08:00
jason jia 27dcfc2fe9 fix(playback): reuse completed HLS output and preserve resume positions 2026-10-17 01:30:04 +00:00
jason jia 984b98e61b fix: preserve database compatibility and harden request lifecycles 2026-10-17 01:29:51 +00:00
truewhile 5e496b35f1 Merge pull request #40 from truewhile/fix/search-library-grouping
fix(search): 按媒体库区分同名番剧搜索结果
2026-10-16 23:52:34 +08:00
jason jia 91eb0dadf6 fix(search): keep same series separate across libraries 2026-10-16 15:50:55 +00:00
truewhile 3d7d661199 Merge pull request #39 from truewhile/work
优化媒体搜索页面与返回状态保留
2026-10-16 22:58:45 +08:00
jason jia 8686d8cae6 优化媒体搜索页面与返回状态保留 2026-10-16 14:57:30 +00:00
truewhile cd6b37c6a0 Merge pull request #35 from truewhile/feature/reading
Feature/reading
2026-10-01 21:59:11 +08:00
truewhile ef8b017c63 Merge pull request #19 from truewhile/main
拉取最新代码
2026-09-02 17:13:21 +08:00
105 changed files with 6171 additions and 505 deletions
+34 -1
View File
@@ -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"
+9
View File
@@ -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`。
+3
View File
@@ -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()
+91
View File
@@ -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 目前只开放连接、登录、会话恢复与退出;媒体与阅读入口未实现。
+389
View File
@@ -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: 可选的稳定结构化细节,未定义时客户端必须忽略。
+238
View File
@@ -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)
}
})
})
}
}
+50
View File
@@ -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
}
+13 -11
View File
@@ -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 的替代品。
+1 -1
View File
@@ -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())
+15 -15
View File
@@ -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()
}
}
+79
View File
@@ -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")
}
}
+26
View File
@@ -197,6 +197,32 @@ func deleteEmbyMountHandler(svc *service.Container) gin.HandlerFunc {
}
}
// setEmbyMountsEnabledHandler 一键启用/停用某账号下的全部挂载。
//
// 远端整站不可用时(如 Cloudflare 522),管理员可以用它把该账号的挂载
// 全部从媒体库摘掉,首页立刻只返回本地库;远端恢复后再一键启用。
func setEmbyMountsEnabledHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Enabled *bool `json:"enabled" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if svc.EmbyRemote == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "emby remote service not available"})
return
}
affected, err := svc.EmbyRemote.SetMountsEnabled(c.Request.Context(), c.Param("id"), *req.Enabled)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "affected": affected})
}
}
type reorderEmbyMountsReq struct {
IDs []string `json:"ids" binding:"required"`
}
@@ -0,0 +1,108 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// TestSetEmbyMountsEnabledHandler 覆盖「一键停用/启用某账号下全部挂载」:
// 只影响目标账号,且配置保留(不是删除)。
func TestSetEmbyMountsEnabledHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
ctx := t.Context()
m1 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-1", Enabled: true}
m2 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-2", Enabled: true}
other := &model.EmbyMount{AccountID: "acct-2", RemoteViewID: "view-3", Enabled: true}
for _, m := range []*model.EmbyMount{m1, m2, other} {
if err := repos.EmbyMount.Create(ctx, m); err != nil {
t.Fatalf("create mount: %v", err)
}
}
svc := &service.Container{
Repo: repos,
EmbyRemote: service.NewEmbyRemoteService(nil, zap.NewNop(), repos, nil),
}
router := gin.New()
router.PUT("/admin/emby/accounts/:id/mounts/enabled", setEmbyMountsEnabledHandler(svc))
call := func(body map[string]any) *httptest.ResponseRecorder {
raw, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPut, "/admin/emby/accounts/acct-1/mounts/enabled", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
w := call(map[string]any{"enabled": false})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
OK bool `json:"ok"`
Affected int64 `json:"affected"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !resp.OK || resp.Affected != 2 {
t.Fatalf("expected ok with affected=2, got %+v", resp)
}
rows, err := repos.EmbyMount.List(ctx)
if err != nil {
t.Fatal(err)
}
for _, row := range rows {
want := row.AccountID != "acct-1" // 目标账号停用,其他账号不受影响
if row.Enabled != want {
t.Fatalf("mount %s enabled=%v, want %v", row.ID, row.Enabled, want)
}
}
// 重新启用:挂载记录仍在,只是开关回 true。
w = call(map[string]any{"enabled": true})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
rows, err = repos.EmbyMount.List(ctx)
if err != nil {
t.Fatal(err)
}
if len(rows) != 3 {
t.Fatalf("expected mounts to be preserved, got %d rows", len(rows))
}
for _, row := range rows {
if !row.Enabled {
t.Fatalf("mount %s should be enabled again", row.ID)
}
}
// 缺少 enabled 字段必须拒绝,避免误把全部挂载停掉。
if w := call(map[string]any{}); w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for missing enabled, got %d: %s", w.Code, w.Body.String())
}
}
+2
View File
@@ -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)
+111
View File
@@ -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
}
}
+3 -1
View File
@@ -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)
}
+395
View File
@@ -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
}
+434
View File
@@ -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)
}
}
+129
View File
@@ -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()
}
}
+81
View File
@@ -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)
}
}
+44 -2
View File
@@ -51,11 +51,53 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
// 远程 Emby 挂载没有本地文件,不能提供转码地址。STRM 默认直连,直连失败时可走 HLS。
hlsURL = ""
}
c.JSON(http.StatusOK, gin.H{
// 起播路径原本还要各发一次 /playback/{id}/resume 和
// /media/{id}/subtitles。把这两份数据并进播放地址响应后,移动端起播
// 只剩一个请求 —— 跨境链路上每多一条连接就多一次 DNS/TLS 建连,
// 实测单次建连可到数秒。老客户端不读这两个字段,行为不变。
uid, _ := c.Get(middleware.CtxUserID)
resumePayload := gin.H{"position_ms": int64(0), "duration_ms": int64(0), "completed": false}
if svc.Playback != nil {
if row, err := svc.Playback.GetProgress(c.Request.Context(), toString(uid), m.ID); err == nil && row != nil {
resumePayload = gin.H{
"position_ms": row.PositionMs,
"duration_ms": row.DurationMs,
"completed": row.Completed,
}
}
}
// 直连播放只需要外挂字幕;内嵌字幕由播放器从容器里解。
subtitles := []service.SubtitleTrack{}
if svc.Subtitle != nil && !(svc.EmbyRemote != nil && service.IsEmbyRemoteID(m.ID)) {
if discovered, err := svc.Subtitle.DiscoverExternalOnly(c.Request.Context(), m.ID); err == nil && discovered != nil {
subtitles = discovered
}
}
// 能在服务端换链就把最终直链一并返回:客户端直接连 CDN,省掉
// /api/stream 的 302 往返。跨境/高延迟链路上,这一跳等于一次完整的
// DNS + TCP + TLS,实测能到数秒,正好压在起播关键路径上。
// 换链失败、本地文件或需要服务端加头的网盘保持原样,客户端仍走
// stream_url 跳转,行为与改动前一致。
directURL, hasDirectURL := "", false
if svc.Stream != nil {
// `?refresh=1`:客户端手上的直链已失效(CDN 403),要求丢掉缓存
// 重新换链,别把同一条死链再发一次。
if strings.EqualFold(strings.TrimSpace(c.Query("refresh")), "1") {
service.InvalidateMediaLinkCache(m)
}
directURL, hasDirectURL = svc.Stream.ResolveDirectPlayURL(c.Request, m)
}
payload := gin.H{
"media": m,
"stream_url": "/api/stream/" + m.ID + "?token=" + url.QueryEscape(token) + profileQuery,
"hls_url": hlsURL,
})
"resume": resumePayload,
"subtitles": subtitles,
}
if hasDirectURL {
payload["direct_url"] = directURL
}
c.JSON(http.StatusOK, payload)
}
}
+65
View File
@@ -22,6 +22,7 @@ import (
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/cloud"
)
func TestExternalURLUsesMediaScopedPlaybackToken(t *testing.T) {
@@ -435,6 +436,10 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
var payload struct {
StreamURL string `json:"stream_url"`
HlsURL string `json:"hls_url"`
// 移动端起播只打这一个接口:resume / subtitles 必须始终存在,
// 哪怕是默认值,否则客户端会退回额外两个请求。
Resume *struct{ PositionMs int64 `json:"position_ms"` } `json:"resume"`
Subtitles *[]struct{} `json:"subtitles"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v", err)
@@ -445,6 +450,66 @@ func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
if payload.HlsURL == "" || !strings.Contains(payload.HlsURL, "/api/hls/media-1/") {
t.Fatalf("expected strm hls_url, got %q", payload.HlsURL)
}
if payload.Resume == nil {
t.Fatalf("playback info must always carry resume (got body=%s)", w.Body.String())
}
if payload.Subtitles == nil {
t.Fatalf("playback info must always carry subtitles (got body=%s)", w.Body.String())
}
}
// 服务端能换到最终直链时,播放地址响应要把直链一并给客户端:移动端起播就能
// 跳过 /api/stream 的 302 往返(跨境链路上等于一次完整 DNS+TCP+TLS)。
func TestPlaybackInfoCarriesPrefetchedDirectURL(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
loginToken := signedTestToken(t, secret)
const direct = "https://cdnfhnfile.115cdn.net/637b/Movie.mkv?t=1&k=sig"
var gotUA string
svc.Stream.SetStrmPlayTargetResolver(func(_ context.Context, _, userAgent string) (*service.StrmPlayResult, error) {
gotUA = userAgent
return &service.StrmPlayResult{
RedirectURL: direct,
Link: &cloud.DirectLink{URL: direct, Headers: map[string]string{"User-Agent": userAgent}},
}, nil
})
if err := svc.Repo.DB.Create(&model.Media{
Base: model.Base{ID: "media-direct"},
LibraryID: "lib-1",
Title: "Direct",
Path: "cloud://cloud115/Movie.mkv",
Container: "strm",
DurationSec: 3600,
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1",
}).Error; err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-direct/info", nil)
req.Header.Set("Authorization", "Bearer "+loginToken)
req.Header.Set("User-Agent", "MeBox-Android/0.1.0")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var payload struct {
DirectURL string `json:"direct_url"`
StreamURL string `json:"stream_url"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v", err)
}
if payload.DirectURL != direct {
t.Fatalf("direct_url = %q, want %q (body=%s)", payload.DirectURL, direct, w.Body.String())
}
if !strings.Contains(payload.StreamURL, "/api/stream/media-direct") {
t.Fatalf("stream_url must stay as the fallback, got %q", payload.StreamURL)
}
// 115 直链绑定换取时的 UA:预置直链必须按播放器的 UA 换,否则播放会 403。
if gotUA != "MeBox-Android/0.1.0" {
t.Fatalf("resolver UA = %q, want the client UA", gotUA)
}
}
func TestSubtitleListOnlyProbesEmbeddedTracksForHLS(t *testing.T) {
+1
View File
@@ -50,6 +50,7 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
// Emby 挂载管理:远程 Emby 媒体库挂载(账号复用 strm/accounts)
admin.GET("/emby/accounts/:id/views", embyAccountViewsHandler(svc))
admin.POST("/emby/accounts/:id/full-mount", fullMountEmbyAccountHandler(svc))
admin.PUT("/emby/accounts/:id/mounts/enabled", setEmbyMountsEnabledHandler(svc))
admin.GET("/emby/mounts", listEmbyMountsHandler(svc))
admin.POST("/emby/mounts", createEmbyMountsHandler(svc))
admin.PUT("/emby/mounts/reorder", reorderEmbyMountsHandler(svc))
+20
View File
@@ -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)
}
+48
View File
@@ -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)
}
+2
View File
@@ -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.
+49
View File
@@ -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()
}
+1
View File
@@ -49,6 +49,7 @@ func AllModels() []interface{} {
&APIConfig{},
&UserPermission{},
&RefreshToken{},
&AuthSession{},
&PlayProfile{},
&RegistrationCode{},
&SignIn{},
+8 -6
View File
@@ -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
}
@@ -3,6 +3,7 @@ package repository
import (
"context"
"errors"
"strings"
"time"
"gorm.io/gorm"
@@ -89,6 +90,22 @@ func (r *EmbyMountRepository) SetSortOrder(ctx context.Context, ids []string) er
})
}
// SetEnabledByAccountID 批量设置账号下全部挂载的启用状态。
func (r *EmbyMountRepository) SetEnabledByAccountID(ctx context.Context, accountID string, enabled bool) (int64, error) {
var affected int64
if strings.TrimSpace(accountID) == "" {
return 0, nil
}
err := withSQLiteBusyRetry(ctx, func() error {
res := r.db.WithContext(ctx).Model(&model.EmbyMount{}).
Where("account_id = ?", accountID).
Updates(map[string]any{"enabled": enabled, "updated_at": time.Now()})
affected = res.RowsAffected
return res.Error
})
return affected, err
}
func (r *EmbyMountRepository) CountByAccountID(ctx context.Context, accountID string) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.EmbyMount{}).Where("account_id = ?", accountID).Count(&count).Error
+11 -11
View File
@@ -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))
+16 -7
View File
@@ -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
+4 -19
View File
@@ -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 {
+53
View File
@@ -0,0 +1,53 @@
package repository
import (
"context"
"github.com/truewhile/MeBox/internal/model"
)
// MediaSeriesIndexColumns 是解析剧集归组 key 所需的全部列。
//
// 剧集归组(首页「最近添加」、媒体库卡片)必须看到整库每一行才能算出正确的
// key 和集数,因此不能只用 SQL 顶端的几行。但整行扫描会把 overview、genres、
// strm_url 这些大文本列一并读进来再丢掉:7k 行规模下驱动层的逐列 bind 占了
// 绝大部分耗时(实测 ~60%)。这里只取归组真正用到的列,卡片代表行在选出
// 前 N 个剧集后按 id 再补一次完整行。
const MediaSeriesIndexColumns = "" +
"id, created_at, updated_at, library_id, library_root_id, series_id, " +
"title, original_name, path, poster_url, backdrop_url, " +
"year, release_date, season_num, episode_num, episode_fraction, " +
"tm_db_id, bangumi_id, douban_id, thetvdb_id, " +
"width, height, size_bytes, video_codec, audio_codec, container, nsfw"
// ListMediaSeriesIndex 按 created_at 倒序返回可见媒体的「归组投影」。
//
// 与 SearchFiltered 的区别:
// - 不做 COUNT(*)(调用方不需要总数,7k 行时这一条 SQL 白跑一遍全表);
// - 只 SELECT 归组所需列,避免大文本列的解码与内存拷贝。
func (r *MediaRepository) ListMediaSeriesIndex(ctx context.Context, limit int, filter MediaQueryFilter) ([]model.Media, error) {
var items []model.Media
q := r.db.WithContext(ctx).Model(&model.Media{}).
Select(MediaSeriesIndexColumns).
Order("created_at desc")
q = applyMediaQueryFilter(q, filter)
if limit > 0 {
q = q.Limit(limit)
}
if err := q.Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
// ListMediaByIDs 按 ID 批量取完整行(用于补齐剧集卡片的代表行)。
func (r *MediaRepository) ListMediaByIDs(ctx context.Context, ids []string) ([]model.Media, error) {
if len(ids) == 0 {
return nil, nil
}
var items []model.Media
if err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
@@ -46,16 +46,20 @@ func (r *RefreshTokenRepository) RevokeByUserID(ctx context.Context, userID stri
})
}
// RevokeOldestActiveByUserID keeps at most limit active refresh tokens for a
// user by revoking the oldest non-expired, non-revoked tokens.
func (r *RefreshTokenRepository) RevokeOldestActiveByUserID(ctx context.Context, userID string, limit int) error {
// RevokeOldestActiveLegacyByUserID keeps at most limit active refresh tokens
// for a user among tokens that are not bound to a device auth session
// (legacy web clients). Device sessions are independent: trimming one
// device's token because another device logged in would silently sign the
// first device out even though its session is still valid.
func (r *RefreshTokenRepository) RevokeOldestActiveLegacyByUserID(ctx context.Context, userID string, limit int) error {
if limit < 1 {
limit = 1
}
return withSQLiteBusyRetry(ctx, func() error {
var tokens []model.RefreshToken
if err := r.db.WithContext(ctx).
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()).
Where("user_id = ? AND revoked = ? AND expires_at > ? AND (auth_session_id IS NULL OR auth_session_id = '')",
userID, false, time.Now()).
Order("created_at desc, id desc").
Find(&tokens).Error; err != nil {
return err
@@ -79,7 +83,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 +91,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))
+2
View File
@@ -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},
+24 -7
View File
@@ -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) {
+187
View File
@@ -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])
}
+261
View File
@@ -0,0 +1,261 @@
package service
import (
"context"
"errors"
"fmt"
"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)
}
}
// TestMobileDeviceSessionsDoNotTrimEachOther 覆盖同一账号多设备登录:
// 每台设备有独立认证会话与刷新令牌,新设备登录或任一设备刷新都不应撤销
// 其他设备的刷新令牌。历史实现把「最大同时登录终端」直接作用在刷新令牌
// 上,第 N+1 台设备登录会把最早那台设备的刷新令牌标记为已撤销,那台设备
// 下一次刷新就会收到 SESSION_REVOKED,被强制退回登录页。
func TestMobileDeviceSessionsDoNotTrimEachOther(t *testing.T) {
sessions, tokens, repos := newAuthSessionTestEnv(t)
ctx := context.Background()
user := seedSessionUser(t, repos)
// 默认上限是 3,这里故意登录 4 台设备。
const devices = 4
pairs := make([]*TokenPair, 0, devices)
for i := 0; i < devices; i++ {
session, err := sessions.Create(ctx, CreateAuthSessionInput{
UserID: user.ID,
DeviceID: fmt.Sprintf("device-%02d", i),
DeviceName: fmt.Sprintf("Phone %d", i+1),
})
if err != nil {
t.Fatalf("create session %d: %v", i+1, err)
}
pair, err := tokens.IssuePairForSession(ctx, user.ID, user.Role, user.Tier, session.ID)
if err != nil {
t.Fatalf("issue pair %d: %v", i+1, err)
}
pairs = append(pairs, pair)
}
for i, pair := range pairs {
row, err := repos.RefreshToken.FindByHash(ctx, repository.HashToken(pair.RefreshToken))
if err != nil {
t.Fatalf("load device %d token: %v", i+1, err)
}
if row == nil {
t.Fatalf("device %d refresh token missing", i+1)
}
if row.Revoked {
t.Fatalf("device %d refresh token was revoked by another device", i+1)
}
if _, err := tokens.Refresh(ctx, pair.RefreshToken); err != nil {
t.Fatalf("device %d refresh failed: %v", i+1, err)
}
}
}
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)
}
}
+8 -2
View File
@@ -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
}
+2 -1
View File
@@ -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)
}
}
+89 -10
View File
@@ -1,26 +1,34 @@
// 下载直链进程级缓存。
//
// 115 的 /open/ufile/downurl 换取结果在一段时间内有效,且换取请求是
// WAF 风控重点关注的密集接口。缓存按 pickcode 存直链、避免对同一文件
// 反复换取;下载失败(http 403/404 等)时由调用方主动清除对应条目,
// 下一轮重试会重新换取链接。
// 115 的 /open/ufile/downurl 换取结果带 `t=` 过期时间戳(实测为「换取时刻 +
// 1800 秒」,即 30 分钟),且换取请求是 WAF 风控重点关注的密集接口。缓存按
// pickcode 存直链、避免对同一文件反复换取;下载失败(http 403/404 等)时由
// 调用方主动清除对应条目,下一轮重试会重新换取链接。
package cloud115
import (
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// downloadURLCacheTTL 直链缓存时长。115 直链一般有效 1 小时左右,
// 取 45 分钟留出余量。QMediaSync 同类缓存使用 50 分钟。
const downloadURLCacheTTL = 45 * time.Minute
// downloadURLCacheMaxTTL 直链缓存上限:仅在解析不出直链自带过期时间(`t=`)
// 时使用。115 直链实际有效 30 分钟,这里取 45 分钟只是兜底上限,正常情况下
// 由 linkExpirySafety 提前失效,避免把已经过期的直链发给客户端。
const downloadURLCacheMaxTTL = 45 * time.Minute
// linkExpirySafety 提前量:直链自身的过期时间戳到达前就停止使用,留出客户端
// 建连与传输的余量,防止出现「服务端认为还有效、CDN 已经 403」的窗口。
const linkExpirySafety = 2 * time.Minute
// maxCachedURLs 超过该数量时顺带清理过期条目,防止 map 无限膨胀。
const maxCachedURLs = 10000
type urlCacheEntry struct {
url string
createdAt time.Time
expiresAt time.Time
}
@@ -37,6 +45,43 @@ func urlCacheKey(pickCode, ua string) string {
return pickCode + "@" + ua
}
// LinkExpiry 解析 115 直链自带的过期时间戳(`t=` 参数,单位秒)。
// 解析不出时返回零值,调用方按兜底 TTL 处理。
func LinkExpiry(rawURL string) time.Time {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || u == nil {
return time.Time{}
}
raw := strings.TrimSpace(u.Query().Get("t"))
if raw == "" {
return time.Time{}
}
sec, err := strconv.ParseInt(raw, 10, 64)
if err != nil || sec <= 0 {
return time.Time{}
}
return time.Unix(sec, 0)
}
// cacheExpiry 计算写入缓存时应使用的过期时刻:跟随直链自身的 `t=` 并提前
// linkExpirySafety,解析不出时退回 downloadURLCacheMaxTTL。返回零值表示这条
// 直链已经过期,不应写入缓存。
func cacheExpiry(rawURL string, now time.Time) time.Time {
maxExpiry := now.Add(downloadURLCacheMaxTTL)
linkExpiry := LinkExpiry(rawURL)
if linkExpiry.IsZero() {
return maxExpiry
}
expiry := linkExpiry.Add(-linkExpirySafety)
if !expiry.After(now) {
return time.Time{}
}
if expiry.After(maxExpiry) {
return maxExpiry
}
return expiry
}
// GetDownloadURLCache 返回未过期的缓存直链;不存在或已过期返回空串。
func GetDownloadURLCache(pickCode string, uas ...string) string {
if pickCode == "" {
@@ -59,11 +104,17 @@ func GetDownloadURLCache(pickCode string, uas ...string) string {
return entry.url
}
// SetDownloadURLCache 写入直链缓存。
// SetDownloadURLCache 写入直链缓存。过期时刻跟随直链自身的 `t=`,已经过期的
// 直链直接丢弃,避免把死链缓存下来。
func SetDownloadURLCache(pickCode string, url string, uas ...string) {
if pickCode == "" || url == "" {
return
}
now := time.Now()
expiresAt := cacheExpiry(url, now)
if expiresAt.IsZero() {
return
}
ua := ""
if len(uas) > 0 {
ua = uas[0]
@@ -71,7 +122,6 @@ func SetDownloadURLCache(pickCode string, url string, uas ...string) {
urlCacheMu.Lock()
defer urlCacheMu.Unlock()
if len(urlCache) >= maxCachedURLs {
now := time.Now()
for k, e := range urlCache {
if now.After(e.expiresAt) {
delete(urlCache, k)
@@ -79,7 +129,7 @@ func SetDownloadURLCache(pickCode string, url string, uas ...string) {
}
}
key := urlCacheKey(pickCode, ua)
urlCache[key] = urlCacheEntry{url: url, expiresAt: time.Now().Add(downloadURLCacheTTL)}
urlCache[key] = urlCacheEntry{url: url, createdAt: now, expiresAt: expiresAt}
}
// ClearDownloadURLCache 删除指定 pickcode 的所有缓存(下载得到非 2xx 时调用)。
@@ -95,3 +145,32 @@ func ClearDownloadURLCache(pickCode string) {
}
}
}
// ClearDownloadURLCacheIfOlderThan 删除指定 pickcode 下「已经存在超过 minAge」
// 的缓存条目,返回是否删除了至少一条。
//
// 客户端上报「直链被 CDN 拒绝」时用它强制换链:既让下一次解析拿到新地址,
// 又不会让同一秒内的重试风暴把 115 换链接口打出突发。
func ClearDownloadURLCacheIfOlderThan(pickCode string, minAge time.Duration) bool {
if pickCode == "" {
return false
}
if minAge < 0 {
minAge = 0
}
now := time.Now()
removed := false
urlCacheMu.Lock()
defer urlCacheMu.Unlock()
for k, e := range urlCache {
if k != pickCode && !strings.HasPrefix(k, pickCode+"@") {
continue
}
if now.Sub(e.createdAt) < minAge {
continue
}
delete(urlCache, k)
removed = true
}
return removed
}
@@ -1,6 +1,7 @@
package cloud115
import (
"fmt"
"testing"
"time"
)
@@ -29,3 +30,72 @@ func TestDownloadURLCache(t *testing.T) {
t.Fatalf("cleared entry should be a miss, got %q", got)
}
}
// 115 直链自带 `t=` 过期时间戳(实测为换取时刻 + 1800 秒)。缓存必须跟随它,
// 否则会出现「服务端认为还有效、CDN 已经 403」的窗口:直链被缓存到 45 分钟,
// 但第 30 分钟起客户端就必然拿到 403。
func TestDownloadURLCacheExpiryFollowsLinkTimestamp(t *testing.T) {
now := time.Now()
link := fmt.Sprintf("https://cdn/1/a.mkv?t=%d&u=1&k=abc", now.Add(30*time.Minute).Unix())
got := cacheExpiry(link, now)
want := now.Add(30*time.Minute - linkExpirySafety)
if diff := got.Sub(want); diff > time.Second || diff < -time.Second {
t.Fatalf("cacheExpiry = %v, want %v", got, want)
}
// 解析不出 t= 时退回兜底 TTL,保持旧行为。
fallback := cacheExpiry("https://cdn/1/a.mkv", now)
if diff := fallback.Sub(now.Add(downloadURLCacheMaxTTL)); diff > time.Second || diff < -time.Second {
t.Fatalf("fallback cacheExpiry = %v, want %v", fallback, now.Add(downloadURLCacheMaxTTL))
}
// 直链本身已经过期(或只剩不足提前量的寿命)时不进缓存。
if got := cacheExpiry(fmt.Sprintf("https://cdn/1/a.mkv?t=%d", now.Add(-time.Minute).Unix()), now); !got.IsZero() {
t.Fatalf("expired link must not be cached, got expiry %v", got)
}
if got := cacheExpiry(fmt.Sprintf("https://cdn/1/a.mkv?t=%d", now.Add(time.Minute).Unix()), now); !got.IsZero() {
t.Fatalf("link expiring inside the safety margin must not be cached, got %v", got)
}
}
// 客户端上报「直链被 CDN 拒绝」时按 pickcode 清缓存,但要挡住刚写入的条目,
// 避免重试风暴把 115 换链接口打出突发。
func TestClearDownloadURLCacheIfOlderThan(t *testing.T) {
urlCacheMu.Lock()
urlCache = map[string]urlCacheEntry{}
urlCacheMu.Unlock()
t.Cleanup(func() {
urlCacheMu.Lock()
urlCache = map[string]urlCacheEntry{}
urlCacheMu.Unlock()
})
future := time.Now().Add(30 * time.Minute)
link := fmt.Sprintf("https://cdn/1/a.mkv?t=%d", future.Unix())
SetDownloadURLCache("pick-fresh", link, "ua-a")
if ClearDownloadURLCacheIfOlderThan("pick-fresh", time.Minute) {
t.Fatal("fresh entry must survive the min-age guard")
}
if got := GetDownloadURLCache("pick-fresh", "ua-a"); got != link {
t.Fatalf("fresh entry lost, got %q", got)
}
SetDownloadURLCache("pick-old", link, "ua-a")
urlCacheMu.Lock()
entry := urlCache[urlCacheKey("pick-old", "ua-a")]
entry.createdAt = time.Now().Add(-10 * time.Minute)
urlCache[urlCacheKey("pick-old", "ua-a")] = entry
urlCacheMu.Unlock()
if !ClearDownloadURLCacheIfOlderThan("pick-old", time.Minute) {
t.Fatal("stale entry should be cleared")
}
if got := GetDownloadURLCache("pick-old", "ua-a"); got != "" {
t.Fatalf("stale entry should be gone, got %q", got)
}
// 无关 pickcode 不受影响。
if got := GetDownloadURLCache("pick-fresh", "ua-a"); got != link {
t.Fatalf("unrelated pickcode lost, got %q", got)
}
}
+16
View File
@@ -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
}
+7
View File
@@ -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.
+67 -1
View File
@@ -40,6 +40,23 @@ import (
// embyRemoteHTTPTimeout 远程 Emby 常规 API 请求超时(流式代理不在此列)。
const embyRemoteHTTPTimeout = 15 * time.Second
// embyRemoteViewsBudget 是网页端聚合远程媒体库时的总时间预算。
//
// 上游 Emby 整体不可用时(典型表现:Cloudflare 522,连接卡住近 20 秒),
// 首页 /api/libraries 会被同步阻塞,而前端 axios 30 秒就放弃,于是一个挂掉的
// 远端会把整个首页拖成加载失败。这里给聚合调用一个硬预算:超时即降级为
// 「只返回本地库」,把首屏确定性地控制在几秒内。
var embyRemoteViewsBudget = 5 * time.Second
// embyRemoteViewsFailureTTL 是远程 Views 拉取失败后的负缓存时长。
//
// 失败结果也缓存一小段时间,避免每个请求都重新等满预算(首页每次刷新、
// 每个并发客户端都会各打一次)。时间很短,远端恢复后最多 30 秒即可自动重试。
var embyRemoteViewsFailureTTL = 30 * time.Second
// errRemoteViewsRecentlyFailed 表示远程 Emby 刚失败过,当前处于负缓存窗口。
var errRemoteViewsRecentlyFailed = errors.New("远程 Emby 暂时不可用(近期请求失败,稍后自动重试)")
// embyRemoteUA 桌面浏览器 UA:远程 Emby 前方若有 Cloudflare/WAF 会拦截
// Go-http-client 等非浏览器 UA(403 error code: 1010),必须伪装浏览器。
const embyRemoteUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
@@ -422,6 +439,22 @@ func (r *EmbyRemoteService) DeleteMount(ctx context.Context, id string) error {
return err
}
// SetMountsEnabled 批量启用/停用某账号下的全部挂载。
//
// 账号整体不可用时(远端宕机、线路全挂),管理员可以一键把所有挂载从媒体库
// 中摘掉:网页端 /api/libraries 不再为该账号发起任何远程请求,首页恢复秒开;
// 远端恢复后再一键启用即可,挂载配置和播放设置都不丢。
func (r *EmbyRemoteService) SetMountsEnabled(ctx context.Context, accountID string, enabled bool) (int64, error) {
affected, err := r.repo.EmbyMount.SetEnabledByAccountID(ctx, accountID, enabled)
if err != nil {
return 0, err
}
// 启停会改变可见库集合,顺手清掉远程元数据缓存(含 Views 失败负缓存),
// 让下一次请求立刻按新状态重新探测,而不是继续吃 30 秒的负缓存。
r.invalidateRemoteMediaCache(ctx)
return affected, nil
}
// ReorderMounts 批量重排挂载媒体库顺序。
func (r *EmbyRemoteService) ReorderMounts(ctx context.Context, ids []string) error {
if len(ids) == 0 {
@@ -822,7 +855,13 @@ func (r *EmbyRemoteService) TestConnection(ctx context.Context, acct *model.Strm
return err
}
var out json.RawMessage
return r.doGet(ctx, acct, cfg, "/System/Info", nil, &out)
if err := r.doGet(ctx, acct, cfg, "/System/Info", nil, &out); err != nil {
return err
}
// 连通性恢复:清掉 Views 失败负缓存与旧元数据,让下一次媒体库列表请求
// 立即重新探测,而不是继续在负缓存窗口里显示「没有远程库」。
r.invalidateRemoteMediaCache(ctx)
return nil
}
// ProxyPlayOf 返回账号是否配置了播放代理(供账号列表/编辑回显)。
@@ -837,7 +876,34 @@ func (r *EmbyRemoteService) ProxyPlayOf(acct *model.StrmAccount) (bool, error) {
// ─── 元数据 / 目录聚合 ─────────────────────────────────────────────────────────
// RemoteViews 拉取远程媒体库(View)列表,返回远程原始 view map(未重写)。
//
// 失败时写入短时负缓存:远程整站不可用时,避免每次首页刷新都重新等满超时。
func (r *EmbyRemoteService) RemoteViews(ctx context.Context, acct *model.StrmAccount) ([]map[string]any, error) {
if acct == nil {
return nil, errors.New("缺少远程 Emby 账号")
}
failKey := r.remoteCacheKey("views-fail", acct.ID)
var failed bool
if r.cache != nil && r.cache.GetJSON(ctx, failKey, &failed) && failed {
return nil, errRemoteViewsRecentlyFailed
}
views, err := r.remoteViewsUncached(ctx, acct)
if err != nil {
// 客户端主动断开(切换页面 / 关标签)不算上游故障,不能让它把
// 负缓存窗口写进去,否则正常用户会被别人的一次取消拖慢 30 秒。
if r.cache != nil && !errors.Is(err, context.Canceled) {
r.cache.SetJSON(ctx, failKey, true, embyRemoteViewsFailureTTL)
}
return nil, err
}
if r.cache != nil {
r.cache.SetJSON(ctx, failKey, false, embyRemoteViewsFailureTTL)
}
return views, nil
}
// remoteViewsUncached 是 RemoteViews 的实际拉取逻辑(含正常结果缓存)。
func (r *EmbyRemoteService) remoteViewsUncached(ctx context.Context, acct *model.StrmAccount) ([]map[string]any, error) {
cfg, err := r.remoteConfigWithToken(ctx, acct)
if err != nil {
return nil, err
@@ -0,0 +1,155 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// TestRemoteLibrariesFastFailAndNegativeCache 覆盖首页可用的底线保证:
// 上游 Emby 整体挂起时,聚合远程库必须在一个很短的预算内失败并降级为
// 「没有远程库」,并且在负缓存窗口内不再重复打上游。
func TestRemoteLibrariesFastFailAndNegativeCache(t *testing.T) {
oldBudget, oldTTL := embyRemoteViewsBudget, embyRemoteViewsFailureTTL
embyRemoteViewsBudget = 200 * time.Millisecond
embyRemoteViewsFailureTTL = time.Minute
t.Cleanup(func() {
embyRemoteViewsBudget = oldBudget
embyRemoteViewsFailureTTL = oldTTL
})
release := make(chan struct{})
var hits int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
// 模拟 Cloudflare 522 前近 20 秒的挂起:请求一直不返回。
select {
case <-release:
case <-time.After(10 * time.Second):
}
}))
defer upstream.Close()
defer close(release)
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
remote := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop())).
SetRuntimeCache(NewRuntimeCacheService(nil, zap.NewNop()))
rawConfig, _ := json.Marshal(map[string]string{
"url": upstream.URL,
"api_key": "test-api-key",
"remote_user_id": "uid-1",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-hang"},
Name: "挂起的远端",
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
mount := &model.EmbyMount{
Base: model.Base{ID: "mount-hang"},
AccountID: acct.ID,
RemoteViewID: "view-1",
RemoteViewName: "卡住的库",
CollectionType: "movies",
Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
t.Fatalf("create mount: %v", err)
}
start := time.Now()
views, err := remote.RemoteLibraries(t.Context())
elapsed := time.Since(start)
if err != nil {
t.Fatalf("RemoteLibraries should degrade instead of failing: %v", err)
}
if len(views) != 0 {
t.Fatalf("expected no remote libraries while upstream hangs, got %d", len(views))
}
if elapsed > 2*time.Second {
t.Fatalf("RemoteLibraries took %v, want within the short budget", elapsed)
}
if atomic.LoadInt32(&hits) == 0 {
t.Fatal("expected upstream to be probed once")
}
// 负缓存窗口内:不再打上游,且立即返回空结果。
before := atomic.LoadInt32(&hits)
start = time.Now()
views, err = remote.RemoteLibraries(t.Context())
elapsed = time.Since(start)
if err != nil {
t.Fatalf("second RemoteLibraries should not fail: %v", err)
}
if len(views) != 0 {
t.Fatalf("expected no remote libraries from negative cache, got %d", len(views))
}
if elapsed > time.Second {
t.Fatalf("negative cache should answer immediately, took %v", elapsed)
}
if got := atomic.LoadInt32(&hits); got != before {
t.Fatalf("negative cache should skip upstream, hits went %d -> %d", before, got)
}
}
// TestRemoteViewsNotNegativeCachedOnClientCancel 保证客户端主动断开不会污染
// 负缓存:否则一个用户切页就会让所有人在 30 秒内看不到远程库。
func TestRemoteViewsNotNegativeCachedOnClientCancel(t *testing.T) {
release := make(chan struct{})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
case <-time.After(10 * time.Second):
}
}))
defer upstream.Close()
defer close(release)
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
repos := repository.New(db)
cache := NewRuntimeCacheService(nil, zap.NewNop())
remote := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop())).
SetRuntimeCache(cache)
rawConfig, _ := json.Marshal(map[string]string{
"url": upstream.URL,
"api_key": "test-api-key",
"remote_user_id": "uid-1",
})
acct := &model.StrmAccount{
Base: model.Base{ID: "acct-cancel"},
Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig),
Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
t.Fatalf("create account: %v", err)
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
if _, err := remote.RemoteViews(ctx, acct); err == nil {
t.Fatal("expected canceled context to fail")
}
var failed bool
if cache.GetJSON(t.Context(), remote.remoteCacheKey("views-fail", acct.ID), &failed) && failed {
t.Fatal("client cancel must not write the negative cache")
}
}
+6
View File
@@ -35,6 +35,12 @@ type RemoteLibraryView struct {
// RemoteLibraries 把所有启用挂载的远程媒体库映射为网页媒体库列表
// (只有显式挂载的库才出现在本项目媒体库中)。
func (r *EmbyRemoteService) RemoteLibraries(ctx context.Context) ([]RemoteLibraryView, error) {
// 远程库只是「追加」到本地库列表上,绝不允许它拖住整页首屏:给整段
// 聚合(多账号 × 多线路故障转移)一个总预算,超时后各账号按失败处理,
// 调用方随即降级为只返回本地库。
ctx, cancel := context.WithTimeout(ctx, embyRemoteViewsBudget)
defer cancel()
mounts, err := r.ListMounts(ctx)
if err != nil || len(mounts) == 0 {
return nil, err
+15 -3
View File
@@ -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).
+13
View File
@@ -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) {
+2
View File
@@ -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"} {
+4 -60
View File
@@ -14,14 +14,12 @@ package service
import (
"context"
"errors"
"net"
"net/http"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"time"
"go.uber.org/zap"
@@ -103,68 +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)与显式配置的服务(如
// MetaTube)豁免该私网限制。豁免必须在 DialContext 这一层判断:
// Control 回调收到的 address 已经是**解析后的 IP**,而放行名单里
// 存的是主机名(mebox-metatube-1、NAS 名),在 Control 里比较永远
// 不相等——那段分支对主机名条目是死代码,现场表现就是「名单里有
// 这个主机,回源依然被拒」。
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 {
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
},
}
// 放行目标的专用拨号器:不再做私网校验,但仍然只连到请求里的那个
// 主机(豁免的是私网限制,不是「任意目标」)。
allowlistedDialer := &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
}
if proxy.isAllowedRemoteHost(host) {
return allowlistedDialer.DialContext(ctx, network, address)
}
return guardDialer.DialContext(ctx, network, address)
}
}
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
@@ -76,9 +76,37 @@ func TestImageProxyStillBlocksUnlistedHostnameResolvingToPrivateIP(t *testing.T)
proxy.SetAllowedRemoteHostsProvider(func() []string { return nil })
_, port := hostAndPortOf(t, upstream.URL)
resp, err := proxy.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")
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)
}
}
+3 -1
View File
@@ -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}
+67
View File
@@ -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)
}
}
+6 -3
View File
@@ -22,8 +22,9 @@ import (
const (
// IntroDBBaseURL is the public read endpoint. Overridable on the service
// for tests and for pointing at a mirror.
IntroDBBaseURL = "https://api.theintrodb.org/v3"
// for tests and for pointing at a mirror. v1 已删除,v2/v3 冻结在只读的
// GET /media 上并被官方标记为弃用,所以这里直接跟 v4。
IntroDBBaseURL = "https://api.theintrodb.org/v4"
// IntroDBSource tags rows that came from this provider.
IntroDBSource = "theintrodb"
@@ -92,8 +93,10 @@ type introDBRange struct {
EndMs *int64 `json:"end_ms"`
}
// introDBResponse 对应 v4 的 MediaResponse。v4 以 TVDB 为身份,响应只带
// tvdb_id —— tmdb_id 字段已被官方移除,需要 tmdb_id 只能从请求侧自己记。
type introDBResponse struct {
TMDbID int `json:"tmdb_id"`
TVDBID int `json:"tvdb_id"`
Type string `json:"type"`
Intro []introDBRange `json:"intro"`
Recap []introDBRange `json:"recap"`
+9 -8
View File
@@ -11,11 +11,12 @@ import (
"go.uber.org/zap"
)
// 这两段响应是从 api.theintrodb.org/v3/media 实测抓下来的原文,
// 用来锁住 null 语义:start_ms: null = 从片头开始,end_ms: null = 一直到片尾。
// 这两段响应是从 api.theintrodb.org/v4/media 实测抓下来的原文(v4 以 TVDB 为身份,
// 响应里只有 tvdb_id),用来锁住 null 语义:start_ms: null = 从片头开始,
// end_ms: null = 一直到片尾。
const (
introDBTVPayload = `{"tmdb_id":1396,"type":"tv","season":1,"episode":1,"intro":[{"start_ms":228664,"end_ms":246143}],"credits":[{"start_ms":3431000,"end_ms":null}]}`
introDBMoviePayload = `{"tmdb_id":27205,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}]}`
introDBTVPayload = `{"tvdb_id":81189,"type":"tv","season":1,"episode":1,"intro":[{"start_ms":228664,"end_ms":246143}],"credits":[{"start_ms":3431000,"end_ms":null}]}`
introDBMoviePayload = `{"tvdb_id":113,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}]}`
)
func TestParseIntroDBResponseResolvesNullBounds(t *testing.T) {
@@ -48,7 +49,7 @@ func TestParseIntroDBResponseResolvesNullBounds(t *testing.T) {
}
func TestParseIntroDBResponseDropsEmptyRanges(t *testing.T) {
body := `{"tmdb_id":1,"type":"movie",
body := `{"tvdb_id":1,"type":"movie",
"intro":[{"start_ms":5000,"end_ms":5000},{"start_ms":9000,"end_ms":8000},{"start_ms":1000,"end_ms":2000}],
"recap":[],"credits":[],"preview":[]}`
spans, err := parseIntroDBResponse([]byte(body))
@@ -62,7 +63,7 @@ func TestParseIntroDBResponseDropsEmptyRanges(t *testing.T) {
}
func TestParseIntroDBResponseOrdersByType(t *testing.T) {
body := `{"tmdb_id":1,"type":"tv","credits":[{"start_ms":900,"end_ms":1000}],
body := `{"tvdb_id":1,"type":"tv","credits":[{"start_ms":900,"end_ms":1000}],
"intro":[{"start_ms":100,"end_ms":200}],"recap":[{"start_ms":50,"end_ms":60}]}`
spans, err := parseIntroDBResponse([]byte(body))
if err != nil {
@@ -82,12 +83,12 @@ func TestParseIntroDBResponseOrdersByType(t *testing.T) {
func TestIntroDBMediaURLOnlyAddsSeasonEpisodeForTV(t *testing.T) {
svc := NewIntroDBService(zap.NewNop())
if got, want := svc.mediaURL(1396, 1, 1),
"https://api.theintrodb.org/v3/media?tmdb_id=1396&season=1&episode=1"; got != want {
"https://api.theintrodb.org/v4/media?tmdb_id=1396&season=1&episode=1"; got != want {
t.Fatalf("tv url = %q, want %q", got, want)
}
// 电影(season/episode 为 0)不能带季集参数,否则会被当成剧集查不到。
if got, want := svc.mediaURL(27205, 0, 0),
"https://api.theintrodb.org/v3/media?tmdb_id=27205"; got != want {
"https://api.theintrodb.org/v4/media?tmdb_id=27205"; got != want {
t.Fatalf("movie url = %q, want %q", got, want)
}
}
@@ -184,6 +184,85 @@ func TestReadAdultMetadataDerivesDMMPosterFromRemoteFanart(t *testing.T) {
}
}
func TestReadAdultArtworkIgnoresLoosePosterInLibraryRoot(t *testing.T) {
root := t.TempDir()
// 云下载这类库把不同影片平铺在同一个根目录里,根目录中残留的松散海报
// 属于别的影片,不能被抓来当作当前影片的封面。
if err := os.WriteFile(filepath.Join(root, "AAA-001-poster.jpg"), []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "AAA-001-fanart.jpg"), []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(root, "BBB-002.mp4")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadLocalMetadata(mediaPath, root, false)
if err != nil {
t.Fatal(err)
}
if got != nil && (got.PosterURL != "" || got.BackdropURL != "") {
t.Fatalf("library-root artwork must not be inherited: poster=%q backdrop=%q", got.PosterURL, got.BackdropURL)
}
}
func TestReadAdultArtworkIgnoresLoosePosterInSharedFolder(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "shared")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"AAA-001.mp4", "BBB-002.mp4"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(dir, "AAA-001-poster.jpg"), []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "AAA-001-fanart.jpg"), []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadLocalMetadata(filepath.Join(dir, "BBB-002.mp4"), root, false)
if err != nil {
t.Fatal(err)
}
if got != nil && (got.PosterURL != "" || got.BackdropURL != "") {
t.Fatalf("sibling artwork must not be inherited in a shared folder: poster=%q backdrop=%q", got.PosterURL, got.BackdropURL)
}
}
func TestReadAdultArtworkKeepsLooseFallbackInItemFolder(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "BBB-002")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(dir, "video.mp4")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
poster := filepath.Join(dir, "japanese-title-cover.jpg")
fanart := filepath.Join(dir, "japanese-title-fanart.jpg")
if err := os.WriteFile(poster, []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(fanart, []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadLocalMetadata(mediaPath, root, false)
if err != nil {
t.Fatal(err)
}
if got == nil || got.PosterURL != poster || got.BackdropURL != fanart {
t.Fatalf("single-item folder should keep loose artwork fallback: %+v", got)
}
}
func TestReadAdultArtworkByCodeWithoutNFO(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "SSIS-001-CD1.mp4")
+47 -5
View File
@@ -11,23 +11,23 @@ import (
"strings"
)
func metadataFromArtwork(mediaPath, showBaseDir string) *LocalMetadata {
func metadataFromArtwork(mediaPath, showBaseDir, libraryRoot string) *LocalMetadata {
meta := &LocalMetadata{}
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
mergeArtworkMetadata(meta, mediaPath, showBaseDir, libraryRoot)
if meta.PosterURL == "" && meta.BackdropURL == "" {
return nil
}
return meta
}
func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir, libraryRoot string) {
if meta == nil {
return
}
mediaDir := filepath.Dir(mediaPath)
if localPoster := firstLocalPoster(mediaPath, showBaseDir); localPoster != "" {
meta.PosterURL = localPoster
} else if meta.PosterURL == "" {
} else if meta.PosterURL == "" && looseArtworkFallbackAllowed(mediaDir, libraryRoot) {
meta.PosterURL = firstAdultLooseImage(mediaDir, "poster")
}
dirs := []string{mediaDir, showBaseDir}
@@ -39,7 +39,7 @@ func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
meta.BackdropURL = img
break
}
if meta.BackdropURL == "" {
if meta.BackdropURL == "" && looseArtworkFallbackAllowed(dir, libraryRoot) {
meta.BackdropURL = firstAdultLooseImage(dir, "backdrop")
}
}
@@ -245,6 +245,48 @@ func firstExistingPosterImage(dir string, names ...string) string {
return ""
}
// looseArtworkFallbackAllowed reports whether an unmatched image sitting in dir
// can be attributed to mediaPath. Such an image carries no filename relation to
// a specific video, so it is only trustworthy when the folder holds a single
// media file and that folder is not the library root. Download tools routinely
// drop unrelated titles flat into one directory (a 115 cloud-download library,
// for example); claiming the first "*-poster.jpg" there made every item show the
// same artwork. Mirrors the root-level guard findMovieNFO already applies to
// loose NFO files.
func looseArtworkFallbackAllowed(dir, libraryRoot string) bool {
if dir == "" {
return false
}
if libraryRoot != "" && samePath(dir, libraryRoot) {
return false
}
return countMediaFilesInDir(dir) <= 1
}
// countMediaFilesInDir counts the video files directly inside dir, stopping once
// more than one is found. It walks the real directory rather than reusing the
// loose-image glob because a shared folder may contain no images at all.
func countMediaFilesInDir(dir string) int {
entries, err := os.ReadDir(dir)
if err != nil {
return 0
}
count := 0
for _, entry := range entries {
if entry.IsDir() {
continue
}
if _, ok := videoExtensions[strings.ToLower(filepath.Ext(entry.Name()))]; !ok {
continue
}
count++
if count > 1 {
return count
}
}
return count
}
func firstAdultLooseImage(dir, kind string) string {
if dir == "" {
return ""
+4 -4
View File
@@ -17,12 +17,12 @@ func ReadLocalMetadata(mediaPath, libraryRoot string, seriesLike bool) (*LocalMe
doc, path, err := findMovieNFO(mediaPath, libraryRoot)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return metadataFromArtwork(mediaPath, ""), nil
return metadataFromArtwork(mediaPath, "", libraryRoot), nil
}
return nil, err
}
meta := metadataFromDoc(doc, filepath.Dir(path), false)
mergeArtworkMetadata(meta, mediaPath, filepath.Dir(path))
mergeArtworkMetadata(meta, mediaPath, filepath.Dir(path), libraryRoot)
// A sidecar written while resolution tokens were misread as SxxExx must not
// reintroduce the bogus season/episode on rescan.
dropResolutionArtifactEpisodeIdentity(meta, mediaPath)
@@ -115,9 +115,9 @@ func readSeriesMetadata(mediaPath, libraryRoot string) (*LocalMetadata, error) {
}
}
if meta == nil {
meta = metadataFromArtwork(mediaPath, showBaseDir)
meta = metadataFromArtwork(mediaPath, showBaseDir, libraryRoot)
} else {
mergeArtworkMetadata(meta, mediaPath, showBaseDir)
mergeArtworkMetadata(meta, mediaPath, showBaseDir, libraryRoot)
}
// A sidecar written while resolution tokens were misread as SxxExx must not
// reintroduce the bogus season/episode on rescan.
+7 -6
View File
@@ -12,12 +12,13 @@ import (
// MediaService offers high-level CRUD over libraries and media items.
type MediaService struct {
cfg *config.Config
log *zap.Logger
repo *repository.Container
cache *RuntimeCacheService
groupedMediaFlight singleflight.Group
libraryRowsFlight singleflight.Group
cfg *config.Config
log *zap.Logger
repo *repository.Container
cache *RuntimeCacheService
groupedMediaFlight singleflight.Group
libraryRowsFlight singleflight.Group
recentSeriesFlight singleflight.Group
}
type MediaVisibility struct {
+69
View File
@@ -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
}
}
+9 -22
View File
@@ -239,26 +239,6 @@ func (s *MediaService) completedMediaIDSet(ctx context.Context, filters MediaLis
return out
}
func (s *MediaService) ListRecentSeriesCards(ctx context.Context, limit int, visibility MediaVisibility) ([]SeriesCard, error) {
if limit <= 0 {
limit = 24
} else if limit > 100 {
limit = 100
}
rows, err := s.SearchMediaVisible(ctx, "", maxMediaSearchLimit, visibility)
if err != nil {
return nil, err
}
cards := groupMediaSeriesCards(rows)
if len(cards) == 0 {
return []SeriesCard{}, nil
}
if len(cards) > limit {
cards = cards[:limit]
}
return cards, nil
}
func (s *MediaService) ListLibrarySeriesEpisodes(ctx context.Context, libraryID, key string, visibility MediaVisibility) ([]model.Media, error) {
rows, err := s.libraryRowsWithIndex(ctx, libraryID, visibility)
if err != nil {
@@ -524,9 +504,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{}
}
+62 -7
View File
@@ -5,6 +5,7 @@ import (
"path/filepath"
"regexp"
"strings"
"sync"
"github.com/truewhile/MeBox/internal/model"
)
@@ -129,15 +130,28 @@ var (
)
func normalizeSeriesTitle(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = seriesYearRE.ReplaceAllString(value, " ")
value = seriesIDRE.ReplaceAllString(value, " ")
value = seriesBraceRE.ReplaceAllString(value, " ")
value = seriesSpacerRE.ReplaceAllString(value, " ")
return strings.TrimSpace(value)
// 归组时每行都会调用(且同一部剧的每集标题相同),纯函数缓存能省掉
// 4 条正则的重复执行。
if normalized, ok := seriesTitleNormMemo.get(value); ok {
return normalized
}
normalized := strings.ToLower(strings.TrimSpace(value))
normalized = seriesYearRE.ReplaceAllString(normalized, " ")
normalized = seriesIDRE.ReplaceAllString(normalized, " ")
normalized = seriesBraceRE.ReplaceAllString(normalized, " ")
normalized = seriesSpacerRE.ReplaceAllString(normalized, " ")
normalized = strings.TrimSpace(normalized)
seriesTitleNormMemo.put(value, normalized)
return normalized
}
func normalizeSeriesPathTitle(value string) string {
// 整库归组时每个文件都会走到这里,而 CleanQuery 内部是十几条正则。
// 同一部剧的几十集共享同一个目录名,按目录名缓存后,首页「最近添加」
// 的分组阶段从 240ms 降到几十毫秒(7k 行实测)。
if title, ok := seriesPathTitleMemo.get(value); ok {
return title
}
title, _ := CleanQuery(value)
if title == "" {
title = normalizeSeriesTitle(value)
@@ -146,11 +160,52 @@ func normalizeSeriesPathTitle(value string) string {
}
stripped := stripSeriesSpecialSuffix(title)
if stripped != "" {
return stripped
title = stripped
}
seriesPathTitleMemo.put(value, title)
return title
}
// boundedStringMemo 是纯字符串函数的定长缓存。
//
// 只用于「入参唯一决定出参、不读配置/数据库」的函数:剧集标题规范化、
// 目录名清洗等。整库归组会把这些函数在每行上重复调用几十次,缓存后首页
// 「最近添加」的 CPU 从 ~240ms 降到 ~40ms(7k 行实测)。上限到达后整体
// 清空,避免长跑进程无限增长(不同目录名/标题的数量远小于集数)。
type boundedStringMemo struct {
mu sync.RWMutex
max int
entries map[string]string
}
func newBoundedStringMemo(max int) *boundedStringMemo {
if max <= 0 {
max = 1024
}
return &boundedStringMemo{max: max, entries: make(map[string]string, max/8)}
}
func (m *boundedStringMemo) get(key string) (string, bool) {
m.mu.RLock()
value, ok := m.entries[key]
m.mu.RUnlock()
return value, ok
}
func (m *boundedStringMemo) put(key, value string) {
m.mu.Lock()
if len(m.entries) >= m.max {
m.entries = make(map[string]string, m.max/4)
}
m.entries[key] = value
m.mu.Unlock()
}
var (
seriesPathTitleMemo = newBoundedStringMemo(4096)
seriesTitleNormMemo = newBoundedStringMemo(4096)
)
func stripSeriesSpecialSuffix(title string) string {
for _, re := range []*regexp.Regexp{seriesSpecialCodeRE, seriesSpecialCJKRE} {
stripped := strings.TrimSpace(re.ReplaceAllString(title, ""))
+171
View File
@@ -0,0 +1,171 @@
package service
import (
"context"
"fmt"
"sort"
"strings"
"time"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// recentSeriesCardsCacheValue 缓存「最近添加」剧集卡片。Cards 视为不可变,
// 调用方只读不改(与 seriesCardsCacheValue 相同的约定)。
type recentSeriesCardsCacheValue struct {
Cards []SeriesCard
}
// ListRecentSeriesCards 返回最近入库的剧集卡片(首页「最近添加」)。
//
// 性能约定:首页每次刷新都会打这条路径,因此不能再用 SearchMediaVisible:
// 它会先对整表跑一次 COUNT(*),再 SELECT 全部列(含 overview/genres 等大文本)。
// 7k 行规模下,驱动层逐列解码就占掉 60% 的耗时,实测 ≈1.6s。
//
// 现在的做法:
// 1. 只取归组必需的列(repository.ListMediaSeriesIndex),不做 COUNT;
// 2. 在内存里完成分组/计数/排序,截断到 limit;
// 3. 只对最终这几十张卡片的代表行补一次完整行(客户端要 overview、genres);
// 4. 结果按可见性缓存,媒体入库/刮削会按 "media:" 前缀统一失效。
//
// 语义与旧实现一致:key 与 Count 仍然基于整库所有可见行计算,不能只看
// 前 N 行,否则剧集 key 会和 /libraries/{id}/series/episodes 对不上。
func (s *MediaService) ListRecentSeriesCards(ctx context.Context, limit int, visibility MediaVisibility) ([]SeriesCard, error) {
if limit <= 0 {
limit = 24
} else if limit > 100 {
limit = 100
}
cacheKey := s.recentSeriesCardsCacheKey(limit, visibility)
if cards, ok := s.cachedRecentSeriesCards(cacheKey); ok {
return cards, nil
}
// singleflight:多个客户端同时刷新首页时只扫一遍库。
value, err, _ := s.recentSeriesFlight.Do(cacheKey, func() (any, error) {
if cards, ok := s.cachedRecentSeriesCards(cacheKey); ok {
return &recentSeriesCardsCacheValue{Cards: cards}, nil
}
// 首个调用者取消不应让其它等待者一起失败。
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
defer cancel()
cards, err := s.loadRecentSeriesCards(loadCtx, limit, visibility)
if err != nil {
return nil, err
}
cached := &recentSeriesCardsCacheValue{Cards: cards}
if s.cache != nil {
s.cache.SetObject(cacheKey, cached, s.derivedReadCacheTTL())
}
return cached, nil
})
if err != nil {
return nil, err
}
if cached, ok := value.(*recentSeriesCardsCacheValue); ok {
return cached.Cards, nil
}
return []SeriesCard{}, nil
}
func (s *MediaService) cachedRecentSeriesCards(cacheKey string) ([]SeriesCard, bool) {
if s == nil || s.cache == nil {
return nil, false
}
obj, ok := s.cache.GetObject(cacheKey)
if !ok {
return nil, false
}
cached, ok := obj.(*recentSeriesCardsCacheValue)
if !ok || cached == nil {
return nil, false
}
return cached.Cards, true
}
func (s *MediaService) loadRecentSeriesCards(ctx context.Context, limit int, visibility MediaVisibility) ([]SeriesCard, error) {
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
rows, err := s.repo.Media.ListMediaSeriesIndex(ctx, maxMediaSearchLimit, repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
})
if err != nil {
return nil, err
}
if len(rows) == 0 {
return []SeriesCard{}, nil
}
// 归组 key 依赖库元数据(display library 路径参与指纹计算)。
s.attachLibraryMetadata(ctx, rows)
cards := groupMediaSeriesCards(rows)
if len(cards) == 0 {
return []SeriesCard{}, nil
}
if len(cards) > limit {
cards = cards[:limit]
}
s.hydrateSeriesCardRows(ctx, cards)
return cards, nil
}
// hydrateSeriesCardRows 把投影行替换成完整媒体行。
//
// 归组只需要少量列,但客户端展示卡片要用整行(简介、类型、编码等)。这里
// 只针对最终返回的卡片取两次 id 集合,一次 IN 查询补齐,避免把整库大文本列
// 都读出来。
func (s *MediaService) hydrateSeriesCardRows(ctx context.Context, cards []SeriesCard) {
if s == nil || s.repo == nil || len(cards) == 0 {
return
}
seen := make(map[string]struct{}, len(cards)*2)
ids := make([]string, 0, len(cards)*2)
add := func(id string) {
id = strings.TrimSpace(id)
if id == "" {
return
}
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
ids = append(ids, id)
}
for i := range cards {
add(cards[i].Rep.ID)
add(cards[i].LinkMedia.ID)
}
if len(ids) == 0 {
return
}
rows, err := s.repo.Media.ListMediaByIDs(ctx, ids)
if err != nil || len(rows) == 0 {
// 补齐失败时保留投影行:卡片列表仍可用,只是缺少大文本字段。
return
}
s.attachLibraryMetadata(ctx, rows)
byID := make(map[string]model.Media, len(rows))
for _, row := range rows {
byID[row.ID] = row
}
for i := range cards {
if row, ok := byID[cards[i].Rep.ID]; ok {
cards[i].Rep = row
}
if row, ok := byID[cards[i].LinkMedia.ID]; ok {
cards[i].LinkMedia = row
}
}
}
func (s *MediaService) recentSeriesCardsCacheKey(limit int, visibility MediaVisibility) string {
allowed := append([]string(nil), visibility.AllowedLibraryIDs...)
hidden := append([]string(nil), visibility.HiddenLibraryIDs...)
sort.Strings(allowed)
sort.Strings(hidden)
return "media:obj:recent-series:" + hashObjectCacheKey([]string{
fmt.Sprintf("%d:%t", limit, visibility.IncludeNSFW),
strings.Join(allowed, ","),
strings.Join(hidden, ","),
})
}
@@ -0,0 +1,125 @@
package service
import (
"fmt"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// 「最近添加」必须只做投影扫描,但仍然要返回完整媒体行、正确的集数和
// 可用于剧集导航的 key —— 这些语义是 /libraries/{id}/series/episodes 的
// 前置条件,任何一项退化成「近似值」都会让点开卡片后选集为空。
func TestListRecentSeriesCardsHydratesRowsAndKeepsExactCounts(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
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)
}
now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC)
rows := make([]model.Media, 0, 64)
// 长剧:48 集,最新一集入库时间最晚,必须排在首位。
for i := 1; i <= 48; i++ {
rows = append(rows, model.Media{
Base: model.Base{ID: fmt.Sprintf("long-%02d", i), CreatedAt: now.Add(time.Duration(i) * time.Minute)},
LibraryID: lib.ID,
Title: "长剧",
Path: fmt.Sprintf("/media/anime/长剧/Season 1/长剧.S01E%02d.mkv", i),
Overview: "简介内容",
Genres: "Animation",
PosterURL: "/media/anime/长剧/poster.jpg",
SeasonNum: 1,
EpisodeNum: i,
ReleaseDate: "2026-01-01",
})
}
// 另一部短剧,入库更早。
for i := 1; i <= 2; i++ {
rows = append(rows, model.Media{
Base: model.Base{ID: fmt.Sprintf("short-%02d", i), CreatedAt: now.Add(-time.Hour)},
LibraryID: lib.ID,
Title: "短剧",
Path: fmt.Sprintf("/media/anime/短剧/Season 1/短剧.S01E%02d.mkv", i),
SeasonNum: 1,
EpisodeNum: i,
ReleaseDate: "2025-05-05",
})
}
if err := db.CreateInBatches(&rows, 50).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
cards, err := svc.ListRecentSeriesCards(t.Context(), 2, MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if len(cards) != 2 {
t.Fatalf("cards = %d, want 2", len(cards))
}
if cards[0].Count != 48 {
t.Fatalf("长剧 count = %d, want 48 (不能只统计扫描窗口内的行)", cards[0].Count)
}
// 投影行没有 overview/genres,返回前必须补齐完整体。
if cards[0].Rep.Overview == "" || cards[0].Rep.Genres == "" {
t.Fatalf("representative row was not hydrated: %#v", cards[0].Rep)
}
if cards[0].Rep.LibraryName != lib.Name {
t.Fatalf("library metadata missing on hydrated row: %q", cards[0].Rep.LibraryName)
}
// 卡片 key 必须能解析出同一部剧的全部剧集(详情页选集依赖它)。
episodes, err := svc.ListLibrarySeriesEpisodes(t.Context(), lib.ID, cards[0].Key, MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if len(episodes) != 48 {
t.Fatalf("episodes for card key = %d, want 48", len(episodes))
}
}
// 结果缓存命中后必须返回同样的卡片,且不能因为缓存把调用方可见的切片搞乱。
func TestListRecentSeriesCardsCachesResult(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
if err := db.Create(&model.Media{
Base: model.Base{ID: fmt.Sprintf("movie-%d", i), CreatedAt: time.Now().Add(-time.Duration(i) * time.Hour)},
LibraryID: lib.ID,
Title: fmt.Sprintf("电影 %d", i),
Path: fmt.Sprintf("/media/movies/电影 %d/movie.mkv", i),
ReleaseDate: "2024-01-01",
}).Error; err != nil {
t.Fatal(err)
}
}
cache := NewRuntimeCacheService(&config.Config{}, zap.NewNop())
t.Cleanup(func() { cache.Close() })
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).SetRuntimeCache(cache)
first, err := svc.ListRecentSeriesCards(t.Context(), 2, MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
second, err := svc.ListRecentSeriesCards(t.Context(), 2, MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if len(first) != 2 || len(second) != 2 {
t.Fatalf("first = %d second = %d, want 2/2", len(first), len(second))
}
if first[0].Key != second[0].Key || first[0].Count != second[0].Count {
t.Fatalf("cached result mismatch: %#v vs %#v", first[0], second[0])
}
}
+126
View File
@@ -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,
}
}
+2
View File
@@ -60,6 +60,8 @@ type Container struct {
OrganizePipeline *OrganizePipelineService
Douban *DoubanProvider
Token *TokenService
AuthSessions *AuthSessionService
ServerIdentity *ServerIdentityService
ApiConfig *ApiConfigService
Device *DeviceService
Telegram *TelegramService
+10
View File
@@ -234,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)
@@ -3,11 +3,13 @@ package service
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"go.uber.org/zap"
@@ -15,6 +17,7 @@ import (
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/cloud"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
// directRedirectTestRepo 建一个带网盘账号表的库:normalizeCloudPlayTarget 需要
@@ -145,6 +148,53 @@ func TestServeFileFallsBackWhenDirectResolveUnavailable(t *testing.T) {
}
}
// `?refresh=1`:客户端手上的直链已被 CDN 403,服务端必须丢掉缓存重新换链,
// 不能把同一条死链再发一次;不带该参数时缓存照旧命中,保住快路径。
func TestServeFileRefreshDropsCachedDirectLink(t *testing.T) {
repos := directRedirectTestRepo(t)
seedCloudSTRMMedia(t, repos, "cloud-refresh", "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc-refresh")
cached := fmt.Sprintf("https://cdn.example/stale.mkv?t=%d", time.Now().Add(30*time.Minute).Unix())
fresh := "https://cdn.example/fresh.mkv?t=2"
cloud115.SetDownloadURLCache("pc-refresh", cached, "RodelPlayer/2.0")
t.Cleanup(func() { cloud115.ClearDownloadURLCache("pc-refresh") })
oldMinAge := directLinkRefreshMinAge
directLinkRefreshMinAge = 0
defer func() { directLinkRefreshMinAge = oldMinAge }()
run := func(t *testing.T, query string) (cachedAtResolve, location string) {
t.Helper()
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil).
SetStrmPlayTargetResolver(func(_ context.Context, _ string, userAgent string) (*StrmPlayResult, error) {
cachedAtResolve = cloud115.GetDownloadURLCache("pc-refresh", userAgent)
return &StrmPlayResult{
RedirectURL: fresh,
Link: &cloud.DirectLink{URL: fresh, Headers: map[string]string{"User-Agent": userAgent}},
}, nil
})
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/cloud-refresh?token=jwt123"+query, nil)
req.Header.Set("User-Agent", "RodelPlayer/2.0")
w := httptest.NewRecorder()
if err := svc.ServeFile(w, req, "cloud-refresh"); err != nil {
t.Fatalf("ServeFile: %v", err)
}
return cachedAtResolve, w.Header().Get("Location")
}
// 正常播放:缓存里的直链照旧命中,服务端不该白跑一次换链。
if got, loc := run(t, ""); got != cached {
t.Fatalf("cached link = %q, want the cached %q", got, cached)
} else if loc != fresh {
t.Fatalf("Location = %q, want the resolved direct link", loc)
}
// 客户端报告直链失效:换链前必须先清掉缓存。
if got, loc := run(t, "&refresh=1"); got != "" {
t.Fatalf("cached link must be dropped before resolving, got %q", got)
} else if loc != fresh {
t.Fatalf("Location = %q, want the freshly resolved direct link", loc)
}
}
// 没有注入解析器(测试/精简部署)时保持原有跳转,不受本次优化影响。
func TestServeFileKeepsSTRMEndpointWithoutResolver(t *testing.T) {
repos := directRedirectTestRepo(t)
+79 -1
View File
@@ -10,6 +10,7 @@ import (
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
// ServeFile streams the file backing the given media ID using
@@ -22,6 +23,47 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI
return s.ServeFileWithCloudMode(w, r, mediaID, "")
}
// PickCodeFromPlayTarget 从 /api/strm/play/... 形式的播放目标里取出 pickcode。
// 115 直链缓存按 pickcode 分键,客户端要求强制换链时用它定位缓存条目。
func PickCodeFromPlayTarget(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u == nil {
return ""
}
return strings.TrimSpace(u.Query().Get("pickcode"))
}
// directLinkRefreshMinAge 是「强制换链」的最小间隔:缓存条目刚写入不久就
// 收到换链请求时忽略它,避免客户端重试风暴把 115 换链接口打出突发。
// 测试会临时改成 0,让「CDN 拒绝 → 立刻换链」的路径可验证。
var directLinkRefreshMinAge = 30 * time.Second
// wantsFreshLink 判断客户端是否要求跳过直链缓存重新换链(`?refresh=1`)。
//
// 115 直链只有 30 分钟有效期,客户端拿到过期直链被 CDN 403 后需要一条新链;
// 正常路径下缓存会先于直链过期,这里是客户端上报失败时的兜底。
func wantsFreshLink(r *http.Request) bool {
if r == nil {
return false
}
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("refresh"))) {
case "1", "true", "yes":
return true
default:
return false
}
}
// InvalidateMediaLinkCache 清除媒体播放目标对应的云盘直链缓存,让下一次解析
// 重新换链。非云盘/无 pickcode 的媒体是空操作。
func InvalidateMediaLinkCache(m *model.Media) {
pickCode := PickCodeFromPlayTarget(MediaSTRMTarget(m))
if pickCode == "" {
return
}
cloud115.ClearDownloadURLCacheIfOlderThan(pickCode, directLinkRefreshMinAge)
}
// MediaSTRMTarget 返回媒体行固化的 STRM 播放目标。STRMURL 为空时回读本地
// .strm 文件内容兜底(扫描时内容解析失败的行只剩 Container=strm + Path),
// 仍拿不到返回空串。
@@ -57,6 +99,11 @@ func (s *StreamService) ServeFileWithCloudMode(w http.ResponseWriter, r *http.Re
// 能在服务端换到最终直链就直接 302 过去:客户端少跟随一次 302,等于
// 省掉一次「DNS+TCP+TLS+请求」的往返。解析结果同时写进 strm 层直链
// 缓存,后续 /api/strm/play 请求直接命中。
// `?refresh=1` 表示客户端手上的直链已经失效(被 CDN 403),要求丢掉
// 缓存重新换链。
if wantsFreshLink(r) {
InvalidateMediaLinkCache(m)
}
if direct, ok := s.resolveDirectPlayTargetURL(r, strmURL); ok {
setCloudRedirectNoStore(w)
http.Redirect(w, r, direct, http.StatusFound)
@@ -110,6 +157,29 @@ func setCloudRedirectNoStore(w http.ResponseWriter) {
// 由 /api/strm/play 再去换链,最坏情况只是回到改动前的行为。
const directPlayResolveTimeout = 10 * time.Second
// directPlayInfoResolveTimeout 是播放地址响应(/api/playback/{id}/info)里
// 预换链的等待上限。这条路径是用户点播放后的同步阻塞,宁可退回 /api/stream
// 跳转,也不能把起播卡在换链上;命中直链缓存时通常只有微秒级。
const directPlayInfoResolveTimeout = 3 * time.Second
// ResolveDirectPlayURL 尝试把媒体的 STRM/网盘目标换成客户端可直接播放的最终
// 直链,供 /api/playback/{id}/info 预置给客户端。
//
// 客户端拿到直链后可以跳过 /api/stream 的 302 往返;在高延迟/跨境链路上,
// 这一次「DNS + TCP + TLS + 请求」常常是数秒,正好落在起播关键路径上。
// 返回 false 表示必须继续走 /api/stream(本地文件、需要服务端加头的网盘、
// 解析失败或超时),行为与改动前一致。
func (s *StreamService) ResolveDirectPlayURL(r *http.Request, m *model.Media) (string, bool) {
if s == nil || r == nil || m == nil {
return "", false
}
raw := MediaSTRMTarget(m)
if raw == "" || !playableSTRMTarget(r.Context(), s.repo, raw, m) {
return "", false
}
return s.resolveDirectPlayTargetURLWithin(r, raw, directPlayInfoResolveTimeout)
}
// resolveDirectPlayTargetURL 尝试在服务端把 strm 目标解析成客户端可直接拉取的
// 最终直链,供调用方直接 302。
//
@@ -117,10 +187,18 @@ const directPlayResolveTimeout = 10 * time.Second
// 解析到本地文件、以及解析失败都会返回 false,由调用方按改动前的方式回退到
// strm 端点跳转,行为不会变差。
func (s *StreamService) resolveDirectPlayTargetURL(r *http.Request, raw string) (string, bool) {
return s.resolveDirectPlayTargetURLWithin(r, raw, directPlayResolveTimeout)
}
func (s *StreamService) resolveDirectPlayTargetURLWithin(
r *http.Request,
raw string,
timeout time.Duration,
) (string, bool) {
if s == nil || s.strmResolve == nil || r == nil {
return "", false
}
ctx, cancel := context.WithTimeout(r.Context(), directPlayResolveTimeout)
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
// 必须带播放器 UA:115 直链绑定换取时的 UA,且按 UA 分键缓存,换错会拿到
// 与播放器不匹配(或未命中缓存)的地址。
@@ -0,0 +1,89 @@
package service
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud"
)
// /api/playback/{id}/info 预置换链:能换到直链时返回 true,并把客户端 UA
// 透传给 115(直链绑定换取时的 UA,播放器必须用同一个)。
func TestResolveDirectPlayURLForPlaybackInfo(t *testing.T) {
const direct = "https://cdnfhnfile.115cdn.net/637b/Movie.mkv?t=1&k=sig"
var gotUA string
svc := NewStreamService(&config.Config{}, zap.NewNop(), nil, nil).
SetStrmPlayTargetResolver(func(_ context.Context, _, userAgent string) (*StrmPlayResult, error) {
gotUA = userAgent
return &StrmPlayResult{
RedirectURL: direct,
Link: &cloud.DirectLink{URL: direct, Headers: map[string]string{"User-Agent": userAgent}},
}, nil
})
m := &model.Media{
Base: model.Base{ID: "cloud-info"},
Path: "cloud://cloud115/Movie.mkv",
Container: "strm",
STRMURL: "https://mebox.example/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1",
}
req := httptest.NewRequest(http.MethodGet, "/api/playback/cloud-info/info", nil)
req.Header.Set("User-Agent", "MeBox-Android/0.1.0")
got, ok := svc.ResolveDirectPlayURL(req, m)
if !ok || got != direct {
t.Fatalf("ResolveDirectPlayURL() = (%q, %v), want (%q, true)", got, ok, direct)
}
if gotUA != "MeBox-Android/0.1.0" {
t.Fatalf("resolver UA = %q, want the requesting client UA", gotUA)
}
}
// 本地文件没有可直连的 CDN 地址,信息接口不能凭空给出 direct_url。
func TestResolveDirectPlayURLSkipsLocalMedia(t *testing.T) {
svc := NewStreamService(&config.Config{}, zap.NewNop(), nil, nil).
SetStrmPlayTargetResolver(func(context.Context, string, string) (*StrmPlayResult, error) {
t.Fatal("本地文件不应该触发换链")
return nil, nil
})
m := &model.Media{
Base: model.Base{ID: "local-1"},
Path: "/media/movie.mkv",
Container: "mkv",
}
req := httptest.NewRequest(http.MethodGet, "/api/playback/local-1/info", nil)
if got, ok := svc.ResolveDirectPlayURL(req, m); ok || got != "" {
t.Fatalf("ResolveDirectPlayURL() = (%q, %v), want (\"\", false)", got, ok)
}
}
// 需要服务端附加其它请求头(Referer 等)的直链不能短路给客户端,否则播放端
// 少了鉴权头会 403;这类媒体继续走 /api/stream 由服务端转发。
func TestResolveDirectPlayURLSkipsHeaderBoundLinks(t *testing.T) {
const direct = "https://cdn.example.com/Movie.mkv"
svc := NewStreamService(&config.Config{}, zap.NewNop(), nil, nil).
SetStrmPlayTargetResolver(func(_ context.Context, _, userAgent string) (*StrmPlayResult, error) {
return &StrmPlayResult{
RedirectURL: direct,
Link: &cloud.DirectLink{
URL: direct,
Headers: map[string]string{"User-Agent": userAgent, "Referer": "https://pan.example"},
},
}, nil
})
m := &model.Media{
Base: model.Base{ID: "cloud-header"},
Path: "cloud://cloud115/Movie.mkv",
Container: "strm",
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1",
}
req := httptest.NewRequest(http.MethodGet, "/api/playback/cloud-header/info", nil)
if got, ok := svc.ResolveDirectPlayURL(req, m); ok || got != "" {
t.Fatalf("ResolveDirectPlayURL() = (%q, %v), want (\"\", false)", got, ok)
}
}
+82 -11
View File
@@ -16,6 +16,7 @@ import (
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
// ErrStrmPlayNotFound 表示 strm 播放目标不存在(handler 返回 404)。
@@ -250,21 +251,87 @@ func (s *StrmService) ProxyMediaDirect(ctx context.Context, w http.ResponseWrite
if err != nil {
return err
}
switch {
case result.Link != nil && result.Link.URL != "":
return s.ProxyDirect(ctx, w, r, result.Link)
case result.RedirectURL != "":
return s.ProxyDirect(ctx, w, r, &cloud.DirectLink{URL: result.RedirectURL})
default:
link := directLinkOfPlayResult(result)
if link == nil {
// 本地文件(LocalPath)由静态文件处理器提供,本身就是同源。
return ErrStrmProxyNotApplicable
}
resp, method, err := s.openDirectUpstream(ctx, r, link)
if err != nil {
return err
}
// 直链被 CDN 拒绝:115 直链带 30 分钟过期时间戳,缓存命中也可能拿到已经
// 失效的地址。这里清掉缓存重新换链再试一次,避免客户端只看到 403。
if directLinkRejected(resp.StatusCode) {
_ = resp.Body.Close()
if fresh := s.refreshDirectLink(ctx, raw); fresh != nil {
if retry, retryMethod, retryErr := s.openDirectUpstream(ctx, r, fresh); retryErr == nil {
resp, method = retry, retryMethod
}
}
}
defer resp.Body.Close()
return writeDirectUpstream(w, resp, method)
}
// directLinkOfPlayResult 取出可反向代理的直链;本地文件返回 nil。
func directLinkOfPlayResult(result *StrmPlayResult) *cloud.DirectLink {
if result == nil {
return nil
}
switch {
case result.Link != nil && result.Link.URL != "":
return result.Link
case result.RedirectURL != "":
return &cloud.DirectLink{URL: result.RedirectURL}
default:
return nil
}
}
// directLinkRejected 判断上游状态码是否属于「直链本身失效」(而不是文件/权限
// 之类与链接无关的问题)。
func directLinkRejected(status int) bool {
switch status {
case http.StatusForbidden, http.StatusNotFound, http.StatusGone:
return true
default:
return false
}
}
// refreshDirectLink 丢弃当前直链缓存并重新换链,返回新的直链;无法换链时返回
// nil,调用方继续用原来那条。
func (s *StrmService) refreshDirectLink(ctx context.Context, raw string) *cloud.DirectLink {
pickCode := PickCodeFromPlayTarget(raw)
if pickCode == "" {
return nil
}
if !cloud115.ClearDownloadURLCacheIfOlderThan(pickCode, directLinkRefreshMinAge) {
// 缓存刚写入不久:说明失败与链接过期无关,重换一次也会拿到同一条。
return nil
}
fresh, err := s.ResolvePlayTarget(ctx, raw)
if err != nil {
return nil
}
return directLinkOfPlayResult(fresh)
}
// ProxyDirect 反向代理渲染直链内容(保留 Range 请求头以支持拖动播放)。
func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, link *cloud.DirectLink) error {
resp, method, err := s.openDirectUpstream(ctx, r, link)
if err != nil {
return err
}
defer resp.Body.Close()
return writeDirectUpstream(w, resp, method)
}
// openDirectUpstream 向网盘直链发起一次请求,保留 Range 与 UA 语义。
func (s *StrmService) openDirectUpstream(ctx context.Context, r *http.Request, link *cloud.DirectLink) (*http.Response, string, error) {
if link == nil || link.URL == "" {
return errors.New("空直链")
return nil, "", errors.New("空直链")
}
method := http.MethodGet
if r != nil && r.Method == http.MethodHead {
@@ -272,7 +339,7 @@ func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r
}
req, err := http.NewRequestWithContext(ctx, method, link.URL, nil)
if err != nil {
return err
return nil, "", err
}
for k, v := range link.Headers {
req.Header.Set(k, v)
@@ -288,9 +355,13 @@ func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r
}
resp, err := s.streamHTTP.Do(req)
if err != nil {
return err
return nil, "", err
}
defer resp.Body.Close()
return resp, method, nil
}
// writeDirectUpstream 把上游响应原样写给客户端。
func writeDirectUpstream(w http.ResponseWriter, resp *http.Response, method string) error {
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified"} {
if value := resp.Header.Get(header); value != "" {
w.Header().Set(header, value)
@@ -310,7 +381,7 @@ func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r
if flusher, ok := w.(http.Flusher); ok {
writer = &flushWriter{writer: w, flusher: flusher}
}
_, err = io.Copy(writer, resp.Body)
_, err := io.Copy(writer, resp.Body)
return err
}
+132
View File
@@ -3,12 +3,16 @@ package service
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"sync"
"testing"
"time"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
// 同源转发必须原样保留 Range 语义:把 206 改写成 200 会让浏览器误判响应长度,
@@ -55,6 +59,134 @@ func TestProxyMediaDirectForwardsRangeAndStatus(t *testing.T) {
}
}
// 115 直链只有 30 分钟寿命,缓存命中也可能拿到已经失效的地址。同源转发遇到
// CDN 403 时必须丢掉缓存重新换链再试一次,而不是把 403 直接透给客户端。
func TestProxyMediaDirectRetriesWithFreshLinkWhenCDNRejects(t *testing.T) {
svc := testStrmService(t)
acct := &model.StrmAccount{Name: "fake115", Provider: model.StrmProvider115, Config: "{}", Enabled: true}
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
t.Fatal(err)
}
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/fresh.mkv" {
w.WriteHeader(http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "video/x-matroska")
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte("fresh-bytes"))
}))
defer cdn.Close()
// 第一次换链给出「已过期」的旧地址,重新换链才给新地址。
var mu sync.Mutex
var calls int
expiry := time.Now().Add(30 * time.Minute).Unix()
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
calls++
attempt := calls
mu.Unlock()
link := cdn.URL + "/old.mkv"
if attempt > 1 {
link = cdn.URL + "/fresh.mkv"
}
_, _ = fmt.Fprintf(w, `{"state":true,"data":{"1":{"pick_code":"pc-retry","url":{"url":%q}}}}`,
fmt.Sprintf("%s?t=%d&u=1&k=abc", link, expiry))
}))
defer api.Close()
oldPro := cloud115.ProAPIBase
cloud115.ProAPIBase = api.URL
defer func() { cloud115.ProAPIBase = oldPro }()
defer cloud115.ClearDownloadURLCache("pc-retry")
oldMinAge := directLinkRefreshMinAge
directLinkRefreshMinAge = 0
defer func() { directLinkRefreshMinAge = oldMinAge }()
media := &model.Media{
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=" + acct.ID + "&pickcode=pc-retry",
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
req.Header.Set("User-Agent", "MeBoxTest/1.0")
if err := svc.ProxyMediaDirect(context.Background(), rec, req, media); err != nil {
t.Fatalf("ProxyMediaDirect: %v", err)
}
if rec.Code != http.StatusPartialContent {
t.Fatalf("status = %d, want 206 (body=%s)", rec.Code, rec.Body.String())
}
if rec.Body.String() != "fresh-bytes" {
t.Fatalf("body = %q, want the fresh link payload", rec.Body.String())
}
mu.Lock()
defer mu.Unlock()
if calls != 2 {
t.Fatalf("downurl calls = %d, want 2 (one retry after the CDN rejected the stale link)", calls)
}
}
// 换链后依然是同一条失效直链时,保持原有行为:原样透传 CDN 的状态码。
func TestProxyMediaDirectPassesRejectedStatusWhenRefreshCannotHelp(t *testing.T) {
svc := testStrmService(t)
acct := &model.StrmAccount{Name: "fake115", Provider: model.StrmProvider115, Config: "{}", Enabled: true}
if err := svc.repo.StrmAccount.Create(context.Background(), acct); err != nil {
t.Fatal(err)
}
cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer cdn.Close()
expiry := time.Now().Add(30 * time.Minute).Unix()
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, `{"state":true,"data":{"1":{"pick_code":"pc-dead","url":{"url":%q}}}}`,
fmt.Sprintf("%s/dead.mkv?t=%d&u=1&k=abc", cdn.URL, expiry))
}))
defer api.Close()
oldPro := cloud115.ProAPIBase
cloud115.ProAPIBase = api.URL
defer func() { cloud115.ProAPIBase = oldPro }()
defer cloud115.ClearDownloadURLCache("pc-dead")
oldMinAge := directLinkRefreshMinAge
directLinkRefreshMinAge = 0
defer func() { directLinkRefreshMinAge = oldMinAge }()
media := &model.Media{
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=" + acct.ID + "&pickcode=pc-dead",
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/stream/media-1?proxy=1", nil)
if err := svc.ProxyMediaDirect(context.Background(), rec, req, media); err != nil {
t.Fatalf("ProxyMediaDirect: %v", err)
}
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want the upstream 403 passed through", rec.Code)
}
}
// 强制换链只认 /api/strm/play 形式播放目标里的 pickcode。
func TestPickCodeFromPlayTarget(t *testing.T) {
cases := []struct {
raw string
want string
}{
{"/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1", "pc1"},
{"https://nas.local/api/strm/play/cloud115/video.mkv?pickcode=pc2&acct=a1", "pc2"},
{"/api/strm/play/cloud115/video.mkv?acct=a1", ""},
{"https://cdn.example.test/Movie.mkv?sign=1", ""},
{"", ""},
}
for _, tc := range cases {
if got := PickCodeFromPlayTarget(tc.raw); got != tc.want {
t.Fatalf("PickCodeFromPlayTarget(%q) = %q, want %q", tc.raw, got, tc.want)
}
}
}
// 本地文件本身就是同源资源,不应该被代理(调用方按原静态文件逻辑处理)。
func TestProxyMediaDirectSkipsLocalFile(t *testing.T) {
svc := testStrmService(t)
+81 -2
View File
@@ -182,6 +182,77 @@ func (s *SubtitleService) discoverExternalUncached(ctx context.Context, mediaID
return discoverExternalSubtitleTracks(m), nil
}
// subtitleLanguageNames 把文件名后缀/容器里的语言标记折成可读名称。
//
// 外挂字幕只能从文件名拿到语言(`movie.chs.ass` → `chs`),直接展示给用户
// 就是「chs」这种看不懂的值;内嵌轨没有 title 标签时同理。认不出来的标记
// 原样保留,交给调用方(例如 Emby 的「字幕 (ASS)」兜底)处理。
var subtitleLanguageNames = map[string]string{
"zh": "中文",
"chi": "中文",
"zho": "中文",
"cn": "中文",
"chs": "简体中文",
"sc": "简体中文",
"zh-cn": "简体中文",
"zh-hans": "简体中文",
"zh-sg": "简体中文",
"cht": "繁体中文",
"tc": "繁体中文",
"zh-tw": "繁体中文",
"zh-hk": "繁体中文",
"zh-mo": "繁体中文",
"zh-hant": "繁体中文",
"jpn": "日本語",
"ja": "日本語",
"jp": "日本語",
"eng": "English",
"en": "English",
"kor": "한국어",
"ko": "한국어",
"yue": "粤语",
}
// subtitleLanguageName 返回语言标记的可读名称,认不出来时返回空串。
func subtitleLanguageName(raw string) string {
tag := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(raw, "_", "-")))
if tag == "" {
return ""
}
if name, ok := subtitleLanguageNames[tag]; ok {
return name
}
// `chs[bilibiliid_danmu]`、`chs.ass` 这类带附加信息的标记先取前缀。
head := tag
if i := strings.IndexAny(head, "[.@"); i > 0 {
head = strings.TrimSpace(head[:i])
}
return subtitleLanguageNames[head]
}
// subtitleDisplayLabel 把语言标记换成可读名称;认不出来时保留原值(含 und)。
func subtitleDisplayLabel(raw string) string {
if name := subtitleLanguageName(raw); name != "" {
return name
}
return strings.TrimSpace(raw)
}
// isDanmakuSubtitle 报告字幕文件是否是弹幕转存(`xxx[BilibiliID_danmu].ass`)。
// 这类轨道是飘过的弹幕而不是对白字幕,名称里要标出来,避免和对白字幕混淆。
func isDanmakuSubtitle(name string) bool {
lower := strings.ToLower(name)
return strings.Contains(lower, "danmu") ||
strings.Contains(lower, "danmaku") ||
strings.Contains(name, "弹幕")
}
func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
dir := filepath.Dir(m.Path)
bases := mediaSidecarBaseVariants(m.Path)
@@ -228,9 +299,13 @@ func discoverExternalSubtitleTracks(m *model.Media) []SubtitleTrack {
matchedBase = bases[0]
}
lang := detectLang(fullName, matchedBase)
label := subtitleDisplayLabel(lang)
if isDanmakuSubtitle(e.Name()) {
label += " · 弹幕"
}
tracks = append(tracks, SubtitleTrack{
Lang: lang,
Label: lang,
Label: label,
Path: filepath.Join(c, e.Name()),
Codec: codec,
Source: "external",
@@ -319,7 +394,11 @@ func subtitleTracksFromProbe(probe embeddedSubtitleProbe) []SubtitleTrack {
}
label := strings.TrimSpace(stream.Tags.Title)
if label == "" {
label = lang
// 没有 title 标签时用可读语言名(chi → 中文)代替裸语言码。
label = subtitleDisplayLabel(lang)
}
if label == "" {
label = "字幕"
}
if stream.Disposition.Forced != 0 {
label += "(强制)"
+100
View File
@@ -202,3 +202,103 @@ func TestSubtitleServeRawWritesSourceBytes(t *testing.T) {
t.Fatal("ServeRaw accepted a non-subtitle file")
}
}
func TestSubtitleLanguageNameMapsCommonTags(t *testing.T) {
cases := map[string]string{
"chs": "简体中文",
"CHS": "简体中文",
"zh-Hans": "简体中文",
"cht": "繁体中文",
"chi": "中文",
"zh": "中文",
"jpn": "日本語",
"eng": "English",
"kor": "한국어",
"chs[bilibiliid_danmu]": "简体中文",
"und": "",
"": "",
}
for raw, want := range cases {
if got := subtitleLanguageName(raw); got != want {
t.Errorf("subtitleLanguageName(%q) = %q, want %q", raw, got, want)
}
}
}
func TestSubtitleDisplayLabelKeepsUnknownTags(t *testing.T) {
// 认不出来的标记原样返回:Emby 的 DisplayTitle 依赖 `und` 走「字幕 (ASS)」兜底。
if got := subtitleDisplayLabel("und"); got != "und" {
t.Fatalf("subtitleDisplayLabel(und) = %q, want und", got)
}
if got := subtitleDisplayLabel("weird"); got != "weird" {
t.Fatalf("subtitleDisplayLabel(weird) = %q, want weird", got)
}
}
func TestIsDanmakuSubtitle(t *testing.T) {
if !isDanmakuSubtitle("S01E01.chs[BilibiliID_danmu].ass") {
t.Fatal("danmaku sidecar was not detected")
}
if !isDanmakuSubtitle("弹幕.ass") {
t.Fatal("Chinese danmaku name was not detected")
}
if isDanmakuSubtitle("S01E01.chs.ass") {
t.Fatal("normal subtitle was misdetected as danmaku")
}
}
func TestDiscoverExternalSubtitleLabelsAreReadable(t *testing.T) {
dir := t.TempDir()
videoPath := filepath.Join(dir, "S01E01.mkv")
files := map[string]string{
"S01E01.chs.ass": "[Script Info]\n",
"S01E01.chs[BilibiliID_danmu].ass": "[Script Info]\n",
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
m := &model.Media{Path: videoPath}
tracks := discoverExternalSubtitleTracks(m)
if len(tracks) != 2 {
t.Fatalf("len(tracks) = %d, want 2: %#v", len(tracks), tracks)
}
labels := map[string]string{}
for _, track := range tracks {
labels[filepath.Base(track.Path)] = track.Label
}
if got := labels["S01E01.chs.ass"]; got != "简体中文" {
t.Fatalf("chs.ass label = %q, want 简体中文", got)
}
if got := labels["S01E01.chs[BilibiliID_danmu].ass"]; got != "简体中文 · 弹幕" {
t.Fatalf("danmaku label = %q, want 简体中文 · 弹幕", got)
}
}
func TestEmbeddedSubtitleProbeUsesReadableLanguage(t *testing.T) {
var probe embeddedSubtitleProbe
raw := []byte(`{"streams":[
{"index":2,"codec_name":"hdmv_pgs_subtitle","tags":{"language":"chi"},"disposition":{"default":1}},
{"index":3,"codec_name":"subrip","tags":{"language":"jpn"}},
{"index":4,"codec_name":"subrip","tags":{"language":"chi","title":"简日双语"}}
]}`)
if err := json.Unmarshal(raw, &probe); err != nil {
t.Fatal(err)
}
tracks := subtitleTracksFromProbe(probe)
if len(tracks) != 3 {
t.Fatalf("len(tracks) = %d, want 3", len(tracks))
}
if got := tracks[0].Label; got != "中文(默认)(内嵌·图片)" {
t.Fatalf("bitmap label = %q", got)
}
if got := tracks[1].Label; got != "日本語(内嵌)" {
t.Fatalf("text label = %q", got)
}
// 容器自带 title 时优先用 title。
if got := tracks[2].Label; got != "简日双语(内嵌)" {
t.Fatalf("titled label = %q", got)
}
}
+70 -16
View File
@@ -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,
@@ -147,8 +170,16 @@ func (s *TokenService) storeRefreshToken(ctx context.Context, rt *model.RefreshT
if err := s.repo.RefreshToken.Create(ctx, rt); err != nil {
return err
}
if err := s.repo.RefreshToken.RevokeOldestActiveByUserID(ctx, rt.UserID, s.maxActiveRefreshTokens(ctx)); err != nil && s.log != nil {
s.log.Warn("failed to enforce refresh token session limit", zap.String("user_id", rt.UserID), zap.Error(err))
// 「最大同时登录终端」只约束未绑定设备会话的旧客户端。
//
// 移动端每台设备有独立认证会话,刷新令牌归属该会话;如果仍按用户统一
// 裁剪活跃令牌,第 N+1 台设备登录(或任一设备刷新)就会把最早那台设备
// 的刷新令牌标记为已撤销,那台设备下次刷新收到 SESSION_REVOKED,被迫
// 重新登录。设备数量由设备策略单独约束,不能靠撤销刷新令牌实现。
if rt.AuthSessionID == "" {
if err := s.repo.RefreshToken.RevokeOldestActiveLegacyByUserID(ctx, rt.UserID, s.maxActiveRefreshTokens(ctx)); err != nil && s.log != nil {
s.log.Warn("failed to enforce refresh token session limit", zap.String("user_id", rt.UserID), zap.Error(err))
}
}
return nil
}
@@ -162,11 +193,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 +264,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 +314,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
}
+21 -18
View File
@@ -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
+2 -2
View File
@@ -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")
+47 -26
View File
@@ -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
+61 -29
View File
@@ -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"))
}
+65 -14
View File
@@ -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)
}
}
+4
View File
@@ -10,6 +10,10 @@ export const embyAPI = {
deleteMount: (id: string) => api.delete(`/admin/emby/mounts/${id}`).then((r) => r.data),
reorderMounts: (ids: string[]) =>
api.put<{ ok: boolean }>('/admin/emby/mounts/reorder', { ids }).then((r) => r.data),
setAccountMountsEnabled: (accountId: string, enabled: boolean) =>
api
.put<{ ok: boolean; affected: number }>(`/admin/emby/accounts/${accountId}/mounts/enabled`, { enabled })
.then((r) => r.data),
listAccountViews: (accountId: string) =>
api.get<RemoteEmbyView[]>(`/admin/emby/accounts/${accountId}/views`).then((r) => r.data),
fullMountAccount: (accountId: string, proxy: boolean) =>
+2 -2
View File
@@ -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),
// 片头/片尾片段:播放开始后再调用,服务端可能需要几秒去外部数据库取数,
+4
View File
@@ -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 /> },
+1
View File
@@ -25,6 +25,7 @@ function isMediaView(pathname: string, search: string): boolean {
return (
pathname === '/' ||
pathname === '/search' ||
pathname === '/libraries' ||
pathname.startsWith('/library') ||
pathname.startsWith('/media') ||
+57 -27
View File
@@ -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>
+8 -1
View File
@@ -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 统一行高,避免有/无徽标的卡片年份基线差几像素。 */}
+3 -2
View File
@@ -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') ||
+1
View File
@@ -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'))
+1
View File
@@ -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/')
+104 -5
View File
@@ -370,6 +370,8 @@ export function EmbyMountPage() {
const [accountDialog, setAccountDialog] = useState<StrmAccount | null | 'new'>(null)
const [mountDialogFor, setMountDialogFor] = useState<StrmAccount | null>(null)
const [testingID, setTestingID] = useState<string | null>(null)
const [togglingAccountID, setTogglingAccountID] = useState<string | null>(null)
const [bulkTogglingID, setBulkTogglingID] = useState<string | null>(null)
const [draggingId, setDraggingId] = useState<string | null>(null)
const [dragOverId, setDragOverId] = useState<string | null>(null)
const [reordering, setReordering] = useState(false)
@@ -504,6 +506,46 @@ export function EmbyMountPage() {
}
}
// 账号级总开关:停用后该服务器不再参与任何远程请求,其下挂载从媒体库中
// 立即隐藏(配置保留),用于远端宕机时先让首页恢复可用。
const toggleAccount = async (acct: StrmAccount) => {
const next = !acct.enabled
const acctMountCount = mounts.filter((m) => m.account_id === acct.id).length
if (!next && !(await confirmAction({
message: `停用 Emby 服务器「${acct.name}」?其下 ${acctMountCount} 个远程媒体库会立即从媒体库中隐藏,首页也不再请求该服务器;挂载配置与播放设置会保留,可随时重新启用。`,
confirmText: '停用账号',
}))) return
setTogglingAccountID(acct.id)
try {
await strmAPI.updateAccount(acct.id, { provider: acct.provider, enabled: next })
toast.success(next ? `已启用「${acct.name}」` : `已停用「${acct.name}」,远程媒体库已隐藏`)
await load()
} catch (err) {
toast.error(apiErrorMessage(err))
} finally {
setTogglingAccountID(null)
}
}
// 一键启停该账号下全部挂载(配置保留,只控制是否出现在媒体库中)。
const setAllMounts = async (acct: StrmAccount, enabled: boolean) => {
const acctMounts = mounts.filter((m) => m.account_id === acct.id)
if (!enabled && !(await confirmAction({
message: `停用「${acct.name}」下的全部 ${acctMounts.length} 个挂载?它们会立即从媒体库中隐藏,配置保留,可随时全部启用。`,
confirmText: '全部停用',
}))) return
setBulkTogglingID(acct.id)
try {
const res = await embyAPI.setAccountMountsEnabled(acct.id, enabled)
toast.success(enabled ? `已启用 ${res.affected} 个挂载` : `已停用 ${res.affected} 个挂载`)
await load()
} catch (err) {
toast.error(apiErrorMessage(err))
} finally {
setBulkTogglingID(null)
}
}
const removeMount = async (mount: EmbyMount) => {
if (!(await confirmAction({ message: `取消挂载「${mount.remote_view_name || mount.name}」?媒体库列表中将不再显示。`, confirmText: '取消挂载' }))) return
try {
@@ -566,26 +608,55 @@ export function EmbyMountPage() {
) : (
accounts.map((acct) => {
const acctMounts = mounts.filter((m) => m.account_id === acct.id)
const anyMountEnabled = acctMounts.some((m) => m.enabled)
return (
<div key={acct.id} className="overflow-hidden rounded-3xl border border-sand-200 bg-white shadow-card">
<div
key={acct.id}
className={`overflow-hidden rounded-3xl border bg-white shadow-card ${
acct.enabled ? 'border-sand-200' : 'border-gray-200 opacity-80'
}`}
>
{/* 账号头部 */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-sand-100 bg-gradient-to-r from-brand-50/60 to-transparent px-5 py-4">
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-brand-100 text-brand-600">
<span className={`flex h-10 w-10 items-center justify-center rounded-2xl ${acct.enabled ? 'bg-brand-100 text-brand-600' : 'bg-gray-100 text-sand-500'}`}>
<Server size={20} />
</span>
<div>
<p className="font-display text-lg font-bold text-ink-600">{acct.name}</p>
<p className="font-display text-lg font-bold text-ink-600">
{acct.name}
{!acct.enabled && (
<span className="ml-2 rounded-full bg-gray-200 px-2 py-0.5 align-middle text-[11px] font-semibold text-sand-500">
已停用
</span>
)}
</p>
<p className="text-xs text-sand-500">
{acct.has_credential ? '凭据已配置' : '待补全凭据'}
{acct.emby_lines && acct.emby_lines.length > 0
? ` · ${acct.emby_lines.length} 条线路`
: ''}
{acct.last_test_result ? ` · 最近测试:${acct.last_test_ok ? '正常' : acct.last_test_result}` : ''}
{!acct.enabled ? ' · 不会发起远程请求' : ''}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => toggleAccount(acct)}
disabled={togglingAccountID === acct.id}
className={`iconButtonCls ${acct.enabled ? '!text-amber-600 hover:!bg-amber-50' : '!text-green-600 hover:!bg-green-50'}`}
title={acct.enabled ? '停用该服务器:其下挂载全部从媒体库隐藏' : '重新启用该服务器'}
>
{togglingAccountID === acct.id ? (
<Loader2 size={13} className="animate-spin" />
) : acct.enabled ? (
<PowerOff size={13} />
) : (
<Power size={13} />
)}
{acct.enabled ? '停用账号' : '启用账号'}
</button>
<button onClick={() => testAccount(acct)} disabled={testingID === acct.id} className="iconButtonCls">
{testingID === acct.id ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
测试
@@ -644,6 +715,7 @@ export function EmbyMountPage() {
<p className="text-xs text-sand-500">
{m.collection_type || 'mixed'}
{m.proxy_play ? ' · 本机代理播放' : ' · 直连播放'}
{!m.enabled ? ' · 已停用' : ''}
</p>
</div>
<div className="flex items-center gap-1">
@@ -687,14 +759,41 @@ export function EmbyMountPage() {
</div>
)}
<div className="mt-4 flex flex-wrap gap-2">
<button onClick={() => setMountDialogFor(acct)} className="btn-outline">
<button
onClick={() => setMountDialogFor(acct)}
disabled={!acct.enabled}
className="btn-outline disabled:cursor-not-allowed disabled:opacity-40"
title={acct.enabled ? undefined : '账号已停用,启用后才能挂载新的媒体库'}
>
<Plus size={14} />
选择挂载媒体库
</button>
<button onClick={() => fullMount(acct)} className="btn-outline">
<button
onClick={() => fullMount(acct)}
disabled={!acct.enabled}
className="btn-outline disabled:cursor-not-allowed disabled:opacity-40"
title={acct.enabled ? undefined : '账号已停用,启用后才能挂载新的媒体库'}
>
<Check size={14} />
全量挂载
</button>
{acctMounts.length > 0 && (
<button
onClick={() => setAllMounts(acct, !anyMountEnabled)}
disabled={bulkTogglingID === acct.id}
className="btn-outline disabled:cursor-not-allowed disabled:opacity-40"
title={anyMountEnabled ? '把该账号下全部挂载从媒体库中隐藏(配置保留)' : '恢复该账号下全部挂载'}
>
{bulkTogglingID === acct.id ? (
<Loader2 size={14} className="animate-spin" />
) : anyMountEnabled ? (
<PowerOff size={14} />
) : (
<Power size={14} />
)}
{anyMountEnabled ? '全部停用' : '全部启用'}
</button>
)}
</div>
</div>
</div>
+3 -1
View File
@@ -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)}
+182
View File
@@ -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>
)
}
+53 -55
View File
@@ -40,6 +40,7 @@ import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, isStrmMe
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'
@@ -992,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],
@@ -1076,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}…`)
@@ -1144,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(() => {
@@ -1294,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
@@ -1310,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 次的话,一次抖动就判死刑,
// 直接掉到本地转码。
@@ -1330,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
@@ -1388,8 +1386,7 @@ export function PlayerPage() {
} catch {
// ignore
}
video.src = url
void video.play().catch(() => undefined)
cleanupHlsResume = attachNativeHls(video, url, pendingSeekRef)
} else {
setHlsUnavailable(true)
showPlayFailure('当前浏览器不支持 HLS,正在尝试直接播放。')
@@ -1421,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)
@@ -1457,6 +1451,8 @@ export function PlayerPage() {
return () => {
cancelled = true
video.removeEventListener('playing', onPlaying)
if (onDirectMetadata) video.removeEventListener('loadedmetadata', onDirectMetadata)
cleanupHlsResume?.()
teardownHls()
}
}, [
@@ -1505,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)
@@ -1536,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)
@@ -1552,7 +1552,7 @@ export function PlayerPage() {
return
}
seekStarted = true
if (isStrmMedia(mediaRef.current)) {
if (mode === 'direct' && isStrmMedia(mediaRef.current)) {
seekStrmDirectTo(resumePosition, {
timeoutMs: 6000,
onSettled: () => {
@@ -1579,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()
}
@@ -1592,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()
@@ -1615,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)
@@ -2223,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')
@@ -2256,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)
@@ -2281,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],
+58
View File
@@ -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')

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