mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
Merge pull request #41 from truewhile/fix/database-playback-stability
修复数据库兼容性与 HLS 播放生命周期问题
This commit is contained in:
@@ -40,6 +40,39 @@ jobs:
|
||||
- name: go test
|
||||
run: go test ./...
|
||||
|
||||
database-compatibility:
|
||||
name: SQLite / PostgreSQL ${{ matrix.postgres }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
postgres: [16, 17]
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:${{ matrix.postgres }}-alpine
|
||||
env:
|
||||
POSTGRES_USER: mebox
|
||||
POSTGRES_PASSWORD: mebox-test-only
|
||||
POSTGRES_DB: mebox_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U mebox -d mebox_test"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
env:
|
||||
MEBOX_TEST_POSTGRES_DSN: postgres://mebox:mebox-test-only@127.0.0.1:5432/mebox_test?sslmode=disable
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26'
|
||||
cache: true
|
||||
|
||||
- name: Database regression tests
|
||||
run: go test ./internal/database ./internal/repository -count=1
|
||||
|
||||
frontend:
|
||||
name: Frontend (Node)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -78,7 +111,7 @@ jobs:
|
||||
ci-success:
|
||||
name: CI Success
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend, smoke]
|
||||
needs: [backend, frontend, database-compatibility, smoke]
|
||||
if: success()
|
||||
steps:
|
||||
- run: echo "CI passed, ready for release"
|
||||
|
||||
@@ -84,6 +84,15 @@ go test ./internal/service -run "TestOrganize|TestSubscription|TestMTeam" -count
|
||||
go test ./cmd/server -count=1
|
||||
```
|
||||
|
||||
数据库兼容测试默认覆盖 SQLite。连接专用测试 PostgreSQL 后可运行真实的双数据库回归:
|
||||
|
||||
```bash
|
||||
MEBOX_TEST_POSTGRES_DSN='postgres://mebox:test-password@127.0.0.1:5432/mebox_test?sslmode=disable' \
|
||||
go test ./internal/database ./internal/repository -count=1
|
||||
```
|
||||
|
||||
测试账号需要创建、删除 schema 的权限;每个用例使用独立的临时 schema。CI 会分别在 PostgreSQL 16、17 上执行这些检查。
|
||||
|
||||
### 代码要求
|
||||
|
||||
- Go 代码使用 `gofmt`。
|
||||
|
||||
@@ -355,6 +355,9 @@ func (m *serverManager) drain(s *http.Server) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
// Stop reusing old connections before Reload returns. Asynchronous Shutdown
|
||||
// alone leaves a window where a client receives the previous HTTP/TLS mode.
|
||||
s.SetKeepAlivesEnabled(false)
|
||||
go func(s *http.Server) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// PostgreSQL tests use a disposable schema, never the caller's existing tables.
|
||||
// Set MEBOX_TEST_POSTGRES_DSN to include a real PostgreSQL server in local runs.
|
||||
func compatibilityDB(t *testing.T, dialect string) *gorm.DB {
|
||||
t.Helper()
|
||||
var db *gorm.DB
|
||||
if dialect == "sqlite" {
|
||||
cfg := &config.Config{}
|
||||
cfg.Database.Type = dialect
|
||||
cfg.Database.DBPath = filepath.Join(t.TempDir(), "test.db")
|
||||
cfg.Database.WALMode = true
|
||||
cfg.Database.BusyTimeout = 5000
|
||||
cfg.Database.MaxOpenConns = 8
|
||||
var err error
|
||||
db, err = Open(cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
dsn := os.Getenv("MEBOX_TEST_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set MEBOX_TEST_POSTGRES_DSN to run PostgreSQL integration tests")
|
||||
}
|
||||
pgcfg, err := pgx.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
t.Fatal("invalid PostgreSQL test configuration")
|
||||
}
|
||||
admin := stdlib.OpenDB(*pgcfg)
|
||||
t.Cleanup(func() { _ = admin.Close() })
|
||||
schemaName := "mebox_test_" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
if _, err := admin.Exec("CREATE SCHEMA " + quoteIdent(schemaName)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if _, err := admin.Exec("DROP SCHEMA " + quoteIdent(schemaName) + " CASCADE"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
pgcfg.RuntimeParams["search_path"] = schemaName + ",public"
|
||||
conn := stdlib.OpenDB(*pgcfg)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
db, err = gorm.Open(postgres.New(postgres.Config{Conn: conn}), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
db.Logger = logger.Default.LogMode(logger.Silent)
|
||||
if conn, err := db.DB(); err == nil {
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
}
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestDatabaseCompatibility(t *testing.T) {
|
||||
for _, dialect := range []string{"sqlite", "postgres"} {
|
||||
t.Run(dialect, func(t *testing.T) {
|
||||
t.Run("long_paths_and_restart", func(t *testing.T) {
|
||||
db := compatibilityDB(t, dialect)
|
||||
// Distinct CJK characters prevent TOAST compression from hiding
|
||||
// the PostgreSQL btree entry size limit (> 2704 bytes).
|
||||
var path strings.Builder
|
||||
path.WriteString("/media/")
|
||||
for i := range 980 {
|
||||
path.WriteRune(rune(0x4e00 + i))
|
||||
}
|
||||
path.WriteString(".mkv")
|
||||
media := model.Media{Path: path.String(), Title: "Long path"}
|
||||
if err := db.Create(&media).Error; err != nil {
|
||||
t.Fatalf("insert long media path: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Media{Path: media.Path}).Error; err == nil {
|
||||
t.Fatal("duplicate path accepted")
|
||||
}
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("migrate existing database: %v", err)
|
||||
}
|
||||
var found model.Media
|
||||
if err := db.Where("path = ?", media.Path).First(&found).Error; err != nil {
|
||||
t.Fatalf("lookup long path: %v", err)
|
||||
}
|
||||
})
|
||||
if dialect == "postgres" {
|
||||
t.Run("upgrade_legacy_path_indexes", func(t *testing.T) {
|
||||
db := compatibilityDB(t, dialect)
|
||||
for _, unique := range []string{"UNIQUE ", ""} {
|
||||
if err := db.Exec("DROP INDEX idx_media_path").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("CREATE " + unique + "INDEX idx_media_path ON media(path)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 2 {
|
||||
if err := ensureMediaPathIndex(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
isUnique, method, found, err := postgresIndexProperties(db, "idx_media_path")
|
||||
if err != nil || !found || isUnique || method != "hash" {
|
||||
t.Fatalf("legacy index not upgraded: unique=%v method=%s found=%v err=%v", isUnique, method, found, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("search_ranking", func(t *testing.T) {
|
||||
db := compatibilityDB(t, dialect)
|
||||
// Exercise LIKE fallback in both engines.
|
||||
if dialect == "sqlite" {
|
||||
resetMediaSearchIndex(db)
|
||||
}
|
||||
for i, title := range []string{"The Alpha story", "Alpha sequel", "Alpha"} {
|
||||
row := model.Media{Base: model.Base{ID: fmt.Sprintf("m-%d", i), CreatedAt: time.Now().Add(time.Duration(i) * time.Minute)}, Title: title, Path: fmt.Sprintf("/media/%d.mkv", i)}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
items, total, err := repository.New(db).Media.SearchFilteredPage(t.Context(), "Alpha", 0, 10, repository.MediaQueryFilter{IncludeNSFW: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 3 || len(items) != 3 || items[0].Title != "Alpha" || items[1].Title != "Alpha sequel" {
|
||||
t.Fatalf("unexpected search ranking: total=%d, items=%v", total, items)
|
||||
}
|
||||
})
|
||||
t.Run("concurrent_media_upsert", func(t *testing.T) {
|
||||
db := compatibilityDB(t, dialect)
|
||||
repo := repository.New(db).Media
|
||||
const workers = 8
|
||||
// Synchronize the initial lookups to force the insert race.
|
||||
var lookups sync.WaitGroup
|
||||
lookups.Add(workers)
|
||||
if dialect == "postgres" {
|
||||
if err := db.Callback().Query().After("gorm:after_query").Register("test:insert_race", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "media" && tx.Error == gorm.ErrRecordNotFound {
|
||||
lookups.Done()
|
||||
lookups.Wait()
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
errs := make(chan error, workers)
|
||||
for range workers {
|
||||
go func() { errs <- repo.Upsert(ctx, &model.Media{Path: "/media/shared.mkv", Title: "Shared"}) }()
|
||||
}
|
||||
for range workers {
|
||||
if err := <-errs; err != nil {
|
||||
t.Errorf("concurrent upsert: %v", err)
|
||||
}
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.Media{}).Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatalf("media count=%d, err=%v", count, err)
|
||||
}
|
||||
})
|
||||
t.Run("search_filters", func(t *testing.T) {
|
||||
db := compatibilityDB(t, dialect)
|
||||
rows := []model.Media{
|
||||
{Base: model.Base{ID: "keep"}, LibraryID: "lib-1", Genres: "Action", Year: 2020, Rating: 8},
|
||||
{Base: model.Base{ID: "other-library"}, LibraryID: "lib-2", Genres: "Action", Year: 2020, Rating: 8},
|
||||
{Base: model.Base{ID: "other-genre"}, LibraryID: "lib-1", Genres: "Comedy", Year: 2020, Rating: 8},
|
||||
{Base: model.Base{ID: "too-old"}, LibraryID: "lib-1", Genres: "Action", Year: 1990, Rating: 8},
|
||||
{Base: model.Base{ID: "too-new"}, LibraryID: "lib-1", Genres: "Action", Year: 2026, Rating: 8},
|
||||
{Base: model.Base{ID: "low-rating"}, LibraryID: "lib-1", Genres: "Action", Year: 2020, Rating: 2},
|
||||
{Base: model.Base{ID: "watched"}, LibraryID: "lib-1", Genres: "Action", Year: 2020, Rating: 8},
|
||||
}
|
||||
for i := range rows {
|
||||
rows[i].Title = "Alpha " + rows[i].ID
|
||||
rows[i].Path = "/media/" + rows[i].ID + ".mkv"
|
||||
if err := db.Create(&rows[i]).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.Create(&model.PlaybackHistory{UserID: "viewer", MediaID: "watched", Completed: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, total, err := repository.New(db).Media.SearchFilteredPage(t.Context(), "alpha", 0, 10, repository.MediaQueryFilter{
|
||||
IncludeNSFW: true, LibraryID: "lib-1", Genres: []string{"Action"}, YearMin: 2000, YearMax: 2025, RatingMin: 5,
|
||||
UnwatchedOnly: true, UnwatchedUserID: "viewer",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(items) != 1 || items[0].ID != "keep" {
|
||||
t.Fatalf("search ignored filters: total=%d, items=%v", total, items)
|
||||
}
|
||||
})
|
||||
t.Run("migration_preserves_zero_values", func(t *testing.T) {
|
||||
src := compatibilityDB(t, "sqlite")
|
||||
dst := compatibilityDB(t, dialect)
|
||||
user := model.User{Username: "disabled", PasswordHash: "hash"}
|
||||
if err := src.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := src.Model(&user).Updates(map[string]any{"is_active": false, "hide_adult": false, "player_volume": 0, "danmaku_enabled": false}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := copyModelTables(src, dst, 500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got model.User
|
||||
if err := dst.First(&got, "id = ?", user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.IsActive || got.HideAdult || got.PlayerVolume != 0 || got.DanmakuEnabled {
|
||||
t.Fatalf("migration replaced stored zero values: active=%v hide_adult=%v volume=%v danmaku=%v", got.IsActive, got.HideAdult, got.PlayerVolume, got.DanmakuEnabled)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// InsertPreservingValues copies persisted model rows without applying creation
|
||||
// hooks or defaults. Struct CreateInBatches treats false, 0 and empty strings as
|
||||
// missing values, which would reactivate disabled accounts during migration or
|
||||
// restore. Column maps preserve the exact values already stored in the source.
|
||||
func InsertPreservingValues(db *gorm.DB, rows any, batchSize int) error {
|
||||
stmt := &gorm.Statement{DB: db}
|
||||
if err := stmt.Parse(rows); err != nil {
|
||||
return err
|
||||
}
|
||||
slice := reflect.Indirect(reflect.ValueOf(rows))
|
||||
if slice.Kind() != reflect.Slice {
|
||||
return fmt.Errorf("persisted rows must be a slice, got %T", rows)
|
||||
}
|
||||
if len(stmt.Schema.DBNames) == 0 {
|
||||
return fmt.Errorf("persisted rows have no database columns")
|
||||
}
|
||||
values := make([]map[string]any, 0, slice.Len())
|
||||
for i := 0; i < slice.Len(); i++ {
|
||||
row := make(map[string]any, len(stmt.Schema.DBNames))
|
||||
for _, name := range stmt.Schema.DBNames {
|
||||
field := stmt.Schema.FieldsByDBName[name]
|
||||
if field.Creatable {
|
||||
row[name], _ = field.ValueOf(db.Statement.Context, slice.Index(i))
|
||||
}
|
||||
}
|
||||
values = append(values, row)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Bound batches by each engine's bind-parameter limit.
|
||||
parameterLimit := 65535
|
||||
if isSQLite(db) {
|
||||
parameterLimit = 32766
|
||||
}
|
||||
maxBatch := parameterLimit / len(stmt.Schema.DBNames)
|
||||
if batchSize <= 0 || batchSize > maxBatch {
|
||||
batchSize = maxBatch
|
||||
}
|
||||
return db.Table(stmt.Schema.Table).CreateInBatches(values, batchSize).Error
|
||||
}
|
||||
@@ -71,8 +71,8 @@ func AutoMigrateWithConfig(db *gorm.DB, cfg *config.Config, log *zap.Logger) err
|
||||
// index row size ... exceeds btree version 4 maximum 2704
|
||||
//
|
||||
// 所以 PostgreSQL 改用 md5(path) 的表达式唯一索引(定长 32 字节,永远塞得下),
|
||||
// 同时保留一条普通索引 idx_media_path 供 path = ? 等值查找(扫描与 upsert 的
|
||||
// 热路径)使用。SQLite 没有这个长度限制,沿用整列唯一索引。
|
||||
// 同时用 hash 索引 idx_media_path 加速 path = ? 等值查找;非唯一 btree
|
||||
// 也有索引项长度限制,不能继续对完整 path 建 btree。SQLite 沿用整列唯一索引。
|
||||
func ensureMediaPathIndex(db *gorm.DB) error {
|
||||
if db == nil || !db.Migrator().HasTable("media") {
|
||||
return nil
|
||||
@@ -84,37 +84,39 @@ func ensureMediaPathIndex(db *gorm.DB) error {
|
||||
if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_media_path_hash ON media(md5(path))`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
unique, found, err := postgresIndexIsUnique(db, "idx_media_path")
|
||||
unique, method, found, err := postgresIndexProperties(db, "idx_media_path")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found && unique {
|
||||
if found && (unique || method != "hash") {
|
||||
if err := db.Exec(`DROP INDEX IF EXISTS idx_media_path`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Exec(`CREATE INDEX IF NOT EXISTS idx_media_path ON media(path)`).Error
|
||||
return db.Exec(`CREATE INDEX IF NOT EXISTS idx_media_path ON media USING hash(path)`).Error
|
||||
}
|
||||
|
||||
// postgresIndexIsUnique 报告当前 schema 下是否存在该索引、以及它是否唯一。
|
||||
func postgresIndexIsUnique(db *gorm.DB, indexName string) (unique bool, found bool, err error) {
|
||||
// postgresIndexProperties 返回当前 schema 下索引的唯一性与访问方法。
|
||||
func postgresIndexProperties(db *gorm.DB, indexName string) (unique bool, method string, found bool, err error) {
|
||||
// 用指针接 bool:没有匹配行时 Scan 不报错,nil 即代表索引不存在。
|
||||
var row struct {
|
||||
IsUnique *bool
|
||||
Method string
|
||||
}
|
||||
if err := db.Raw(`
|
||||
SELECT i.indisunique AS is_unique
|
||||
SELECT i.indisunique AS is_unique, a.amname AS method
|
||||
FROM pg_class c
|
||||
JOIN pg_index i ON i.indexrelid = c.oid
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
JOIN pg_am a ON a.oid = c.relam
|
||||
WHERE n.nspname = current_schema() AND c.relname = ?
|
||||
`, indexName).Scan(&row).Error; err != nil {
|
||||
return false, false, err
|
||||
return false, "", false, err
|
||||
}
|
||||
if row.IsUnique == nil {
|
||||
return false, false, nil
|
||||
return false, "", false, nil
|
||||
}
|
||||
return *row.IsUnique, true, nil
|
||||
return *row.IsUnique, row.Method, true, nil
|
||||
}
|
||||
|
||||
// ensurePostgresSearchIndexes 给 PostgreSQL 补上 SQLite FTS5 的替代品。
|
||||
|
||||
@@ -78,7 +78,7 @@ func copyModelTables(src, target *gorm.DB, batchSize int) (map[string]int64, int
|
||||
if filtered.Len() > 0 {
|
||||
filteredPtr := reflect.New(filtered.Type())
|
||||
filteredPtr.Elem().Set(filtered)
|
||||
if err := target.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(filteredPtr.Interface(), batchSize).Error; err != nil {
|
||||
if err := InsertPreservingValues(target.Clauses(clause.OnConflict{DoNothing: true}), filteredPtr.Interface(), batchSize); err != nil {
|
||||
return tableCounts, totalCopied, fmt.Errorf("copy sqlite table %s: %w", table, err)
|
||||
}
|
||||
copiedForTable += int64(filtered.Len())
|
||||
|
||||
@@ -104,19 +104,18 @@ func (g *sqliteWriteGate) Lock(ctx context.Context) (*sqliteGateHolder, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
// ctx 取消时唤醒等待者(cond 无法感知 ctx,用旁路 goroutine 广播)。
|
||||
if done := ctx.Done(); done != nil {
|
||||
stop := make(chan struct{})
|
||||
defer close(stop)
|
||||
go func() {
|
||||
select {
|
||||
case <-done:
|
||||
// 广播也必须持有同一把锁,否则取消可能落在检查与 Wait 之间而丢失。
|
||||
wake := func() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.cond.Broadcast()
|
||||
case <-stop:
|
||||
}
|
||||
}()
|
||||
}
|
||||
stopCancel := context.AfterFunc(ctx, wake)
|
||||
defer stopCancel()
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if g.owner == nil {
|
||||
holder := &sqliteGateHolder{
|
||||
id: sqliteGateHolderSeq.Add(1),
|
||||
@@ -125,16 +124,17 @@ func (g *sqliteWriteGate) Lock(ctx context.Context) (*sqliteGateHolder, error) {
|
||||
g.owner = holder
|
||||
return holder, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if time.Since(g.owner.acquired) > sqliteGateHoldLimit {
|
||||
remaining := sqliteGateHoldLimit - time.Since(g.owner.acquired)
|
||||
if remaining <= 0 {
|
||||
// 持有者疑似 panic 泄漏(After 回调未执行):强制回收。
|
||||
g.owner = nil
|
||||
g.cond.Broadcast()
|
||||
continue
|
||||
}
|
||||
// 无后续写入或取消时,也要在租约到期后主动唤醒等待者。
|
||||
timer := time.AfterFunc(remaining, wake)
|
||||
g.cond.Wait()
|
||||
timer.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSQLiteWriteGateRejectsCancelledContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
gate := newSQLiteWriteGate()
|
||||
holder, err := gate.Lock(ctx)
|
||||
defer gate.Unlock(holder)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Lock error=%v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteWriteGateWakesForExpiredHolder(t *testing.T) {
|
||||
gate := newSQLiteWriteGate()
|
||||
holder, err := gate.Lock(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer gate.Unlock(holder)
|
||||
// Make the lease expire shortly, without waiting a full minute.
|
||||
gate.mu.Lock()
|
||||
holder.acquired = time.Now().Add(-sqliteGateHoldLimit + 20*time.Millisecond)
|
||||
gate.mu.Unlock()
|
||||
done := make(chan *sqliteGateHolder, 1)
|
||||
go func() {
|
||||
next, _ := gate.Lock(t.Context())
|
||||
done <- next
|
||||
}()
|
||||
select {
|
||||
case next := <-done:
|
||||
gate.Unlock(holder) // A stale holder must not unlock its successor.
|
||||
gate.mu.Lock()
|
||||
owner := gate.owner
|
||||
gate.mu.Unlock()
|
||||
gate.Unlock(next)
|
||||
if next == nil || owner != next {
|
||||
t.Fatal("expired holder released the new owner's lock")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
gate.Unlock(holder)
|
||||
gate.Unlock(<-done)
|
||||
t.Fatal("waiter was not woken when the holder expired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteWriteGateWakesForCancellation(t *testing.T) {
|
||||
gate := newSQLiteWriteGate()
|
||||
holder, err := gate.Lock(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer gate.Unlock(holder)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
next, err := gate.Lock(ctx)
|
||||
gate.Unlock(next)
|
||||
done <- err
|
||||
}()
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Lock error=%v, want context.Canceled", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
gate.Unlock(holder)
|
||||
<-done
|
||||
t.Fatal("waiter was not woken for cancellation")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -63,38 +63,38 @@ type MediaQueryFilter struct {
|
||||
|
||||
func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
|
||||
if !filter.IncludeNSFW {
|
||||
q = q.Where("nsfw = ?", false)
|
||||
q = q.Where("media.nsfw = ?", false)
|
||||
}
|
||||
if len(filter.HiddenLibraryIDs) > 0 {
|
||||
q = q.Where("library_id NOT IN ?", filter.HiddenLibraryIDs)
|
||||
q = q.Where("media.library_id NOT IN ?", filter.HiddenLibraryIDs)
|
||||
}
|
||||
if len(filter.AllowedLibraryIDs) > 0 {
|
||||
q = q.Where("library_id IN ?", filter.AllowedLibraryIDs)
|
||||
q = q.Where("media.library_id IN ?", filter.AllowedLibraryIDs)
|
||||
}
|
||||
if libraryID := strings.TrimSpace(filter.LibraryID); libraryID != "" {
|
||||
q = q.Where("library_id = ?", libraryID)
|
||||
q = q.Where("media.library_id = ?", libraryID)
|
||||
}
|
||||
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
|
||||
q = q.Where("series_id = ?", seriesID)
|
||||
q = q.Where("media.series_id = ?", seriesID)
|
||||
}
|
||||
if len(filter.Genres) > 0 {
|
||||
q = q.Where(genreMatchClause(filter.Genres), genreMatchArgs(filter.Genres)...)
|
||||
}
|
||||
if filter.YearMin > 0 {
|
||||
q = q.Where("year >= ?", filter.YearMin)
|
||||
q = q.Where("media.year >= ?", filter.YearMin)
|
||||
}
|
||||
if filter.YearMax > 0 {
|
||||
q = q.Where("year <= ?", filter.YearMax)
|
||||
q = q.Where("media.year <= ?", filter.YearMax)
|
||||
}
|
||||
if filter.RatingMin > 0 {
|
||||
q = q.Where("rating >= ?", filter.RatingMin)
|
||||
q = q.Where("media.rating >= ?", filter.RatingMin)
|
||||
}
|
||||
if filter.UnwatchedOnly {
|
||||
userID := strings.TrimSpace(filter.UnwatchedUserID)
|
||||
// 没有用户上下文时忽略该条件:否则会把整个库筛成空,看起来像「坏了」。
|
||||
if userID != "" {
|
||||
q = q.Where(
|
||||
"id NOT IN (SELECT media_id FROM playback_histories WHERE user_id = ? AND completed = ?)",
|
||||
"media.id NOT IN (SELECT media_id FROM playback_histories WHERE user_id = ? AND completed = ?)",
|
||||
userID, true,
|
||||
)
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func applyMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
|
||||
func genreMatchClause(genres []string) string {
|
||||
clauses := make([]string, 0, len(genres))
|
||||
for range genres {
|
||||
clauses = append(clauses, "',' || REPLACE(REPLACE(TRIM(genres), ' ', ''), ',', ',') || ',' LIKE ?")
|
||||
clauses = append(clauses, "',' || REPLACE(REPLACE(TRIM(media.genres), ' ', ''), ',', ',') || ',' LIKE ?")
|
||||
}
|
||||
return "(" + strings.Join(clauses, " OR ") + ")"
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func genreMatchClause(genres []string) string {
|
||||
//
|
||||
// 必须与 genreMatchClause 的列端处理完全对称:
|
||||
// - TRIM → TrimSpace
|
||||
// - REPLACE(…, ' ', '') → ReplaceAll(…, " ", "") ← 多词类型(如 "Science Fiction")
|
||||
// - REPLACE(…, ' ', ”) → ReplaceAll(…, " ", "") ← 多词类型(如 "Science Fiction")
|
||||
// - REPLACE(…, ',', ',') → ReplaceAll(…, ",", ",")
|
||||
func genreMatchArgs(genres []string) []any {
|
||||
args := make([]any, 0, len(genres))
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
@@ -95,6 +96,9 @@ func (r *MediaRepository) UpsertBatchWithAliases(ctx context.Context, items []Me
|
||||
|
||||
// indexByIDBestEffort 在事务提交后按 ID 回读最新行并同步搜索索引。
|
||||
func (r *MediaRepository) indexByIDBestEffort(ctx context.Context, ids []string) {
|
||||
if _, ok := r.searchBackend.(MediaSearchSyncBackend); !ok {
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id == "" {
|
||||
continue
|
||||
@@ -193,14 +197,19 @@ func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.
|
||||
if m.ScrapeStatus == "" {
|
||||
m.ScrapeStatus = "pending"
|
||||
}
|
||||
if createErr := db.WithContext(ctx).Create(m).Error; createErr == nil {
|
||||
return *m, true, false, nil
|
||||
} else if retryErr := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; retryErr != nil {
|
||||
return model.Media{}, false, false, createErr
|
||||
} else {
|
||||
// 并发插入竞态:重查已命中既有行,直接走更新分支。
|
||||
return existing, false, false, nil
|
||||
// PostgreSQL 唯一键异常会中止整个事务,之后重查也会失败。
|
||||
// DO NOTHING 保持事务可用,并兼容 path 的表达式唯一索引。
|
||||
result := db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(m)
|
||||
if result.Error != nil {
|
||||
return model.Media{}, false, false, result.Error
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
return *m, true, false, nil
|
||||
}
|
||||
if err := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error; err != nil {
|
||||
return model.Media{}, false, false, err
|
||||
}
|
||||
return existing, false, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return model.Media{}, false, false, err
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
@@ -86,7 +87,7 @@ func (r *MediaRepository) searchFilteredFTS(ctx context.Context, query string, o
|
||||
Joins("JOIN media_search_fts ON media_search_fts.rowid = media.rowid").
|
||||
Where("media.deleted_at IS NULL").
|
||||
Where("media_search_fts MATCH ?", ftsQuery)
|
||||
q = applyQualifiedMediaQueryFilter(q, filter)
|
||||
q = applyMediaQueryFilter(q, filter)
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, false
|
||||
}
|
||||
@@ -122,10 +123,10 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string,
|
||||
if query != "" {
|
||||
prefix := escapeLike(query) + "%"
|
||||
exact := query
|
||||
q = q.Order(gorm.Expr(
|
||||
q = q.Order(clause.OrderBy{Expression: gorm.Expr(
|
||||
"CASE WHEN title = ? THEN 0 WHEN original_name = ? THEN 1 WHEN title "+likeOp+" ? ESCAPE '\\' THEN 2 WHEN original_name "+likeOp+" ? ESCAPE '\\' THEN 3 ELSE 4 END, created_at desc",
|
||||
exact, exact, prefix, prefix,
|
||||
))
|
||||
)})
|
||||
} else {
|
||||
q = q.Order("created_at desc")
|
||||
}
|
||||
@@ -133,22 +134,6 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string,
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
func applyQualifiedMediaQueryFilter(q *gorm.DB, filter MediaQueryFilter) *gorm.DB {
|
||||
if !filter.IncludeNSFW {
|
||||
q = q.Where("media.nsfw = ?", false)
|
||||
}
|
||||
if len(filter.HiddenLibraryIDs) > 0 {
|
||||
q = q.Where("media.library_id NOT IN ?", filter.HiddenLibraryIDs)
|
||||
}
|
||||
if len(filter.AllowedLibraryIDs) > 0 {
|
||||
q = q.Where("media.library_id IN ?", filter.AllowedLibraryIDs)
|
||||
}
|
||||
if seriesID := strings.TrimSpace(filter.SeriesID); seriesID != "" {
|
||||
q = q.Where("media.series_id = ?", seriesID)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func mediaFTSQuery(query string) string {
|
||||
terms := mediaSearchTerms(query)
|
||||
if len(terms) == 0 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,8 @@ func TestSafeZipTargetRejectsTraversal(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFFmpegToolsStatusNoPanic(t *testing.T) {
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
t.Chdir(t.TempDir())
|
||||
svc := NewFFmpegToolsService(&config.Config{}, zap.NewNop(), nil)
|
||||
st := svc.Status(context.Background())
|
||||
for _, key := range []string{"installing", "message", "error", "install_dir", "target", "ffmpeg", "ffprobe"} {
|
||||
|
||||
@@ -14,14 +14,12 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -103,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")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ func (p *ImageProxy) remoteImageFetchClients(host string) []remoteImageFetchClie
|
||||
}
|
||||
direct := p.directClient
|
||||
if direct == nil {
|
||||
direct = &http.Client{Timeout: timeout, Transport: NewInternalTransport()}
|
||||
transport := NewInternalTransport()
|
||||
p.guardTransport(transport)
|
||||
direct = &http.Client{Timeout: timeout, Transport: transport}
|
||||
}
|
||||
directCandidate := remoteImageFetchClient{name: "direct", client: direct}
|
||||
defaultCandidate := remoteImageFetchClient{name: "default", client: client}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// guardTransport checks resolved destination addresses even when a deployment
|
||||
// has an outbound proxy. Only actual proxy endpoints and explicitly configured
|
||||
// image services may use private addresses; NO_PROXY and direct fallback
|
||||
// requests still need the connection-level check against DNS rebinding.
|
||||
func (p *ImageProxy) guardTransport(transport *http.Transport) {
|
||||
var proxyEndpoints sync.Map
|
||||
if selectProxy := transport.Proxy; selectProxy != nil {
|
||||
transport.Proxy = func(req *http.Request) (*url.URL, error) {
|
||||
proxyURL, err := selectProxy(req)
|
||||
if err == nil && proxyURL != nil {
|
||||
port := proxyURL.Port()
|
||||
if port == "" {
|
||||
switch strings.ToLower(proxyURL.Scheme) {
|
||||
case "https":
|
||||
port = "443"
|
||||
case "socks5", "socks5h":
|
||||
port = "1080"
|
||||
default:
|
||||
port = "80"
|
||||
}
|
||||
}
|
||||
proxyEndpoints.Store(net.JoinHostPort(strings.ToLower(proxyURL.Hostname()), port), true)
|
||||
}
|
||||
return proxyURL, err
|
||||
}
|
||||
}
|
||||
guardDialer := &net.Dialer{
|
||||
Timeout: 15 * time.Second,
|
||||
Control: func(_, address string, _ syscall.RawConn) error {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil || isPrivateIP(ip) {
|
||||
return errors.New("image proxy: requests to private/internal hosts are not allowed")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
trustedDialer := &net.Dialer{Timeout: 15 * time.Second}
|
||||
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, isProxyEndpoint := proxyEndpoints.Load(strings.ToLower(address))
|
||||
if isProxyEndpoint || p.isAllowedRemoteHost(host) {
|
||||
return trustedDialer.DialContext(ctx, network, address)
|
||||
}
|
||||
return guardDialer.DialContext(ctx, network, address)
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,9 @@ type TranscoderService struct {
|
||||
availMu sync.Mutex
|
||||
availCheckedAt time.Time
|
||||
availValue bool
|
||||
availKey string
|
||||
ffmpegMu sync.Mutex
|
||||
ffmpegCache ffmpegValidationCache
|
||||
}
|
||||
|
||||
// hlsJob holds the live state of one ffmpeg run.
|
||||
@@ -83,6 +86,11 @@ type hlsJob struct {
|
||||
quality string
|
||||
// done is closed when the ffmpeg goroutine fully exits (after process death).
|
||||
done chan struct{}
|
||||
// Completed outputs stay reusable until idle eviction without occupying a
|
||||
// running-process slot. The binary and config are fixed for this generation.
|
||||
completed bool
|
||||
ffmpegPath string
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -117,24 +125,37 @@ func (t *TranscoderService) Available() bool {
|
||||
if t == nil || t.cfg == nil {
|
||||
return false
|
||||
}
|
||||
if !t.cfg.Transcoder.Enabled {
|
||||
cfg := t.transcodeConfig()
|
||||
if !cfg.Transcoder.Enabled {
|
||||
return false
|
||||
}
|
||||
encoder := ffmpegArgSettingsFromConfig(cfg, nil).encoder
|
||||
key := cfg.App.FFmpegPath + "\x00" + encoder
|
||||
t.availMu.Lock()
|
||||
defer t.availMu.Unlock()
|
||||
if !t.availCheckedAt.IsZero() && time.Since(t.availCheckedAt) < transcodeAvailabilityTTL {
|
||||
return t.availValue
|
||||
if !t.availValue && t.availKey == key && !t.availCheckedAt.IsZero() && time.Since(t.availCheckedAt) < transcodeAvailabilityTTL {
|
||||
return false
|
||||
}
|
||||
_, err := t.resolveFFmpegPath()
|
||||
_, err := t.resolveFFmpegPathFor(context.Background(), cfg.App.FFmpegPath, encoder)
|
||||
available := err == nil
|
||||
if !available && t.log != nil {
|
||||
t.log.Debug("local HLS renditions unavailable", zap.Error(err))
|
||||
}
|
||||
t.availValue = available
|
||||
t.availCheckedAt = time.Now()
|
||||
t.availKey = key
|
||||
return available
|
||||
}
|
||||
|
||||
func (t *TranscoderService) transcodeConfig() *config.Config {
|
||||
config.RuntimeMu.RLock()
|
||||
defer config.RuntimeMu.RUnlock()
|
||||
return &config.Config{
|
||||
App: config.AppConfig{FFmpegPath: t.cfg.App.FFmpegPath, VAAPIDevice: t.cfg.App.VAAPIDevice},
|
||||
Transcoder: t.cfg.Transcoder,
|
||||
}
|
||||
}
|
||||
|
||||
// HLSDir is the per-media directory that holds index.m3u8 + segment files.
|
||||
func (t *TranscoderService) HLSDir(mediaID string) string {
|
||||
return filepath.Join(t.cfg.Cache.CacheDir, "hls", mediaID)
|
||||
@@ -174,7 +195,8 @@ func (t *TranscoderService) EnsureJobFromSubtitle(ctx context.Context, mediaID s
|
||||
// EnsureJobFromSubtitleQuality is EnsureJobFromSubtitle with an explicit local
|
||||
// HLS quality profile. Empty qualityID keeps the global transcoder config.
|
||||
func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, mediaID string, startSec float64, seekGen int64, subtitleStream int, qualityID string) (string, error) {
|
||||
if !t.cfg.Transcoder.Enabled {
|
||||
cfg := t.transcodeConfig()
|
||||
if !cfg.Transcoder.Enabled {
|
||||
return "", ErrTranscodeDisabled
|
||||
}
|
||||
if startSec < 0 {
|
||||
@@ -203,12 +225,17 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
|
||||
|
||||
t.mu.Lock()
|
||||
if existing, ok := t.jobs[mediaID]; ok {
|
||||
if sameHLSConfiguration(existing, startSec, subtitleStream, qualityID) {
|
||||
reusable := true
|
||||
if existing.completed {
|
||||
info, err := os.Stat(t.PlaylistPath(mediaID))
|
||||
reusable = err == nil && info.Size() > 0
|
||||
}
|
||||
if reusable && sameHLSConfiguration(existing, startSec, subtitleStream, qualityID) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
if !shouldReplaceHLSJobConfigurationQuality(existing, startSec, seekGen, subtitleStream, qualityID) {
|
||||
if reusable && !shouldReplaceHLSJobConfigurationQuality(existing, startSec, seekGen, subtitleStream, qualityID) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
@@ -230,7 +257,13 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
|
||||
}
|
||||
input.Quality = quality
|
||||
t.maybeFillDuration(ctx, m, input)
|
||||
if _, err := t.resolveFFmpegPath(); err != nil {
|
||||
settings := ffmpegArgSettingsFromConfig(cfg, quality)
|
||||
jobEncoder := settings.encoder
|
||||
if settings.noScale || subtitleStream >= 0 {
|
||||
jobEncoder = ""
|
||||
}
|
||||
bin, err := t.resolveFFmpegPathFor(ctx, cfg.App.FFmpegPath, jobEncoder)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -242,29 +275,15 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
if existing, ok := t.jobs[mediaID]; ok {
|
||||
if sameHLSConfiguration(existing, startSec, subtitleStream, qualityID) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
if !shouldReplaceHLSJobConfigurationQuality(existing, startSec, seekGen, subtitleStream, qualityID) {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
prev := t.detachJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
waitJobExit(prev, 12*time.Second)
|
||||
t.mu.Lock()
|
||||
}
|
||||
if max := t.maxConcurrent(); max > 0 && len(t.jobs) >= max {
|
||||
if max := t.maxConcurrent(); max > 0 && t.runningJobCountLocked() >= max {
|
||||
t.mu.Unlock()
|
||||
return "", ErrTranscodeBusy
|
||||
}
|
||||
|
||||
jobCtx, cancel := context.WithCancel(context.Background())
|
||||
jobEncoder := t.effectiveEncoder()
|
||||
if jobEncoder == "" {
|
||||
jobEncoder = "software"
|
||||
}
|
||||
if subtitleStream >= 0 {
|
||||
jobEncoder = "software (subtitle burn-in)"
|
||||
}
|
||||
@@ -280,6 +299,8 @@ func (t *TranscoderService) EnsureJobFromSubtitleQuality(ctx context.Context, me
|
||||
subtitleStream: subtitleStream,
|
||||
quality: qualityID,
|
||||
done: make(chan struct{}),
|
||||
ffmpegPath: bin,
|
||||
config: cfg,
|
||||
}
|
||||
t.jobs[mediaID] = job
|
||||
t.mu.Unlock()
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
// be false both when transcoding is disabled by configuration and when no usable
|
||||
// ffmpeg can be resolved.
|
||||
func TestTranscoderAvailableGatesOnConfigAndBinary(t *testing.T) {
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
t.Chdir(t.TempDir())
|
||||
cases := []struct {
|
||||
name string
|
||||
enabled bool
|
||||
|
||||
@@ -11,29 +11,17 @@ import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
)
|
||||
|
||||
func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, input transcodeInput) {
|
||||
bin, err := t.resolveFFmpegPath()
|
||||
if err != nil {
|
||||
t.log.Warn("ffmpeg unavailable", zap.String("media_id", job.mediaID), zap.Error(err))
|
||||
t.mu.Lock()
|
||||
delete(t.jobs, job.mediaID)
|
||||
t.mu.Unlock()
|
||||
t.hub.Publish("transcode", map[string]any{
|
||||
"media_id": job.mediaID,
|
||||
"status": "error",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
playlist := filepath.Join(job.outputDir, "index.m3u8")
|
||||
segments := filepath.Join(job.outputDir, "seg_%05d.ts")
|
||||
|
||||
args := buildFFmpegArgsForInput(t.cfg, input, playlist, segments)
|
||||
args := buildFFmpegArgsForInput(job.config, input, playlist, segments)
|
||||
|
||||
cmd := exec.CommandContext(ctx, bin, args...) // #nosec G204 -- bin is resolved by resolveFFmpegPath and args are passed without a shell.
|
||||
cmd := exec.CommandContext(ctx, job.ffmpegPath, args...) // #nosec G204 -- the binary was validated before creating this job.
|
||||
setFFmpegSysProcAttr(cmd)
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
@@ -50,7 +38,8 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, input tr
|
||||
"start_sec": input.StartSec,
|
||||
})
|
||||
|
||||
if err := cmd.Run(); err != nil && !errors.Is(ctx.Err(), context.Canceled) {
|
||||
err := cmd.Run()
|
||||
if err != nil && !errors.Is(ctx.Err(), context.Canceled) {
|
||||
t.log.Warn("ffmpeg exited",
|
||||
zap.String("media_id", job.mediaID),
|
||||
zap.Float64("start_sec", input.StartSec),
|
||||
@@ -59,11 +48,18 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, input tr
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
// Only drop the map entry if we are still the registered generation.
|
||||
// Only update the map entry if we are still the registered generation.
|
||||
if cur, ok := t.jobs[job.mediaID]; ok && cur == job {
|
||||
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()
|
||||
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
|
||||
t.mu.Unlock()
|
||||
}
|
||||
config.RuntimeMu.Unlock()
|
||||
return bin, nil
|
||||
}
|
||||
if lastErr != nil {
|
||||
@@ -95,10 +125,11 @@ func (t *TranscoderService) resolveFFmpegPath() (string, error) {
|
||||
}
|
||||
|
||||
func (t *TranscoderService) effectiveEncoder() string {
|
||||
if !t.cfg.Transcoder.HardwareAccel {
|
||||
cfg := t.transcodeConfig()
|
||||
if !cfg.Transcoder.HardwareAccel {
|
||||
return ""
|
||||
}
|
||||
return normalizedHardwareEncoder(t.cfg.Transcoder.Encoder)
|
||||
return normalizedHardwareEncoder(cfg.Transcoder.Encoder)
|
||||
}
|
||||
|
||||
func normalizedHardwareEncoder(encoder string) string {
|
||||
@@ -160,6 +191,7 @@ func hasFFmpegListEntry(output, name string) bool {
|
||||
|
||||
// HumanFFmpegProfile is exposed for the admin UI / settings view.
|
||||
func (t *TranscoderService) HumanFFmpegProfile() string {
|
||||
cfg := t.transcodeConfig()
|
||||
return fmt.Sprintf("ffmpeg=%s, encoder=%s, output=%s",
|
||||
t.cfg.App.FFmpegPath, t.cfg.Transcoder.Encoder, filepath.Join(t.cfg.Cache.CacheDir, "hls"))
|
||||
cfg.App.FFmpegPath, cfg.Transcoder.Encoder, filepath.Join(t.cfg.Cache.CacheDir, "hls"))
|
||||
}
|
||||
|
||||
@@ -6,41 +6,58 @@ import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
)
|
||||
|
||||
// WaitReady blocks (with a deadline) until the playlist file shows up on
|
||||
// disk for the *current* job generation. Stale playlists left behind by a
|
||||
// failed RemoveAll / still-exiting ffmpeg must not unblock a mid-file restart.
|
||||
func (t *TranscoderService) WaitReady(ctx context.Context, mediaID string, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
ticker := time.NewTicker(250 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
t.mu.Lock()
|
||||
job, ok := t.jobs[mediaID]
|
||||
var started time.Time
|
||||
var completed bool
|
||||
if ok {
|
||||
started = job.startedAt
|
||||
completed = job.completed
|
||||
}
|
||||
t.mu.Unlock()
|
||||
if !ok {
|
||||
return false // A failed/exited job cannot become ready later.
|
||||
}
|
||||
if ok {
|
||||
if info, err := os.Stat(t.PlaylistPath(mediaID)); err == nil {
|
||||
// Allow a small clock skew; reject anything older than this job.
|
||||
if !info.ModTime().Before(started.Add(-2 * time.Second)) {
|
||||
t.mu.Lock()
|
||||
if j, exists := t.jobs[mediaID]; exists {
|
||||
if j, exists := t.jobs[mediaID]; exists && j == job {
|
||||
j.playlistOK = true
|
||||
}
|
||||
t.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
t.mu.Unlock()
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
// 片头/片尾片段:播放开始后再调用,服务端可能需要几秒去外部数据库取数,
|
||||
|
||||
@@ -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,9 +1418,7 @@ export function PlayerPage() {
|
||||
)
|
||||
pendingDirectStartRef.current = 0
|
||||
if (resumeAt > 0) {
|
||||
video.addEventListener(
|
||||
'loadedmetadata',
|
||||
() => {
|
||||
onDirectMetadata = () => {
|
||||
// STRM/115 直链要等 seekable 覆盖目标位置再写 currentTime(否则会被
|
||||
// 钳回 0),并顺带打开误报宽限期,避免这次跳转被当成播放失败又弹回 HLS。
|
||||
if (isStrmMedia(currentMedia)) {
|
||||
@@ -1435,9 +1430,8 @@ export function PlayerPage() {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
}
|
||||
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],
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { applyPendingHlsSeek, attachNativeHls, bindPendingHlsSeek, hlsManifestTimeoutMs, playbackPositionSec } from './hlsPlayback.ts'
|
||||
|
||||
class FakeVideo extends EventTarget {
|
||||
src = ''
|
||||
rejectSeek = false
|
||||
position = 0
|
||||
playCalls = 0
|
||||
get currentTime() { return this.position }
|
||||
set currentTime(value: number) {
|
||||
if (this.rejectSeek) throw new Error('metadata not ready')
|
||||
this.position = value
|
||||
}
|
||||
play() { this.playCalls += 1; return Promise.resolve() }
|
||||
}
|
||||
|
||||
const video = new FakeVideo()
|
||||
const pending = { current: 420 as number | null }
|
||||
const detach = attachNativeHls(video, '/cloud/master.m3u8', pending)
|
||||
assert.equal(video.src, '/cloud/master.m3u8')
|
||||
assert.equal(video.playCalls, 1)
|
||||
assert.equal(video.currentTime, 0, 'seek must wait for the new source metadata')
|
||||
video.rejectSeek = true
|
||||
video.dispatchEvent(new Event('loadedmetadata'))
|
||||
assert.equal(pending.current, 420, 'a rejected seek must survive until canplay')
|
||||
video.rejectSeek = false
|
||||
video.dispatchEvent(new Event('canplay'))
|
||||
assert.equal(video.currentTime, 420, 'native HLS must restore the absolute cloud position')
|
||||
assert.equal(pending.current, null)
|
||||
pending.current = 600
|
||||
detach()
|
||||
video.dispatchEvent(new Event('loadedmetadata'))
|
||||
assert.equal(video.currentTime, 420, 'a detached source must not apply late metadata events')
|
||||
assert.equal(pending.current, 600)
|
||||
|
||||
const msePending = { current: 120 as number | null }
|
||||
video.rejectSeek = true
|
||||
assert.equal(applyPendingHlsSeek(video, msePending), false)
|
||||
assert.equal(msePending.current, 120)
|
||||
video.rejectSeek = false
|
||||
assert.equal(applyPendingHlsSeek(video, msePending), true)
|
||||
assert.equal(msePending.current, null)
|
||||
const lateMsePending = { current: 900 as number | null }
|
||||
video.rejectSeek = true
|
||||
const detachMse = bindPendingHlsSeek(video, lateMsePending)
|
||||
assert.equal(applyPendingHlsSeek(video, lateMsePending), false)
|
||||
video.rejectSeek = false
|
||||
video.dispatchEvent(new Event('loadedmetadata'))
|
||||
assert.equal(video.currentTime, 900, 'hls.js seeks rejected during manifest parsing must retry after metadata')
|
||||
assert.equal(lateMsePending.current, null)
|
||||
detachMse()
|
||||
assert.ok(hlsManifestTimeoutMs('local', 1800) > 120_000, 'deep seeks must outlast the server readiness deadline')
|
||||
assert.equal(hlsManifestTimeoutMs('local', 0), 60_000)
|
||||
assert.equal(hlsManifestTimeoutMs('cloud', 1800), 60_000)
|
||||
assert.equal(playbackPositionSec('hls', 'cloud', 420, 600), 600, 'cloud subtitle/quality switches must not add an old local offset')
|
||||
assert.equal(playbackPositionSec('hls', 'local', 420, 60), 480, 'local subtitle switches preserve the source position')
|
||||
assert.equal(playbackPositionSec('direct', 'local', 420, 60), 60)
|
||||
console.log('hlsPlayback.test.ts ok')
|
||||
@@ -0,0 +1,43 @@
|
||||
type HlsVideo = Pick<HTMLVideoElement, 'src' | 'currentTime' | 'addEventListener' | 'removeEventListener' | 'play'>
|
||||
type PendingSeek = { current: number | null }
|
||||
|
||||
/** Local mid-file restarts may wait 120 seconds on the server before a playlist exists. */
|
||||
export function hlsManifestTimeoutMs(source: 'cloud' | 'local', startSec: number): number {
|
||||
return source === 'local' && startSec > 0.05 ? 135_000 : 60_000
|
||||
}
|
||||
|
||||
/** Keep a pending seek until the media element accepts it; metadata can arrive later. */
|
||||
export function applyPendingHlsSeek(video: Pick<HlsVideo, 'currentTime'>, pending: PendingSeek): boolean {
|
||||
if (pending.current === null) return true
|
||||
if (!Number.isFinite(pending.current) || pending.current < 0) return false
|
||||
try {
|
||||
video.currentTime = pending.current
|
||||
pending.current = null
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function bindPendingHlsSeek(video: HlsVideo, pending: PendingSeek): () => void {
|
||||
const restore = () => { applyPendingHlsSeek(video, pending) }
|
||||
video.addEventListener('loadedmetadata', restore)
|
||||
video.addEventListener('canplay', restore)
|
||||
return () => {
|
||||
video.removeEventListener('loadedmetadata', restore)
|
||||
video.removeEventListener('canplay', restore)
|
||||
}
|
||||
}
|
||||
|
||||
/** Native HLS needs the same seek restoration as hls.js, with removable listeners. */
|
||||
export function attachNativeHls(video: HlsVideo, url: string, pending: PendingSeek): () => void {
|
||||
const detach = bindPendingHlsSeek(video, pending)
|
||||
video.src = url
|
||||
void video.play().catch(() => undefined)
|
||||
return detach
|
||||
}
|
||||
|
||||
/** Only locally transcoded HLS uses a relative timeline starting at startSec. */
|
||||
export function playbackPositionSec(mode: 'direct' | 'hls', source: 'cloud' | 'local', startSec: number, currentTime: number): number {
|
||||
return mode === 'hls' && source === 'local' ? startSec + currentTime : currentTime
|
||||
}
|
||||
@@ -35,7 +35,6 @@ export default function ReaderReplacePage() {
|
||||
.then(setRules)
|
||||
.catch(() => toast.error('加载替换规则失败'))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(load, [])
|
||||
|
||||
const save = async () => {
|
||||
|
||||
@@ -49,7 +49,6 @@ export default function ReaderSourcesPage() {
|
||||
.then(setSources)
|
||||
.catch(() => toast.error('加载书源失败'))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(load, [])
|
||||
|
||||
// 读取书源文件(选择或拖入),走与粘贴相同的导入链路
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
@@ -266,6 +266,19 @@ function settleTextDrag(dragEl: HTMLDivElement, turn: 1 | -1, dx: number, stride
|
||||
})
|
||||
}
|
||||
|
||||
function ReaderMenuButton({ icon, label, onClick }: { icon: ReactNode; label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex flex-1 flex-col items-center gap-1 py-1 opacity-80 hover:opacity-100"
|
||||
>
|
||||
{icon}
|
||||
<span className="text-2xs">{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ReaderViewPage() {
|
||||
const { bookId = '' } = useParams()
|
||||
const settings = useReaderSettingsStore()
|
||||
@@ -316,6 +329,7 @@ export default function ReaderViewPage() {
|
||||
const [page, setPage] = useState(0)
|
||||
const [pageCount, setPageCount] = useState(1)
|
||||
const [vw, setVw] = useState(0)
|
||||
const [scrollProgress, setScrollProgress] = useState(0)
|
||||
// 文本翻页:位移与页脚先走 DOM,React 的 page state 等到过渡结束后再同步。
|
||||
// 若在同一次点击/触摸里同步 setPage,整页重渲染会堵住下一次绘制,
|
||||
// 命令式 transform 也要等卡完才开始——点按和滑动都会顿一下。
|
||||
@@ -338,8 +352,13 @@ export default function ReaderViewPage() {
|
||||
const paragraphWarmRef = useRef<Uint8Array | null>(null)
|
||||
/** 当前窗口中心页;-1 表示窗口失效(映射刚重算过),下次应用时重建。 */
|
||||
const warmCenterRef = useRef(-1)
|
||||
const currentChapter = chapterIndex !== null ? chapters[chapterIndex] : null
|
||||
const chapterTitle = currentChapter?.title ?? ''
|
||||
useLayoutEffect(() => {
|
||||
vwRef.current = vw
|
||||
pageCountRef.current = pageCount
|
||||
chapterTitleRef.current = chapterTitle
|
||||
}, [vw, pageCount, chapterTitle])
|
||||
// 音频/漫画媒体状态
|
||||
const [media, setMedia] = useState<ReaderChapterContent | null>(null)
|
||||
const [restorePos, setRestorePos] = useState(0) // 音频秒数 / 漫画图片序号
|
||||
@@ -448,6 +467,7 @@ export default function ReaderViewPage() {
|
||||
// ── 加载书籍与章节 ──
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const cache = contentCache.current
|
||||
;(async () => {
|
||||
try {
|
||||
setLoadingStage('book')
|
||||
@@ -510,7 +530,7 @@ export default function ReaderViewPage() {
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
contentCache.current.clear()
|
||||
cache.clear()
|
||||
}
|
||||
}, [bookId, reloadKey])
|
||||
|
||||
@@ -910,6 +930,16 @@ export default function ReaderViewPage() {
|
||||
if (menuOpen) flushTextPageReactSync()
|
||||
}, [menuOpen, flushTextPageReactSync])
|
||||
|
||||
// Only refresh the scroll slider while its menu is visible, keeping scroll
|
||||
// events on the reading path free of whole-page React renders.
|
||||
useLayoutEffect(() => {
|
||||
if (!menuOpen || settings.pageMode !== 'scroll') return
|
||||
const el = scrollRef.current
|
||||
if (el) {
|
||||
setScrollProgress(Math.round((el.scrollTop / Math.max(1, el.scrollHeight - el.clientHeight)) * 1000))
|
||||
}
|
||||
}, [menuOpen, settings.pageMode, content])
|
||||
|
||||
// 滚动模式恢复进度。
|
||||
//
|
||||
// 同时清掉 dataset.last:它是「上次保存的滚动百分比」,换章后必须重算。
|
||||
@@ -1355,9 +1385,6 @@ export default function ReaderViewPage() {
|
||||
return () => el.removeEventListener('wheel', onWheel)
|
||||
}, [settings.pageMode, menuOpen, contentType, goPrev, goNext])
|
||||
|
||||
const currentChapter = chapterIndex !== null ? chapters[chapterIndex] : null
|
||||
chapterTitleRef.current = currentChapter?.title ?? ''
|
||||
|
||||
// 顶栏高度量一次:菜单打开时正文下移,开头几行不被顶栏压住。
|
||||
// 用 transform 而不是 padding,避免改变视口高度触发重新分页。
|
||||
const menuShiftStyle = useMemo(
|
||||
@@ -1397,11 +1424,7 @@ export default function ReaderViewPage() {
|
||||
value:
|
||||
settings.pageMode === 'page'
|
||||
? page + 1
|
||||
: Math.round(
|
||||
((scrollRef.current?.scrollTop ?? 0) /
|
||||
Math.max(1, (scrollRef.current?.scrollHeight ?? 1) - (scrollRef.current?.clientHeight ?? 1))) *
|
||||
1000,
|
||||
),
|
||||
: scrollProgress,
|
||||
onChange: (v: number) => {
|
||||
if (settings.pageMode === 'page') {
|
||||
commitTextPage(v - 1)
|
||||
@@ -1569,6 +1592,7 @@ export default function ReaderViewPage() {
|
||||
const max = el.scrollHeight - el.clientHeight
|
||||
if (max > 0) {
|
||||
const pct = el.scrollTop / max
|
||||
if (menuOpen) setScrollProgress(Math.round(pct * 1000))
|
||||
if (shouldSaveScrollProgress(pct, el.dataset.last)) {
|
||||
el.dataset.last = formatScrollPct(pct)
|
||||
savePos(Math.round(el.scrollTop))
|
||||
@@ -1753,62 +1777,35 @@ export default function ReaderViewPage() {
|
||||
用 flex 等分而不是 grid-cols-N:动作数量随内容类型和书源能力变化,
|
||||
grid 列数得写死成字面量类名,多一个按钮就要再加一档。 */}
|
||||
<div className="flex pt-1" style={{ color: theme.text }}>
|
||||
{([
|
||||
{ icon: <LayoutList size={18} />, label: '目录', action: () => openPanel('toc') },
|
||||
{ icon: <BookOpen size={18} />, label: '界面', action: () => openPanel('style') },
|
||||
{
|
||||
icon: settings.night ? <Sun size={18} /> : <Moon size={18} />,
|
||||
label: settings.night ? '日间' : '夜间',
|
||||
action: () => settings.toggleNight(),
|
||||
},
|
||||
{
|
||||
icon: settings.pageMode === 'page' ? <ScrollText size={18} /> : <ListEnd size={18} />,
|
||||
label: settings.pageMode === 'page' ? '滚动' : '翻页',
|
||||
action: () => settings.setPageMode(settings.pageMode === 'page' ? 'scroll' : 'page'),
|
||||
},
|
||||
// 双页铺开只对漫画的翻页模式有意义;窗口太窄时并排两页没法看,
|
||||
// 干脆不显示入口(不是灰掉,避免用户以为坏了)。
|
||||
...(contentType === 'image' && settings.pageMode === 'page' && windowWidth >= COMIC_SPREAD_MIN_WIDTH
|
||||
? [
|
||||
{
|
||||
icon: <Columns2 size={18} />,
|
||||
label: settings.comicDoublePage ? '单页' : '双页',
|
||||
action: () => settings.setComicDoublePage(!settings.comicDoublePage),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// 全屏:只在浏览器支持元素全屏时出现(iOS Safari 不支持,见上方注释)。
|
||||
...(fullscreenSupported
|
||||
? [
|
||||
{
|
||||
icon: fullscreen ? <Minimize size={18} /> : <Maximize size={18} />,
|
||||
label: fullscreen ? '还原' : '全屏',
|
||||
action: toggleFullscreen,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canSwitchSource
|
||||
? [
|
||||
{
|
||||
icon: <ArrowLeftRight size={18} />,
|
||||
label: '换源',
|
||||
action: () => {
|
||||
void openSourcePicker()
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]).map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
onClick={item.action}
|
||||
className="flex flex-1 flex-col items-center gap-1 py-1 opacity-80 hover:opacity-100"
|
||||
>
|
||||
{item.icon}
|
||||
<span className="text-2xs">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<ReaderMenuButton icon={<LayoutList size={18} />} label="目录" onClick={() => openPanel('toc')} />
|
||||
<ReaderMenuButton icon={<BookOpen size={18} />} label="界面" onClick={() => openPanel('style')} />
|
||||
<ReaderMenuButton
|
||||
icon={settings.night ? <Sun size={18} /> : <Moon size={18} />}
|
||||
label={settings.night ? '日间' : '夜间'}
|
||||
onClick={() => settings.toggleNight()}
|
||||
/>
|
||||
<ReaderMenuButton
|
||||
icon={settings.pageMode === 'page' ? <ScrollText size={18} /> : <ListEnd size={18} />}
|
||||
label={settings.pageMode === 'page' ? '滚动' : '翻页'}
|
||||
onClick={() => settings.setPageMode(settings.pageMode === 'page' ? 'scroll' : 'page')}
|
||||
/>
|
||||
{contentType === 'image' && settings.pageMode === 'page' && windowWidth >= COMIC_SPREAD_MIN_WIDTH && (
|
||||
<ReaderMenuButton
|
||||
icon={<Columns2 size={18} />}
|
||||
label={settings.comicDoublePage ? '单页' : '双页'}
|
||||
onClick={() => settings.setComicDoublePage(!settings.comicDoublePage)}
|
||||
/>
|
||||
)}
|
||||
{fullscreenSupported && (
|
||||
<ReaderMenuButton
|
||||
icon={fullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
||||
label={fullscreen ? '还原' : '全屏'}
|
||||
onClick={toggleFullscreen}
|
||||
/>
|
||||
)}
|
||||
{canSwitchSource && (
|
||||
<ReaderMenuButton icon={<ArrowLeftRight size={18} />} label="换源" onClick={() => { void openSourcePicker() }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 界面设置面板(主题 / 字号 / 行距 / 段距 / 漫画图片尺寸) */}
|
||||
|
||||
Reference in New Issue
Block a user