优化
@@ -23,6 +23,7 @@ import (
|
|||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/truewhile/MeBox/internal/database"
|
"github.com/truewhile/MeBox/internal/database"
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
"github.com/truewhile/MeBox/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
@@ -121,7 +122,7 @@ func main() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
go services.Boot()
|
helper.Go(logger, "services.boot", services.Boot)
|
||||||
|
|
||||||
// Graceful shutdown.
|
// Graceful shutdown.
|
||||||
stop := make(chan os.Signal, 1)
|
stop := make(chan os.Signal, 1)
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import (
|
|||||||
|
|
||||||
// AutoMigrate creates tables for every model registered in the model package.
|
// AutoMigrate creates tables for every model registered in the model package.
|
||||||
func AutoMigrate(db *gorm.DB) error {
|
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 {
|
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -37,6 +42,27 @@ func ensureSQLiteQueryOptimizer(db *gorm.DB) error {
|
|||||||
return db.Exec("ANALYZE").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 {
|
func ensurePostgresColumnCompatibility(db *gorm.DB) error {
|
||||||
if !isPostgres(db) {
|
if !isPostgres(db) {
|
||||||
return nil
|
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/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
"github.com/truewhile/MeBox/internal/middleware"
|
"github.com/truewhile/MeBox/internal/middleware"
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
@@ -164,18 +165,20 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID)
|
helper.Run(svc.Log, "media.remotePreview", func() {
|
||||||
if acct == nil {
|
acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID)
|
||||||
return
|
if acct == nil {
|
||||||
}
|
return
|
||||||
tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}}
|
}
|
||||||
itemTypes := remoteLibraryItemTypes(v.CollectionType)
|
tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}}
|
||||||
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
|
itemTypes := remoteLibraryItemTypes(v.CollectionType)
|
||||||
remotePayloads[i].Total = total
|
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
|
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
|
||||||
}
|
remotePayloads[i].Cards = cards
|
||||||
|
}
|
||||||
|
})
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
"github.com/truewhile/MeBox/internal/service"
|
"github.com/truewhile/MeBox/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,7 +42,11 @@ func scanLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
task := startScanHTTPTask(svc, "手动扫描入库", lib.Name, lib.Path)
|
task := startScanHTTPTask(svc, "手动扫描入库", lib.Name, lib.Path)
|
||||||
go func(libraryID string, task *service.TaskHandle, finish func()) {
|
go func(libraryID string, task *service.TaskHandle, finish func()) {
|
||||||
defer finish()
|
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 {
|
if err != nil {
|
||||||
finishHTTPTask(task, err, "scan", "手动扫描入库失败", scanTaskMetrics(res), scanTaskDetails(res, 20))
|
finishHTTPTask(task, err, "scan", "手动扫描入库失败", scanTaskMetrics(res), scanTaskDetails(res, 20))
|
||||||
return
|
return
|
||||||
@@ -75,7 +80,11 @@ func scanLibraryRootHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
task := startScanHTTPTask(svc, "手动扫描媒体库路径", id, rootID)
|
task := startScanHTTPTask(svc, "手动扫描媒体库路径", id, rootID)
|
||||||
go func(libraryID, libraryRootID string, task *service.TaskHandle, finish func()) {
|
go func(libraryID, libraryRootID string, task *service.TaskHandle, finish func()) {
|
||||||
defer finish()
|
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 {
|
if err != nil {
|
||||||
finishHTTPTask(task, err, "scan", "手动扫描路径失败", scanTaskMetrics(res), scanTaskDetails(res, 20))
|
finishHTTPTask(task, err, "scan", "手动扫描路径失败", scanTaskMetrics(res), scanTaskDetails(res, 20))
|
||||||
return
|
return
|
||||||
@@ -110,11 +119,13 @@ func queueLibraryRootScan(svc *service.Container, libraryID, rootID string) {
|
|||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
defer finish()
|
defer finish()
|
||||||
if strings.TrimSpace(rootID) == "" {
|
helper.Run(svc.Log, "scan.queuedRoot", func() {
|
||||||
_, _ = svc.Scan.ScanLibrary(context.Background(), libraryID)
|
if strings.TrimSpace(rootID) == "" {
|
||||||
return
|
_, _ = svc.Scan.ScanLibrary(context.Background(), libraryID)
|
||||||
}
|
return
|
||||||
_, _ = svc.Scan.ScanLibraryRoot(context.Background(), libraryID, rootID)
|
}
|
||||||
|
_, _ = 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"
|
import "time"
|
||||||
|
|
||||||
// PlaybackHistory 记录当前播放位置以支持续播。
|
// PlaybackHistory 记录当前播放位置以支持续播。
|
||||||
|
// (user_id, media_id) 唯一:播放进度每几秒上报一次,唯一索引保证并发上报
|
||||||
|
// 不会插入重复行(否则续播列表会出现重复卡片),也让 upsert 单语句完成。
|
||||||
type PlaybackHistory struct {
|
type PlaybackHistory struct {
|
||||||
Base
|
Base
|
||||||
UserID string `gorm:"index;size:36;not null" json:"user_id"`
|
UserID string `gorm:"index;size:36;not null;uniqueIndex:uniq_user_history" json:"user_id"`
|
||||||
MediaID string `gorm:"index;size:128;not null" json:"media_id"`
|
MediaID string `gorm:"index;size:128;not null;uniqueIndex:uniq_user_history" json:"media_id"`
|
||||||
PositionMs int64 `json:"position_ms"`
|
PositionMs int64 `json:"position_ms"`
|
||||||
DurationMs int64 `json:"duration_ms"`
|
DurationMs int64 `json:"duration_ms"`
|
||||||
WatchedAt time.Time `json:"watched_at"`
|
WatchedAt time.Time `json:"watched_at"`
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
)
|
)
|
||||||
@@ -13,25 +13,24 @@ import (
|
|||||||
// upserts on (UserID, MediaID) so resume always reads the latest position.
|
// upserts on (UserID, MediaID) so resume always reads the latest position.
|
||||||
type HistoryRepository struct{ db *gorm.DB }
|
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 {
|
func (r *HistoryRepository) Upsert(ctx context.Context, h *model.PlaybackHistory) error {
|
||||||
var existing model.PlaybackHistory
|
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||||
err := r.db.WithContext(ctx).
|
Columns: []clause.Column{{Name: "user_id"}, {Name: "media_id"}},
|
||||||
Where("user_id = ? AND media_id = ?", h.UserID, h.MediaID).
|
DoUpdates: clause.Assignments(map[string]any{
|
||||||
First(&existing).Error
|
"position_ms": h.PositionMs,
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
// 沿用旧语义:未知时长(0)不覆盖已记录的时长。
|
||||||
return r.db.WithContext(ctx).Create(h).Error
|
"duration_ms": gorm.Expr(
|
||||||
}
|
"CASE WHEN ? > 0 THEN ? ELSE playback_histories.duration_ms END",
|
||||||
if err != nil {
|
h.DurationMs, h.DurationMs,
|
||||||
return err
|
),
|
||||||
}
|
"watched_at": h.WatchedAt,
|
||||||
existing.PositionMs = h.PositionMs
|
"completed": h.Completed,
|
||||||
if h.DurationMs > 0 {
|
"deleted_at": nil,
|
||||||
existing.DurationMs = h.DurationMs
|
}),
|
||||||
}
|
}).Create(h).Error
|
||||||
existing.WatchedAt = h.WatchedAt
|
|
||||||
existing.Completed = h.Completed
|
|
||||||
return r.db.WithContext(ctx).Save(&existing).Error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListByUser returns the most recent history rows for the user.
|
// 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"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
)
|
)
|
||||||
@@ -50,6 +51,8 @@ func (a *AuditService) RecordBestEffort(userID, action, target, ip, detail strin
|
|||||||
go func() {
|
go func() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
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"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
)
|
)
|
||||||
@@ -176,9 +177,11 @@ func (s *AuthService) touchLoginBestEffort(userID string) {
|
|||||||
go func() {
|
go func() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := s.repo.User.TouchLogin(ctx, userID); err != nil && s.log != nil {
|
helper.Run(s.log, "auth.touchLogin", func() {
|
||||||
s.log.Debug("touch login delayed", zap.String("user_id", userID), zap.Error(err))
|
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 {
|
for i := range rows {
|
||||||
key := strings.TrimSpace(rows[i].SeriesID)
|
key := strings.TrimSpace(rows[i].SeriesID)
|
||||||
if key == "" {
|
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{}{}
|
seen[key] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,8 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
|||||||
pos = h.PositionMs
|
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 带前缀)直接透传远程。
|
// 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 {
|
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
groups := e.seriesGroupsFromMedia(rows)
|
groups := e.seriesGroupsFromMedia(ctx, rows)
|
||||||
sortSeriesGroups(groups, ItemsParams{SortBy: "premieredate", SortOrder: "Descending"})
|
sortSeriesGroups(groups, ItemsParams{SortBy: "premieredate", SortOrder: "Descending"})
|
||||||
if len(groups) > limit {
|
if len(groups) > limit {
|
||||||
groups = groups[:limit]
|
groups = groups[:limit]
|
||||||
@@ -415,9 +416,9 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
|
|||||||
seasonID := ""
|
seasonID := ""
|
||||||
if e.mediaShouldBeEpisode(ctx, m) {
|
if e.mediaShouldBeEpisode(ctx, m) {
|
||||||
itemType = "Episode"
|
itemType = "Episode"
|
||||||
seriesID = e.seriesIDForMedia(m)
|
seriesID = e.seriesIDForMedia(ctx, m)
|
||||||
seriesName = e.seriesNameForMedia(m)
|
seriesName = e.seriesNameForMedia(ctx, m)
|
||||||
seasonID = e.seasonIDForMedia(m)
|
seasonID = e.seasonIDForMedia(ctx, m)
|
||||||
parentID = seasonID
|
parentID = seasonID
|
||||||
episodeTitle := strings.TrimSpace(m.EpisodeTitle)
|
episodeTitle := strings.TrimSpace(m.EpisodeTitle)
|
||||||
if 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) {
|
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)
|
rows = e.collapseMediaVersionRows(ctx, rows)
|
||||||
userFavs := map[string]bool{}
|
userFavs := map[string]bool{}
|
||||||
userPos := map[string]int64{}
|
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 {
|
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
groups := e.seriesGroupsFromMedia(rows)
|
groups := e.seriesGroupsFromMedia(ctx, rows)
|
||||||
sortSeriesGroups(groups, p)
|
sortSeriesGroups(groups, p)
|
||||||
total := len(groups)
|
total := len(groups)
|
||||||
items := make([]map[string]any, 0, minInt(p.Limit, 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
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
seriesGroups := e.seriesGroupsFromMedia(episodicRows)
|
seriesGroups := e.seriesGroupsFromMedia(ctx, episodicRows)
|
||||||
|
|
||||||
// 真正的电影 -> Movie 项(剔除剧集结构行)。
|
// 真正的电影 -> Movie 项(剔除剧集结构行)。
|
||||||
movieQ := apply(e.repo.DB.WithContext(ctx).Model(&model.Media{}))
|
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) == "" {
|
if strings.TrimSpace(libraryID) == "" {
|
||||||
return false, nil
|
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
|
return false, err
|
||||||
} else if lib != nil {
|
} else if ok {
|
||||||
return embyLibraryTypeIsEpisodic(lib.Type), nil
|
return embyLibraryTypeIsEpisodic(typ), nil
|
||||||
}
|
}
|
||||||
var count int64
|
var count int64
|
||||||
err := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
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) == "" {
|
if e == nil || m == nil || strings.TrimSpace(m.LibraryID) == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
lib, err := e.repo.Library.FindByID(ctx, m.LibraryID)
|
typ, ok, err := e.payloadLibraryType(ctx, m.LibraryID)
|
||||||
if err != nil || lib == nil {
|
if err != nil || !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return embyLibraryTypeIsEpisodic(lib.Type)
|
return embyLibraryTypeIsEpisodic(typ)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *EmbyService) mediaShouldBeEpisode(ctx context.Context, m *model.Media) bool {
|
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"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -609,32 +610,34 @@ func (r *EmbyRemoteService) RemoteSearchMedia(ctx context.Context, query string,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
q := url.Values{}
|
helper.Run(r.log, "emby.remoteSearch", func() {
|
||||||
q.Set("ParentId", target.mount.RemoteViewID)
|
q := url.Values{}
|
||||||
q.Set("Recursive", "true")
|
q.Set("ParentId", target.mount.RemoteViewID)
|
||||||
q.Set("SearchTerm", query)
|
q.Set("Recursive", "true")
|
||||||
q.Set("IncludeItemTypes", "Movie,Series")
|
q.Set("SearchTerm", query)
|
||||||
q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating")
|
q.Set("IncludeItemTypes", "Movie,Series")
|
||||||
q.Set("Limit", strconv.Itoa(limit))
|
q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating")
|
||||||
q.Set("StartIndex", "0")
|
q.Set("Limit", strconv.Itoa(limit))
|
||||||
|
q.Set("StartIndex", "0")
|
||||||
|
|
||||||
var body struct {
|
var body struct {
|
||||||
Items []map[string]any `json:"Items"`
|
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))
|
|
||||||
}
|
}
|
||||||
return
|
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 {
|
||||||
medias := make([]model.Media, 0, len(body.Items))
|
r.log.Warn("remote search failed",
|
||||||
for _, it := range body.Items {
|
zap.String("mount", target.mount.RemoteViewName), zap.Error(err))
|
||||||
RewriteEmbyRemoteIDs(it, target.mount.ID)
|
}
|
||||||
m := r.MapRemoteItemToMedia(searchCtx, &target.mount, target.acct, target.cfg, it)
|
return
|
||||||
medias = append(medias, m)
|
}
|
||||||
}
|
medias := make([]model.Media, 0, len(body.Items))
|
||||||
results[idx] = searchResult{items: medias}
|
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)
|
}(i, t)
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|||||||
@@ -49,11 +49,15 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e
|
|||||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||||
if !strings.HasPrefix(id, embyVirtualSeriesPrefix) {
|
if !strings.HasPrefix(id, embyVirtualSeriesPrefix) {
|
||||||
q = q.Where("series_id = ?", id)
|
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 {
|
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
|
return embySeriesGroup{}, false, err
|
||||||
}
|
}
|
||||||
for _, group := range e.seriesGroupsFromMedia(rows) {
|
for _, group := range e.seriesGroupsFromMedia(ctx, rows) {
|
||||||
if group.ID == id {
|
if group.ID == id {
|
||||||
e.rememberSeriesGroup(group)
|
e.rememberSeriesGroup(group)
|
||||||
return group, true, nil
|
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 {
|
if season, ok := e.cachedSeasonGroup(id); ok {
|
||||||
return season, true, nil
|
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
|
var rows []model.Media
|
||||||
q := e.repo.DB.WithContext(ctx).Model(&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)
|
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||||
if err := q.
|
if err := q.
|
||||||
Order("media.season_num asc, media.episode_num asc, media.created_at asc").
|
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 {
|
Find(&rows).Error; err != nil {
|
||||||
return embySeasonGroup{}, false, err
|
return embySeasonGroup{}, false, err
|
||||||
}
|
}
|
||||||
for _, series := range e.seriesGroupsFromMedia(rows) {
|
for _, series := range e.seriesGroupsFromMedia(ctx, rows) {
|
||||||
for _, season := range e.seasonsForSeries(series) {
|
for _, season := range e.seasonsForSeries(series) {
|
||||||
if season.ID == id {
|
if season.ID == id {
|
||||||
e.rememberSeriesGroup(series)
|
e.rememberSeriesGroup(series)
|
||||||
@@ -109,18 +122,77 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
|
|||||||
return embySeasonGroup{}, false, nil
|
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{}
|
byID := map[string]*embySeriesGroup{}
|
||||||
order := []string{}
|
order := []string{}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
row := row
|
row := row
|
||||||
seriesID := e.seriesIDForMedia(&row)
|
seriesID := e.seriesIDForMedia(ctx, &row)
|
||||||
group, ok := byID[seriesID]
|
group, ok := byID[seriesID]
|
||||||
if !ok {
|
if !ok {
|
||||||
group = &embySeriesGroup{
|
group = &embySeriesGroup{
|
||||||
ID: seriesID,
|
ID: seriesID,
|
||||||
LibraryID: row.LibraryID,
|
LibraryID: row.LibraryID,
|
||||||
Name: e.seriesNameForMedia(&row),
|
Name: e.seriesNameForMedia(ctx, &row),
|
||||||
Year: row.Year,
|
Year: row.Year,
|
||||||
ReleaseDate: row.ReleaseDate,
|
ReleaseDate: row.ReleaseDate,
|
||||||
TMDbID: row.TMDbID,
|
TMDbID: row.TMDbID,
|
||||||
|
|||||||
@@ -14,21 +14,22 @@ import (
|
|||||||
"github.com/truewhile/MeBox/internal/model"
|
"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) != "" {
|
if strings.TrimSpace(m.SeriesID) != "" {
|
||||||
return 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 {
|
func (e *EmbyService) seasonIDForMedia(ctx context.Context, m *model.Media) string {
|
||||||
return seasonID(e.seriesIDForMedia(m), m.SeasonNum)
|
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 strings.TrimSpace(m.SeriesID) != "" {
|
||||||
if series, err := e.repo.Series.FindByID(context.Background(), m.SeriesID); err == nil && series != nil && strings.TrimSpace(series.Title) != "" {
|
// 走请求级缓存;无缓存 ctx 时退化为单次查询。
|
||||||
return series.Title
|
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) != "" {
|
if strings.EqualFold(strings.TrimSpace(m.ScrapeStatus), "matched") && strings.TrimSpace(m.Title) != "" {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -152,7 +153,7 @@ func (s *FFmpegToolsService) StartInstall(ctx context.Context) error {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
s.setMessage("准备下载…")
|
s.setMessage("准备下载…")
|
||||||
go s.runInstall()
|
helper.Go(s.log, "ffmpeg.install", s.runInstall)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ func (s *ScannerService) startLocalMediaProbeWorkers() {
|
|||||||
s.localMediaProbeOnce.Do(func() {
|
s.localMediaProbeOnce.Do(func() {
|
||||||
workers := s.ffprobeWorkerCount()
|
workers := s.ffprobeWorkerCount()
|
||||||
for i := 0; i < workers; i++ {
|
for i := 0; i < workers; i++ {
|
||||||
go s.localMediaProbeWorker()
|
helper.Go(s.log, "scanner.probeWorker", s.localMediaProbeWorker)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *ScannerService) invalidateMediaCache(ctx context.Context) {
|
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)
|
scrapeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Minute)
|
||||||
go func() {
|
go func() {
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_, err := s.scraper.EnrichLibraryDetailedWithOptions(scrapeCtx, libraryID, skipEpisodeArtworkOptions(false))
|
// 扫描触发的后台刮削与请求线程无关,panic 只记日志,不能带崩进程。
|
||||||
if err != nil {
|
helper.Run(s.log, "scanner.autoScrape", func() {
|
||||||
s.log.Warn("scraper enrich failed", zap.Error(err))
|
_, err := s.scraper.EnrichLibraryDetailedWithOptions(scrapeCtx, libraryID, skipEpisodeArtworkOptions(false))
|
||||||
return
|
if err != nil {
|
||||||
}
|
s.log.Warn("scraper enrich failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
})
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -139,7 +140,7 @@ func (s *SchedulerService) Start(ctx context.Context) {
|
|||||||
// 首轮等满一个完整周期再跑,平时节奏不变。
|
// 首轮等满一个完整周期再跑,平时节奏不变。
|
||||||
initialDelay = j.interval
|
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"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
)
|
)
|
||||||
|
|
||||||
// JobStatus is a snapshot suitable for the admin UI.
|
// 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 {
|
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()
|
s.mu.Lock()
|
||||||
j.lastRun = s.currentTime()
|
j.lastRun = s.currentTime()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -73,7 +74,10 @@ func (s *ScraperService) queueWorker(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
|
|
||||||
s.processScrapeTask(ctx, t)
|
// 刮削要解析远端元数据响应,单个任务 panic 不应拖垮队列 worker。
|
||||||
|
helper.Run(s.log, "scraper.task", func() {
|
||||||
|
s.processScrapeTask(ctx, t)
|
||||||
|
})
|
||||||
}(&tasks[i])
|
}(&tasks[i])
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ func (c *Container) Boot() {
|
|||||||
if err := c.APIConfig.SeedDefaults(c.stopCtx); err != nil {
|
if err := c.APIConfig.SeedDefaults(c.stopCtx); err != nil {
|
||||||
c.Log.Warn("api config seed failed", zap.Error(err))
|
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)
|
c.Scheduler.Start(c.stopCtx)
|
||||||
@@ -119,7 +120,7 @@ func (c *Container) Boot() {
|
|||||||
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
|
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
|
||||||
// 每天触发一次评估;规则里的窗口可随机,不固定。
|
// 每天触发一次评估;规则里的窗口可随机,不固定。
|
||||||
if c.Device != nil {
|
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"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
)
|
)
|
||||||
@@ -47,12 +48,12 @@ func newServiceContainer(cfg *config.Config, log *zap.Logger, repos *repository.
|
|||||||
|
|
||||||
func (b *serviceContainerBuilder) startRealtimeServices() {
|
func (b *serviceContainerBuilder) startRealtimeServices() {
|
||||||
b.c.WSHub = NewHub(b.log)
|
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.Tasks = NewTaskTrackerService(b.log, b.c.WSHub)
|
||||||
b.c.SystemUpdate = NewSystemUpdateService(b.cfg, b.log, b.repos, b.c.Tasks, b.version)
|
b.c.SystemUpdate = NewSystemUpdateService(b.cfg, b.log, b.repos, b.c.Tasks, b.version)
|
||||||
|
|
||||||
b.c.SSEHub = NewSSEHub(b.log)
|
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() {
|
func (b *serviceContainerBuilder) initProviderServices() {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/truewhile/MeBox/internal/service/cloud"
|
"github.com/truewhile/MeBox/internal/service/cloud"
|
||||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||||
@@ -66,7 +67,10 @@ func (s *StrmService) downloadWorker(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer s.releaseDownloadSlot(task.Provider)
|
defer s.releaseDownloadSlot(task.Provider)
|
||||||
s.processDownloadTask(ctx, task)
|
// 单个任务 panic 不应拖垮整个下载 worker。
|
||||||
|
helper.Run(s.log, "strm.downloadTask", func() {
|
||||||
|
s.processDownloadTask(ctx, task)
|
||||||
|
})
|
||||||
}(i)
|
}(i)
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
"github.com/truewhile/MeBox/internal/service/cloud"
|
"github.com/truewhile/MeBox/internal/service/cloud"
|
||||||
@@ -186,14 +187,14 @@ func (s *StrmService) Start(ctx context.Context) {
|
|||||||
uploadThreads = 4
|
uploadThreads = 4
|
||||||
}
|
}
|
||||||
for i := 0; i < downloadThreads; i++ {
|
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++ {
|
for i := 0; i < uploadThreads; i++ {
|
||||||
go s.uploadWorker(ctx)
|
helper.Go(s.log, "strm.uploadWorker", func() { s.uploadWorker(ctx) })
|
||||||
}
|
}
|
||||||
go s.cronLoop(ctx)
|
helper.Go(s.log, "strm.cronLoop", func() { s.cronLoop(ctx) })
|
||||||
go s.queueCleanupLoop(ctx)
|
helper.Go(s.log, "strm.queueCleanupLoop", func() { s.queueCleanupLoop(ctx) })
|
||||||
go s.refresh115TokensLoop(ctx)
|
helper.Go(s.log, "strm.refresh115TokensLoop", func() { s.refresh115TokensLoop(ctx) })
|
||||||
s.log.Info("strm service started",
|
s.log.Info("strm service started",
|
||||||
zap.Int("download_threads", downloadThreads),
|
zap.Int("download_threads", downloadThreads),
|
||||||
zap.Int("upload_threads", uploadThreads))
|
zap.Int("upload_threads", uploadThreads))
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/truewhile/MeBox/internal/service/cloud"
|
"github.com/truewhile/MeBox/internal/service/cloud"
|
||||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||||
@@ -105,7 +106,7 @@ func (s *StrmService) StartSync(ctx context.Context, pathID string, syncType ...
|
|||||||
p.LastSyncMessage = "同步进行中"
|
p.LastSyncMessage = "同步进行中"
|
||||||
_ = s.repo.StrmSyncPath.Update(ctx, p)
|
_ = 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,38 +362,45 @@ func (st *strmSyncState) walkRemote() error {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for task := range queue {
|
// worker 解析远端响应 panic 时取消整个同步,让 closer 与其余
|
||||||
if ctx.Err() != nil {
|
// worker 正常收尾,避免队列与 pending 计数卡死;正常退出不取消。
|
||||||
return
|
if err := helper.Recover(st.s.log, "strm.sync.walkRemote", func() error {
|
||||||
}
|
for task := range queue {
|
||||||
entries, err := st.provider.List(ctx, task.id)
|
if ctx.Err() != nil {
|
||||||
if err != nil {
|
return nil
|
||||||
errMu.Lock()
|
|
||||||
if firstErr == nil {
|
|
||||||
firstErr = fmt.Errorf("列出远端目录 %s 失败:%w", task.id, err)
|
|
||||||
}
|
}
|
||||||
errMu.Unlock()
|
entries, err := st.provider.List(ctx, task.id)
|
||||||
cancel()
|
if err != nil {
|
||||||
return
|
errMu.Lock()
|
||||||
}
|
if firstErr == nil {
|
||||||
for _, entry := range entries {
|
firstErr = fmt.Errorf("列出远端目录 %s 失败:%w", task.id, err)
|
||||||
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 {
|
errMu.Unlock()
|
||||||
st.processRemoteFile(entry, rel)
|
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)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for t := range taskCh {
|
// 分页拉取 panic 时取消整个同步;正常退出不取消。
|
||||||
if ctx.Err() != nil {
|
if err := helper.Recover(st.s.log, "strm.sync.walk115.page", func() error {
|
||||||
return
|
for t := range taskCh {
|
||||||
}
|
if ctx.Err() != nil {
|
||||||
files, _, err := open115.GetFsListFlat(ctx, rootCID, t.offset, pageSize)
|
return nil
|
||||||
if err != nil {
|
|
||||||
errMu.Lock()
|
|
||||||
if fetchErr == nil {
|
|
||||||
fetchErr = err
|
|
||||||
}
|
}
|
||||||
errMu.Unlock()
|
files, _, err := open115.GetFsListFlat(ctx, rootCID, t.offset, pageSize)
|
||||||
return
|
if err != nil {
|
||||||
|
errMu.Lock()
|
||||||
|
if fetchErr == nil {
|
||||||
|
fetchErr = err
|
||||||
|
}
|
||||||
|
errMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
filesMu.Lock()
|
||||||
|
allFiles = append(allFiles, files...)
|
||||||
|
filesMu.Unlock()
|
||||||
}
|
}
|
||||||
filesMu.Lock()
|
return nil
|
||||||
allFiles = append(allFiles, files...)
|
}); err != nil {
|
||||||
filesMu.Unlock()
|
cancel()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -632,63 +646,70 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
|
|||||||
pwg.Add(1)
|
pwg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer pwg.Done()
|
defer pwg.Done()
|
||||||
for pid := range pidCh {
|
// 解析目录详情 panic 时中止整个同步(避免带着损坏的相对路径
|
||||||
if ctx.Err() != nil {
|
// 继续执行);正常退出不取消。
|
||||||
return
|
if err := helper.Recover(st.s.log, "strm.sync.walk115.dirTree", func() error {
|
||||||
}
|
for pid := range pidCh {
|
||||||
if _, loaded := st.dirCache.Load(pid); loaded {
|
if ctx.Err() != nil {
|
||||||
if n := doneDirs.Add(1); n%20 == 0 || n == int64(totalDirs) {
|
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))
|
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"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
@@ -35,6 +37,21 @@ type SubtitleService struct {
|
|||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
repo *repository.Container
|
repo *repository.Container
|
||||||
cfg *config.Config
|
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.
|
// 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) {
|
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)
|
m, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -132,7 +133,7 @@ func (s *SystemUpdateService) Apply(ctx context.Context) (SystemUpdateStatus, er
|
|||||||
s.last = &status
|
s.last = &status
|
||||||
s.mu.Unlock()
|
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
|
return status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
)
|
)
|
||||||
@@ -26,11 +27,14 @@ func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash string, exp
|
|||||||
go func() {
|
go func() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
done <- s.storeRefreshToken(ctx, &model.RefreshToken{
|
err := helper.Recover(s.log, "token.storeRefreshToken", func() error {
|
||||||
UserID: userID,
|
return s.storeRefreshToken(ctx, &model.RefreshToken{
|
||||||
TokenHash: tokenHash,
|
UserID: userID,
|
||||||
ExpiresAt: expiresAt,
|
TokenHash: tokenHash,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
done <- err
|
||||||
}()
|
}()
|
||||||
select {
|
select {
|
||||||
case err := <-done:
|
case err := <-done:
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/truewhile/MeBox/internal/config"
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"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.jobs[mediaID] = job
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
|
|
||||||
go t.monitorIdle(jobCtx, job)
|
helper.Go(t.log, "transcoder.monitorIdle", func() { t.monitorIdle(jobCtx, job) })
|
||||||
go t.runFFmpeg(jobCtx, job, m.Path)
|
helper.Go(t.log, "transcoder.ffmpeg", func() { t.runFFmpeg(jobCtx, job, m.Path) })
|
||||||
return t.PlaylistPath(mediaID), nil
|
return t.PlaylistPath(mediaID), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/helper"
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
"github.com/truewhile/MeBox/internal/repository"
|
"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 {
|
if err := w.Refresh(ctx); err != nil {
|
||||||
w.log.Warn("watcher refresh failed", zap.Error(err))
|
w.log.Warn("watcher refresh failed", zap.Error(err))
|
||||||
}
|
}
|
||||||
go w.loop(ctx)
|
helper.Go(w.log, "watcher.loop", func() { w.loop(ctx) })
|
||||||
go w.debouncer(ctx)
|
helper.Go(w.log, "watcher.debouncer", func() { w.debouncer(ctx) })
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,8 @@
|
|||||||
<link rel="icon" type="image/png" href="/brand/logo-64.png" />
|
<link rel="icon" type="image/png" href="/brand/logo-64.png" />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/brand/logo-192.png" />
|
<link rel="apple-touch-icon" href="/brand/logo-192.png" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<!-- 字体已自托管(见 src/index.css 的 @font-face),不再使用 Google Fonts 外链:
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
自托管环境常无法访问 Google,渲染阻塞外链会拖慢首屏。 -->
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
<title>MeBox</title>
|
<title>MeBox</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="font-body antialiased">
|
<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 |
@@ -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="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">
|
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-lg bg-sand-100">
|
||||||
{item.poster_url ? (
|
{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>
|
<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 { Link } from 'react-router-dom'
|
||||||
import { motion } from 'framer-motion'
|
|
||||||
import { Film, Play, Layers, Star } from 'lucide-react'
|
import { Film, Play, Layers, Star } from 'lucide-react'
|
||||||
import { imageURL } from '../api/client'
|
import { imageURL } from '../api/client'
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
|
|
||||||
export const MediaCard = ({
|
// memo:父级状态变化(如轮播切图、其它卡片操作)不再级联重渲染所有卡片。
|
||||||
media, progress, count, rating, linkTo, onClick, actions, compact,
|
// 注意 actions/renderActions 必须引用稳定(用 renderActions 传函数)memo 才生效。
|
||||||
|
export const MediaCard = memo(function MediaCard({
|
||||||
|
media, progress, count, rating, linkTo, onClick, actions, renderActions, compact,
|
||||||
}: {
|
}: {
|
||||||
media: Media
|
media: Media
|
||||||
progress?: number
|
progress?: number
|
||||||
@@ -15,25 +16,26 @@ export const MediaCard = ({
|
|||||||
linkTo?: string
|
linkTo?: string
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
actions?: ReactNode
|
actions?: ReactNode
|
||||||
|
renderActions?: (media: Media) => ReactNode
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
}) => {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const href = linkTo ?? `/media/${media.id}`
|
const href = linkTo ?? `/media/${media.id}`
|
||||||
const [posterFit, setPosterFit] = useState<'cover' | 'contain'>('cover')
|
const [posterFit, setPosterFit] = useState<'cover' | 'contain'>('cover')
|
||||||
const posterSrc = imageURL(media.poster_url, media.updated_at)
|
const posterSrc = imageURL(media.poster_url, media.updated_at)
|
||||||
const displayRating = rating ?? media.rating
|
const displayRating = rating ?? media.rating
|
||||||
const versionCount = media.versions?.length ?? 0
|
const versionCount = media.versions?.length ?? 0
|
||||||
|
// renderActions 延迟到卡片自身渲染时才调用,保证 memo 生效
|
||||||
|
const actionContent = actions ?? renderActions?.(media)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPosterFit('cover')
|
setPosterFit('cover')
|
||||||
}, [media.poster_url, media.updated_at])
|
}, [media.poster_url, media.updated_at])
|
||||||
|
|
||||||
const card = (
|
const card = (
|
||||||
<motion.div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
whileHover={{ scale: 1.04, y: -6 }}
|
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)]"
|
||||||
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)]"
|
|
||||||
>
|
>
|
||||||
{/* Poster Wrapper */}
|
{/* Poster Wrapper */}
|
||||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-[var(--app-panel-soft)]">
|
<div className="relative aspect-[2/3] w-full overflow-hidden bg-[var(--app-panel-soft)]">
|
||||||
@@ -95,16 +97,11 @@ export const MediaCard = ({
|
|||||||
</span>
|
</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 ${
|
<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'
|
compact ? 'p-3' : 'p-4'
|
||||||
}`}>
|
}`}>
|
||||||
<motion.div
|
<div className="space-y-2 translate-y-4 transition-transform duration-200 group-hover:translate-y-0">
|
||||||
initial={{ y: 15, opacity: 0 }}
|
|
||||||
whileHover={{ y: 0, opacity: 1 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="space-y-2"
|
|
||||||
>
|
|
||||||
<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 ${
|
<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'
|
compact ? 'py-1.5 text-[11px]' : 'text-xs py-2'
|
||||||
}`}>
|
}`}>
|
||||||
@@ -116,7 +113,7 @@ export const MediaCard = ({
|
|||||||
{media.overview || "暂无简介内容"}
|
{media.overview || "暂无简介内容"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress Bar overlay */}
|
{/* Progress Bar overlay */}
|
||||||
@@ -148,7 +145,7 @@ export const MediaCard = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
@@ -157,23 +154,23 @@ export const MediaCard = ({
|
|||||||
<button type="button" onClick={onClick} className="block w-full text-left">
|
<button type="button" onClick={onClick} className="block w-full text-left">
|
||||||
{card}
|
{card}
|
||||||
</button>
|
</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">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actions) {
|
if (actionContent) {
|
||||||
return (
|
return (
|
||||||
<div className="group relative block">
|
<div className="group relative block">
|
||||||
<Link to={href} className="block">
|
<Link to={href} className="block">
|
||||||
{card}
|
{card}
|
||||||
</Link>
|
</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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -184,4 +181,4 @@ export const MediaCard = ({
|
|||||||
{card}
|
{card}
|
||||||
</Link>
|
</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 } = {}
|
options: { autoFetch?: boolean } = {}
|
||||||
): boolean {
|
): boolean {
|
||||||
const { autoFetch = true } = options
|
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 tier = useAuthStore((state) => state.tier)
|
||||||
const role = useAuthStore((state) => state.user?.role)
|
const role = useAuthStore((state) => state.user?.role)
|
||||||
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
||||||
const hasSuperAccess = isSuper || tier === 'plus' || role === 'admin'
|
const hasSuperAccess = isSuper || tier === 'plus' || role === 'admin'
|
||||||
|
|
||||||
// 权限未加载时自动获取
|
// 权限未加载时自动获取;用 getState() 读最新快照而不是渲染闭包,
|
||||||
|
// 同一次 commit 内挂载的多个消费方也只会有一个发出请求(store 内还有 inflight 去重兜底)。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasSuperAccess && isAuthenticated && autoFetch && Object.keys(permissions).length === 0 && !isLoading) {
|
if (!hasSuperAccess && isAuthenticated && autoFetch) {
|
||||||
fetchPermissions()
|
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) {
|
if (hasSuperAccess) {
|
||||||
@@ -78,7 +85,11 @@ export function usePermission(
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function usePermissions() {
|
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 tier = useAuthStore((state) => state.tier)
|
||||||
const role = useAuthStore((state) => state.user?.role)
|
const role = useAuthStore((state) => state.user?.role)
|
||||||
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
||||||
|
|||||||
@@ -2,6 +2,74 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@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%; }
|
html, body, #root { height: 100%; }
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ function LibraryTableRow({ library, dragging, dragOver, onDragStart, onDragOver,
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-2 pr-3 font-medium text-ink-600">
|
<td className="py-2 pr-3 font-medium text-ink-600">
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<span>{library.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ export function AdminUsersPanel() {
|
|||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh().catch(() => undefined)
|
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)
|
return () => window.clearInterval(timer)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ReactNode } from 'react'
|
import { memo, type ReactNode } from 'react'
|
||||||
import { Film } from 'lucide-react'
|
import { Film } from 'lucide-react'
|
||||||
|
|
||||||
import { MediaCard } from '../components/MediaCard'
|
import { MediaCard } from '../components/MediaCard'
|
||||||
@@ -33,7 +33,9 @@ export function LibraryMediaSections({
|
|||||||
{!isSeries && items.length > 0 && (
|
{!isSeries && items.length > 0 && (
|
||||||
<MediaGrid count={items.length} renderItem={(index) => {
|
<MediaGrid count={items.length} renderItem={(index) => {
|
||||||
const media = items[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) => {
|
<MediaGrid count={seriesCards.length} renderItem={(index) => {
|
||||||
const series = seriesCards[index]
|
const series = seriesCards[index]
|
||||||
return (
|
return (
|
||||||
<MediaCard
|
<SeriesCardItem
|
||||||
key={series.key}
|
key={series.key}
|
||||||
media={series.rep}
|
series={series}
|
||||||
count={series.count}
|
cardActions={cardActions}
|
||||||
actions={cardActions(series.rep)}
|
onSeriesClick={onSeriesClick}
|
||||||
onClick={() => onSeriesClick(series)}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}} />
|
}} />
|
||||||
@@ -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 }) {
|
function MediaGrid({ count, renderItem }: { count: number; renderItem: (index: number) => ReactNode }) {
|
||||||
if (count <= VIRTUALIZE_THRESHOLD) {
|
if (count <= VIRTUALIZE_THRESHOLD) {
|
||||||
return <div className={MEDIA_GRID_CLASS}>{Array.from({ length: count }, (_, index) => renderItem(index))}</div>
|
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 { useLocation, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ export function LibraryPage() {
|
|||||||
setManualMovie,
|
setManualMovie,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleToggleFavourite = async (mediaID: string) => {
|
const handleToggleFavourite = useCallback(async (mediaID: string) => {
|
||||||
if (!canFavorite || favouriteBusyID) return
|
if (!canFavorite || favouriteBusyID) return
|
||||||
setFavouriteBusyID(mediaID)
|
setFavouriteBusyID(mediaID)
|
||||||
try {
|
try {
|
||||||
@@ -183,9 +183,11 @@ export function LibraryPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setFavouriteBusyID('')
|
setFavouriteBusyID('')
|
||||||
}
|
}
|
||||||
}
|
}, [canFavorite, favouriteBusyID, toggleFavourite])
|
||||||
|
|
||||||
const cardActions = (media: Media): ReactNode => {
|
// useCallback 稳定引用:配合 MediaCard 的 memo,仅在收藏状态/操作集变化时
|
||||||
|
// 才让卡片重渲染。
|
||||||
|
const cardActions = useCallback((media: Media): ReactNode => {
|
||||||
const actions: ReactNode[] = []
|
const actions: ReactNode[] = []
|
||||||
if (canFavorite) {
|
if (canFavorite) {
|
||||||
actions.push(
|
actions.push(
|
||||||
@@ -206,7 +208,7 @@ export function LibraryPage() {
|
|||||||
}
|
}
|
||||||
if (actions.length === 0) return undefined
|
if (actions.length === 0) return undefined
|
||||||
return <>{actions}</>
|
return <>{actions}</>
|
||||||
}
|
}, [canFavorite, favouriteBusyID, handleToggleFavourite, isFavourite, movieActions])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ export function LibrarySeriesDetailHeader({
|
|||||||
<img
|
<img
|
||||||
src={imageURL(series.rep.poster_url, series.rep.updated_at)}
|
src={imageURL(series.rep.poster_url, series.rep.updated_at)}
|
||||||
alt={series.rep.title}
|
alt={series.rep.title}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
className="aspect-[2/3] w-full object-cover"
|
className="aspect-[2/3] w-full object-cover"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Ban,
|
Ban,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
Copy,
|
|
||||||
ExternalLink,
|
|
||||||
Eye,
|
Eye,
|
||||||
Film,
|
Film,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
@@ -26,6 +23,8 @@ import { imageURL } from '../api/client'
|
|||||||
import { scraperAPI } from '../api/scraper'
|
import { scraperAPI } from '../api/scraper'
|
||||||
import type { ScrapeQueueSnapshot, ScrapeTask, ScrapeTaskStatus } from '../types/scraper'
|
import type { ScrapeQueueSnapshot, ScrapeTask, ScrapeTaskStatus } from '../types/scraper'
|
||||||
import { apiErrorMessage, formatTime, taskStatusMeta } from './StrmManagePage'
|
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 }[] = [
|
const FILTERS: { key: 'all' | ScrapeTaskStatus; label: string; icon: typeof Clock; color: string }[] = [
|
||||||
{ key: 'all', label: '全部', icon: Sparkles, color: 'text-ink-600' },
|
{ 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' },
|
{ 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> = {
|
const TYPE_ICONS: Record<string, ReactNode> = {
|
||||||
movie: <Film size={14} className="text-blue-500" />,
|
movie: <Film size={14} className="text-blue-500" />,
|
||||||
tv: <Tv size={14} className="text-purple-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" />,
|
adult: <Film size={14} className="text-rose-500" />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_LABELS: Record<string, string> = {
|
|
||||||
movie: '电影',
|
|
||||||
tv: '剧集',
|
|
||||||
anime: '动漫',
|
|
||||||
adult: 'Adult',
|
|
||||||
}
|
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
||||||
@@ -70,7 +54,7 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
|||||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||||
const [batchBusy, setBatchBusy] = useState(false)
|
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 [detailTask, setDetailTask] = useState<ScrapeTask | null>(null)
|
||||||
|
|
||||||
const refresh = useCallback(
|
const refresh = useCallback(
|
||||||
@@ -103,19 +87,17 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoRefresh) return
|
if (!autoRefresh) return
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
|
if (document.hidden) return
|
||||||
refresh().catch(() => undefined)
|
refresh().catch(() => undefined)
|
||||||
}, 3000)
|
}, 3000)
|
||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
}, [autoRefresh, refresh])
|
}, [autoRefresh, refresh])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedIds(new Set())
|
clearSelection()
|
||||||
}, [filter, page])
|
}, [filter, page, clearSelection])
|
||||||
|
|
||||||
const copyText = (text: string, label: string) => {
|
const copyText = copyToClipboard
|
||||||
navigator.clipboard.writeText(text)
|
|
||||||
toast.success(`已复制${label}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Row actions
|
// Row actions
|
||||||
const cancelTask = async (task: ScrapeTask) => {
|
const cancelTask = async (task: ScrapeTask) => {
|
||||||
@@ -237,22 +219,7 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
|||||||
const allCurrentChecked =
|
const allCurrentChecked =
|
||||||
filteredTasks.length > 0 && filteredTasks.every((t) => selectedIds.has(t.id))
|
filteredTasks.length > 0 && filteredTasks.every((t) => selectedIds.has(t.id))
|
||||||
|
|
||||||
const toggleSelectAll = () => {
|
const toggleSelectAll = () => toggleAllIds(filteredTasks.map((t) => t.id))
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -950,216 +917,3 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
|||||||
</div>
|
</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 toast from 'react-hot-toast'
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Cloud,
|
Cloud,
|
||||||
ExternalLink,
|
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
QrCode,
|
QrCode,
|
||||||
RefreshCw,
|
|
||||||
Search,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
Tv,
|
Tv,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import QRCode from 'qrcode'
|
import { strmAPI, type StrmRemoteEntry } from '../api/strm'
|
||||||
|
|
||||||
import { strmAPI, type Strm115Source, type StrmRemoteEntry } from '../api/strm'
|
|
||||||
import type { SettingDef } from './SettingsRow'
|
import type { SettingDef } from './SettingsRow'
|
||||||
import { SettingRow } from './SettingsRow'
|
import { SettingRow } from './SettingsRow'
|
||||||
import type { StrmAccount, StrmSyncPath, StrmSyncPathInput, StrmProvider } from '../types/strm'
|
import type { StrmAccount, StrmSyncPath, StrmSyncPathInput, StrmProvider } from '../types/strm'
|
||||||
import { STRM_PROVIDER_LABELS } from '../types/strm'
|
import { STRM_PROVIDER_LABELS } from '../types/strm'
|
||||||
import { apiErrorMessage } from './StrmManagePage'
|
import { apiErrorMessage } from './StrmManagePage'
|
||||||
|
import { Strm115AuthPanel } from './strm-dialogs/Strm115AuthPanel'
|
||||||
import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog'
|
import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog'
|
||||||
import {
|
import {
|
||||||
defaultEmbyRemoteLines,
|
defaultEmbyRemoteLines,
|
||||||
@@ -496,309 +492,6 @@ export function StrmAccountDialog({
|
|||||||
// ─── 115 登录二维码(canvas 渲染;115 返回的是扫码页面链接而非图片) ───────────
|
// ─── 115 登录二维码(canvas 渲染;115 返回的是扫码页面链接而非图片) ───────────
|
||||||
|
|
||||||
// QR 尺寸:物理像素 = CSS 像素(避免 canvas 被 CSS 缩放导致裁切/模糊)
|
// 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[] = [
|
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.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' },
|
{ key: 'strm.video_ext', label: '视频扩展名', type: 'text', hint: '逗号分隔,命中即生成 .strm' },
|
||||||
|
|||||||
@@ -95,7 +95,10 @@ export function StrmManagePage() {
|
|||||||
// 有进行中的同步时轮询刷新状态
|
// 有进行中的同步时轮询刷新状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!paths.some((p) => p.last_sync_status === 'running')) return
|
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)
|
return () => clearInterval(timer)
|
||||||
}, [paths, refresh])
|
}, [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 toast from 'react-hot-toast'
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -8,12 +8,7 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
File,
|
|
||||||
FileText,
|
|
||||||
Film,
|
|
||||||
Image as ImageIcon,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
MessageSquare,
|
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
@@ -26,6 +21,8 @@ import { strmAPI } from '../api/strm'
|
|||||||
import type { StrmQueueSnapshot, StrmTask, StrmTaskStatus } from '../types/strm'
|
import type { StrmQueueSnapshot, StrmTask, StrmTaskStatus } from '../types/strm'
|
||||||
import { STRM_PROVIDER_LABELS } from '../types/strm'
|
import { STRM_PROVIDER_LABELS } from '../types/strm'
|
||||||
import { apiErrorMessage, formatBytes, formatTime, taskStatusMeta } from './StrmManagePage'
|
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 }[] = [
|
const FILTERS: { key: 'all' | StrmTaskStatus; label: string; icon: typeof Clock; color: string }[] = [
|
||||||
{ key: 'all', label: '全部', icon: Clock, color: 'text-ink-600' },
|
{ 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
|
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({
|
export function StrmQueuePanel({
|
||||||
kind,
|
kind,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
@@ -71,7 +51,7 @@ export function StrmQueuePanel({
|
|||||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||||
const [batchBusy, setBatchBusy] = useState(false)
|
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 [detailTask, setDetailTask] = useState<StrmTask | null>(null)
|
||||||
|
|
||||||
const isDownload = kind === 'download'
|
const isDownload = kind === 'download'
|
||||||
@@ -109,6 +89,7 @@ export function StrmQueuePanel({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoRefresh) return
|
if (!autoRefresh) return
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
|
if (document.hidden) return
|
||||||
refresh().catch(() => undefined)
|
refresh().catch(() => undefined)
|
||||||
}, 3000)
|
}, 3000)
|
||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
@@ -116,13 +97,10 @@ export function StrmQueuePanel({
|
|||||||
|
|
||||||
// Clear selections when changing filter or page
|
// Clear selections when changing filter or page
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedIds(new Set())
|
clearSelection()
|
||||||
}, [filter, page])
|
}, [filter, page, clearSelection])
|
||||||
|
|
||||||
const copyText = (text: string, label: string) => {
|
const copyText = copyToClipboard
|
||||||
navigator.clipboard.writeText(text)
|
|
||||||
toast.success(`已复制${label}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Task actions
|
// Task actions
|
||||||
const cancelTask = async (task: StrmTask) => {
|
const cancelTask = async (task: StrmTask) => {
|
||||||
@@ -234,22 +212,7 @@ export function StrmQueuePanel({
|
|||||||
const allCurrentChecked =
|
const allCurrentChecked =
|
||||||
filteredTasks.length > 0 && filteredTasks.every((t) => selectedIds.has(t.id))
|
filteredTasks.length > 0 && filteredTasks.every((t) => selectedIds.has(t.id))
|
||||||
|
|
||||||
const toggleSelectAll = () => {
|
const toggleSelectAll = () => toggleAllIds(filteredTasks.map((t) => t.id))
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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() {
|
export function StrmDownloadQueuePage() {
|
||||||
return <StrmQueuePanel kind="download" />
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ interface PermissionState {
|
|||||||
clearPermissions: () => void
|
clearPermissions: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 进行中的请求共享同一个 Promise:同一次 commit 内挂载的多个消费方
|
||||||
|
// 同时触发 fetchPermissions 时只会发出一次 /permissions 请求。
|
||||||
|
let inflight: Promise<void> | null = null
|
||||||
|
|
||||||
export const usePermissionStore = create<PermissionState>((set, get) => ({
|
export const usePermissionStore = create<PermissionState>((set, get) => ({
|
||||||
permissions: {},
|
permissions: {},
|
||||||
role: '',
|
role: '',
|
||||||
@@ -23,22 +27,28 @@ export const usePermissionStore = create<PermissionState>((set, get) => ({
|
|||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
fetchPermissions: async () => {
|
fetchPermissions: async () => {
|
||||||
|
if (inflight) return inflight
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
try {
|
inflight = (async () => {
|
||||||
const result = await getMyPermissions()
|
try {
|
||||||
set({
|
const result = await getMyPermissions()
|
||||||
permissions: result.permissions ?? {},
|
set({
|
||||||
role: result.role ?? '',
|
permissions: result.permissions ?? {},
|
||||||
tier: result.tier ?? 'free',
|
role: result.role ?? '',
|
||||||
isSuper: result.is_super ?? false,
|
tier: result.tier ?? 'free',
|
||||||
isLoading: false,
|
isSuper: result.is_super ?? false,
|
||||||
})
|
isLoading: false,
|
||||||
} catch (err) {
|
})
|
||||||
set({
|
} catch (err) {
|
||||||
isLoading: false,
|
set({
|
||||||
error: err instanceof Error ? err.message : 'Failed to fetch permissions',
|
isLoading: false,
|
||||||
})
|
error: err instanceof Error ? err.message : 'Failed to fetch permissions',
|
||||||
}
|
})
|
||||||
|
} finally {
|
||||||
|
inflight = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return inflight
|
||||||
},
|
},
|
||||||
|
|
||||||
hasPermission: (key: string) => {
|
hasPermission: (key: string) => {
|
||||||
|
|||||||
@@ -24,5 +24,25 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
sourcemap: false,
|
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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||