Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a311438aa1 | |||
| 203abd106a | |||
| cc40169616 | |||
| cd720ae879 | |||
| fc84291346 | |||
| c37e936f48 | |||
| 389cb99bcf | |||
| 1b611a6181 | |||
| 25c03f2b0d | |||
| a56b1801f9 | |||
| 99c755dc29 | |||
| 086c0307c3 |
@@ -23,6 +23,7 @@ import (
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/database"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
@@ -121,7 +122,7 @@ func main() {
|
||||
)
|
||||
}
|
||||
}()
|
||||
go services.Boot()
|
||||
helper.Go(logger, "services.boot", services.Boot)
|
||||
|
||||
// Graceful shutdown.
|
||||
stop := make(chan os.Signal, 1)
|
||||
|
||||
@@ -8,6 +8,11 @@ import (
|
||||
|
||||
// AutoMigrate creates tables for every model registered in the model package.
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
// 必须先于 AutoMigrate:旧库中可能已有重复的 (user_id, media_id) 历史行,
|
||||
// 不去重会导致唯一索引 uniq_user_history 创建失败。
|
||||
if err := dedupePlaybackHistories(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -37,6 +42,27 @@ func ensureSQLiteQueryOptimizer(db *gorm.DB) error {
|
||||
return db.Exec("ANALYZE").Error
|
||||
}
|
||||
|
||||
// dedupePlaybackHistories removes duplicate (user_id, media_id) rows left by
|
||||
// the former read-then-write upsert, so the uniq_user_history composite unique
|
||||
// index can be created on existing databases. Keeps the most recent row per
|
||||
// pair, preferring live rows over soft-deleted ones.
|
||||
func dedupePlaybackHistories(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable("playback_histories") {
|
||||
return nil
|
||||
}
|
||||
return db.Exec(`
|
||||
DELETE FROM playback_histories WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY user_id, media_id
|
||||
ORDER BY deleted_at IS NULL DESC, watched_at DESC, id DESC
|
||||
) AS rn
|
||||
FROM playback_histories
|
||||
) ranked
|
||||
WHERE ranked.rn > 1
|
||||
)`).Error
|
||||
}
|
||||
|
||||
func ensurePostgresColumnCompatibility(db *gorm.DB) error {
|
||||
if !isPostgres(db) {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestAutoMigrateDedupesPlaybackHistories reproduces the upgrade path: a legacy
|
||||
// database contains duplicate (user_id, media_id) history rows created by the
|
||||
// old read-then-write upsert. AutoMigrate must merge them before creating the
|
||||
// uniq_user_history composite unique index, otherwise the upgrade fails.
|
||||
func TestAutoMigrateDedupesPlaybackHistories(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 旧 schema:无 uniq_user_history 唯一索引。
|
||||
if err := db.Exec(`CREATE TABLE playback_histories (
|
||||
id varchar(36) PRIMARY KEY,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
deleted_at datetime,
|
||||
user_id varchar(36) NOT NULL,
|
||||
media_id varchar(128) NOT NULL,
|
||||
position_ms integer,
|
||||
duration_ms integer,
|
||||
watched_at datetime,
|
||||
completed numeric
|
||||
)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := time.Now()
|
||||
rows := []struct {
|
||||
id string
|
||||
position int64
|
||||
watchedAt time.Time
|
||||
}{
|
||||
{"h-old", 1_000, base.Add(-2 * time.Hour)},
|
||||
{"h-mid", 2_000, base.Add(-1 * time.Hour)},
|
||||
{"h-new", 3_000, base},
|
||||
}
|
||||
for _, r := range rows {
|
||||
if err := db.Exec(
|
||||
`INSERT INTO playback_histories (id, user_id, media_id, position_ms, watched_at, created_at, updated_at)
|
||||
VALUES (?, 'u-1', 'm-1', ?, ?, ?, ?)`,
|
||||
r.id, r.position, r.watchedAt, r.watchedAt, r.watchedAt,
|
||||
).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate with duplicate histories: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Table("playback_histories").Where("user_id = ? AND media_id = ?", "u-1", "m-1").Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected duplicate rows merged to 1, got %d", count)
|
||||
}
|
||||
var position int64
|
||||
if err := db.Table("playback_histories").
|
||||
Where("user_id = ? AND media_id = ?", "u-1", "m-1").
|
||||
Select("position_ms").Scan(&position).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if position != 3_000 {
|
||||
t.Fatalf("dedupe should keep the most recent row, got position_ms=%d", position)
|
||||
}
|
||||
|
||||
// 唯一索引存在时,重复插入同一 (user_id, media_id) 应触发冲突而非新增行。
|
||||
if err := db.Exec(
|
||||
`INSERT INTO playback_histories (id, user_id, media_id, position_ms, watched_at, created_at, updated_at)
|
||||
VALUES ('h-dup', 'u-1', 'm-1', 4_000, ?, ?, ?)`,
|
||||
base, base, base,
|
||||
).Error; err == nil {
|
||||
t.Fatal("insert violating uniq_user_history should fail")
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
@@ -164,18 +165,20 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID)
|
||||
if acct == nil {
|
||||
return
|
||||
}
|
||||
tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}}
|
||||
itemTypes := remoteLibraryItemTypes(v.CollectionType)
|
||||
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
|
||||
remotePayloads[i].Total = total
|
||||
}
|
||||
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
|
||||
remotePayloads[i].Cards = cards
|
||||
}
|
||||
helper.Run(svc.Log, "media.remotePreview", func() {
|
||||
acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID)
|
||||
if acct == nil {
|
||||
return
|
||||
}
|
||||
tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}}
|
||||
itemTypes := remoteLibraryItemTypes(v.CollectionType)
|
||||
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
|
||||
remotePayloads[i].Total = total
|
||||
}
|
||||
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
|
||||
remotePayloads[i].Cards = cards
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
@@ -41,7 +42,11 @@ func scanLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
task := startScanHTTPTask(svc, "手动扫描入库", lib.Name, lib.Path)
|
||||
go func(libraryID string, task *service.TaskHandle, finish func()) {
|
||||
defer finish()
|
||||
res, err := svc.Scan.ScanLibrary(context.Background(), libraryID)
|
||||
var res *service.ScanResult
|
||||
var err error
|
||||
helper.Run(svc.Log, "scan.library", func() {
|
||||
res, err = svc.Scan.ScanLibrary(context.Background(), libraryID)
|
||||
})
|
||||
if err != nil {
|
||||
finishHTTPTask(task, err, "scan", "手动扫描入库失败", scanTaskMetrics(res), scanTaskDetails(res, 20))
|
||||
return
|
||||
@@ -75,7 +80,11 @@ func scanLibraryRootHandler(svc *service.Container) gin.HandlerFunc {
|
||||
task := startScanHTTPTask(svc, "手动扫描媒体库路径", id, rootID)
|
||||
go func(libraryID, libraryRootID string, task *service.TaskHandle, finish func()) {
|
||||
defer finish()
|
||||
res, err := svc.Scan.ScanLibraryRoot(context.Background(), libraryID, libraryRootID)
|
||||
var res *service.ScanResult
|
||||
var err error
|
||||
helper.Run(svc.Log, "scan.libraryRoot", func() {
|
||||
res, err = svc.Scan.ScanLibraryRoot(context.Background(), libraryID, libraryRootID)
|
||||
})
|
||||
if err != nil {
|
||||
finishHTTPTask(task, err, "scan", "手动扫描路径失败", scanTaskMetrics(res), scanTaskDetails(res, 20))
|
||||
return
|
||||
@@ -110,11 +119,13 @@ func queueLibraryRootScan(svc *service.Container, libraryID, rootID string) {
|
||||
}
|
||||
go func() {
|
||||
defer finish()
|
||||
if strings.TrimSpace(rootID) == "" {
|
||||
_, _ = svc.Scan.ScanLibrary(context.Background(), libraryID)
|
||||
return
|
||||
}
|
||||
_, _ = svc.Scan.ScanLibraryRoot(context.Background(), libraryID, rootID)
|
||||
helper.Run(svc.Log, "scan.queuedRoot", func() {
|
||||
if strings.TrimSpace(rootID) == "" {
|
||||
_, _ = svc.Scan.ScanLibrary(context.Background(), libraryID)
|
||||
return
|
||||
}
|
||||
_, _ = svc.Scan.ScanLibraryRoot(context.Background(), libraryID, rootID)
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Package helper provides shared utilities.
|
||||
package helper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Go runs fn in a new goroutine and recovers from panics so that a failure in
|
||||
// a background task (scraper parsing remote responses, cloud-drive sync, ...)
|
||||
// is logged instead of crashing the whole process. log may be nil.
|
||||
func Go(log *zap.Logger, name string, fn func()) {
|
||||
go Run(log, name, fn)
|
||||
}
|
||||
|
||||
// Run executes fn and recovers from panics, logging the task name and stack.
|
||||
// Use it as the first statement inside goroutines spawned elsewhere, or wrap
|
||||
// loop bodies so one bad iteration cannot kill a long-running worker.
|
||||
func Run(log *zap.Logger, name string, fn func()) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logPanic(log, name, r)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
|
||||
// Recover runs fn and converts a panic into an error so callers can run their
|
||||
// own deferred cleanup (releasing locks, updating job state) before unwinding.
|
||||
func Recover(log *zap.Logger, name string, fn func() error) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logPanic(log, name, r)
|
||||
err = fmt.Errorf("%s panicked: %v", name, r)
|
||||
}
|
||||
}()
|
||||
return fn()
|
||||
}
|
||||
|
||||
func logPanic(log *zap.Logger, name string, r any) {
|
||||
if log == nil {
|
||||
fmt.Fprintf(os.Stderr, "background task panicked: task=%s panic=%v\n%s\n", name, r, debug.Stack())
|
||||
return
|
||||
}
|
||||
log.Error("background task panicked",
|
||||
zap.String("task", name),
|
||||
zap.Any("panic", r),
|
||||
zap.ByteString("stack", debug.Stack()),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
func newObservedLogger(t *testing.T) (*zap.Logger, *observer.ObservedLogs) {
|
||||
t.Helper()
|
||||
core, logs := observer.New(zap.ErrorLevel)
|
||||
return zap.New(core), logs
|
||||
}
|
||||
|
||||
func waitForLogs(t *testing.T, logs *observer.ObservedLogs, n int) []observer.LoggedEntry {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if entries := logs.All(); len(entries) >= n {
|
||||
return entries
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %d log entries, got %d", n, logs.Len())
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRunRecoversPanic(t *testing.T) {
|
||||
log, logs := newObservedLogger(t)
|
||||
ran := false
|
||||
Run(log, "unit.panic", func() {
|
||||
ran = true
|
||||
panic("boom")
|
||||
})
|
||||
if !ran {
|
||||
t.Fatal("fn should have run before panicking")
|
||||
}
|
||||
entries := waitForLogs(t, logs, 1)
|
||||
if entries[0].Message != "background task panicked" {
|
||||
t.Fatalf("unexpected message: %s", entries[0].Message)
|
||||
}
|
||||
found := false
|
||||
for _, f := range entries[0].Context {
|
||||
if f.Key == "task" && f.String == "unit.panic" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected task name in log context: %v", entries[0].Context)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoPanicNoLog(t *testing.T) {
|
||||
log, logs := newObservedLogger(t)
|
||||
Run(log, "unit.ok", func() {})
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
if logs.Len() != 0 {
|
||||
t.Fatalf("expected no error log, got %d", logs.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverConvertsPanicToError(t *testing.T) {
|
||||
log, _ := newObservedLogger(t)
|
||||
err := Recover(log, "unit.recover", func() error {
|
||||
panic("kaboom")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from recovered panic")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "kaboom") {
|
||||
t.Fatalf("panic value should be in error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverReturnsFnError(t *testing.T) {
|
||||
sentinel := errors.New("plain failure")
|
||||
err := Recover(nil, "unit.err", func() error { return sentinel })
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("expected fn error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithNilLoggerDoesNotCrash(t *testing.T) {
|
||||
Run(nil, "unit.nillog", func() { panic("still caught") })
|
||||
}
|
||||
|
||||
func TestGoLogsPanicFromSpawnedGoroutine(t *testing.T) {
|
||||
log, logs := newObservedLogger(t)
|
||||
Go(log, "unit.go", func() { panic("async boom") })
|
||||
waitForLogs(t, logs, 1)
|
||||
}
|
||||
@@ -3,10 +3,12 @@ package model
|
||||
import "time"
|
||||
|
||||
// PlaybackHistory 记录当前播放位置以支持续播。
|
||||
// (user_id, media_id) 唯一:播放进度每几秒上报一次,唯一索引保证并发上报
|
||||
// 不会插入重复行(否则续播列表会出现重复卡片),也让 upsert 单语句完成。
|
||||
type PlaybackHistory struct {
|
||||
Base
|
||||
UserID string `gorm:"index;size:36;not null" json:"user_id"`
|
||||
MediaID string `gorm:"index;size:128;not null" json:"media_id"`
|
||||
UserID string `gorm:"index;size:36;not null;uniqueIndex:uniq_user_history" json:"user_id"`
|
||||
MediaID string `gorm:"index;size:128;not null;uniqueIndex:uniq_user_history" json:"media_id"`
|
||||
PositionMs int64 `json:"position_ms"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
WatchedAt time.Time `json:"watched_at"`
|
||||
|
||||
@@ -2,9 +2,9 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
@@ -13,25 +13,24 @@ import (
|
||||
// upserts on (UserID, MediaID) so resume always reads the latest position.
|
||||
type HistoryRepository struct{ db *gorm.DB }
|
||||
|
||||
// Upsert atomically inserts/updates the resume position.
|
||||
// Upsert atomically inserts/updates the resume position in a single statement,
|
||||
// relying on the uniq_user_history composite unique index. Concurrent progress
|
||||
// reports for the same (user, media) can no longer double-insert.
|
||||
func (r *HistoryRepository) Upsert(ctx context.Context, h *model.PlaybackHistory) error {
|
||||
var existing model.PlaybackHistory
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND media_id = ?", h.UserID, h.MediaID).
|
||||
First(&existing).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return r.db.WithContext(ctx).Create(h).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing.PositionMs = h.PositionMs
|
||||
if h.DurationMs > 0 {
|
||||
existing.DurationMs = h.DurationMs
|
||||
}
|
||||
existing.WatchedAt = h.WatchedAt
|
||||
existing.Completed = h.Completed
|
||||
return r.db.WithContext(ctx).Save(&existing).Error
|
||||
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "media_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{
|
||||
"position_ms": h.PositionMs,
|
||||
// 沿用旧语义:未知时长(0)不覆盖已记录的时长。
|
||||
"duration_ms": gorm.Expr(
|
||||
"CASE WHEN ? > 0 THEN ? ELSE playback_histories.duration_ms END",
|
||||
h.DurationMs, h.DurationMs,
|
||||
),
|
||||
"watched_at": h.WatchedAt,
|
||||
"completed": h.Completed,
|
||||
"deleted_at": nil,
|
||||
}),
|
||||
}).Create(h).Error
|
||||
}
|
||||
|
||||
// ListByUser returns the most recent history rows for the user.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/database"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func TestHistoryUpsertSingleRowPerUserMedia(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := New(db)
|
||||
ctx := t.Context()
|
||||
watched := time.Now()
|
||||
|
||||
first := &model.PlaybackHistory{UserID: "u-1", MediaID: "m-1", PositionMs: 30_000, DurationMs: 0, WatchedAt: watched, Completed: false}
|
||||
if err := repos.History.Upsert(ctx, first); err != nil {
|
||||
t.Fatalf("first upsert: %v", err)
|
||||
}
|
||||
second := &model.PlaybackHistory{UserID: "u-1", MediaID: "m-1", PositionMs: 90_000, DurationMs: 120_000, WatchedAt: watched.Add(time.Minute), Completed: true}
|
||||
if err := repos.History.Upsert(ctx, second); err != nil {
|
||||
t.Fatalf("second upsert: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&model.PlaybackHistory{}).Where("user_id = ? AND media_id = ?", "u-1", "m-1").Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected 1 history row after upserts, got %d", count)
|
||||
}
|
||||
var got model.PlaybackHistory
|
||||
if err := db.Where("user_id = ? AND media_id = ?", "u-1", "m-1").First(&got).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.PositionMs != 90_000 || !got.Completed {
|
||||
t.Fatalf("position/completion not updated: %#v", got)
|
||||
}
|
||||
if got.DurationMs != 120_000 {
|
||||
t.Fatalf("duration should update when known, got %d", got.DurationMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryUpsertKeepsDurationWhenUnknown(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := New(db)
|
||||
ctx := t.Context()
|
||||
watched := time.Now()
|
||||
|
||||
if err := repos.History.Upsert(ctx, &model.PlaybackHistory{UserID: "u-1", MediaID: "m-2", PositionMs: 10, DurationMs: 600_000, WatchedAt: watched}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.History.Upsert(ctx, &model.PlaybackHistory{UserID: "u-1", MediaID: "m-2", PositionMs: 20, DurationMs: 0, WatchedAt: watched.Add(time.Second)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got model.PlaybackHistory
|
||||
if err := db.Where("user_id = ? AND media_id = ?", "u-1", "m-2").First(&got).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.DurationMs != 600_000 {
|
||||
t.Fatalf("duration_ms=0 upsert must not clear stored duration, got %d", got.DurationMs)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
@@ -50,6 +51,8 @@ func (a *AuditService) RecordBestEffort(userID, action, target, ip, detail strin
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
a.Record(ctx, userID, action, target, ip, detail)
|
||||
helper.Run(a.log, "audit.record", func() {
|
||||
a.Record(ctx, userID, action, target, ip, detail)
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
@@ -176,9 +177,11 @@ func (s *AuthService) touchLoginBestEffort(userID string) {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := s.repo.User.TouchLogin(ctx, userID); err != nil && s.log != nil {
|
||||
s.log.Debug("touch login delayed", zap.String("user_id", userID), zap.Error(err))
|
||||
}
|
||||
helper.Run(s.log, "auth.touchLogin", func() {
|
||||
if err := s.repo.User.TouchLogin(ctx, userID); err != nil && s.log != nil {
|
||||
s.log.Debug("touch login delayed", zap.String("user_id", userID), zap.Error(err))
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ func (e *EmbyService) countVisibleSeries(ctx context.Context, userID string) (in
|
||||
for i := range rows {
|
||||
key := strings.TrimSpace(rows[i].SeriesID)
|
||||
if key == "" {
|
||||
key = stableEmbyID(embyVirtualSeriesPrefix, rows[i].LibraryID, e.seriesNameForMedia(&rows[i]))
|
||||
key = stableEmbyID(embyVirtualSeriesPrefix, rows[i].LibraryID, e.seriesNameForMedia(ctx, &rows[i]))
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,8 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
||||
pos = h.PositionMs
|
||||
}
|
||||
}
|
||||
return e.itemPayload(ctx, m, fav, pos), nil
|
||||
// 单条目 payload 内部对库类型/series 标题有多次查找,挂请求级缓存合并。
|
||||
return e.itemPayload(e.withPayloadCache(ctx), m, fav, pos), nil
|
||||
}
|
||||
|
||||
// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)直接透传远程。
|
||||
@@ -177,7 +178,7 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
|
||||
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups := e.seriesGroupsFromMedia(rows)
|
||||
groups := e.seriesGroupsFromMedia(ctx, rows)
|
||||
sortSeriesGroups(groups, ItemsParams{SortBy: "premieredate", SortOrder: "Descending"})
|
||||
if len(groups) > limit {
|
||||
groups = groups[:limit]
|
||||
@@ -415,9 +416,9 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
|
||||
seasonID := ""
|
||||
if e.mediaShouldBeEpisode(ctx, m) {
|
||||
itemType = "Episode"
|
||||
seriesID = e.seriesIDForMedia(m)
|
||||
seriesName = e.seriesNameForMedia(m)
|
||||
seasonID = e.seasonIDForMedia(m)
|
||||
seriesID = e.seriesIDForMedia(ctx, m)
|
||||
seriesName = e.seriesNameForMedia(ctx, m)
|
||||
seasonID = e.seasonIDForMedia(ctx, m)
|
||||
parentID = seasonID
|
||||
episodeTitle := strings.TrimSpace(m.EpisodeTitle)
|
||||
if episodeTitle != "" {
|
||||
|
||||
@@ -149,6 +149,9 @@ func (e *EmbyService) episodeItems(ctx context.Context, rows []model.Media, p It
|
||||
}
|
||||
|
||||
func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media, userID string) ([]map[string]any, error) {
|
||||
// 请求级缓存:库类型与 series 标题整页只查一次,消除逐条目 N+1。
|
||||
ctx = e.withPayloadCache(ctx)
|
||||
e.prefetchPayloadCache(ctx, rows)
|
||||
rows = e.collapseMediaVersionRows(ctx, rows)
|
||||
userFavs := map[string]bool{}
|
||||
userPos := map[string]int64{}
|
||||
@@ -240,7 +243,7 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin
|
||||
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups := e.seriesGroupsFromMedia(rows)
|
||||
groups := e.seriesGroupsFromMedia(ctx, rows)
|
||||
sortSeriesGroups(groups, p)
|
||||
total := len(groups)
|
||||
items := make([]map[string]any, 0, minInt(p.Limit, len(groups)))
|
||||
|
||||
@@ -65,7 +65,7 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
seriesGroups := e.seriesGroupsFromMedia(episodicRows)
|
||||
seriesGroups := e.seriesGroupsFromMedia(ctx, episodicRows)
|
||||
|
||||
// 真正的电影 -> Movie 项(剔除剧集结构行)。
|
||||
movieQ := apply(e.repo.DB.WithContext(ctx).Model(&model.Media{}))
|
||||
@@ -135,10 +135,11 @@ func (e *EmbyService) libraryIsEpisodic(ctx context.Context, libraryID string) (
|
||||
if strings.TrimSpace(libraryID) == "" {
|
||||
return false, nil
|
||||
}
|
||||
if lib, err := e.repo.Library.FindByID(ctx, libraryID); err != nil {
|
||||
// 走请求级缓存(若有),避免同一请求内对同一库重复查表。
|
||||
if typ, ok, err := e.payloadLibraryType(ctx, libraryID); err != nil {
|
||||
return false, err
|
||||
} else if lib != nil {
|
||||
return embyLibraryTypeIsEpisodic(lib.Type), nil
|
||||
} else if ok {
|
||||
return embyLibraryTypeIsEpisodic(typ), nil
|
||||
}
|
||||
var count int64
|
||||
err := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
@@ -151,11 +152,11 @@ func (e *EmbyService) mediaBelongsToEpisodicLibrary(ctx context.Context, m *mode
|
||||
if e == nil || m == nil || strings.TrimSpace(m.LibraryID) == "" {
|
||||
return false
|
||||
}
|
||||
lib, err := e.repo.Library.FindByID(ctx, m.LibraryID)
|
||||
if err != nil || lib == nil {
|
||||
typ, ok, err := e.payloadLibraryType(ctx, m.LibraryID)
|
||||
if err != nil || !ok {
|
||||
return false
|
||||
}
|
||||
return embyLibraryTypeIsEpisodic(lib.Type)
|
||||
return embyLibraryTypeIsEpisodic(typ)
|
||||
}
|
||||
|
||||
func (e *EmbyService) mediaShouldBeEpisode(ctx context.Context, m *model.Media) bool {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 请求级 payload 构建缓存:/Items 列表为每行构建 payload 时,
|
||||
// mediaShouldBeEpisode 需要库类型、剧集 payload 需要 series 标题。
|
||||
// 一次页面请求内这些值高度重复(同一库、同一部剧),挂在 ctx 上的
|
||||
// 小缓存可以把每条目 2-3 次 DB 查询降为整个请求各 1 次预取。
|
||||
|
||||
type embyPayloadCacheKey struct{}
|
||||
|
||||
type embyLibraryTypeEntry struct {
|
||||
typ string
|
||||
found bool // 库不存在时 found=false,调用方可退回计数启发式
|
||||
}
|
||||
|
||||
type embyPayloadCache struct {
|
||||
mu sync.Mutex
|
||||
libTypes map[string]embyLibraryTypeEntry
|
||||
series map[string]string // series_id -> title("" 表示不存在/无标题)
|
||||
}
|
||||
|
||||
func (c *embyPayloadCache) libraryType(id string) (embyLibraryTypeEntry, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
entry, ok := c.libTypes[id]
|
||||
return entry, ok
|
||||
}
|
||||
|
||||
func (c *embyPayloadCache) setLibraryType(id string, entry embyLibraryTypeEntry) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.libTypes[id] = entry
|
||||
}
|
||||
|
||||
func (c *embyPayloadCache) seriesTitle(id string) (string, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
title, ok := c.series[id]
|
||||
return title, ok
|
||||
}
|
||||
|
||||
func (c *embyPayloadCache) setSeriesTitle(id, title string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.series[id] = title
|
||||
}
|
||||
|
||||
// withPayloadCache attaches a fresh request-scoped cache if none exists yet.
|
||||
func (e *EmbyService) withPayloadCache(ctx context.Context) context.Context {
|
||||
if e == nil || e.repo == nil {
|
||||
return ctx
|
||||
}
|
||||
if ctx.Value(embyPayloadCacheKey{}) != nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, embyPayloadCacheKey{}, &embyPayloadCache{
|
||||
libTypes: map[string]embyLibraryTypeEntry{},
|
||||
series: map[string]string{},
|
||||
})
|
||||
}
|
||||
|
||||
// prefetchPayloadCache warms the cache for the given media rows with two bulk
|
||||
// queries (library types, series titles) instead of per-item lookups.
|
||||
func (e *EmbyService) prefetchPayloadCache(ctx context.Context, rows []model.Media) {
|
||||
cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache)
|
||||
if !ok || len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
libIDs := make([]string, 0, 8)
|
||||
seriesIDs := make([]string, 0, 8)
|
||||
seenLib := map[string]struct{}{}
|
||||
seenSeries := map[string]struct{}{}
|
||||
for i := range rows {
|
||||
row := &rows[i]
|
||||
if id := strings.TrimSpace(row.LibraryID); id != "" {
|
||||
if _, done := seenLib[id]; !done {
|
||||
// 已在缓存中的库不必再查。
|
||||
if _, hit := cache.libraryType(id); !hit {
|
||||
seenLib[id] = struct{}{}
|
||||
libIDs = append(libIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if id := strings.TrimSpace(row.SeriesID); id != "" {
|
||||
if _, done := seenSeries[id]; !done {
|
||||
if _, hit := cache.seriesTitle(id); !hit {
|
||||
seenSeries[id] = struct{}{}
|
||||
seriesIDs = append(seriesIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(libIDs) > 0 {
|
||||
var libs []model.Library
|
||||
if err := e.repo.DB.WithContext(ctx).Select("id, type").Where("id IN ?", libIDs).Find(&libs).Error; err == nil {
|
||||
found := map[string]string{}
|
||||
for _, lib := range libs {
|
||||
found[lib.ID] = lib.Type
|
||||
}
|
||||
for _, id := range libIDs {
|
||||
typ, ok := found[id]
|
||||
cache.setLibraryType(id, embyLibraryTypeEntry{typ: typ, found: ok})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(seriesIDs) > 0 {
|
||||
var series []model.Series
|
||||
if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id IN ?", seriesIDs).Find(&series).Error; err == nil {
|
||||
for _, s := range series {
|
||||
cache.setSeriesTitle(s.ID, s.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// payloadLibraryType resolves a library type through the request cache,
|
||||
// falling back to a direct lookup when no cache is attached. found=false
|
||||
// means the library row does not exist (soft-deleted or orphaned id).
|
||||
func (e *EmbyService) payloadLibraryType(ctx context.Context, libraryID string) (typ string, found bool, err error) {
|
||||
if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok {
|
||||
if entry, hit := cache.libraryType(libraryID); hit {
|
||||
return entry.typ, entry.found, nil
|
||||
}
|
||||
var lib model.Library
|
||||
if dbErr := e.repo.DB.WithContext(ctx).Select("id, type").Where("id = ?", libraryID).First(&lib).Error; dbErr != nil {
|
||||
cache.setLibraryType(libraryID, embyLibraryTypeEntry{})
|
||||
return "", false, nil
|
||||
}
|
||||
cache.setLibraryType(lib.ID, embyLibraryTypeEntry{typ: lib.Type, found: true})
|
||||
return lib.Type, true, nil
|
||||
}
|
||||
var lib model.Library
|
||||
if err = e.repo.DB.WithContext(ctx).Select("id, type").Where("id = ?", libraryID).First(&lib).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
return lib.Type, true, nil
|
||||
}
|
||||
|
||||
// payloadSeriesTitle resolves a series title through the request cache,
|
||||
// falling back to a direct lookup when no cache is attached.
|
||||
func (e *EmbyService) payloadSeriesTitle(ctx context.Context, seriesID string) (string, bool, error) {
|
||||
if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok {
|
||||
if title, hit := cache.seriesTitle(seriesID); hit {
|
||||
return title, true, nil
|
||||
}
|
||||
var s model.Series
|
||||
if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id = ?", seriesID).First(&s).Error; err != nil {
|
||||
cache.setSeriesTitle(seriesID, "")
|
||||
return "", true, nil
|
||||
}
|
||||
cache.setSeriesTitle(s.ID, s.Title)
|
||||
return s.Title, true, nil
|
||||
}
|
||||
series, err := e.repo.Series.FindByID(ctx, seriesID)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if series == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
return series.Title, true, nil
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
@@ -609,32 +610,34 @@ func (r *EmbyRemoteService) RemoteSearchMedia(ctx context.Context, query string,
|
||||
return
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("ParentId", target.mount.RemoteViewID)
|
||||
q.Set("Recursive", "true")
|
||||
q.Set("SearchTerm", query)
|
||||
q.Set("IncludeItemTypes", "Movie,Series")
|
||||
q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating")
|
||||
q.Set("Limit", strconv.Itoa(limit))
|
||||
q.Set("StartIndex", "0")
|
||||
helper.Run(r.log, "emby.remoteSearch", func() {
|
||||
q := url.Values{}
|
||||
q.Set("ParentId", target.mount.RemoteViewID)
|
||||
q.Set("Recursive", "true")
|
||||
q.Set("SearchTerm", query)
|
||||
q.Set("IncludeItemTypes", "Movie,Series")
|
||||
q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating")
|
||||
q.Set("Limit", strconv.Itoa(limit))
|
||||
q.Set("StartIndex", "0")
|
||||
|
||||
var body struct {
|
||||
Items []map[string]any `json:"Items"`
|
||||
}
|
||||
if err := r.doGet(searchCtx, target.acct, target.cfg, "/Users/"+url.PathEscape(r.remoteUserID(target.cfg))+"/Items", q, &body); err != nil {
|
||||
if r.log != nil {
|
||||
r.log.Warn("remote search failed",
|
||||
zap.String("mount", target.mount.RemoteViewName), zap.Error(err))
|
||||
var body struct {
|
||||
Items []map[string]any `json:"Items"`
|
||||
}
|
||||
return
|
||||
}
|
||||
medias := make([]model.Media, 0, len(body.Items))
|
||||
for _, it := range body.Items {
|
||||
RewriteEmbyRemoteIDs(it, target.mount.ID)
|
||||
m := r.MapRemoteItemToMedia(searchCtx, &target.mount, target.acct, target.cfg, it)
|
||||
medias = append(medias, m)
|
||||
}
|
||||
results[idx] = searchResult{items: medias}
|
||||
if err := r.doGet(searchCtx, target.acct, target.cfg, "/Users/"+url.PathEscape(r.remoteUserID(target.cfg))+"/Items", q, &body); err != nil {
|
||||
if r.log != nil {
|
||||
r.log.Warn("remote search failed",
|
||||
zap.String("mount", target.mount.RemoteViewName), zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
medias := make([]model.Media, 0, len(body.Items))
|
||||
for _, it := range body.Items {
|
||||
RewriteEmbyRemoteIDs(it, target.mount.ID)
|
||||
m := r.MapRemoteItemToMedia(searchCtx, &target.mount, target.acct, target.cfg, it)
|
||||
medias = append(medias, m)
|
||||
}
|
||||
results[idx] = searchResult{items: medias}
|
||||
})
|
||||
}(i, t)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
@@ -49,11 +49,15 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
if !strings.HasPrefix(id, embyVirtualSeriesPrefix) {
|
||||
q = q.Where("series_id = ?", id)
|
||||
} else {
|
||||
// 虚拟 series ID 只可能来自 series_id 为空的媒体:
|
||||
// 有 series_id 时分组 key 就是 series_id 本身(UUID,不带虚拟前缀)。
|
||||
q = q.Where("series_id IS NULL OR series_id = ''")
|
||||
}
|
||||
if err := q.Order("media.season_num asc, media.episode_num asc, media.created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
|
||||
return embySeriesGroup{}, false, err
|
||||
}
|
||||
for _, group := range e.seriesGroupsFromMedia(rows) {
|
||||
for _, group := range e.seriesGroupsFromMedia(ctx, rows) {
|
||||
if group.ID == id {
|
||||
e.rememberSeriesGroup(group)
|
||||
return group, true, nil
|
||||
@@ -88,9 +92,18 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
|
||||
if season, ok := e.cachedSeasonGroup(id); ok {
|
||||
return season, true, nil
|
||||
}
|
||||
// 虚拟 Season ID 是 hash(seriesKey, seasonNum),无法反解出 series。
|
||||
// 常见情况(已刮削、series_id 非空)先用一条小型 DISTINCT 查询枚举候选对,
|
||||
// 在内存中算哈希匹配,命中后只加载该一部剧的剧集行,避免整库扫描。
|
||||
if season, ok, err := e.findSeasonGroupBySeriesCandidates(ctx, id, userID); err != nil {
|
||||
return embySeasonGroup{}, false, err
|
||||
} else if ok {
|
||||
return season, true, nil
|
||||
}
|
||||
// 回退:未刮削(series_id 为空,虚拟 key 由库名+名称派生)的媒体只能全量分组。
|
||||
var rows []model.Media
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("season_num > 0 OR episode_num > 0")
|
||||
Where("(series_id IS NULL OR series_id = '') AND (season_num > 0 OR episode_num > 0)")
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
if err := q.
|
||||
Order("media.season_num asc, media.episode_num asc, media.created_at asc").
|
||||
@@ -98,7 +111,7 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
|
||||
Find(&rows).Error; err != nil {
|
||||
return embySeasonGroup{}, false, err
|
||||
}
|
||||
for _, series := range e.seriesGroupsFromMedia(rows) {
|
||||
for _, series := range e.seriesGroupsFromMedia(ctx, rows) {
|
||||
for _, season := range e.seasonsForSeries(series) {
|
||||
if season.ID == id {
|
||||
e.rememberSeriesGroup(series)
|
||||
@@ -109,18 +122,77 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
|
||||
return embySeasonGroup{}, false, nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) seriesGroupsFromMedia(rows []model.Media) []embySeriesGroup {
|
||||
// findSeasonGroupBySeriesCandidates resolves virtual season IDs for media that
|
||||
// carry a real series_id: enumerate distinct (series_id, season_num) pairs via
|
||||
// SQL, hash each candidate to find the matching season, then load only that
|
||||
// one series' episodes.
|
||||
func (e *EmbyService) findSeasonGroupBySeriesCandidates(ctx context.Context, id, userID string) (embySeasonGroup, bool, error) {
|
||||
type seasonCandidate struct {
|
||||
SeriesID string
|
||||
SeasonNum int
|
||||
}
|
||||
var candidates []seasonCandidate
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Select("DISTINCT series_id, season_num").
|
||||
Where("series_id <> '' AND (season_num > 0 OR episode_num > 0)")
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
if err := q.Find(&candidates).Error; err != nil {
|
||||
return embySeasonGroup{}, false, err
|
||||
}
|
||||
matched := make([]string, 0, 1)
|
||||
for _, cand := range candidates {
|
||||
if seasonID(cand.SeriesID, cand.SeasonNum) == id {
|
||||
matched = append(matched, cand.SeriesID)
|
||||
}
|
||||
}
|
||||
for _, matchedSeries := range matched {
|
||||
season, ok, err := e.seasonGroupForSeries(ctx, id, matchedSeries, userID)
|
||||
if err != nil || ok {
|
||||
return season, ok, err
|
||||
}
|
||||
}
|
||||
return embySeasonGroup{}, false, nil
|
||||
}
|
||||
|
||||
// seasonGroupForSeries rebuilds the season groups of one series (small row
|
||||
// set) and returns the one matching the virtual season id.
|
||||
func (e *EmbyService) seasonGroupForSeries(ctx context.Context, id, seriesID, userID string) (embySeasonGroup, bool, error) {
|
||||
var rows []model.Media
|
||||
rq := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("series_id = ? AND (season_num > 0 OR episode_num > 0)", seriesID)
|
||||
rq = e.applyUserMediaVisibility(ctx, rq, userID)
|
||||
if err := rq.
|
||||
Order("media.season_num asc, media.episode_num asc, media.created_at asc").
|
||||
Limit(embySeriesGroupingLimit).
|
||||
Find(&rows).Error; err != nil {
|
||||
return embySeasonGroup{}, false, err
|
||||
}
|
||||
for _, series := range e.seriesGroupsFromMedia(ctx, rows) {
|
||||
if series.ID != seriesID {
|
||||
continue
|
||||
}
|
||||
for _, season := range e.seasonsForSeries(series) {
|
||||
if season.ID == id {
|
||||
e.rememberSeriesGroup(series)
|
||||
return season, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return embySeasonGroup{}, false, nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) seriesGroupsFromMedia(ctx context.Context, rows []model.Media) []embySeriesGroup {
|
||||
byID := map[string]*embySeriesGroup{}
|
||||
order := []string{}
|
||||
for _, row := range rows {
|
||||
row := row
|
||||
seriesID := e.seriesIDForMedia(&row)
|
||||
seriesID := e.seriesIDForMedia(ctx, &row)
|
||||
group, ok := byID[seriesID]
|
||||
if !ok {
|
||||
group = &embySeriesGroup{
|
||||
ID: seriesID,
|
||||
LibraryID: row.LibraryID,
|
||||
Name: e.seriesNameForMedia(&row),
|
||||
Name: e.seriesNameForMedia(ctx, &row),
|
||||
Year: row.Year,
|
||||
ReleaseDate: row.ReleaseDate,
|
||||
TMDbID: row.TMDbID,
|
||||
|
||||
@@ -14,21 +14,22 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func (e *EmbyService) seriesIDForMedia(m *model.Media) string {
|
||||
func (e *EmbyService) seriesIDForMedia(ctx context.Context, m *model.Media) string {
|
||||
if strings.TrimSpace(m.SeriesID) != "" {
|
||||
return m.SeriesID
|
||||
}
|
||||
return stableEmbyID(embyVirtualSeriesPrefix, m.LibraryID, e.seriesNameForMedia(m))
|
||||
return stableEmbyID(embyVirtualSeriesPrefix, m.LibraryID, e.seriesNameForMedia(ctx, m))
|
||||
}
|
||||
|
||||
func (e *EmbyService) seasonIDForMedia(m *model.Media) string {
|
||||
return seasonID(e.seriesIDForMedia(m), m.SeasonNum)
|
||||
func (e *EmbyService) seasonIDForMedia(ctx context.Context, m *model.Media) string {
|
||||
return seasonID(e.seriesIDForMedia(ctx, m), m.SeasonNum)
|
||||
}
|
||||
|
||||
func (e *EmbyService) seriesNameForMedia(m *model.Media) string {
|
||||
func (e *EmbyService) seriesNameForMedia(ctx context.Context, m *model.Media) string {
|
||||
if strings.TrimSpace(m.SeriesID) != "" {
|
||||
if series, err := e.repo.Series.FindByID(context.Background(), m.SeriesID); err == nil && series != nil && strings.TrimSpace(series.Title) != "" {
|
||||
return series.Title
|
||||
// 走请求级缓存;无缓存 ctx 时退化为单次查询。
|
||||
if title, ok, err := e.payloadSeriesTitle(ctx, m.SeriesID); err == nil && ok && strings.TrimSpace(title) != "" {
|
||||
return title
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(m.ScrapeStatus), "matched") && strings.TrimSpace(m.Title) != "" {
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
@@ -152,7 +153,7 @@ func (s *FFmpegToolsService) StartInstall(ctx context.Context) error {
|
||||
s.mu.Unlock()
|
||||
|
||||
s.setMessage("准备下载…")
|
||||
go s.runInstall()
|
||||
helper.Go(s.log, "ffmpeg.install", s.runInstall)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -189,19 +189,40 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有聚合到多集,尝试同父目录匹配
|
||||
if len(out) <= 1 && target.Path != "" {
|
||||
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
|
||||
dirMatches := make([]model.Media, 0)
|
||||
for _, row := range rows {
|
||||
if row.Path != "" && filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) == targetDir {
|
||||
dirMatches = append(dirMatches, row)
|
||||
// 如果没有聚合到多集,尝试同父目录匹配(排除合集目录和公共分类目录,且同目录文件不能是互不相同的独立电影)
|
||||
if len(out) <= 1 && target.Path != "" {
|
||||
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
|
||||
parentBase := filepath.Base(targetDir)
|
||||
if !mediaParentLooksLikeCollection(target.Path) && !seriesTitleIsGenericContainer(parentBase, *target) {
|
||||
targetTitleNorm := normalizeSeriesTitle(target.Title)
|
||||
targetDirNorm := normalizeSeriesTitle(parentBase)
|
||||
dirMatches := make([]model.Media, 0)
|
||||
for _, row := range rows {
|
||||
if row.Path == "" || filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) != targetDir {
|
||||
continue
|
||||
}
|
||||
if row.ID == target.ID {
|
||||
dirMatches = append(dirMatches, row)
|
||||
continue
|
||||
}
|
||||
rowTitleNorm := normalizeSeriesTitle(row.Title)
|
||||
allowMatch := false
|
||||
if isGenericMovieTitle(rowTitleNorm) || isGenericMovieTitle(targetTitleNorm) {
|
||||
allowMatch = true
|
||||
} else if rowTitleNorm != "" && rowTitleNorm == targetTitleNorm {
|
||||
allowMatch = true
|
||||
} else if rowTitleNorm != "" && targetDirNorm != "" && rowTitleNorm == targetDirNorm {
|
||||
allowMatch = true
|
||||
}
|
||||
if allowMatch {
|
||||
dirMatches = append(dirMatches, row)
|
||||
}
|
||||
}
|
||||
if len(dirMatches) > 1 {
|
||||
out = dirMatches
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(dirMatches) > 1 {
|
||||
out = dirMatches
|
||||
}
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
out = []model.Media{*target}
|
||||
|
||||
@@ -11,6 +11,16 @@ import (
|
||||
|
||||
var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]`)
|
||||
|
||||
var genericMovieTitleRE = regexp.MustCompile(`(?i)^(?:cd\s*\d+|part\s*\d+|disc\s*\d+|disk\s*\d+|dvd\s*\d+|movie|film|video|main|feature|track\s*\d+|preview|sample|trailer|\d{3,4}p|4k|2160p|1080p|720p)$`)
|
||||
|
||||
func isGenericMovieTitle(title string) bool {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return true
|
||||
}
|
||||
return genericMovieTitleRE.MatchString(title)
|
||||
}
|
||||
|
||||
func mediaSeriesKey(media model.Media) string {
|
||||
return compactSeriesKey(mediaSeriesRawKey(media))
|
||||
}
|
||||
@@ -54,7 +64,11 @@ func mediaSeriesRawKey(media model.Media) string {
|
||||
return seriesFingerprint("movie-external", fmt.Sprintf("bgm:%d", media.BangumiID))
|
||||
}
|
||||
if fromPath != "" && !mediaParentLooksLikeCollection(media.Path) {
|
||||
return seriesFingerprint("library-path", media.LibraryID, fromPath)
|
||||
titleNorm := normalizeSeriesTitle(media.Title)
|
||||
fromPathNorm := normalizeSeriesTitle(fromPath)
|
||||
if titleNorm == "" || isGenericMovieTitle(titleNorm) || titleNorm == fromPathNorm {
|
||||
return seriesFingerprint("library-path", media.LibraryID, fromPath)
|
||||
}
|
||||
}
|
||||
return seriesFingerprint("library-title", media.LibraryID, normalizeSeriesTitle(media.Title))
|
||||
}
|
||||
|
||||
@@ -445,3 +445,67 @@ func TestGroupMediaSeriesCardsBridgesReleaseFoldersByMatchedSeriesTitle(t *testi
|
||||
t.Fatalf("cards=%#v, want one series bridged by matched title", cards)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaSeriesCardsKeepsIndependentMoviesSeparateInSharedSubdirectory(t *testing.T) {
|
||||
// 同一分类子目录下存放多部不同标题的独立电影,不应被强制折叠成 1 部
|
||||
items := []model.Media{
|
||||
{LibraryID: "movies", Title: "老师2024偷窥篇", Path: `/media/小姐姐/国产/nana/老师2024偷窥篇.strm`},
|
||||
{LibraryID: "movies", Title: "紫光灯下的肉体诱惑", Path: `/media/小姐姐/国产/nana/紫光灯下的肉体诱惑.strm`},
|
||||
{LibraryID: "movies", Title: "修洗衣机", Path: `/media/小姐姐/国产/nana/修洗衣机.strm`},
|
||||
}
|
||||
cards := groupMediaSeriesCards(items)
|
||||
if len(cards) != 3 {
|
||||
t.Fatalf("got %d cards, want 3 independent movie cards", len(cards))
|
||||
}
|
||||
|
||||
// 但同一部电影的 CD1 和 CD2 仍应正确折叠为 1 部
|
||||
cdItems := []model.Media{
|
||||
{LibraryID: "movies", Title: "cd1", Path: `/media/电影/指环王 (2001)/cd1.mkv`},
|
||||
{LibraryID: "movies", Title: "cd2", Path: `/media/电影/指环王 (2001)/cd2.mkv`},
|
||||
}
|
||||
cdCards := groupMediaSeriesCards(cdItems)
|
||||
if len(cdCards) != 1 {
|
||||
t.Fatalf("got %d cards for cd1/cd2, want 1 folded movie card", len(cdCards))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Base: model.Base{ID: "lib-movies"}, Name: "电影", Type: "movies", Enabled: true}
|
||||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m1 := model.Media{
|
||||
Base: model.Base{ID: "m1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "老师2024偷窥篇",
|
||||
Path: `/media/小姐姐/国产/nana/老师2024偷窥篇.strm`,
|
||||
}
|
||||
m2 := model.Media{
|
||||
Base: model.Base{ID: "m2"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "紫光灯下的肉体诱惑",
|
||||
Path: `/media/小姐姐/国产/nana/紫光灯下的肉体诱惑.strm`,
|
||||
}
|
||||
m3 := model.Media{
|
||||
Base: model.Base{ID: "m3"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "修洗衣机",
|
||||
Path: `/media/小姐姐/国产/nana/修洗衣机.strm`,
|
||||
}
|
||||
if err := repos.DB.Create(&[]model.Media{m1, m2, m3}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||
eps, err := svc.ListMediaEpisodes(t.Context(), "m1", MediaVisibility{IncludeNSFW: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(eps) != 1 || eps[0].ID != "m1" {
|
||||
t.Fatalf("ListMediaEpisodes got %#v, want exactly m1", eps)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
@@ -45,7 +46,7 @@ func (s *ScannerService) startLocalMediaProbeWorkers() {
|
||||
s.localMediaProbeOnce.Do(func() {
|
||||
workers := s.ffprobeWorkerCount()
|
||||
for i := 0; i < workers; i++ {
|
||||
go s.localMediaProbeWorker()
|
||||
helper.Go(s.log, "scanner.probeWorker", s.localMediaProbeWorker)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
)
|
||||
|
||||
func (s *ScannerService) invalidateMediaCache(ctx context.Context) {
|
||||
@@ -18,10 +20,12 @@ func (s *ScannerService) startAutoScrape(ctx context.Context, libraryID string)
|
||||
scrapeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Minute)
|
||||
go func() {
|
||||
defer cancel()
|
||||
_, err := s.scraper.EnrichLibraryDetailedWithOptions(scrapeCtx, libraryID, skipEpisodeArtworkOptions(false))
|
||||
if err != nil {
|
||||
s.log.Warn("scraper enrich failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
// 扫描触发的后台刮削与请求线程无关,panic 只记日志,不能带崩进程。
|
||||
helper.Run(s.log, "scanner.autoScrape", func() {
|
||||
_, err := s.scraper.EnrichLibraryDetailedWithOptions(scrapeCtx, libraryID, skipEpisodeArtworkOptions(false))
|
||||
if err != nil {
|
||||
s.log.Warn("scraper enrich failed", zap.Error(err))
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
@@ -139,7 +140,7 @@ func (s *SchedulerService) Start(ctx context.Context) {
|
||||
// 首轮等满一个完整周期再跑,平时节奏不变。
|
||||
initialDelay = j.interval
|
||||
}
|
||||
go s.loopWithInitialDelay(ctx, j, initialDelay)
|
||||
helper.Go(s.log, "scheduler.loop."+j.name, func() { s.loopWithInitialDelay(ctx, j, initialDelay) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
)
|
||||
|
||||
// JobStatus is a snapshot suitable for the admin UI.
|
||||
@@ -142,7 +144,8 @@ func (s *SchedulerService) beginRun(j *scheduledJob) error {
|
||||
}
|
||||
|
||||
func (s *SchedulerService) runReserved(ctx context.Context, j *scheduledJob) error {
|
||||
err := j.run(ctx)
|
||||
// 任务 panic 转为 error,保证下方 running/lastErr 状态照常清理、调度循环存活。
|
||||
err := helper.Recover(s.log, "scheduler.job."+j.name, func() error { return j.run(ctx) })
|
||||
s.mu.Lock()
|
||||
j.lastRun = s.currentTime()
|
||||
if err != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
@@ -73,7 +74,10 @@ func (s *ScraperService) queueWorker(ctx context.Context) {
|
||||
}
|
||||
defer func() { <-sem }()
|
||||
|
||||
s.processScrapeTask(ctx, t)
|
||||
// 刮削要解析远端元数据响应,单个任务 panic 不应拖垮队列 worker。
|
||||
helper.Run(s.log, "scraper.task", func() {
|
||||
s.processScrapeTask(ctx, t)
|
||||
})
|
||||
}(&tasks[i])
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
@@ -96,7 +97,7 @@ func (c *Container) Boot() {
|
||||
if err := c.APIConfig.SeedDefaults(c.stopCtx); err != nil {
|
||||
c.Log.Warn("api config seed failed", zap.Error(err))
|
||||
}
|
||||
go c.warmMediaSearchIndex(c.stopCtx)
|
||||
helper.Go(c.Log, "service.warmMediaSearchIndex", func() { c.warmMediaSearchIndex(c.stopCtx) })
|
||||
|
||||
// 启动调度器定时任务
|
||||
c.Scheduler.Start(c.stopCtx)
|
||||
@@ -119,7 +120,7 @@ func (c *Container) Boot() {
|
||||
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
|
||||
// 每天触发一次评估;规则里的窗口可随机,不固定。
|
||||
if c.Device != nil {
|
||||
go c.runInactivitySweeper(c.stopCtx)
|
||||
helper.Go(c.Log, "service.inactivitySweeper", func() { c.runInactivitySweeper(c.stopCtx) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
@@ -47,12 +48,12 @@ func newServiceContainer(cfg *config.Config, log *zap.Logger, repos *repository.
|
||||
|
||||
func (b *serviceContainerBuilder) startRealtimeServices() {
|
||||
b.c.WSHub = NewHub(b.log)
|
||||
go b.c.WSHub.Run()
|
||||
helper.Go(b.log, "ws.hub", b.c.WSHub.Run)
|
||||
b.c.Tasks = NewTaskTrackerService(b.log, b.c.WSHub)
|
||||
b.c.SystemUpdate = NewSystemUpdateService(b.cfg, b.log, b.repos, b.c.Tasks, b.version)
|
||||
|
||||
b.c.SSEHub = NewSSEHub(b.log)
|
||||
go b.c.SSEHub.Run()
|
||||
helper.Go(b.log, "sse.hub", b.c.SSEHub.Run)
|
||||
}
|
||||
|
||||
func (b *serviceContainerBuilder) initProviderServices() {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||
@@ -66,7 +67,10 @@ func (s *StrmService) downloadWorker(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
defer s.releaseDownloadSlot(task.Provider)
|
||||
s.processDownloadTask(ctx, task)
|
||||
// 单个任务 panic 不应拖垮整个下载 worker。
|
||||
helper.Run(s.log, "strm.downloadTask", func() {
|
||||
s.processDownloadTask(ctx, task)
|
||||
})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud"
|
||||
@@ -186,14 +187,14 @@ func (s *StrmService) Start(ctx context.Context) {
|
||||
uploadThreads = 4
|
||||
}
|
||||
for i := 0; i < downloadThreads; i++ {
|
||||
go s.downloadWorker(ctx)
|
||||
helper.Go(s.log, "strm.downloadWorker", func() { s.downloadWorker(ctx) })
|
||||
}
|
||||
for i := 0; i < uploadThreads; i++ {
|
||||
go s.uploadWorker(ctx)
|
||||
helper.Go(s.log, "strm.uploadWorker", func() { s.uploadWorker(ctx) })
|
||||
}
|
||||
go s.cronLoop(ctx)
|
||||
go s.queueCleanupLoop(ctx)
|
||||
go s.refresh115TokensLoop(ctx)
|
||||
helper.Go(s.log, "strm.cronLoop", func() { s.cronLoop(ctx) })
|
||||
helper.Go(s.log, "strm.queueCleanupLoop", func() { s.queueCleanupLoop(ctx) })
|
||||
helper.Go(s.log, "strm.refresh115TokensLoop", func() { s.refresh115TokensLoop(ctx) })
|
||||
s.log.Info("strm service started",
|
||||
zap.Int("download_threads", downloadThreads),
|
||||
zap.Int("upload_threads", uploadThreads))
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||
@@ -105,7 +106,7 @@ func (s *StrmService) StartSync(ctx context.Context, pathID string, syncType ...
|
||||
p.LastSyncMessage = "同步进行中"
|
||||
_ = s.repo.StrmSyncPath.Update(ctx, p)
|
||||
|
||||
go s.runSync(runCtx, p, rec)
|
||||
helper.Go(s.log, "strm.sync", func() { s.runSync(runCtx, p, rec) })
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -361,38 +362,45 @@ func (st *strmSyncState) walkRemote() error {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for task := range queue {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
entries, err := st.provider.List(ctx, task.id)
|
||||
if err != nil {
|
||||
errMu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("列出远端目录 %s 失败:%w", task.id, err)
|
||||
// worker 解析远端响应 panic 时取消整个同步,让 closer 与其余
|
||||
// worker 正常收尾,避免队列与 pending 计数卡死;正常退出不取消。
|
||||
if err := helper.Recover(st.s.log, "strm.sync.walkRemote", func() error {
|
||||
for task := range queue {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
errMu.Unlock()
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
cleanName := cleanEntryName(entry.Name, entry.IsDir)
|
||||
rel := cleanName
|
||||
if task.rel != "" {
|
||||
rel = task.rel + "/" + cleanName
|
||||
}
|
||||
if entry.IsDir {
|
||||
pending.Add(1)
|
||||
select {
|
||||
case queue <- dirTask{id: entry.ID, rel: rel}:
|
||||
case <-ctx.Done():
|
||||
pending.Add(-1)
|
||||
entries, err := st.provider.List(ctx, task.id)
|
||||
if err != nil {
|
||||
errMu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("列出远端目录 %s 失败:%w", task.id, err)
|
||||
}
|
||||
} else {
|
||||
st.processRemoteFile(entry, rel)
|
||||
errMu.Unlock()
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
for _, entry := range entries {
|
||||
cleanName := cleanEntryName(entry.Name, entry.IsDir)
|
||||
rel := cleanName
|
||||
if task.rel != "" {
|
||||
rel = task.rel + "/" + cleanName
|
||||
}
|
||||
if entry.IsDir {
|
||||
pending.Add(1)
|
||||
select {
|
||||
case queue <- dirTask{id: entry.ID, rel: rel}:
|
||||
case <-ctx.Done():
|
||||
pending.Add(-1)
|
||||
}
|
||||
} else {
|
||||
st.processRemoteFile(entry, rel)
|
||||
}
|
||||
}
|
||||
pending.Add(-1)
|
||||
}
|
||||
pending.Add(-1)
|
||||
return nil
|
||||
}); err != nil {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -560,22 +568,28 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for t := range taskCh {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
files, _, err := open115.GetFsListFlat(ctx, rootCID, t.offset, pageSize)
|
||||
if err != nil {
|
||||
errMu.Lock()
|
||||
if fetchErr == nil {
|
||||
fetchErr = err
|
||||
// 分页拉取 panic 时取消整个同步;正常退出不取消。
|
||||
if err := helper.Recover(st.s.log, "strm.sync.walk115.page", func() error {
|
||||
for t := range taskCh {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
errMu.Unlock()
|
||||
return
|
||||
files, _, err := open115.GetFsListFlat(ctx, rootCID, t.offset, pageSize)
|
||||
if err != nil {
|
||||
errMu.Lock()
|
||||
if fetchErr == nil {
|
||||
fetchErr = err
|
||||
}
|
||||
errMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
filesMu.Lock()
|
||||
allFiles = append(allFiles, files...)
|
||||
filesMu.Unlock()
|
||||
}
|
||||
filesMu.Lock()
|
||||
allFiles = append(allFiles, files...)
|
||||
filesMu.Unlock()
|
||||
return nil
|
||||
}); err != nil {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -632,63 +646,70 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
|
||||
pwg.Add(1)
|
||||
go func() {
|
||||
defer pwg.Done()
|
||||
for pid := range pidCh {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if _, loaded := st.dirCache.Load(pid); loaded {
|
||||
if n := doneDirs.Add(1); n%20 == 0 || n == int64(totalDirs) {
|
||||
// 解析目录详情 panic 时中止整个同步(避免带着损坏的相对路径
|
||||
// 继续执行);正常退出不取消。
|
||||
if err := helper.Recover(st.s.log, "strm.sync.walk115.dirTree", func() error {
|
||||
for pid := range pidCh {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if _, loaded := st.dirCache.Load(pid); loaded {
|
||||
if n := doneDirs.Add(1); n%20 == 0 || n == int64(totalDirs) {
|
||||
st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (%d/%d)...", n, totalDirs))
|
||||
}
|
||||
continue
|
||||
}
|
||||
detail, err := open115.GetFsDetailByCid(ctx, pid)
|
||||
if err != nil {
|
||||
// 目录详情解析失败会导致下游文件 rel 无法还原真实父路径,
|
||||
// seen key 与磁盘路径对不上:增量 prune 会误删本地文件、上传会
|
||||
// 误传本地未变文件、下载会重复下载。这里不是降级容错,而是
|
||||
// 直接中止整个同步——宁可本次同步失败,也不带着损坏的相对路径
|
||||
// 继续执行造成大规模误删/误传/重下(参考用户反馈"云盘没动却重下重传")。
|
||||
errMu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("115: 解析目录树失败(file_id=%s):%w", pid, err)
|
||||
}
|
||||
errMu.Unlock()
|
||||
st.scanIncomplete.Store(true)
|
||||
cancel()
|
||||
return nil
|
||||
} else if detail != nil {
|
||||
// 解析相对路径
|
||||
relPath := cleanDirRel(detail.RelativePath(rootCID))
|
||||
st.dirCache.Store(pid, relPath)
|
||||
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, pid, relPath)
|
||||
|
||||
// 顺便解析并缓存 detail.Paths 中包含的中间各层级目录
|
||||
for _, ancestor := range detail.Paths {
|
||||
if ancestor.FileId == "0" || ancestor.FileId == rootCID {
|
||||
continue
|
||||
}
|
||||
if _, loaded := st.dirCache.Load(ancestor.FileId); !loaded {
|
||||
subDetail := &cloud115.RemoteFileDetail{
|
||||
FileId: ancestor.FileId,
|
||||
FileName: ancestor.Name,
|
||||
Paths: nil,
|
||||
}
|
||||
for _, p := range detail.Paths {
|
||||
subDetail.Paths = append(subDetail.Paths, p)
|
||||
if p.FileId == ancestor.FileId {
|
||||
break
|
||||
}
|
||||
}
|
||||
ancestorRel := cleanDirRel(subDetail.RelativePath(rootCID))
|
||||
st.dirCache.Store(ancestor.FileId, ancestorRel)
|
||||
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, ancestor.FileId, ancestorRel)
|
||||
}
|
||||
}
|
||||
}
|
||||
if n := doneDirs.Add(1); n%10 == 0 || n == int64(totalDirs) {
|
||||
st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (%d/%d)...", n, totalDirs))
|
||||
}
|
||||
continue
|
||||
}
|
||||
detail, err := open115.GetFsDetailByCid(ctx, pid)
|
||||
if err != nil {
|
||||
// 目录详情解析失败会导致下游文件 rel 无法还原真实父路径,
|
||||
// seen key 与磁盘路径对不上:增量 prune 会误删本地文件、上传会
|
||||
// 误传本地未变文件、下载会重复下载。这里不是降级容错,而是
|
||||
// 直接中止整个同步——宁可本次同步失败,也不带着损坏的相对路径
|
||||
// 继续执行造成大规模误删/误传/重下(参考用户反馈"云盘没动却重下重传")。
|
||||
errMu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("115: 解析目录树失败(file_id=%s):%w", pid, err)
|
||||
}
|
||||
errMu.Unlock()
|
||||
st.scanIncomplete.Store(true)
|
||||
cancel()
|
||||
return
|
||||
} else if detail != nil {
|
||||
// 解析相对路径
|
||||
relPath := cleanDirRel(detail.RelativePath(rootCID))
|
||||
st.dirCache.Store(pid, relPath)
|
||||
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, pid, relPath)
|
||||
|
||||
// 顺便解析并缓存 detail.Paths 中包含的中间各层级目录
|
||||
for _, ancestor := range detail.Paths {
|
||||
if ancestor.FileId == "0" || ancestor.FileId == rootCID {
|
||||
continue
|
||||
}
|
||||
if _, loaded := st.dirCache.Load(ancestor.FileId); !loaded {
|
||||
subDetail := &cloud115.RemoteFileDetail{
|
||||
FileId: ancestor.FileId,
|
||||
FileName: ancestor.Name,
|
||||
Paths: nil,
|
||||
}
|
||||
for _, p := range detail.Paths {
|
||||
subDetail.Paths = append(subDetail.Paths, p)
|
||||
if p.FileId == ancestor.FileId {
|
||||
break
|
||||
}
|
||||
}
|
||||
ancestorRel := cleanDirRel(subDetail.RelativePath(rootCID))
|
||||
st.dirCache.Store(ancestor.FileId, ancestorRel)
|
||||
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, ancestor.FileId, ancestorRel)
|
||||
}
|
||||
}
|
||||
}
|
||||
if n := doneDirs.Add(1); n%10 == 0 || n == int64(totalDirs) {
|
||||
st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (%d/%d)...", n, totalDirs))
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -35,6 +37,21 @@ type SubtitleService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
cfg *config.Config
|
||||
|
||||
// 目录发现是 Emby 条目列表的热路径(每个媒体源一次 DB 查询 + 最多 5 次
|
||||
// os.ReadDir),而字幕文件极少变化:按 media_id 做短 TTL 缓存。
|
||||
cacheMu sync.Mutex
|
||||
discovery map[string]subtitleDiscoveryEntry
|
||||
}
|
||||
|
||||
const (
|
||||
subtitleDiscoveryTTL = 2 * time.Minute
|
||||
subtitleDiscoveryCacheCap = 4096
|
||||
)
|
||||
|
||||
type subtitleDiscoveryEntry struct {
|
||||
tracks []SubtitleTrack
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// NewSubtitleService is the constructor.
|
||||
@@ -73,6 +90,50 @@ func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID stri
|
||||
}
|
||||
|
||||
func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
if tracks, ok := s.cachedDiscovery(mediaID); ok {
|
||||
return tracks, nil
|
||||
}
|
||||
tracks, err := s.discoverUncached(ctx, mediaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.rememberDiscovery(mediaID, tracks)
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
func (s *SubtitleService) cachedDiscovery(mediaID string) ([]SubtitleTrack, bool) {
|
||||
now := time.Now()
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
entry, ok := s.discovery[mediaID]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(s.discovery, mediaID)
|
||||
return nil, false
|
||||
}
|
||||
// 返回副本,避免调用方修改缓存内容。
|
||||
return append([]SubtitleTrack(nil), entry.tracks...), true
|
||||
}
|
||||
|
||||
func (s *SubtitleService) rememberDiscovery(mediaID string, tracks []SubtitleTrack) {
|
||||
now := time.Now()
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
if s.discovery == nil {
|
||||
s.discovery = make(map[string]subtitleDiscoveryEntry)
|
||||
}
|
||||
if len(s.discovery) >= subtitleDiscoveryCacheCap {
|
||||
s.discovery = make(map[string]subtitleDiscoveryEntry)
|
||||
}
|
||||
s.discovery[mediaID] = subtitleDiscoveryEntry{
|
||||
tracks: append([]SubtitleTrack(nil), tracks...),
|
||||
expiresAt: now.Add(subtitleDiscoveryTTL),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
m, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
@@ -132,7 +133,7 @@ func (s *SystemUpdateService) Apply(ctx context.Context) (SystemUpdateStatus, er
|
||||
s.last = &status
|
||||
s.mu.Unlock()
|
||||
|
||||
go s.runUpdate(context.Background(), status, task)
|
||||
helper.Go(s.log, "system.update", func() { s.runUpdate(context.Background(), status, task) })
|
||||
return status, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
@@ -26,11 +27,14 @@ func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash string, exp
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
done <- s.storeRefreshToken(ctx, &model.RefreshToken{
|
||||
UserID: userID,
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: expiresAt,
|
||||
err := helper.Recover(s.log, "token.storeRefreshToken", func() error {
|
||||
return s.storeRefreshToken(ctx, &model.RefreshToken{
|
||||
UserID: userID,
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
})
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
@@ -143,7 +144,7 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
||||
t.jobs[mediaID] = job
|
||||
t.mu.Unlock()
|
||||
|
||||
go t.monitorIdle(jobCtx, job)
|
||||
go t.runFFmpeg(jobCtx, job, m.Path)
|
||||
helper.Go(t.log, "transcoder.monitorIdle", func() { t.monitorIdle(jobCtx, job) })
|
||||
helper.Go(t.log, "transcoder.ffmpeg", func() { t.runFFmpeg(jobCtx, job, m.Path) })
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
@@ -69,8 +70,8 @@ func (w *WatcherService) Start(ctx context.Context) error {
|
||||
if err := w.Refresh(ctx); err != nil {
|
||||
w.log.Warn("watcher refresh failed", zap.Error(err))
|
||||
}
|
||||
go w.loop(ctx)
|
||||
go w.debouncer(ctx)
|
||||
helper.Go(w.log, "watcher.loop", func() { w.loop(ctx) })
|
||||
helper.Go(w.log, "watcher.debouncer", func() { w.debouncer(ctx) })
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,8 @@
|
||||
<link rel="icon" type="image/png" href="/brand/logo-64.png" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/brand/logo-192.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Cabinet+Grotesk:wght@400;500;600;700;800&family=Geist:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<!-- 字体已自托管(见 src/index.css 的 @font-face),不再使用 Google Fonts 外链:
|
||||
自托管环境常无法访问 Google,渲染阻塞外链会拖慢首屏。 -->
|
||||
<title>MeBox</title>
|
||||
</head>
|
||||
<body class="font-body antialiased">
|
||||
|
||||
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 408 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
@@ -2,25 +2,34 @@
|
||||
import { lazy, type ReactElement } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
|
||||
const HomePage = lazy(() => import('./pages/HomePage').then((m) => ({ default: m.HomePage })))
|
||||
const LibraryPage = lazy(() => import('./pages/LibraryPage').then((m) => ({ default: m.LibraryPage })))
|
||||
const LibrariesPage = lazy(() => import('./pages/LibrariesPage').then((m) => ({ default: m.LibrariesPage })))
|
||||
const FavouritesPage = lazy(() => import('./pages/FavouritesPage').then((m) => ({ default: m.FavouritesPage })))
|
||||
const PlaylistsPage = lazy(() => import('./pages/PlaylistsPage').then((m) => ({ default: m.PlaylistsPage })))
|
||||
const PlaylistDetailPage = lazy(() =>
|
||||
import('./pages/PlaylistDetailPage').then((m) => ({ default: m.PlaylistDetailPage })),
|
||||
)
|
||||
const MediaDetailPage = lazy(() => import('./pages/MediaDetailPage').then((m) => ({ default: m.MediaDetailPage })))
|
||||
const PlayerPage = lazy(() => import('./pages/PlayerPage').then((m) => ({ default: m.PlayerPage })))
|
||||
const HomePageLoader = () => import('./pages/HomePage').then((m) => ({ default: m.HomePage }))
|
||||
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 PlaylistsPageLoader = () => import('./pages/PlaylistsPage').then((m) => ({ default: m.PlaylistsPage }))
|
||||
const PlaylistDetailPageLoader = () =>
|
||||
import('./pages/PlaylistDetailPage').then((m) => ({ default: m.PlaylistDetailPage }))
|
||||
const MediaDetailPageLoader = () =>
|
||||
import('./pages/MediaDetailPage').then((m) => ({ default: m.MediaDetailPage }))
|
||||
const PlayerPageLoader = () => import('./pages/PlayerPage').then((m) => ({ default: m.PlayerPage }))
|
||||
const WatchHistoryPageLoader = () =>
|
||||
import('./pages/WatchHistoryPage').then((m) => ({ default: m.WatchHistoryPage }))
|
||||
|
||||
const HomePage = lazy(HomePageLoader)
|
||||
const LibraryPage = lazy(LibraryPageLoader)
|
||||
const LibrariesPage = lazy(LibrariesPageLoader)
|
||||
const FavouritesPage = lazy(FavouritesPageLoader)
|
||||
const PlaylistsPage = lazy(PlaylistsPageLoader)
|
||||
const PlaylistDetailPage = lazy(PlaylistDetailPageLoader)
|
||||
const MediaDetailPage = lazy(MediaDetailPageLoader)
|
||||
const PlayerPage = lazy(PlayerPageLoader)
|
||||
const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage })))
|
||||
const ProfilePage = lazy(() => import('./pages/ProfilePage').then((m) => ({ default: m.ProfilePage })))
|
||||
const DlnaPage = lazy(() => import('./pages/DlnaPage').then((m) => ({ default: m.DlnaPage })))
|
||||
const FileManagerPage = lazy(() =>
|
||||
import('./pages/FileManagerPage').then((m) => ({ default: m.FileManagerPage })),
|
||||
)
|
||||
const WatchHistoryPage = lazy(() =>
|
||||
import('./pages/WatchHistoryPage').then((m) => ({ default: m.WatchHistoryPage })),
|
||||
)
|
||||
const WatchHistoryPage = lazy(WatchHistoryPageLoader)
|
||||
const PosterWallPage = lazy(() => import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage })))
|
||||
const ProfileManagementPage = lazy(() =>
|
||||
import('./pages/ProfileManagementPage').then((m) => ({ default: m.ProfileManagementPage })),
|
||||
@@ -37,6 +46,34 @@ const StrmUploadQueuePage = lazy(() =>
|
||||
const ScraperQueuePage = lazy(() => import('./pages/ScraperQueuePage').then((m) => ({ default: m.ScraperQueuePage })))
|
||||
const TaskQueuePage = lazy(() => import('./pages/TaskQueuePage').then((m) => ({ default: m.TaskQueuePage })))
|
||||
|
||||
// 常用页面的路由 chunk 空闲预取:应用加载完成后浏览器一空闲就把浏览路径
|
||||
// (库列表/库详情/媒体详情/播放/收藏/历史等)的 chunk 拉下来,
|
||||
// 首次点击进入时不再出现"加载中…"等 chunk 下载。失败静默(导航时会重试)。
|
||||
let prefetchStarted = false
|
||||
export function prefetchCommonRouteChunks() {
|
||||
if (prefetchStarted || typeof window === 'undefined') return
|
||||
prefetchStarted = true
|
||||
const loaders = [
|
||||
LibrariesPageLoader,
|
||||
LibraryPageLoader,
|
||||
MediaDetailPageLoader,
|
||||
PlayerPageLoader,
|
||||
FavouritesPageLoader,
|
||||
WatchHistoryPageLoader,
|
||||
PlaylistsPageLoader,
|
||||
]
|
||||
const run = () => {
|
||||
for (const load of loaders) {
|
||||
load().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
requestIdleCallback(run, { timeout: 5000 })
|
||||
} else {
|
||||
window.setTimeout(run, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
export type AppRoute = {
|
||||
path?: string
|
||||
index?: boolean
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { prefetchCommonRouteChunks } from '../appRoutes'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePlayProfileStore } from '../stores/playProfile'
|
||||
import {
|
||||
@@ -37,6 +39,13 @@ function isMediaView(pathname: string, search: string): boolean {
|
||||
export function Layout() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
// 登录后的外壳挂载即开始空闲预取常用页面的路由 chunk,
|
||||
// 让首次点击进入各页面时不出现"加载中…"等 chunk 下载。
|
||||
useEffect(() => {
|
||||
prefetchCommonRouteChunks()
|
||||
}, [])
|
||||
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const activeProfileId = usePlayProfileStore((s) => s.activeProfileId)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-rea
|
||||
import { imageURL } from '../api/client'
|
||||
import { mediaAPI } from '../api/library'
|
||||
import type { Media, PlayProfile, User } from '../types'
|
||||
import { favouriteMediaLink } from '../utils/mediaNavigation'
|
||||
import { resolveHeaderBack } from './layoutNavigation'
|
||||
import { LayoutThemeToggle } from './LayoutThemeToggle'
|
||||
import { LayoutUserMenu } from './LayoutUserMenu'
|
||||
@@ -149,17 +150,17 @@ function LayoutHeaderSearch() {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handleSelect = (mediaId: string) => {
|
||||
const handleSelect = (item: Media) => {
|
||||
setIsOpen(false)
|
||||
setQuery('')
|
||||
navigate(`/media/${mediaId}`)
|
||||
navigate(favouriteMediaLink(item))
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
} else if (e.key === 'Enter' && results.length > 0) {
|
||||
handleSelect(results[0].id)
|
||||
handleSelect(results[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +221,7 @@ function LayoutHeaderSearch() {
|
||||
{results.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelect(item.id)}
|
||||
onClick={() => handleSelect(item)}
|
||||
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)]">
|
||||
|
||||
@@ -202,7 +202,7 @@ function ManualScrapeCandidateRow({
|
||||
<div className="flex flex-col gap-4 rounded-xl border border-sand-200 bg-white p-3 shadow-sm sm:flex-row">
|
||||
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-lg bg-sand-100">
|
||||
{item.poster_url ? (
|
||||
<img src={imageURL(item.poster_url)} alt={item.title} className="h-full w-full object-cover" referrerPolicy="no-referrer" />
|
||||
<img src={imageURL(item.poster_url)} alt={item.title} loading="lazy" decoding="async" className="h-full w-full object-cover" referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-xs text-sand-500">无海报</div>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { memo, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Film, Play, Layers, Star } from 'lucide-react'
|
||||
import { imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
export const MediaCard = ({
|
||||
media, progress, count, rating, linkTo, onClick, actions, compact,
|
||||
// memo:父级状态变化(如轮播切图、其它卡片操作)不再级联重渲染所有卡片。
|
||||
// 注意 actions/renderActions 必须引用稳定(用 renderActions 传函数)memo 才生效。
|
||||
export const MediaCard = memo(function MediaCard({
|
||||
media, progress, count, rating, linkTo, onClick, actions, renderActions, compact,
|
||||
}: {
|
||||
media: Media
|
||||
progress?: number
|
||||
@@ -15,25 +16,26 @@ export const MediaCard = ({
|
||||
linkTo?: string
|
||||
onClick?: () => void
|
||||
actions?: ReactNode
|
||||
renderActions?: (media: Media) => ReactNode
|
||||
compact?: boolean
|
||||
}) => {
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const href = linkTo ?? `/media/${media.id}`
|
||||
const [posterFit, setPosterFit] = useState<'cover' | 'contain'>('cover')
|
||||
const posterSrc = imageURL(media.poster_url, media.updated_at)
|
||||
const displayRating = rating ?? media.rating
|
||||
const versionCount = media.versions?.length ?? 0
|
||||
// renderActions 延迟到卡片自身渲染时才调用,保证 memo 生效
|
||||
const actionContent = actions ?? renderActions?.(media)
|
||||
|
||||
useEffect(() => {
|
||||
setPosterFit('cover')
|
||||
}, [media.poster_url, media.updated_at])
|
||||
|
||||
const card = (
|
||||
<motion.div
|
||||
<div
|
||||
ref={ref}
|
||||
whileHover={{ scale: 1.04, y: -6 }}
|
||||
transition={{ type: 'spring', stiffness: 280, damping: 22 }}
|
||||
className="relative overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:border-brand-500/40 hover:shadow-[0_12px_32px_var(--app-shadow)]"
|
||||
className="relative overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:scale-[1.04] hover:-translate-y-1.5 hover:border-brand-500/40 hover:shadow-[0_12px_32px_var(--app-shadow)]"
|
||||
>
|
||||
{/* Poster Wrapper */}
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-[var(--app-panel-soft)]">
|
||||
@@ -95,16 +97,11 @@ export const MediaCard = ({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Premium Hover Overlay */}
|
||||
{/* Premium Hover Overlay(CSS 过渡替代 framer-motion 逐卡动画实例) */}
|
||||
<div className={`absolute inset-0 bg-gradient-to-t from-[#111827]/90 via-[#111827]/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end ${
|
||||
compact ? 'p-3' : 'p-4'
|
||||
}`}>
|
||||
<motion.div
|
||||
initial={{ y: 15, opacity: 0 }}
|
||||
whileHover={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="space-y-2 translate-y-4 transition-transform duration-200 group-hover:translate-y-0">
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-xl bg-brand-500 px-4 py-2 font-bold text-white shadow-md shadow-brand-500/20 ${
|
||||
compact ? 'py-1.5 text-[11px]' : 'text-xs py-2'
|
||||
}`}>
|
||||
@@ -116,15 +113,15 @@ export const MediaCard = ({
|
||||
{media.overview || "暂无简介内容"}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar overlay */}
|
||||
{progress !== undefined && progress > 0 && progress < 1 && (
|
||||
<div className="absolute inset-x-0 bottom-0 h-1.5 bg-[var(--app-hover)]">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-brand-400 to-brand-500 rounded-r-full transition-all duration-300"
|
||||
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-brand-400 to-brand-500 rounded-r-full transition-all duration-300"
|
||||
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -148,7 +145,7 @@ export const MediaCard = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (onClick) {
|
||||
@@ -157,23 +154,23 @@ export const MediaCard = ({
|
||||
<button type="button" onClick={onClick} className="block w-full text-left">
|
||||
{card}
|
||||
</button>
|
||||
{actions && (
|
||||
{actionContent && (
|
||||
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
{actions}
|
||||
{actionContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (actions) {
|
||||
if (actionContent) {
|
||||
return (
|
||||
<div className="group relative block">
|
||||
<Link to={href} className="block">
|
||||
{card}
|
||||
</Link>
|
||||
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
{actions}
|
||||
{actionContent}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -184,4 +181,4 @@ export const MediaCard = ({
|
||||
{card}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
import { usePermissionStore } from '../stores/permissions'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
interface PermissionGuardProps {
|
||||
permission: string
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
requireSuperUser?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* PermissionGuard 组件用于根据用户权限控制内容显示。
|
||||
*
|
||||
* @param permission - 需要的权限键
|
||||
* @param children - 有权限时显示的内容
|
||||
* @param fallback - 无权限时显示的内容(可选,默认不显示)
|
||||
* @param requireSuperUser - 是否要求超级用户(admin/plus)绕过权限检查
|
||||
*/
|
||||
export function PermissionGuard({
|
||||
permission,
|
||||
children,
|
||||
fallback = null,
|
||||
requireSuperUser = false,
|
||||
}: PermissionGuardProps) {
|
||||
const { hasPermission, isSuper, permissions, isLoading } = usePermissionStore()
|
||||
const tier = useAuthStore((state) => state.tier)
|
||||
const role = useAuthStore((state) => state.user?.role)
|
||||
|
||||
// 超级用户(admin 或 plus)默认有所有权限
|
||||
if (isSuper || tier === 'plus' || role === 'admin') {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// 如果 requireSuperUser 为 true 且用户不是超级用户,则不显示
|
||||
if (requireSuperUser && !isSuper) {
|
||||
return <>{fallback}</>
|
||||
}
|
||||
|
||||
// 加载中时显示 fallback
|
||||
if (isLoading && Object.keys(permissions).length === 0) {
|
||||
return <>{fallback}</>
|
||||
}
|
||||
|
||||
// 检查具体权限
|
||||
if (hasPermission(permission)) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return <>{fallback}</>
|
||||
}
|
||||
@@ -28,18 +28,25 @@ export function usePermission(
|
||||
options: { autoFetch?: boolean } = {}
|
||||
): boolean {
|
||||
const { autoFetch = true } = options
|
||||
const { hasPermission, isSuper, permissions, isLoading, fetchPermissions } = usePermissionStore()
|
||||
// selector 订阅:store 任何无关字段变化不会触发本组件重渲染
|
||||
const hasPermission = usePermissionStore((s) => s.hasPermission)
|
||||
const isSuper = usePermissionStore((s) => s.isSuper)
|
||||
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
|
||||
const tier = useAuthStore((state) => state.tier)
|
||||
const role = useAuthStore((state) => state.user?.role)
|
||||
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
||||
const hasSuperAccess = isSuper || tier === 'plus' || role === 'admin'
|
||||
|
||||
// 权限未加载时自动获取
|
||||
// 权限未加载时自动获取;用 getState() 读最新快照而不是渲染闭包,
|
||||
// 同一次 commit 内挂载的多个消费方也只会有一个发出请求(store 内还有 inflight 去重兜底)。
|
||||
useEffect(() => {
|
||||
if (!hasSuperAccess && isAuthenticated && autoFetch && Object.keys(permissions).length === 0 && !isLoading) {
|
||||
fetchPermissions()
|
||||
if (!hasSuperAccess && isAuthenticated && autoFetch) {
|
||||
const { permissions, isLoading } = usePermissionStore.getState()
|
||||
if (Object.keys(permissions).length === 0 && !isLoading) {
|
||||
fetchPermissions()
|
||||
}
|
||||
}
|
||||
}, [autoFetch, fetchPermissions, hasSuperAccess, isAuthenticated, isLoading, permissions])
|
||||
}, [autoFetch, fetchPermissions, hasSuperAccess, isAuthenticated])
|
||||
|
||||
// 超级用户有所有权限
|
||||
if (hasSuperAccess) {
|
||||
@@ -78,7 +85,11 @@ export function usePermission(
|
||||
* ```
|
||||
*/
|
||||
export function usePermissions() {
|
||||
const { permissions, isSuper, isLoading, fetchPermissions } = usePermissionStore()
|
||||
// selector 订阅:只关注 permissions/isSuper/isLoading 变化
|
||||
const permissions = usePermissionStore((s) => s.permissions)
|
||||
const isSuper = usePermissionStore((s) => s.isSuper)
|
||||
const isLoading = usePermissionStore((s) => s.isLoading)
|
||||
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
|
||||
const tier = useAuthStore((state) => state.tier)
|
||||
const role = useAuthStore((state) => state.user?.role)
|
||||
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
||||
|
||||
@@ -2,6 +2,74 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ── 自托管 Web 字体(OFL 许可,latin 子集,public/fonts/)──
|
||||
取代原先 fonts.googleapis.com 的渲染阻塞外链:自托管服务常部署在
|
||||
无法访问 Google 的网络,外链会拖住首屏渲染直到超时。 */
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url('/fonts/geist-300.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/geist-400.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/fonts/geist-500.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/fonts/geist-600.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/fonts/geist-700.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/jetbrainsmono-400.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/fonts/jetbrainsmono-500.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/fonts/jetbrainsmono-600.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
html, body, #root { height: 100%; }
|
||||
|
||||
:root {
|
||||
|
||||
@@ -169,7 +169,7 @@ function LibraryTableRow({ library, dragging, dragOver, onDragStart, onDragOver,
|
||||
</td>
|
||||
<td className="py-2 pr-3 font-medium text-ink-600">
|
||||
<div className="flex items-center gap-2">
|
||||
{library.cover_url && <img src={library.cover_url} alt="" className="h-10 w-8 rounded object-cover" />}
|
||||
{library.cover_url && <img src={library.cover_url} alt="" loading="lazy" decoding="async" className="h-10 w-8 rounded object-cover" />}
|
||||
<span>{library.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -31,7 +31,10 @@ export function AdminUsersPanel() {
|
||||
}
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
const timer = window.setInterval(() => refresh().catch(() => undefined), 10000)
|
||||
const timer = window.setInterval(() => {
|
||||
if (document.hidden) return
|
||||
refresh().catch(() => undefined)
|
||||
}, 10000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { historyAPI } from '../api/history'
|
||||
import type { HistoryItem } from '../api/playback'
|
||||
import type { Library, Media } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import { fetchLibraries, peekLibraries } from '../utils/libraryCache'
|
||||
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||
import { sortByPinnedIds } from '../utils/pinnedLibraries'
|
||||
import {
|
||||
@@ -18,7 +19,6 @@ import {
|
||||
} from './HomePageSections'
|
||||
|
||||
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
|
||||
const asArray = <T,>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : [])
|
||||
|
||||
export function HomePage() {
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
@@ -30,17 +30,24 @@ export function HomePage() {
|
||||
const [historyLoading, setHistoryLoading] = useState(true)
|
||||
const { pinnedIds } = usePinnedLibraries()
|
||||
|
||||
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)
|
||||
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。
|
||||
// 会话内已有缓存则先用缓存立即渲染,后台仍刷新一次兜底。
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
libraryAPI
|
||||
.list()
|
||||
.then((rows) => asArray<Library>(rows))
|
||||
const cached = peekLibraries()
|
||||
if (cached && cached.length > 0) {
|
||||
setLibraries(cached)
|
||||
setLibrariesLoading(false)
|
||||
}
|
||||
|
||||
fetchLibraries()
|
||||
.then((rows) => (Array.isArray(rows) ? rows : []))
|
||||
.catch(() => [] as Library[])
|
||||
.then((libs) => {
|
||||
if (cancelled) return
|
||||
setLibraries(libs)
|
||||
// 失败兜底返回空数组时,不要覆盖已先行渲染的缓存数据
|
||||
setLibraries((prev) => (libs.length === 0 && prev.length > 0 ? prev : libs))
|
||||
setLibrariesLoading(false)
|
||||
})
|
||||
|
||||
@@ -80,11 +87,11 @@ export function HomePage() {
|
||||
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
|
||||
|
||||
// 按需拉取卡片预览管理
|
||||
const [fetchedLibIds, setFetchedLibIds] = useState<Set<string>>(new Set())
|
||||
const fetchedLibIdsRef = useRef<Set<string>>(new Set())
|
||||
const fetchingRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const fetchPreviews = useCallback(async (ids: string[]) => {
|
||||
const targets = ids.filter((id) => !fetchedLibIds.has(id) && !fetchingRef.current.has(id))
|
||||
const targets = ids.filter((id) => !fetchedLibIdsRef.current.has(id) && !fetchingRef.current.has(id))
|
||||
if (targets.length === 0) return
|
||||
targets.forEach((id) => fetchingRef.current.add(id))
|
||||
|
||||
@@ -104,16 +111,12 @@ export function HomePage() {
|
||||
} catch {
|
||||
// 容错
|
||||
} finally {
|
||||
setFetchedLibIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
targets.forEach((id) => {
|
||||
next.add(id)
|
||||
fetchingRef.current.delete(id)
|
||||
})
|
||||
return next
|
||||
targets.forEach((id) => {
|
||||
fetchedLibIdsRef.current.add(id)
|
||||
fetchingRef.current.delete(id)
|
||||
})
|
||||
}
|
||||
}, [fetchedLibIds])
|
||||
}, [])
|
||||
|
||||
// 3. 首屏优先加载:轮播图库 + 媒体库卡片区前 20 个库 + 首屏前 3 个内容行
|
||||
useEffect(() => {
|
||||
|
||||
@@ -461,6 +461,7 @@ export function HomeLibrariesSection({
|
||||
key={lib.id}
|
||||
to={`/library/${lib.id}`}
|
||||
className="group relative flex flex-col justify-between overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3 transition-all duration-300 hover:-translate-y-1 hover:border-brand-500/50 hover:bg-[var(--app-hover)]/40 hover:shadow-lg hover:shadow-brand-500/10"
|
||||
title={lib.name}
|
||||
>
|
||||
{/* 封面图片展示区:和媒体库页面一样,显示设置好的或生成的图片 */}
|
||||
<div
|
||||
@@ -496,7 +497,10 @@ export function HomeLibrariesSection({
|
||||
|
||||
{/* 媒体库信息 */}
|
||||
<div className="mt-3 flex flex-col justify-between">
|
||||
<h3 className="truncate font-display text-sm font-bold text-[var(--app-text)] group-hover:text-brand-500">
|
||||
<h3
|
||||
className="line-clamp-2 break-words font-display text-sm font-bold text-[var(--app-text)] group-hover:text-brand-500"
|
||||
title={lib.name}
|
||||
>
|
||||
{lib.name}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-[var(--app-muted)]">
|
||||
@@ -542,7 +546,7 @@ export function HomeLibraryRowSection({
|
||||
{TYPE_ICONS[library.type] || <FolderOpen size={18} />}
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]" title={library.name}>
|
||||
{library.name}
|
||||
</h2>
|
||||
<span className="text-xs text-[var(--app-muted)]">
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type { LibraryPreview } from './librariesPageModel'
|
||||
import type { Library } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache'
|
||||
import { sortLibraryPreviews } from '../utils/pinnedLibraries'
|
||||
|
||||
export function LibrariesPage() {
|
||||
@@ -23,11 +24,11 @@ export function LibrariesPage() {
|
||||
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false)
|
||||
const [repairMsg, setRepairMsg] = useState('')
|
||||
|
||||
const [fetchedLibIds, setFetchedLibIds] = useState<Set<string>>(new Set())
|
||||
const fetchedLibIdsRef = useRef<Set<string>>(new Set())
|
||||
const fetchingRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const fetchPreviews = useCallback(async (ids: string[]) => {
|
||||
const targets = ids.filter((id) => !fetchedLibIds.has(id) && !fetchingRef.current.has(id))
|
||||
const targets = ids.filter((id) => !fetchedLibIdsRef.current.has(id) && !fetchingRef.current.has(id))
|
||||
if (targets.length === 0) return
|
||||
targets.forEach((id) => fetchingRef.current.add(id))
|
||||
|
||||
@@ -46,21 +47,25 @@ export function LibrariesPage() {
|
||||
} catch {
|
||||
// 容错
|
||||
} finally {
|
||||
setFetchedLibIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
targets.forEach((id) => {
|
||||
next.add(id)
|
||||
fetchingRef.current.delete(id)
|
||||
})
|
||||
return next
|
||||
targets.forEach((id) => {
|
||||
fetchedLibIdsRef.current.add(id)
|
||||
fetchingRef.current.delete(id)
|
||||
})
|
||||
}
|
||||
}, [fetchedLibIds])
|
||||
}, [])
|
||||
|
||||
const loadLibraries = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const loadLibraries = useCallback(async (options?: { force?: boolean }) => {
|
||||
if (options?.force) invalidateLibraries()
|
||||
// 会话缓存命中时先行渲染,避免每次进入都白等一轮请求
|
||||
const cached = peekLibraries()
|
||||
if (cached) {
|
||||
setLibraries(cached)
|
||||
setLoading(false)
|
||||
} else {
|
||||
setLoading(true)
|
||||
}
|
||||
try {
|
||||
const libs = await libraryAPI.list()
|
||||
const libs = await fetchLibraries()
|
||||
setLibraries(libs)
|
||||
// 优先拉取入口卡片网格当前页(前 20 个库)的预览
|
||||
const topIds = libs.slice(0, 20).map((l) => l.id)
|
||||
@@ -86,7 +91,7 @@ export function LibrariesPage() {
|
||||
|
||||
const handleManageLibraries = async () => {
|
||||
await openManageLibrariesDialog()
|
||||
await loadLibraries()
|
||||
await loadLibraries({ force: true })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -332,6 +332,7 @@ function LibraryEntryCard({
|
||||
<Link
|
||||
to={`/library/${library.id}`}
|
||||
className="flex min-w-0 flex-1"
|
||||
title={library.name}
|
||||
>
|
||||
<div className={`grid h-20 w-24 sm:h-24 sm:w-36 shrink-0 gap-1 overflow-hidden rounded-xl sm:rounded-2xl bg-[linear-gradient(135deg,#fff7ed,#f8fafc)] ${artwork.length > 1 ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{artwork.length > 0 ? (
|
||||
@@ -362,7 +363,10 @@ function LibraryEntryCard({
|
||||
{preview.total.toLocaleString()} 个条目
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="line-clamp-2 font-display text-sm font-bold leading-tight text-ink-600 group-hover:text-brand-600 sm:truncate sm:text-xl sm:font-black">
|
||||
<h2
|
||||
className="line-clamp-2 break-words font-display text-sm font-bold leading-snug text-ink-600 group-hover:text-brand-600 sm:text-base lg:text-lg sm:font-black"
|
||||
title={library.name}
|
||||
>
|
||||
{library.name}
|
||||
{pinned ? <span className="ml-1.5 align-middle text-[10px] font-bold text-brand-600">置顶</span> : null}
|
||||
</h2>
|
||||
@@ -395,7 +399,10 @@ function LibraryShelf({ preview, pinned }: { preview: LibraryPreview; pinned?: b
|
||||
{TYPE_ICONS[library.type] ?? <LibraryIcon size={14} />}
|
||||
{TYPE_LABELS[library.type] ?? library.type}
|
||||
</div>
|
||||
<h2 className="line-clamp-2 font-display text-xl sm:text-2xl font-black text-ink-600 sm:truncate">
|
||||
<h2
|
||||
className="line-clamp-2 break-words font-display text-xl sm:text-2xl font-black text-ink-600"
|
||||
title={library.name}
|
||||
>
|
||||
{library.name}
|
||||
{pinned ? <span className="ml-2 align-middle text-xs font-bold text-brand-600">置顶</span> : null}
|
||||
</h2>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { memo, type ReactNode } from 'react'
|
||||
import { Film } from 'lucide-react'
|
||||
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
@@ -33,7 +33,9 @@ export function LibraryMediaSections({
|
||||
{!isSeries && items.length > 0 && (
|
||||
<MediaGrid count={items.length} renderItem={(index) => {
|
||||
const media = items[index]
|
||||
return <MediaCard key={media.id} media={media} actions={cardActions(media)} />
|
||||
// renderActions 传函数引用:MediaCard 重渲染时才构建操作按钮,
|
||||
// 配合 memo,父级无关状态变化不再级联重渲染全部卡片。
|
||||
return <MediaCard key={media.id} media={media} renderActions={cardActions} />
|
||||
}} />
|
||||
)}
|
||||
|
||||
@@ -45,12 +47,11 @@ export function LibraryMediaSections({
|
||||
<MediaGrid count={seriesCards.length} renderItem={(index) => {
|
||||
const series = seriesCards[index]
|
||||
return (
|
||||
<MediaCard
|
||||
<SeriesCardItem
|
||||
key={series.key}
|
||||
media={series.rep}
|
||||
count={series.count}
|
||||
actions={cardActions(series.rep)}
|
||||
onClick={() => onSeriesClick(series)}
|
||||
series={series}
|
||||
cardActions={cardActions}
|
||||
onSeriesClick={onSeriesClick}
|
||||
/>
|
||||
)
|
||||
}} />
|
||||
@@ -63,6 +64,27 @@ export function LibraryMediaSections({
|
||||
)
|
||||
}
|
||||
|
||||
// 独立 memo 组件:onClick 闭包在其内部创建,props 均为稳定引用,
|
||||
// 父级重渲染不会穿透到每张剧集卡片。
|
||||
const SeriesCardItem = memo(function SeriesCardItem({
|
||||
series,
|
||||
cardActions,
|
||||
onSeriesClick,
|
||||
}: {
|
||||
series: SeriesCard
|
||||
cardActions: (media: Media) => ReactNode
|
||||
onSeriesClick: (series: SeriesCard) => void
|
||||
}) {
|
||||
return (
|
||||
<MediaCard
|
||||
media={series.rep}
|
||||
count={series.count}
|
||||
renderActions={cardActions}
|
||||
onClick={() => onSeriesClick(series)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
function MediaGrid({ count, renderItem }: { count: number; renderItem: (index: number) => ReactNode }) {
|
||||
if (count <= VIRTUALIZE_THRESHOLD) {
|
||||
return <div className={MEDIA_GRID_CLASS}>{Array.from({ length: count }, (_, index) => renderItem(index))}</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState, Fragment, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState, Fragment, type ReactNode } from 'react'
|
||||
import { useLocation, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
@@ -175,7 +175,7 @@ export function LibraryPage() {
|
||||
setManualMovie,
|
||||
})
|
||||
|
||||
const handleToggleFavourite = async (mediaID: string) => {
|
||||
const handleToggleFavourite = useCallback(async (mediaID: string) => {
|
||||
if (!canFavorite || favouriteBusyID) return
|
||||
setFavouriteBusyID(mediaID)
|
||||
try {
|
||||
@@ -183,9 +183,11 @@ export function LibraryPage() {
|
||||
} finally {
|
||||
setFavouriteBusyID('')
|
||||
}
|
||||
}
|
||||
}, [canFavorite, favouriteBusyID, toggleFavourite])
|
||||
|
||||
const cardActions = (media: Media): ReactNode => {
|
||||
// useCallback 稳定引用:配合 MediaCard 的 memo,仅在收藏状态/操作集变化时
|
||||
// 才让卡片重渲染。
|
||||
const cardActions = useCallback((media: Media): ReactNode => {
|
||||
const actions: ReactNode[] = []
|
||||
if (canFavorite) {
|
||||
actions.push(
|
||||
@@ -206,7 +208,7 @@ export function LibraryPage() {
|
||||
}
|
||||
if (actions.length === 0) return undefined
|
||||
return <>{actions}</>
|
||||
}
|
||||
}, [canFavorite, favouriteBusyID, handleToggleFavourite, isFavourite, movieActions])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
@@ -70,6 +70,8 @@ export function LibrarySeriesDetailHeader({
|
||||
<img
|
||||
src={imageURL(series.rep.poster_url, series.rep.updated_at)}
|
||||
alt={series.rep.title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="aspect-[2/3] w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function MediaDetailPage() {
|
||||
media={media}
|
||||
isAdmin={role === 'admin'}
|
||||
favourite={detail.favourite}
|
||||
playTargetId={detail.firstPlayableEpisode?.id}
|
||||
playTargetId={media.id || detail.firstPlayableEpisode?.id}
|
||||
scrapeEpisodeArtwork={detail.scrapeEpisodeArtwork}
|
||||
onToggleFavourite={detail.toggleFavourite}
|
||||
onScrapeEpisodeArtworkChange={detail.setScrapeEpisodeArtwork}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function MediaDetailPlaybackActions({
|
||||
onToggleFavourite,
|
||||
playTargetId,
|
||||
}: MediaDetailPlaybackActionsProps) {
|
||||
const targetId = playTargetId || media.id
|
||||
const targetId = media.id || playTargetId || ''
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link to={`/play/${targetId}`} state={{ from: `/media/${media.id}` }} className="btn-primary px-6 py-3.5 shadow-sm">
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
AlertCircle,
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
Film,
|
||||
Image as ImageIcon,
|
||||
@@ -26,6 +23,8 @@ import { imageURL } from '../api/client'
|
||||
import { scraperAPI } from '../api/scraper'
|
||||
import type { ScrapeQueueSnapshot, ScrapeTask, ScrapeTaskStatus } from '../types/scraper'
|
||||
import { apiErrorMessage, formatTime, taskStatusMeta } from './StrmManagePage'
|
||||
import { PROVIDER_LABELS, ScrapeDetailModal } from './scraper-queue/ScrapeDetailModal'
|
||||
import { copyToClipboard, useTaskSelection } from './queue-shared'
|
||||
|
||||
const FILTERS: { key: 'all' | ScrapeTaskStatus; label: string; icon: typeof Clock; color: string }[] = [
|
||||
{ key: 'all', label: '全部', icon: Sparkles, color: 'text-ink-600' },
|
||||
@@ -36,14 +35,6 @@ const FILTERS: { key: 'all' | ScrapeTaskStatus; label: string; icon: typeof Cloc
|
||||
{ key: 'canceled', label: '已取消', icon: Ban, color: 'text-amber-500' },
|
||||
]
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
tmdb: 'TheMovieDB',
|
||||
douban: '豆瓣 Douban',
|
||||
bangumi: 'Bangumi 番组计划',
|
||||
thetvdb: 'TheTVDB',
|
||||
metatube: 'MetaTube',
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<string, ReactNode> = {
|
||||
movie: <Film size={14} className="text-blue-500" />,
|
||||
tv: <Tv size={14} className="text-purple-500" />,
|
||||
@@ -51,13 +42,6 @@ const TYPE_ICONS: Record<string, ReactNode> = {
|
||||
adult: <Film size={14} className="text-rose-500" />,
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
movie: '电影',
|
||||
tv: '剧集',
|
||||
anime: '动漫',
|
||||
adult: 'Adult',
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
||||
@@ -70,7 +54,7 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
const [batchBusy, setBatchBusy] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const { selectedIds, setSelectedIds, reset: clearSelection, toggleRow: toggleSelectRow, toggleAll: toggleAllIds } = useTaskSelection()
|
||||
const [detailTask, setDetailTask] = useState<ScrapeTask | null>(null)
|
||||
|
||||
const refresh = useCallback(
|
||||
@@ -103,19 +87,17 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return
|
||||
const timer = setInterval(() => {
|
||||
if (document.hidden) return
|
||||
refresh().catch(() => undefined)
|
||||
}, 3000)
|
||||
return () => clearInterval(timer)
|
||||
}, [autoRefresh, refresh])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds(new Set())
|
||||
}, [filter, page])
|
||||
clearSelection()
|
||||
}, [filter, page, clearSelection])
|
||||
|
||||
const copyText = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.success(`已复制${label}`)
|
||||
}
|
||||
const copyText = copyToClipboard
|
||||
|
||||
// Row actions
|
||||
const cancelTask = async (task: ScrapeTask) => {
|
||||
@@ -237,22 +219,7 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
||||
const allCurrentChecked =
|
||||
filteredTasks.length > 0 && filteredTasks.every((t) => selectedIds.has(t.id))
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (allCurrentChecked) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(filteredTasks.map((t) => t.id)))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectRow = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
const toggleSelectAll = () => toggleAllIds(filteredTasks.map((t) => t.id))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -950,216 +917,3 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrapeDetailModal({
|
||||
task,
|
||||
onClose,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onCopy,
|
||||
}: {
|
||||
task: ScrapeTask
|
||||
onClose: () => void
|
||||
onRetry: (t: ScrapeTask) => void
|
||||
onCancel: (t: ScrapeTask) => void
|
||||
onDelete: (t: ScrapeTask) => void
|
||||
onCopy: (text: string, label: string) => void
|
||||
}) {
|
||||
const status = taskStatusMeta(task.status)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-xl rounded-3xl border border-gray-200 bg-white shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={16} className="text-brand-500" />
|
||||
<h3 className="font-display text-base font-bold text-ink-600">刮削任务详情</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1 text-gray-400 hover:bg-gray-100 hover:text-ink-600 transition"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-6 max-h-[70vh] overflow-y-auto text-xs">
|
||||
{/* Matched Poster / Info Banner */}
|
||||
{task.matched_title ? (
|
||||
<div className="flex gap-4 rounded-2xl border border-brand-500/20 bg-primary-400/5 p-4">
|
||||
{task.poster_url && (
|
||||
<img
|
||||
src={imageURL(task.poster_url)}
|
||||
alt=""
|
||||
className="h-28 w-20 rounded-xl object-cover border border-brand-500/30 shadow-md shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded bg-brand-500 px-2 py-0.5 text-[10px] font-bold text-white uppercase">
|
||||
已匹配
|
||||
</span>
|
||||
{task.provider && (
|
||||
<span className="rounded border border-gray-200 bg-white px-2 py-0.5 text-[10px] font-semibold text-ink-600">
|
||||
{PROVIDER_LABELS[task.provider] ?? task.provider}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="font-display text-base font-extrabold text-ink-600 truncate">
|
||||
{task.matched_title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-3 text-sand-500 text-[11px]">
|
||||
{task.matched_year > 0 && <span>年份:{task.matched_year}</span>}
|
||||
<span>类型:{TYPE_LABELS[task.media_type] ?? task.media_type}</span>
|
||||
</div>
|
||||
{task.media_id && (
|
||||
<Link
|
||||
to={`/media/${task.media_id}`}
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-1 text-brand-500 font-semibold hover:underline pt-1"
|
||||
>
|
||||
<span>在媒体详情中查看</span>
|
||||
<ExternalLink size={11} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Media Info Box */}
|
||||
<div className="rounded-2xl border border-gray-100 bg-gray-50/70 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">原始媒体标题</span>
|
||||
<span className="font-bold text-ink-600 select-all">{task.media_title}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">所属媒体库</span>
|
||||
<span className="font-medium text-ink-100">{task.library_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">媒体库类型</span>
|
||||
<span className="font-medium text-ink-100">
|
||||
{TYPE_LABELS[task.media_type] ?? task.media_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">当前状态</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${status.cls}`}
|
||||
>
|
||||
{task.status === 'done' ? '已匹配' : task.status === 'failed' ? '未匹配' : status.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">剧照/海报刮削</span>
|
||||
<span className="font-medium text-ink-100">
|
||||
{task.episode_images ? '开启' : '关闭'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File path */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sand-500 font-medium">
|
||||
<span>磁盘文件路径</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.media_path, '文件路径')}
|
||||
className="inline-flex items-center gap-1 text-brand-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 font-mono text-[11px] text-ink-600 break-all select-all">
|
||||
{task.media_path}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message Box */}
|
||||
{task.error && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-rose-500 font-medium">
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertCircle size={13} /> 刮削未匹配 / 异常详情
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.error, '错误日志')}
|
||||
className="inline-flex items-center gap-1 text-rose-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制日志
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50/60 p-3 font-mono text-[11px] text-rose-700 break-all select-all whitespace-pre-wrap">
|
||||
{task.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-2 text-[11px] text-sand-500 border-t border-gray-100">
|
||||
<div>入队时间:{formatTime(task.created_at)}</div>
|
||||
{task.started_at && <div>开始刮削:{formatTime(task.started_at)}</div>}
|
||||
{task.finished_at && <div>完成时间:{formatTime(task.finished_at)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4 bg-gray-50/50">
|
||||
<div>
|
||||
{(task.status === 'done' ||
|
||||
task.status === 'failed' ||
|
||||
task.status === 'canceled') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(task)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-rose-200 bg-white px-3 py-2 text-xs font-semibold text-rose-500 hover:bg-rose-50 transition"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
删除记录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{(task.status === 'pending' || task.status === 'running') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCancel(task)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-amber-200 bg-white px-4 py-2 text-xs font-semibold text-amber-600 hover:bg-amber-50 transition"
|
||||
>
|
||||
<Ban size={13} />
|
||||
取消任务
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRetry(task)}
|
||||
className="neon-button !py-2 !px-4 text-xs font-semibold"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
重新刮削
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-xs font-semibold text-ink-100 hover:bg-gray-50 transition"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FormEvent, useEffect, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ChevronRight,
|
||||
Cloud,
|
||||
ExternalLink,
|
||||
FolderPlus,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
Plus,
|
||||
QrCode,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
Tv,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
import { strmAPI, type Strm115Source, type StrmRemoteEntry } from '../api/strm'
|
||||
import { strmAPI, type StrmRemoteEntry } from '../api/strm'
|
||||
import type { SettingDef } from './SettingsRow'
|
||||
import { SettingRow } from './SettingsRow'
|
||||
import type { StrmAccount, StrmSyncPath, StrmSyncPathInput, StrmProvider } from '../types/strm'
|
||||
import { STRM_PROVIDER_LABELS } from '../types/strm'
|
||||
import { apiErrorMessage } from './StrmManagePage'
|
||||
import { Strm115AuthPanel } from './strm-dialogs/Strm115AuthPanel'
|
||||
import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog'
|
||||
import {
|
||||
defaultEmbyRemoteLines,
|
||||
@@ -496,309 +492,6 @@ export function StrmAccountDialog({
|
||||
// ─── 115 登录二维码(canvas 渲染;115 返回的是扫码页面链接而非图片) ───────────
|
||||
|
||||
// QR 尺寸:物理像素 = CSS 像素(避免 canvas 被 CSS 缩放导致裁切/模糊)
|
||||
const QR_SIZE = 176
|
||||
|
||||
function QrCanvas({ content }: { content: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas || !content) return
|
||||
QRCode.toCanvas(canvas, content, {
|
||||
width: QR_SIZE,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: { dark: '#1f2937', light: '#ffffff' },
|
||||
})
|
||||
.then(() => setError(''))
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : '二维码生成失败'))
|
||||
}, [content])
|
||||
|
||||
return (
|
||||
<div className="relative h-44 w-44 shrink-0 rounded-xl border border-gray-200 bg-white">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={QR_SIZE}
|
||||
height={QR_SIZE}
|
||||
role="img"
|
||||
aria-label="115 授权二维码"
|
||||
style={{ width: QR_SIZE, height: QR_SIZE }}
|
||||
/>
|
||||
{error && (
|
||||
<div className="absolute inset-0 grid place-items-center rounded-xl bg-gray-50 p-3 text-center text-xs text-rose-500">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 115 开放平台授权面板 ─────────────────────────────────────────────────────
|
||||
|
||||
type AuthSourceKey = 'built_in_appid' | 'custom_appid' | 'built_in_relay' | 'third_party_service'
|
||||
|
||||
const AUTH_SOURCE_OPTIONS: { key: AuthSourceKey; label: string; desc: string; needsRelayKey?: boolean }[] = [
|
||||
{ key: 'built_in_appid', label: '官方应用目录', desc: '设备码扫码授权,无需回跳' },
|
||||
{ key: 'custom_appid', label: '自定义 APP ID', desc: '使用自己申请的开放平台应用' },
|
||||
{ key: 'built_in_relay', label: '中继授权', desc: 'QMediaSync / MQFamily 中继', needsRelayKey: true },
|
||||
{ key: 'third_party_service', label: '第三方服务', desc: 'MoviePilot / CloudDrive' },
|
||||
]
|
||||
|
||||
function Strm115AuthPanel({
|
||||
existing,
|
||||
accountName,
|
||||
onAuthed,
|
||||
}: {
|
||||
existing: StrmAccount | null
|
||||
accountName: string
|
||||
onAuthed: (account: StrmAccount) => void
|
||||
}) {
|
||||
const [sources, setSources] = useState<{ built_in: Strm115Source[]; relay: Strm115Source[]; third_party: Strm115Source[] } | null>(null)
|
||||
const [relayKeyConfigured, setRelayKeyConfigured] = useState(false)
|
||||
const [authSource, setAuthSource] = useState<AuthSourceKey>('built_in_appid')
|
||||
const [thirdParty, setThirdParty] = useState<'moviepilot' | 'clouddrive'>('moviepilot')
|
||||
const [relayProvider, setRelayProvider] = useState<'qmediasync' | 'mqfamily'>('qmediasync')
|
||||
const [appID, setAppID] = useState('100195125')
|
||||
const [appKeyword, setAppKeyword] = useState('')
|
||||
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [authUI, setAuthUI] = useState<{ sessionId: string; accountId: string; mode: 'qrcode' | 'url'; authUrl?: string; qrcodeUrl?: string } | null>(null)
|
||||
const [authStatus, setAuthStatus] = useState('')
|
||||
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const stopPolling = () => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
}
|
||||
useEffect(() => stopPolling, [])
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
try {
|
||||
const data = await strmAPI.list115Sources()
|
||||
setSources(data)
|
||||
const settings = await strmAPI.getSettings().catch(() => null)
|
||||
const key = settings?.['strm.115_relay_key']
|
||||
setRelayKeyConfigured(Boolean(key && key.trim()))
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const filteredApps = useMemo(
|
||||
() =>
|
||||
(sources?.built_in ?? []).filter(
|
||||
(s) => !appKeyword || s.app_name.toLowerCase().includes(appKeyword.toLowerCase()) || s.app_id.includes(appKeyword),
|
||||
),
|
||||
[appKeyword, sources],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredApps.length > 0 && !filteredApps.some((source) => source.app_id === appID)) {
|
||||
setAppID(filteredApps[0].app_id)
|
||||
}
|
||||
}, [appID, filteredApps])
|
||||
|
||||
const buildStartPayload = () => {
|
||||
switch (authSource) {
|
||||
case 'built_in_appid':
|
||||
return { auth_source: 'built_in_appid', app_id: appID }
|
||||
case 'custom_appid':
|
||||
return { auth_source: 'custom_appid', app_id: appID }
|
||||
case 'built_in_relay':
|
||||
return { auth_source: 'built_in_relay', provider: relayProvider }
|
||||
case 'third_party_service':
|
||||
return { auth_source: 'third_party_service', provider: thirdParty }
|
||||
}
|
||||
}
|
||||
|
||||
const startAuth = async () => {
|
||||
if (authSource === 'built_in_appid' && filteredApps.length === 0) {
|
||||
toast.error('未找到匹配的官方应用')
|
||||
return
|
||||
}
|
||||
|
||||
setStarting(true)
|
||||
setAuthStatus('')
|
||||
try {
|
||||
// 115 授权需要账号 ID:没有则先创建空凭据账号
|
||||
let account = existing
|
||||
if (!account) {
|
||||
account = await strmAPI.createAccount({ name: accountName || '115 网盘', provider: 'cloud115', config: {} })
|
||||
}
|
||||
const result = await strmAPI.start115OAuth(account.id, buildStartPayload())
|
||||
setAuthUI(
|
||||
result.mode === 'qrcode'
|
||||
? { sessionId: result.session_id, accountId: account.id, mode: 'qrcode', qrcodeUrl: result.qrcode?.qrcode }
|
||||
: { sessionId: result.session_id, accountId: account.id, mode: 'url', authUrl: result.auth_url },
|
||||
)
|
||||
stopPolling()
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const status = await strmAPI.poll115OAuth(account.id, result.session_id)
|
||||
setAuthStatus(status.tip)
|
||||
if (status.status === 'confirmed') {
|
||||
stopPolling()
|
||||
const updated = await strmAPI.testAccount(account.id)
|
||||
onAuthed(updated)
|
||||
}
|
||||
if (status.status === 'expired') {
|
||||
stopPolling()
|
||||
setAuthUI(null)
|
||||
toast.error('授权已过期,请重新发起')
|
||||
}
|
||||
} catch {
|
||||
/* 轮询失败等下一次 */
|
||||
}
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openAuthWindow = () => {
|
||||
if (authUI?.mode === 'url' && authUI.authUrl) {
|
||||
window.open(authUI.authUrl, '_blank', 'noopener')
|
||||
}
|
||||
}
|
||||
|
||||
const resetAuth = () => {
|
||||
stopPolling()
|
||||
setAuthUI(null)
|
||||
setAuthStatus('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl bg-gray-50 p-4">
|
||||
{existing?.has_credential && (
|
||||
<p className="rounded-xl bg-emerald-50 px-3 py-2 text-sm text-emerald-600">
|
||||
✓ 该账号已授权;重新授权会替换现有令牌
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!authUI && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{AUTH_SOURCE_OPTIONS.map((option) => {
|
||||
const active = authSource === option.key
|
||||
const disabled = Boolean(option.needsRelayKey && !relayKeyConfigured)
|
||||
return (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setAuthSource(option.key)}
|
||||
className={
|
||||
'rounded-xl border-2 p-2.5 text-left transition disabled:cursor-not-allowed disabled:opacity-40 ' +
|
||||
(active ? 'border-brand-400 bg-brand-50' : 'border-gray-100 bg-white hover:border-gray-200')
|
||||
}
|
||||
>
|
||||
<p className="text-sm font-bold text-ink-600">{option.label}</p>
|
||||
<p className="text-[11px] text-sand-500">{option.desc}</p>
|
||||
{disabled && <p className="text-[10px] text-rose-400">需在 STRM 设置配置共享密钥</p>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{authSource === 'built_in_appid' && (
|
||||
<div className="space-y-2">
|
||||
<Field label="选择官方应用" hint="应用目录来自 QMediaSync 内置;媒体播放器 / 飞牛 / 恒星等">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search size={14} className="shrink-0 text-sand-400" />
|
||||
<input className={inputCls} placeholder="搜索应用名称或 ID…" value={appKeyword} onChange={(e) => setAppKeyword(e.target.value)} />
|
||||
</div>
|
||||
<select className={inputCls} value={appID} onChange={(e) => setAppID(e.target.value)} size={5}>
|
||||
{filteredApps.map((source) => (
|
||||
<option key={source.app_id} value={source.app_id}>
|
||||
{source.display_name}({source.app_id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{filteredApps.length === 0 && <p className="text-xs text-sand-500">未找到匹配的官方应用</p>}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
{authSource === 'custom_appid' && (
|
||||
<Field label="自定义 APP ID" hint="使用自己在 115 开放平台申请的应用 ID">
|
||||
<input className={inputCls} value={appID} placeholder="100195125" onChange={(e) => setAppID(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
{authSource === 'built_in_relay' && (
|
||||
<Field label="中继服务">
|
||||
<select className={inputCls} value={relayProvider} onChange={(e) => setRelayProvider(e.target.value as typeof relayProvider)}>
|
||||
<option value="qmediasync">QMediaSync(oauth.qmediasync.cn)</option>
|
||||
<option value="mqfamily">MQFamily(api.mqfamily.top)</option>
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{authSource === 'third_party_service' && (
|
||||
<Field label="第三方授权服务">
|
||||
<select className={inputCls} value={thirdParty} onChange={(e) => setThirdParty(e.target.value as typeof thirdParty)}>
|
||||
<option value="moviepilot">MoviePilot(https://movie-pilot.org)</option>
|
||||
<option value="clouddrive">CloudDrive(redirect115.zhenyunpan.com)</option>
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={startAuth}
|
||||
disabled={starting || (authSource === 'built_in_appid' && filteredApps.length === 0)}
|
||||
className="neon-button disabled:opacity-50"
|
||||
>
|
||||
{starting ? <Loader2 size={16} className="animate-spin" /> : <QrCode size={16} />}
|
||||
{authSource === 'built_in_appid' || authSource === 'custom_appid' ? '获取登录二维码' : '获取授权链接'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authUI && (
|
||||
<div className="space-y-3">
|
||||
{authUI.mode === 'qrcode' && authUI.qrcodeUrl ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<QrCanvas content={authUI.qrcodeUrl} />
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<p className="font-medium text-ink-600">{authStatus || '等待扫码…'}</p>
|
||||
<p className="text-xs text-sand-500">请使用 115 手机客户端扫码并确认授权,5 分钟内有效</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-ink-100">请点击下方按钮在新窗口完成授权:</p>
|
||||
<button type="button" onClick={openAuthWindow} className="neon-button">
|
||||
<ExternalLink size={16} />
|
||||
打开授权页面
|
||||
</button>
|
||||
<p className="text-xs text-sand-500">完成后回到此页面等待自动确认(授权成功后可关闭弹窗)</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Loader2 size={14} className="animate-spin text-brand-500" />
|
||||
<span className="text-ink-50">{authStatus}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetAuth}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2.5 py-1.5 text-xs font-semibold text-ink-100 transition hover:bg-gray-50"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
重新发起授权
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── STRM 设置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const SETTING_DEFS: SettingDef[] = [
|
||||
{ key: 'strm.base_url', label: 'STRM 链接基础地址', type: 'text', hint: '生成的 strm 文件指向的播放地址;留空依次自动使用 app.server_url,最后回退到本机地址(http://127.0.0.1:端口)。Emby 在其他设备时请配置局域网/公网地址' },
|
||||
{ key: 'strm.video_ext', label: '视频扩展名', type: 'text', hint: '逗号分隔,命中即生成 .strm' },
|
||||
|
||||
@@ -95,7 +95,10 @@ export function StrmManagePage() {
|
||||
// 有进行中的同步时轮询刷新状态
|
||||
useEffect(() => {
|
||||
if (!paths.some((p) => p.last_sync_status === 'running')) return
|
||||
const timer = setInterval(() => refresh().catch(() => undefined), 4000)
|
||||
const timer = setInterval(() => {
|
||||
if (document.hidden) return
|
||||
refresh().catch(() => undefined)
|
||||
}, 4000)
|
||||
return () => clearInterval(timer)
|
||||
}, [paths, refresh])
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -8,12 +8,7 @@ import {
|
||||
Copy,
|
||||
Download,
|
||||
Eye,
|
||||
File,
|
||||
FileText,
|
||||
Film,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
PlayCircle,
|
||||
RefreshCw,
|
||||
Search,
|
||||
@@ -26,6 +21,8 @@ import { strmAPI } from '../api/strm'
|
||||
import type { StrmQueueSnapshot, StrmTask, StrmTaskStatus } from '../types/strm'
|
||||
import { STRM_PROVIDER_LABELS } from '../types/strm'
|
||||
import { apiErrorMessage, formatBytes, formatTime, taskStatusMeta } from './StrmManagePage'
|
||||
import { TaskDetailModal, getFileIcon } from './strm-queue/TaskDetailModal'
|
||||
import { copyToClipboard, useTaskSelection } from './queue-shared'
|
||||
|
||||
const FILTERS: { key: 'all' | StrmTaskStatus; label: string; icon: typeof Clock; color: string }[] = [
|
||||
{ key: 'all', label: '全部', icon: Clock, color: 'text-ink-600' },
|
||||
@@ -38,23 +35,6 @@ const FILTERS: { key: 'all' | StrmTaskStatus; label: string; icon: typeof Clock;
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
function getFileIcon(filename: string): ReactNode {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
||||
if (['nfo', 'txt', 'xml', 'json'].includes(ext)) {
|
||||
return <FileText size={15} className="text-amber-500 shrink-0" />
|
||||
}
|
||||
if (['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'svg'].includes(ext)) {
|
||||
return <ImageIcon size={15} className="text-blue-500 shrink-0" />
|
||||
}
|
||||
if (['srt', 'ass', 'ssa', 'sub', 'vtt'].includes(ext)) {
|
||||
return <MessageSquare size={15} className="text-purple-500 shrink-0" />
|
||||
}
|
||||
if (['mkv', 'mp4', 'avi', 'mov', 'wmv', 'ts', 'flv', 'iso', 'm4v', 'strm'].includes(ext)) {
|
||||
return <Film size={15} className="text-emerald-500 shrink-0" />
|
||||
}
|
||||
return <File size={15} className="text-gray-400 shrink-0" />
|
||||
}
|
||||
|
||||
export function StrmQueuePanel({
|
||||
kind,
|
||||
embedded = false,
|
||||
@@ -71,7 +51,7 @@ export function StrmQueuePanel({
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
const [batchBusy, setBatchBusy] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const { selectedIds, setSelectedIds, reset: clearSelection, toggleRow: toggleSelectRow, toggleAll: toggleAllIds } = useTaskSelection()
|
||||
const [detailTask, setDetailTask] = useState<StrmTask | null>(null)
|
||||
|
||||
const isDownload = kind === 'download'
|
||||
@@ -109,6 +89,7 @@ export function StrmQueuePanel({
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return
|
||||
const timer = setInterval(() => {
|
||||
if (document.hidden) return
|
||||
refresh().catch(() => undefined)
|
||||
}, 3000)
|
||||
return () => clearInterval(timer)
|
||||
@@ -116,13 +97,10 @@ export function StrmQueuePanel({
|
||||
|
||||
// Clear selections when changing filter or page
|
||||
useEffect(() => {
|
||||
setSelectedIds(new Set())
|
||||
}, [filter, page])
|
||||
clearSelection()
|
||||
}, [filter, page, clearSelection])
|
||||
|
||||
const copyText = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.success(`已复制${label}`)
|
||||
}
|
||||
const copyText = copyToClipboard
|
||||
|
||||
// Task actions
|
||||
const cancelTask = async (task: StrmTask) => {
|
||||
@@ -234,22 +212,7 @@ export function StrmQueuePanel({
|
||||
const allCurrentChecked =
|
||||
filteredTasks.length > 0 && filteredTasks.every((t) => selectedIds.has(t.id))
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (allCurrentChecked) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(filteredTasks.map((t) => t.id)))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectRow = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
const toggleSelectAll = () => toggleAllIds(filteredTasks.map((t) => t.id))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -996,201 +959,6 @@ export function StrmQueuePanel({
|
||||
)
|
||||
}
|
||||
|
||||
function TaskDetailModal({
|
||||
task,
|
||||
isDownload,
|
||||
onClose,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onCopy,
|
||||
}: {
|
||||
task: StrmTask
|
||||
isDownload: boolean
|
||||
onClose: () => void
|
||||
onRetry: (t: StrmTask) => void
|
||||
onCancel: (t: StrmTask) => void
|
||||
onDelete: (t: StrmTask) => void
|
||||
onCopy: (text: string, label: string) => void
|
||||
}) {
|
||||
const status = taskStatusMeta(task.status)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-xl rounded-3xl border border-gray-200 bg-white shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{getFileIcon(task.file_name)}
|
||||
<h3 className="font-display text-base font-bold text-ink-600">任务详情</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1 text-gray-400 hover:bg-gray-100 hover:text-ink-600 transition"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-6 max-h-[70vh] overflow-y-auto text-xs">
|
||||
{/* Main Info Box */}
|
||||
<div className="rounded-2xl border border-gray-100 bg-gray-50/70 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">任务 ID</span>
|
||||
<span className="font-mono text-ink-100 select-all">{task.id}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">文件名称</span>
|
||||
<span className="font-bold text-ink-600 select-all">{task.file_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">文件大小</span>
|
||||
<span className="font-mono text-ink-100">{formatBytes(task.size)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">当前状态</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${status.cls}`}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">云盘提供方</span>
|
||||
<span className="font-medium text-ink-100">
|
||||
{STRM_PROVIDER_LABELS[task.provider] ?? task.provider}
|
||||
</span>
|
||||
</div>
|
||||
{task.retry_count > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">已重试次数</span>
|
||||
<span className="font-bold text-amber-600">{task.retry_count} 次</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Paths */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sand-500 font-medium">
|
||||
<span>{isDownload ? '本地输出目标路径' : '本地来源路径'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.local_path, '本地路径')}
|
||||
className="inline-flex items-center gap-1 text-brand-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 font-mono text-[11px] text-ink-600 break-all select-all">
|
||||
{task.local_path}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sand-500 font-medium">
|
||||
<span>远端网盘路径</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.remote_path, '远端路径')}
|
||||
className="inline-flex items-center gap-1 text-brand-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 font-mono text-[11px] text-ink-600 break-all select-all">
|
||||
{task.remote_path}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message Box */}
|
||||
{task.error && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-rose-500 font-medium">
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertCircle size={13} /> 错误详情
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.error!, '错误信息')}
|
||||
className="inline-flex items-center gap-1 text-rose-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制错误
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50/60 p-3 font-mono text-[11px] text-rose-700 break-all select-all whitespace-pre-wrap">
|
||||
{task.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-2 text-[11px] text-sand-500 border-t border-gray-100">
|
||||
<div>创建时间:{formatTime(task.created_at)}</div>
|
||||
{task.started_at && <div>开始时间:{formatTime(task.started_at)}</div>}
|
||||
{task.finished_at && <div>结束时间:{formatTime(task.finished_at)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4 bg-gray-50/50">
|
||||
<div>
|
||||
{(task.status === 'done' ||
|
||||
task.status === 'failed' ||
|
||||
task.status === 'canceled') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(task)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-rose-200 bg-white px-3 py-2 text-xs font-semibold text-rose-500 hover:bg-rose-50 transition"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
删除记录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{(task.status === 'pending' || task.status === 'running') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCancel(task)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-amber-200 bg-white px-4 py-2 text-xs font-semibold text-amber-600 hover:bg-amber-50 transition"
|
||||
>
|
||||
<Ban size={13} />
|
||||
取消任务
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(task.status === 'failed' || task.status === 'canceled') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRetry(task)}
|
||||
className="neon-button !py-2 !px-4 text-xs font-semibold"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
重新入队
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-xs font-semibold text-ink-100 hover:bg-gray-50 transition"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StrmDownloadQueuePage() {
|
||||
return <StrmQueuePanel kind="download" />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
/** 复制文本到剪贴板并提示(Strm/Scraper 队列页共用)。 */
|
||||
export function copyToClipboard(text: string, label: string) {
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.success(`已复制${label}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务列表多选状态:StrmQueuePage 与 ScraperQueuePage 共用。
|
||||
* allIds 变化(翻页/筛选)时调用 reset() 清空选中。
|
||||
*/
|
||||
export function useTaskSelection() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const reset = useCallback(() => setSelectedIds(new Set()), [])
|
||||
|
||||
const toggleRow = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleAll = useCallback((visibleIds: string[]) => {
|
||||
setSelectedIds((prev) => {
|
||||
if (visibleIds.length > 0 && visibleIds.every((id) => prev.has(id))) {
|
||||
return new Set()
|
||||
}
|
||||
return new Set(visibleIds)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return { selectedIds, setSelectedIds, reset, toggleRow, toggleAll }
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { AlertCircle, Ban, Copy, ExternalLink, RefreshCw, Sparkles, Trash2, X } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../../api/client'
|
||||
import type { ScrapeTask } from '../../types/scraper'
|
||||
import { formatTime, taskStatusMeta } from '../StrmManagePage'
|
||||
|
||||
export const PROVIDER_LABELS: Record<string, string> = {
|
||||
tmdb: 'TheMovieDB',
|
||||
douban: '豆瓣 Douban',
|
||||
bangumi: 'Bangumi 番组计划',
|
||||
thetvdb: 'TheTVDB',
|
||||
metatube: 'MetaTube',
|
||||
}
|
||||
|
||||
export const TYPE_LABELS: Record<string, string> = {
|
||||
movie: '电影',
|
||||
tv: '剧集',
|
||||
anime: '动漫',
|
||||
adult: 'Adult',
|
||||
}
|
||||
|
||||
export function ScrapeDetailModal({
|
||||
task,
|
||||
onClose,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onCopy,
|
||||
}: {
|
||||
task: ScrapeTask
|
||||
onClose: () => void
|
||||
onRetry: (t: ScrapeTask) => void
|
||||
onCancel: (t: ScrapeTask) => void
|
||||
onDelete: (t: ScrapeTask) => void
|
||||
onCopy: (text: string, label: string) => void
|
||||
}) {
|
||||
const status = taskStatusMeta(task.status)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-xl rounded-3xl border border-gray-200 bg-white shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={16} className="text-brand-500" />
|
||||
<h3 className="font-display text-base font-bold text-ink-600">刮削任务详情</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1 text-gray-400 hover:bg-gray-100 hover:text-ink-600 transition"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-6 max-h-[70vh] overflow-y-auto text-xs">
|
||||
{/* Matched Poster / Info Banner */}
|
||||
{task.matched_title ? (
|
||||
<div className="flex gap-4 rounded-2xl border border-brand-500/20 bg-primary-400/5 p-4">
|
||||
{task.poster_url && (
|
||||
<img
|
||||
src={imageURL(task.poster_url)}
|
||||
alt=""
|
||||
className="h-28 w-20 rounded-xl object-cover border border-brand-500/30 shadow-md shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded bg-brand-500 px-2 py-0.5 text-[10px] font-bold text-white uppercase">
|
||||
已匹配
|
||||
</span>
|
||||
{task.provider && (
|
||||
<span className="rounded border border-gray-200 bg-white px-2 py-0.5 text-[10px] font-semibold text-ink-600">
|
||||
{PROVIDER_LABELS[task.provider] ?? task.provider}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="font-display text-base font-extrabold text-ink-600 truncate">
|
||||
{task.matched_title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-3 text-sand-500 text-[11px]">
|
||||
{task.matched_year > 0 && <span>年份:{task.matched_year}</span>}
|
||||
<span>类型:{TYPE_LABELS[task.media_type] ?? task.media_type}</span>
|
||||
</div>
|
||||
{task.media_id && (
|
||||
<Link
|
||||
to={`/media/${task.media_id}`}
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-1 text-brand-500 font-semibold hover:underline pt-1"
|
||||
>
|
||||
<span>在媒体详情中查看</span>
|
||||
<ExternalLink size={11} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Media Info Box */}
|
||||
<div className="rounded-2xl border border-gray-100 bg-gray-50/70 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">原始媒体标题</span>
|
||||
<span className="font-bold text-ink-600 select-all">{task.media_title}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">所属媒体库</span>
|
||||
<span className="font-medium text-ink-100">{task.library_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">媒体库类型</span>
|
||||
<span className="font-medium text-ink-100">
|
||||
{TYPE_LABELS[task.media_type] ?? task.media_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">当前状态</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${status.cls}`}
|
||||
>
|
||||
{task.status === 'done' ? '已匹配' : task.status === 'failed' ? '未匹配' : status.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">剧照/海报刮削</span>
|
||||
<span className="font-medium text-ink-100">
|
||||
{task.episode_images ? '开启' : '关闭'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File path */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sand-500 font-medium">
|
||||
<span>磁盘文件路径</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.media_path, '文件路径')}
|
||||
className="inline-flex items-center gap-1 text-brand-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 font-mono text-[11px] text-ink-600 break-all select-all">
|
||||
{task.media_path}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message Box */}
|
||||
{task.error && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-rose-500 font-medium">
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertCircle size={13} /> 刮削未匹配 / 异常详情
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.error, '错误日志')}
|
||||
className="inline-flex items-center gap-1 text-rose-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制日志
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50/60 p-3 font-mono text-[11px] text-rose-700 break-all select-all whitespace-pre-wrap">
|
||||
{task.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-2 text-[11px] text-sand-500 border-t border-gray-100">
|
||||
<div>入队时间:{formatTime(task.created_at)}</div>
|
||||
{task.started_at && <div>开始刮削:{formatTime(task.started_at)}</div>}
|
||||
{task.finished_at && <div>完成时间:{formatTime(task.finished_at)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4 bg-gray-50/50">
|
||||
<div>
|
||||
{(task.status === 'done' ||
|
||||
task.status === 'failed' ||
|
||||
task.status === 'canceled') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(task)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-rose-200 bg-white px-3 py-2 text-xs font-semibold text-rose-500 hover:bg-rose-50 transition"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
删除记录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{(task.status === 'pending' || task.status === 'running') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCancel(task)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-amber-200 bg-white px-4 py-2 text-xs font-semibold text-amber-600 hover:bg-amber-50 transition"
|
||||
>
|
||||
<Ban size={13} />
|
||||
取消任务
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRetry(task)}
|
||||
className="neon-button !py-2 !px-4 text-xs font-semibold"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
重新刮削
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-xs font-semibold text-ink-100 hover:bg-gray-50 transition"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ExternalLink, Loader2, QrCode, RefreshCw, Search } from 'lucide-react'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
import { strmAPI, type Strm115Source } from '../../api/strm'
|
||||
import type { StrmAccount } from '../../types/strm'
|
||||
import { apiErrorMessage } from '../StrmManagePage'
|
||||
|
||||
const QR_SIZE = 176
|
||||
|
||||
function QrCanvas({ content }: { content: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas || !content) return
|
||||
QRCode.toCanvas(canvas, content, {
|
||||
width: QR_SIZE,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: { dark: '#1f2937', light: '#ffffff' },
|
||||
})
|
||||
.then(() => setError(''))
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : '二维码生成失败'))
|
||||
}, [content])
|
||||
|
||||
return (
|
||||
<div className="relative h-44 w-44 shrink-0 rounded-xl border border-gray-200 bg-white">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={QR_SIZE}
|
||||
height={QR_SIZE}
|
||||
role="img"
|
||||
aria-label="115 授权二维码"
|
||||
style={{ width: QR_SIZE, height: QR_SIZE }}
|
||||
/>
|
||||
{error && (
|
||||
<div className="absolute inset-0 grid place-items-center rounded-xl bg-gray-50 p-3 text-center text-xs text-rose-500">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type AuthSourceKey = 'built_in_appid' | 'custom_appid' | 'built_in_relay' | 'third_party_service'
|
||||
|
||||
const AUTH_SOURCE_OPTIONS: { key: AuthSourceKey; label: string; desc: string; needsRelayKey?: boolean }[] = [
|
||||
{ key: 'built_in_appid', label: '官方应用目录', desc: '设备码扫码授权,无需回跳' },
|
||||
{ key: 'custom_appid', label: '自定义 APP ID', desc: '使用自己申请的开放平台应用' },
|
||||
{ key: 'built_in_relay', label: '中继授权', desc: 'QMediaSync / MQFamily 中继', needsRelayKey: true },
|
||||
{ key: 'third_party_service', label: '第三方服务', desc: 'MoviePilot / CloudDrive' },
|
||||
]
|
||||
|
||||
const inputCls = 'input-base w-full'
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block space-y-1">
|
||||
<span className="text-sm font-medium text-ink-100">{label}</span>
|
||||
{children}
|
||||
{hint && <span className="block text-xs text-sand-500">{hint}</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function Strm115AuthPanel({
|
||||
existing,
|
||||
accountName,
|
||||
onAuthed,
|
||||
}: {
|
||||
existing: StrmAccount | null
|
||||
accountName: string
|
||||
onAuthed: (account: StrmAccount) => void
|
||||
}) {
|
||||
const [sources, setSources] = useState<{ built_in: Strm115Source[]; relay: Strm115Source[]; third_party: Strm115Source[] } | null>(null)
|
||||
const [relayKeyConfigured, setRelayKeyConfigured] = useState(false)
|
||||
const [authSource, setAuthSource] = useState<AuthSourceKey>('built_in_appid')
|
||||
const [thirdParty, setThirdParty] = useState<'moviepilot' | 'clouddrive'>('moviepilot')
|
||||
const [relayProvider, setRelayProvider] = useState<'qmediasync' | 'mqfamily'>('qmediasync')
|
||||
const [appID, setAppID] = useState('100195125')
|
||||
const [appKeyword, setAppKeyword] = useState('')
|
||||
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [authUI, setAuthUI] = useState<{ sessionId: string; accountId: string; mode: 'qrcode' | 'url'; authUrl?: string; qrcodeUrl?: string } | null>(null)
|
||||
const [authStatus, setAuthStatus] = useState('')
|
||||
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const stopPolling = () => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
}
|
||||
useEffect(() => stopPolling, [])
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
try {
|
||||
const data = await strmAPI.list115Sources()
|
||||
setSources(data)
|
||||
const settings = await strmAPI.getSettings().catch(() => null)
|
||||
const key = settings?.['strm.115_relay_key']
|
||||
setRelayKeyConfigured(Boolean(key && key.trim()))
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const filteredApps = useMemo(
|
||||
() =>
|
||||
(sources?.built_in ?? []).filter(
|
||||
(s) => !appKeyword || s.app_name.toLowerCase().includes(appKeyword.toLowerCase()) || s.app_id.includes(appKeyword),
|
||||
),
|
||||
[appKeyword, sources],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredApps.length > 0 && !filteredApps.some((source) => source.app_id === appID)) {
|
||||
setAppID(filteredApps[0].app_id)
|
||||
}
|
||||
}, [appID, filteredApps])
|
||||
|
||||
const buildStartPayload = () => {
|
||||
switch (authSource) {
|
||||
case 'built_in_appid':
|
||||
return { auth_source: 'built_in_appid', app_id: appID }
|
||||
case 'custom_appid':
|
||||
return { auth_source: 'custom_appid', app_id: appID }
|
||||
case 'built_in_relay':
|
||||
return { auth_source: 'built_in_relay', provider: relayProvider }
|
||||
case 'third_party_service':
|
||||
return { auth_source: 'third_party_service', provider: thirdParty }
|
||||
}
|
||||
}
|
||||
|
||||
const startAuth = async () => {
|
||||
if (authSource === 'built_in_appid' && filteredApps.length === 0) {
|
||||
toast.error('未找到匹配的官方应用')
|
||||
return
|
||||
}
|
||||
|
||||
setStarting(true)
|
||||
setAuthStatus('')
|
||||
try {
|
||||
// 115 授权需要账号 ID:没有则先创建空凭据账号
|
||||
let account = existing
|
||||
if (!account) {
|
||||
account = await strmAPI.createAccount({ name: accountName || '115 网盘', provider: 'cloud115', config: {} })
|
||||
}
|
||||
const result = await strmAPI.start115OAuth(account.id, buildStartPayload())
|
||||
setAuthUI(
|
||||
result.mode === 'qrcode'
|
||||
? { sessionId: result.session_id, accountId: account.id, mode: 'qrcode', qrcodeUrl: result.qrcode?.qrcode }
|
||||
: { sessionId: result.session_id, accountId: account.id, mode: 'url', authUrl: result.auth_url },
|
||||
)
|
||||
stopPolling()
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const status = await strmAPI.poll115OAuth(account.id, result.session_id)
|
||||
setAuthStatus(status.tip)
|
||||
if (status.status === 'confirmed') {
|
||||
stopPolling()
|
||||
const updated = await strmAPI.testAccount(account.id)
|
||||
onAuthed(updated)
|
||||
}
|
||||
if (status.status === 'expired') {
|
||||
stopPolling()
|
||||
setAuthUI(null)
|
||||
toast.error('授权已过期,请重新发起')
|
||||
}
|
||||
} catch {
|
||||
/* 轮询失败等下一次 */
|
||||
}
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openAuthWindow = () => {
|
||||
if (authUI?.mode === 'url' && authUI.authUrl) {
|
||||
window.open(authUI.authUrl, '_blank', 'noopener')
|
||||
}
|
||||
}
|
||||
|
||||
const resetAuth = () => {
|
||||
stopPolling()
|
||||
setAuthUI(null)
|
||||
setAuthStatus('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl bg-gray-50 p-4">
|
||||
{existing?.has_credential && (
|
||||
<p className="rounded-xl bg-emerald-50 px-3 py-2 text-sm text-emerald-600">
|
||||
✓ 该账号已授权;重新授权会替换现有令牌
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!authUI && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{AUTH_SOURCE_OPTIONS.map((option) => {
|
||||
const active = authSource === option.key
|
||||
const disabled = Boolean(option.needsRelayKey && !relayKeyConfigured)
|
||||
return (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setAuthSource(option.key)}
|
||||
className={
|
||||
'rounded-xl border-2 p-2.5 text-left transition disabled:cursor-not-allowed disabled:opacity-40 ' +
|
||||
(active ? 'border-brand-400 bg-brand-50' : 'border-gray-100 bg-white hover:border-gray-200')
|
||||
}
|
||||
>
|
||||
<p className="text-sm font-bold text-ink-600">{option.label}</p>
|
||||
<p className="text-[11px] text-sand-500">{option.desc}</p>
|
||||
{disabled && <p className="text-[10px] text-rose-400">需在 STRM 设置配置共享密钥</p>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{authSource === 'built_in_appid' && (
|
||||
<div className="space-y-2">
|
||||
<Field label="选择官方应用" hint="应用目录来自 QMediaSync 内置;媒体播放器 / 飞牛 / 恒星等">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search size={14} className="shrink-0 text-sand-400" />
|
||||
<input className={inputCls} placeholder="搜索应用名称或 ID…" value={appKeyword} onChange={(e) => setAppKeyword(e.target.value)} />
|
||||
</div>
|
||||
<select className={inputCls} value={appID} onChange={(e) => setAppID(e.target.value)} size={5}>
|
||||
{filteredApps.map((source) => (
|
||||
<option key={source.app_id} value={source.app_id}>
|
||||
{source.display_name}({source.app_id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{filteredApps.length === 0 && <p className="text-xs text-sand-500">未找到匹配的官方应用</p>}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
{authSource === 'custom_appid' && (
|
||||
<Field label="自定义 APP ID" hint="使用自己在 115 开放平台申请的应用 ID">
|
||||
<input className={inputCls} value={appID} placeholder="100195125" onChange={(e) => setAppID(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
{authSource === 'built_in_relay' && (
|
||||
<Field label="中继服务">
|
||||
<select className={inputCls} value={relayProvider} onChange={(e) => setRelayProvider(e.target.value as typeof relayProvider)}>
|
||||
<option value="qmediasync">QMediaSync(oauth.qmediasync.cn)</option>
|
||||
<option value="mqfamily">MQFamily(api.mqfamily.top)</option>
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{authSource === 'third_party_service' && (
|
||||
<Field label="第三方授权服务">
|
||||
<select className={inputCls} value={thirdParty} onChange={(e) => setThirdParty(e.target.value as typeof thirdParty)}>
|
||||
<option value="moviepilot">MoviePilot(https://movie-pilot.org)</option>
|
||||
<option value="clouddrive">CloudDrive(redirect115.zhenyunpan.com)</option>
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={startAuth}
|
||||
disabled={starting || (authSource === 'built_in_appid' && filteredApps.length === 0)}
|
||||
className="neon-button disabled:opacity-50"
|
||||
>
|
||||
{starting ? <Loader2 size={16} className="animate-spin" /> : <QrCode size={16} />}
|
||||
{authSource === 'built_in_appid' || authSource === 'custom_appid' ? '获取登录二维码' : '获取授权链接'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authUI && (
|
||||
<div className="space-y-3">
|
||||
{authUI.mode === 'qrcode' && authUI.qrcodeUrl ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<QrCanvas content={authUI.qrcodeUrl} />
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<p className="font-medium text-ink-600">{authStatus || '等待扫码…'}</p>
|
||||
<p className="text-xs text-sand-500">请使用 115 手机客户端扫码并确认授权,5 分钟内有效</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-ink-100">请点击下方按钮在新窗口完成授权:</p>
|
||||
<button type="button" onClick={openAuthWindow} className="neon-button">
|
||||
<ExternalLink size={16} />
|
||||
打开授权页面
|
||||
</button>
|
||||
<p className="text-xs text-sand-500">完成后回到此页面等待自动确认(授权成功后可关闭弹窗)</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Loader2 size={14} className="animate-spin text-brand-500" />
|
||||
<span className="text-ink-50">{authStatus}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetAuth}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2.5 py-1.5 text-xs font-semibold text-ink-100 transition hover:bg-gray-50"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
重新发起授权
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertCircle, Ban, Copy, FileText, Film, Image as ImageIcon, File, MessageSquare, RefreshCw, Trash2, X } from 'lucide-react'
|
||||
|
||||
import type { StrmTask } from '../../types/strm'
|
||||
import { STRM_PROVIDER_LABELS } from '../../types/strm'
|
||||
import { formatBytes, formatTime, taskStatusMeta } from '../StrmManagePage'
|
||||
|
||||
export function getFileIcon(filename: string): ReactNode {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
||||
if (['nfo', 'txt', 'xml', 'json'].includes(ext)) {
|
||||
return <FileText size={15} className="text-amber-500 shrink-0" />
|
||||
}
|
||||
if (['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'svg'].includes(ext)) {
|
||||
return <ImageIcon size={15} className="text-blue-500 shrink-0" />
|
||||
}
|
||||
if (['srt', 'ass', 'ssa', 'sub', 'vtt'].includes(ext)) {
|
||||
return <MessageSquare size={15} className="text-purple-500 shrink-0" />
|
||||
}
|
||||
if (['mkv', 'mp4', 'avi', 'mov', 'wmv', 'ts', 'flv', 'iso', 'm4v', 'strm'].includes(ext)) {
|
||||
return <Film size={15} className="text-emerald-500 shrink-0" />
|
||||
}
|
||||
return <File size={15} className="text-gray-400 shrink-0" />
|
||||
}
|
||||
|
||||
export function TaskDetailModal({
|
||||
task,
|
||||
isDownload,
|
||||
onClose,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onCopy,
|
||||
}: {
|
||||
task: StrmTask
|
||||
isDownload: boolean
|
||||
onClose: () => void
|
||||
onRetry: (t: StrmTask) => void
|
||||
onCancel: (t: StrmTask) => void
|
||||
onDelete: (t: StrmTask) => void
|
||||
onCopy: (text: string, label: string) => void
|
||||
}) {
|
||||
const status = taskStatusMeta(task.status)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-xl rounded-3xl border border-gray-200 bg-white shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{getFileIcon(task.file_name)}
|
||||
<h3 className="font-display text-base font-bold text-ink-600">任务详情</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1 text-gray-400 hover:bg-gray-100 hover:text-ink-600 transition"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-6 max-h-[70vh] overflow-y-auto text-xs">
|
||||
{/* Main Info Box */}
|
||||
<div className="rounded-2xl border border-gray-100 bg-gray-50/70 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">任务 ID</span>
|
||||
<span className="font-mono text-ink-100 select-all">{task.id}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">文件名称</span>
|
||||
<span className="font-bold text-ink-600 select-all">{task.file_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">文件大小</span>
|
||||
<span className="font-mono text-ink-100">{formatBytes(task.size)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">当前状态</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${status.cls}`}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">云盘提供方</span>
|
||||
<span className="font-medium text-ink-100">
|
||||
{STRM_PROVIDER_LABELS[task.provider] ?? task.provider}
|
||||
</span>
|
||||
</div>
|
||||
{task.retry_count > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">已重试次数</span>
|
||||
<span className="font-bold text-amber-600">{task.retry_count} 次</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Paths */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sand-500 font-medium">
|
||||
<span>{isDownload ? '本地输出目标路径' : '本地来源路径'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.local_path, '本地路径')}
|
||||
className="inline-flex items-center gap-1 text-brand-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 font-mono text-[11px] text-ink-600 break-all select-all">
|
||||
{task.local_path}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sand-500 font-medium">
|
||||
<span>远端网盘路径</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.remote_path, '远端路径')}
|
||||
className="inline-flex items-center gap-1 text-brand-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 font-mono text-[11px] text-ink-600 break-all select-all">
|
||||
{task.remote_path}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message Box */}
|
||||
{task.error && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-rose-500 font-medium">
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertCircle size={13} /> 错误详情
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.error!, '错误信息')}
|
||||
className="inline-flex items-center gap-1 text-rose-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制错误
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50/60 p-3 font-mono text-[11px] text-rose-700 break-all select-all whitespace-pre-wrap">
|
||||
{task.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-2 text-[11px] text-sand-500 border-t border-gray-100">
|
||||
<div>创建时间:{formatTime(task.created_at)}</div>
|
||||
{task.started_at && <div>开始时间:{formatTime(task.started_at)}</div>}
|
||||
{task.finished_at && <div>结束时间:{formatTime(task.finished_at)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4 bg-gray-50/50">
|
||||
<div>
|
||||
{(task.status === 'done' ||
|
||||
task.status === 'failed' ||
|
||||
task.status === 'canceled') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(task)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-rose-200 bg-white px-3 py-2 text-xs font-semibold text-rose-500 hover:bg-rose-50 transition"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
删除记录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{(task.status === 'pending' || task.status === 'running') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCancel(task)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-amber-200 bg-white px-4 py-2 text-xs font-semibold text-amber-600 hover:bg-amber-50 transition"
|
||||
>
|
||||
<Ban size={13} />
|
||||
取消任务
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(task.status === 'failed' || task.status === 'canceled') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRetry(task)}
|
||||
className="neon-button !py-2 !px-4 text-xs font-semibold"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
重新入队
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-xs font-semibold text-ink-100 hover:bg-gray-50 transition"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import toast from 'react-hot-toast'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library, LibraryRoot } from '../types'
|
||||
import { invalidateLibraries } from '../utils/libraryCache'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import { apiErrorMessage, createRootPayload, displayLibraryRootName, displayLibraryRootPath, emptyRootDraft, rootDraftKey, type RootDraft } from './adminLibraryPanelModel'
|
||||
|
||||
@@ -18,10 +19,14 @@ export function useAdminLibraryPanel() {
|
||||
|
||||
function useAdminLibraryList() {
|
||||
const [libs, setLibs] = useState<Library[]>([])
|
||||
const refresh = () =>
|
||||
libraryAPI
|
||||
const refresh = () => {
|
||||
// 后台任何库变更都会走到这里;顺带清掉前台会话缓存,
|
||||
// 避免返回首页/媒体库页后 30 秒 TTL 内还显示旧列表。
|
||||
invalidateLibraries()
|
||||
return libraryAPI
|
||||
.list({ includeHidden: true })
|
||||
.then((libs) => setLibs(libs.filter((l) => !l.is_remote_emby))) // 远程挂载库只读,不在后台管理列表内
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
|
||||
@@ -3,6 +3,7 @@ import toast from 'react-hot-toast'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library, Media } from '../types'
|
||||
import { peekLibrary, resolveLibrary } from '../utils/libraryCache'
|
||||
import { groupSeries, isEpisodeLike, type SeriesCard } from '../utils/groupSeries'
|
||||
|
||||
export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | null) {
|
||||
@@ -25,74 +26,79 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
|
||||
return groupSeries(items)
|
||||
}, [isSeries, isSeriesLibrary, items, serverSeriesCards])
|
||||
|
||||
// reloadCurrentLibrary 通过自增 tick 重跑加载(原实现靠克隆 library 对象
|
||||
// 触发第二个 effect,这里合并成单个 bootstrap effect 后改用显式信号)。
|
||||
const [reloadTick, setReloadTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryID) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setLoadingAll(false)
|
||||
setLibrary(null)
|
||||
setItems([])
|
||||
setServerSeriesCards([])
|
||||
setSeriesEpisodeItems([])
|
||||
libraryAPI.get(libraryID)
|
||||
.then((lib) => {
|
||||
if (!cancelled) setLibrary(lib)
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
const bootstrap = async () => {
|
||||
// 库信息先查会话缓存(首页/全部媒体库页已拉过全量列表),命中则
|
||||
// 同步就绪,内容请求在挂载当帧即发出;未命中才退回单独请求,
|
||||
// 消除原先"先等库信息、再等内容"的两段串行首屏等待。
|
||||
let lib = peekLibrary(libraryID)
|
||||
if (lib) setLibrary(lib)
|
||||
try {
|
||||
const resolved = await resolveLibrary(libraryID)
|
||||
if (cancelled) return
|
||||
if (!lib) {
|
||||
lib = resolved
|
||||
setLibrary(lib)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setLibrary(null)
|
||||
setLoading(false)
|
||||
toast.error('媒体库不存在或无权限')
|
||||
}
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [libraryID])
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryID || !library) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setLoadingAll(true)
|
||||
setItems([])
|
||||
setServerSeriesCards([])
|
||||
setSeriesEpisodeItems([])
|
||||
|
||||
const loadAll = async () => {
|
||||
if (isSeriesLibrary) {
|
||||
const collected = await loadAllSeriesCards(libraryID, library.is_remote_emby, (next) => {
|
||||
if (cancelled) return
|
||||
setTotal(next.total)
|
||||
if (next.firstPage) {
|
||||
setServerSeriesCards(next.items)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
if (!cancelled) setServerSeriesCards(collected.items)
|
||||
return
|
||||
}
|
||||
|
||||
const collected = await loadAllMedia(libraryID, library.is_remote_emby, (next) => {
|
||||
if (cancelled) return
|
||||
setTotal(next.total)
|
||||
if (next.firstPage) {
|
||||
setItems(next.items)
|
||||
setLoading(false)
|
||||
const seriesLibrary = isSeriesLibraryType(lib.type)
|
||||
setLoadingAll(true)
|
||||
try {
|
||||
if (seriesLibrary) {
|
||||
const collected = await loadAllSeriesCards(libraryID, lib.is_remote_emby, (next) => {
|
||||
if (cancelled) return
|
||||
setTotal(next.total)
|
||||
if (next.firstPage) {
|
||||
setServerSeriesCards(next.items)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
if (!cancelled) setServerSeriesCards(collected.items)
|
||||
return
|
||||
}
|
||||
})
|
||||
if (!cancelled) setItems(collected.items)
|
||||
}
|
||||
|
||||
loadAll()
|
||||
.catch(() => {
|
||||
const collected = await loadAllMedia(libraryID, lib.is_remote_emby, (next) => {
|
||||
if (cancelled) return
|
||||
setTotal(next.total)
|
||||
if (next.firstPage) {
|
||||
setItems(next.items)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
if (!cancelled) setItems(collected.items)
|
||||
} catch {
|
||||
if (!cancelled) toast.error('媒体库加载失败')
|
||||
})
|
||||
.finally(() => {
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
setLoadingAll(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap()
|
||||
return () => { cancelled = true }
|
||||
}, [libraryID, library, isSeriesLibrary])
|
||||
}, [libraryID, reloadTick])
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryID || !isSeriesLibrary || !selectedSeries) {
|
||||
@@ -117,7 +123,7 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
|
||||
}, [libraryID, isSeriesLibrary, selectedSeries])
|
||||
|
||||
const reloadCurrentLibrary = useCallback(() => {
|
||||
setLibrary((current) => (current ? { ...current } : current))
|
||||
setReloadTick((tick) => tick + 1)
|
||||
}, [])
|
||||
|
||||
const loadingAllText = loadingAll && !loading && (isSeriesLibrary ? total > serverSeriesCards.length : total > items.length)
|
||||
|
||||
@@ -143,21 +143,27 @@ function useMediaDetailRefresh({
|
||||
setLoading(true)
|
||||
setLoadingEpisodes(true)
|
||||
try {
|
||||
const nextMedia = await mediaAPI.get(id)
|
||||
// 三个请求并行发出;详情一到就解锁整页渲染,收藏状态与分集列表
|
||||
// 到达后各自补齐(原先完全串行,首屏要排队等满三个往返)。
|
||||
const nextMediaPromise = mediaAPI.get(id)
|
||||
const favouritesPromise = playbackAPI.listFavourites().catch(() => [])
|
||||
const episodesPromise = mediaAPI
|
||||
.getEpisodes(id)
|
||||
.then((r) => r.items ?? [])
|
||||
.catch(() => [])
|
||||
|
||||
const nextMedia = await nextMediaPromise
|
||||
setMedia(nextMedia)
|
||||
const favourites = await playbackAPI.listFavourites().catch(() => [])
|
||||
setLoading(false)
|
||||
|
||||
const favourites = await favouritesPromise
|
||||
setFavourite(favourites.some((item) => item.id === nextMedia.id))
|
||||
// 异步加载分集列表
|
||||
try {
|
||||
const epRes = await mediaAPI.getEpisodes(id)
|
||||
setEpisodes(epRes.items ?? [])
|
||||
} catch {
|
||||
setEpisodes([])
|
||||
} finally {
|
||||
setLoadingEpisodes(false)
|
||||
}
|
||||
|
||||
const episodes = await episodesPromise
|
||||
setEpisodes(episodes)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoadingEpisodes(false)
|
||||
}
|
||||
}, [id, setFavourite, setLoading, setMedia, setEpisodes, setLoadingEpisodes])
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ interface PermissionState {
|
||||
clearPermissions: () => void
|
||||
}
|
||||
|
||||
// 进行中的请求共享同一个 Promise:同一次 commit 内挂载的多个消费方
|
||||
// 同时触发 fetchPermissions 时只会发出一次 /permissions 请求。
|
||||
let inflight: Promise<void> | null = null
|
||||
|
||||
export const usePermissionStore = create<PermissionState>((set, get) => ({
|
||||
permissions: {},
|
||||
role: '',
|
||||
@@ -23,22 +27,28 @@ export const usePermissionStore = create<PermissionState>((set, get) => ({
|
||||
error: null,
|
||||
|
||||
fetchPermissions: async () => {
|
||||
if (inflight) return inflight
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const result = await getMyPermissions()
|
||||
set({
|
||||
permissions: result.permissions ?? {},
|
||||
role: result.role ?? '',
|
||||
tier: result.tier ?? 'free',
|
||||
isSuper: result.is_super ?? false,
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (err) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to fetch permissions',
|
||||
})
|
||||
}
|
||||
inflight = (async () => {
|
||||
try {
|
||||
const result = await getMyPermissions()
|
||||
set({
|
||||
permissions: result.permissions ?? {},
|
||||
role: result.role ?? '',
|
||||
tier: result.tier ?? 'free',
|
||||
isSuper: result.is_super ?? false,
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (err) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to fetch permissions',
|
||||
})
|
||||
} finally {
|
||||
inflight = null
|
||||
}
|
||||
})()
|
||||
return inflight
|
||||
},
|
||||
|
||||
hasPermission: (key: string) => {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library } from '../types'
|
||||
|
||||
// 会话级媒体库元数据缓存(不落 localStorage)。
|
||||
// 首页 / 媒体库页 / 媒体库详情页都要先拿库列表或库信息才能渲染,
|
||||
// 同一会话内反复进出这些页面时直接命中缓存,避免每次都等一整轮请求。
|
||||
// TTL 内直接返回缓存;过期后重新请求并刷新。并发调用共享同一个在途请求。
|
||||
|
||||
const LIST_TTL_MS = 30_000
|
||||
const LIBRARY_TTL_MS = 30_000
|
||||
|
||||
let listData: Library[] | null = null
|
||||
let listAt = 0
|
||||
let listInflight: Promise<Library[]> | null = null
|
||||
|
||||
const byId = new Map<string, { at: number; data: Library }>()
|
||||
const byIdInflight = new Map<string, Promise<Library>>()
|
||||
|
||||
function fresh(at: number, ttl: number): boolean {
|
||||
return Date.now() - at < ttl
|
||||
}
|
||||
|
||||
function rememberList(data: Library[]) {
|
||||
listData = data
|
||||
listAt = Date.now()
|
||||
for (const lib of data) {
|
||||
byId.set(lib.id, { at: listAt, data: lib })
|
||||
}
|
||||
}
|
||||
|
||||
// fetchLibraries 拉取库列表:TTL 内命中缓存立即返回(含 peek 先行渲染),
|
||||
// 过期或未缓存时发起请求,并发调用共享在途请求。
|
||||
export function fetchLibraries(): Promise<Library[]> {
|
||||
if (listData && fresh(listAt, LIST_TTL_MS)) return Promise.resolve(listData)
|
||||
if (!listInflight) {
|
||||
listInflight = libraryAPI
|
||||
.list()
|
||||
.then((rows) => {
|
||||
const data = Array.isArray(rows) ? rows : []
|
||||
rememberList(data)
|
||||
return data
|
||||
})
|
||||
.finally(() => {
|
||||
listInflight = null
|
||||
})
|
||||
}
|
||||
return listInflight
|
||||
}
|
||||
|
||||
// peekLibraries 同步返回当前缓存的库列表(可能过期),没有则 null。
|
||||
// 页面用它先行渲染上一次的数据,再由 fetchLibraries 在后台补一次刷新。
|
||||
export function peekLibraries(): Library[] | null {
|
||||
return listData
|
||||
}
|
||||
|
||||
// peekLibrary 同步返回单库缓存(库列表缓存也会回填单库索引)。
|
||||
export function peekLibrary(id: string): Library | null {
|
||||
const entry = byId.get(id)
|
||||
return entry?.data ?? null
|
||||
}
|
||||
|
||||
// resolveLibrary 拿单个库信息:命中缓存直接返回;未命中走 /libraries/:id
|
||||
// 并回填缓存。远程 Emby 挂载库的详情接口同样返回 is_remote_emby,缓存可用。
|
||||
export function resolveLibrary(id: string): Promise<Library> {
|
||||
const entry = byId.get(id)
|
||||
if (entry && fresh(entry.at, LIBRARY_TTL_MS)) return Promise.resolve(entry.data)
|
||||
const inflight = byIdInflight.get(id)
|
||||
if (inflight) return inflight
|
||||
const request = libraryAPI
|
||||
.get(id)
|
||||
.then((lib) => {
|
||||
byId.set(id, { at: Date.now(), data: lib })
|
||||
return lib
|
||||
})
|
||||
.finally(() => {
|
||||
byIdInflight.delete(id)
|
||||
})
|
||||
byIdInflight.set(id, request)
|
||||
return request
|
||||
}
|
||||
|
||||
// invalidate 清空缓存。管理页对库做过增删改后调用,确保下次拉到新数据。
|
||||
export function invalidateLibraries() {
|
||||
listData = null
|
||||
listAt = 0
|
||||
listInflight = null
|
||||
byId.clear()
|
||||
byIdInflight.clear()
|
||||
}
|
||||
@@ -24,5 +24,25 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// 只拆哈希长期稳定的核心库:业务代码发版后用户只需重新下载变小的
|
||||
// 业务 chunk,核心 vendor 走浏览器长缓存。其余依赖保持 Vite 默认
|
||||
// 分块,避免把 hls.js 等懒加载库拖进首屏。
|
||||
manualChunks(id) {
|
||||
if (!id.includes('node_modules')) return undefined
|
||||
if (/[\\/]node_modules[\\/](react|react-dom|react-router|react-router-dom|scheduler)[\\/]/.test(id)) {
|
||||
return 'vendor-react'
|
||||
}
|
||||
if (/[\\/]node_modules[\\/](axios|zustand)[\\/]/.test(id)) {
|
||||
return 'vendor-data'
|
||||
}
|
||||
if (/[\\/]node_modules[\\/]framer-motion[\\/]/.test(id)) {
|
||||
return 'vendor-motion'
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||