Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fa05391e1 | |||
| 4790f7753e | |||
| a311438aa1 | |||
| 203abd106a | |||
| cc40169616 | |||
| cd720ae879 | |||
| fc84291346 | |||
| c37e936f48 | |||
| 389cb99bcf | |||
| 1b611a6181 | |||
| 25c03f2b0d | |||
| a56b1801f9 | |||
| 99c755dc29 | |||
| 086c0307c3 | |||
| 51d0f5010e | |||
| b9dd09a5d2 | |||
| e79f393969 | |||
| e422ecce53 |
@@ -23,6 +23,7 @@ import (
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/database"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
@@ -121,7 +122,7 @@ func main() {
|
||||
)
|
||||
}
|
||||
}()
|
||||
go services.Boot()
|
||||
helper.Go(logger, "services.boot", services.Boot)
|
||||
|
||||
// Graceful shutdown.
|
||||
stop := make(chan os.Signal, 1)
|
||||
|
||||
@@ -8,6 +8,11 @@ import (
|
||||
|
||||
// AutoMigrate creates tables for every model registered in the model package.
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
// 必须先于 AutoMigrate:旧库中可能已有重复的 (user_id, media_id) 历史行,
|
||||
// 不去重会导致唯一索引 uniq_user_history 创建失败。
|
||||
if err := dedupePlaybackHistories(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -37,6 +42,27 @@ func ensureSQLiteQueryOptimizer(db *gorm.DB) error {
|
||||
return db.Exec("ANALYZE").Error
|
||||
}
|
||||
|
||||
// dedupePlaybackHistories removes duplicate (user_id, media_id) rows left by
|
||||
// the former read-then-write upsert, so the uniq_user_history composite unique
|
||||
// index can be created on existing databases. Keeps the most recent row per
|
||||
// pair, preferring live rows over soft-deleted ones.
|
||||
func dedupePlaybackHistories(db *gorm.DB) error {
|
||||
if !db.Migrator().HasTable("playback_histories") {
|
||||
return nil
|
||||
}
|
||||
return db.Exec(`
|
||||
DELETE FROM playback_histories WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY user_id, media_id
|
||||
ORDER BY deleted_at IS NULL DESC, watched_at DESC, id DESC
|
||||
) AS rn
|
||||
FROM playback_histories
|
||||
) ranked
|
||||
WHERE ranked.rn > 1
|
||||
)`).Error
|
||||
}
|
||||
|
||||
func ensurePostgresColumnCompatibility(db *gorm.DB) error {
|
||||
if !isPostgres(db) {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestAutoMigrateDedupesPlaybackHistories reproduces the upgrade path: a legacy
|
||||
// database contains duplicate (user_id, media_id) history rows created by the
|
||||
// old read-then-write upsert. AutoMigrate must merge them before creating the
|
||||
// uniq_user_history composite unique index, otherwise the upgrade fails.
|
||||
func TestAutoMigrateDedupesPlaybackHistories(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 旧 schema:无 uniq_user_history 唯一索引。
|
||||
if err := db.Exec(`CREATE TABLE playback_histories (
|
||||
id varchar(36) PRIMARY KEY,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
deleted_at datetime,
|
||||
user_id varchar(36) NOT NULL,
|
||||
media_id varchar(128) NOT NULL,
|
||||
position_ms integer,
|
||||
duration_ms integer,
|
||||
watched_at datetime,
|
||||
completed numeric
|
||||
)`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := time.Now()
|
||||
rows := []struct {
|
||||
id string
|
||||
position int64
|
||||
watchedAt time.Time
|
||||
}{
|
||||
{"h-old", 1_000, base.Add(-2 * time.Hour)},
|
||||
{"h-mid", 2_000, base.Add(-1 * time.Hour)},
|
||||
{"h-new", 3_000, base},
|
||||
}
|
||||
for _, r := range rows {
|
||||
if err := db.Exec(
|
||||
`INSERT INTO playback_histories (id, user_id, media_id, position_ms, watched_at, created_at, updated_at)
|
||||
VALUES (?, 'u-1', 'm-1', ?, ?, ?, ?)`,
|
||||
r.id, r.position, r.watchedAt, r.watchedAt, r.watchedAt,
|
||||
).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
t.Fatalf("auto migrate with duplicate histories: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Table("playback_histories").Where("user_id = ? AND media_id = ?", "u-1", "m-1").Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected duplicate rows merged to 1, got %d", count)
|
||||
}
|
||||
var position int64
|
||||
if err := db.Table("playback_histories").
|
||||
Where("user_id = ? AND media_id = ?", "u-1", "m-1").
|
||||
Select("position_ms").Scan(&position).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if position != 3_000 {
|
||||
t.Fatalf("dedupe should keep the most recent row, got position_ms=%d", position)
|
||||
}
|
||||
|
||||
// 唯一索引存在时,重复插入同一 (user_id, media_id) 应触发冲突而非新增行。
|
||||
if err := db.Exec(
|
||||
`INSERT INTO playback_histories (id, user_id, media_id, position_ms, watched_at, created_at, updated_at)
|
||||
VALUES ('h-dup', 'u-1', 'm-1', 4_000, ?, ?, ?)`,
|
||||
base, base, base,
|
||||
).Error; err == nil {
|
||||
t.Fatal("insert violating uniq_user_history should fail")
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,10 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
@@ -58,17 +60,37 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
role, _ := c.Get(middleware.CtxUserRole)
|
||||
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("include_hidden") == "true" || c.Query("all") == "1")
|
||||
if !includeHidden {
|
||||
libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs)
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
filtered := libs[:0]
|
||||
for _, lib := range libs {
|
||||
if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) {
|
||||
filtered = append(filtered, lib)
|
||||
if !includeHidden {
|
||||
libs = service.FilterDisplayCloudLibraries(ctx, svc.Repo, libs)
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
filtered := libs[:0]
|
||||
for _, lib := range libs {
|
||||
if service.LibraryVisibleForUser(ctx, svc.Repo, lib, visibility) {
|
||||
filtered = append(filtered, lib)
|
||||
}
|
||||
}
|
||||
libs = filtered
|
||||
}
|
||||
rawIDs := strings.TrimSpace(c.Query("ids"))
|
||||
var targetSet map[string]struct{}
|
||||
if rawIDs != "" {
|
||||
targetSet = make(map[string]struct{})
|
||||
for _, id := range strings.Split(rawIDs, ",") {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
targetSet[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
libs = filtered
|
||||
}
|
||||
if len(targetSet) > 0 {
|
||||
filtered := libs[:0]
|
||||
for _, lib := range libs {
|
||||
if _, ok := targetSet[lib.ID]; ok {
|
||||
filtered = append(filtered, lib)
|
||||
}
|
||||
}
|
||||
libs = filtered
|
||||
}
|
||||
withPreview := c.Query("with_preview") == "1" || c.Query("with_preview") == "true"
|
||||
limit := 10
|
||||
if withPreview {
|
||||
@@ -89,22 +111,41 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
for _, p := range previews {
|
||||
out = append(out, webLibraryPayload{Library: p.Library, Total: p.Total, Cards: p.Cards})
|
||||
}
|
||||
} else {
|
||||
for _, l := range libs {
|
||||
out = append(out, webLibraryPayload{Library: l})
|
||||
} else {
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
libIDs := make([]string, len(libs))
|
||||
for i, l := range libs {
|
||||
libIDs[i] = l.ID
|
||||
}
|
||||
counts, _ := svc.Repo.Media.CountByLibraries(ctx, libIDs, repository.MediaQueryFilter{
|
||||
IncludeNSFW: visibility.IncludeNSFW,
|
||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
||||
})
|
||||
for _, l := range libs {
|
||||
var total int64
|
||||
if counts != nil {
|
||||
total = counts[l.ID]
|
||||
}
|
||||
out = append(out, webLibraryPayload{Library: l, Total: total})
|
||||
}
|
||||
}
|
||||
}
|
||||
// 远程 Emby 挂载库追加在本地库之后(非管理员视图仍受 allowed_library_ids 约束)。
|
||||
if svc.EmbyRemote != nil {
|
||||
if views, err := svc.EmbyRemote.RemoteLibraries(ctx); err == nil {
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
allowedViews := make([]service.RemoteLibraryView, 0, len(views))
|
||||
for _, v := range views {
|
||||
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) {
|
||||
continue
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
allowedViews := make([]service.RemoteLibraryView, 0, len(views))
|
||||
for _, v := range views {
|
||||
if !includeHidden && !service.LibraryVisibleForUser(ctx, svc.Repo, v.Library, visibility) {
|
||||
continue
|
||||
}
|
||||
if len(targetSet) > 0 {
|
||||
if _, ok := targetSet[v.Library.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
allowedViews = append(allowedViews, v)
|
||||
}
|
||||
allowedViews = append(allowedViews, v)
|
||||
}
|
||||
remotePayloads := make([]webLibraryPayload, len(allowedViews))
|
||||
for i, v := range allowedViews {
|
||||
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
|
||||
@@ -124,18 +165,20 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID)
|
||||
if acct == nil {
|
||||
return
|
||||
}
|
||||
tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}}
|
||||
itemTypes := remoteLibraryItemTypes(v.CollectionType)
|
||||
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
|
||||
remotePayloads[i].Total = total
|
||||
}
|
||||
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
|
||||
remotePayloads[i].Cards = cards
|
||||
}
|
||||
helper.Run(svc.Log, "media.remotePreview", func() {
|
||||
acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID)
|
||||
if acct == nil {
|
||||
return
|
||||
}
|
||||
tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}}
|
||||
itemTypes := remoteLibraryItemTypes(v.CollectionType)
|
||||
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
|
||||
remotePayloads[i].Total = total
|
||||
}
|
||||
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
|
||||
remotePayloads[i].Cards = cards
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
@@ -41,7 +42,11 @@ func scanLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
task := startScanHTTPTask(svc, "手动扫描入库", lib.Name, lib.Path)
|
||||
go func(libraryID string, task *service.TaskHandle, finish func()) {
|
||||
defer finish()
|
||||
res, err := svc.Scan.ScanLibrary(context.Background(), libraryID)
|
||||
var res *service.ScanResult
|
||||
var err error
|
||||
helper.Run(svc.Log, "scan.library", func() {
|
||||
res, err = svc.Scan.ScanLibrary(context.Background(), libraryID)
|
||||
})
|
||||
if err != nil {
|
||||
finishHTTPTask(task, err, "scan", "手动扫描入库失败", scanTaskMetrics(res), scanTaskDetails(res, 20))
|
||||
return
|
||||
@@ -75,7 +80,11 @@ func scanLibraryRootHandler(svc *service.Container) gin.HandlerFunc {
|
||||
task := startScanHTTPTask(svc, "手动扫描媒体库路径", id, rootID)
|
||||
go func(libraryID, libraryRootID string, task *service.TaskHandle, finish func()) {
|
||||
defer finish()
|
||||
res, err := svc.Scan.ScanLibraryRoot(context.Background(), libraryID, libraryRootID)
|
||||
var res *service.ScanResult
|
||||
var err error
|
||||
helper.Run(svc.Log, "scan.libraryRoot", func() {
|
||||
res, err = svc.Scan.ScanLibraryRoot(context.Background(), libraryID, libraryRootID)
|
||||
})
|
||||
if err != nil {
|
||||
finishHTTPTask(task, err, "scan", "手动扫描路径失败", scanTaskMetrics(res), scanTaskDetails(res, 20))
|
||||
return
|
||||
@@ -110,11 +119,13 @@ func queueLibraryRootScan(svc *service.Container, libraryID, rootID string) {
|
||||
}
|
||||
go func() {
|
||||
defer finish()
|
||||
if strings.TrimSpace(rootID) == "" {
|
||||
_, _ = svc.Scan.ScanLibrary(context.Background(), libraryID)
|
||||
return
|
||||
}
|
||||
_, _ = svc.Scan.ScanLibraryRoot(context.Background(), libraryID, rootID)
|
||||
helper.Run(svc.Log, "scan.queuedRoot", func() {
|
||||
if strings.TrimSpace(rootID) == "" {
|
||||
_, _ = svc.Scan.ScanLibrary(context.Background(), libraryID)
|
||||
return
|
||||
}
|
||||
_, _ = svc.Scan.ScanLibraryRoot(context.Background(), libraryID, rootID)
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
@@ -104,11 +104,16 @@ func TestListLibrariesHidesAdultDirectoriesUnlessAdminRequestsAll(t *testing.T)
|
||||
t.Fatalf("watching library list should hide adult directories, got %#v", visible)
|
||||
}
|
||||
|
||||
all := requestLibraries(t, svc, viewer.ID, "admin", "/api/libraries?include_hidden=1")
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("admin include_hidden list should keep management access, got %#v", all)
|
||||
all := requestLibraries(t, svc, viewer.ID, "admin", "/api/libraries?include_hidden=1")
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("admin include_hidden list should keep management access, got %#v", all)
|
||||
}
|
||||
|
||||
filtered := requestLibraries(t, svc, viewer.ID, "admin", "/api/libraries?include_hidden=1&ids="+safe.ID)
|
||||
if len(filtered) != 1 || filtered[0].ID != safe.ID {
|
||||
t.Fatalf("ids filter should return only requested library, got %#v", filtered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLibraryAllowsEmptyLibrary(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Package helper provides shared utilities.
|
||||
package helper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Go runs fn in a new goroutine and recovers from panics so that a failure in
|
||||
// a background task (scraper parsing remote responses, cloud-drive sync, ...)
|
||||
// is logged instead of crashing the whole process. log may be nil.
|
||||
func Go(log *zap.Logger, name string, fn func()) {
|
||||
go Run(log, name, fn)
|
||||
}
|
||||
|
||||
// Run executes fn and recovers from panics, logging the task name and stack.
|
||||
// Use it as the first statement inside goroutines spawned elsewhere, or wrap
|
||||
// loop bodies so one bad iteration cannot kill a long-running worker.
|
||||
func Run(log *zap.Logger, name string, fn func()) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logPanic(log, name, r)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
|
||||
// Recover runs fn and converts a panic into an error so callers can run their
|
||||
// own deferred cleanup (releasing locks, updating job state) before unwinding.
|
||||
func Recover(log *zap.Logger, name string, fn func() error) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logPanic(log, name, r)
|
||||
err = fmt.Errorf("%s panicked: %v", name, r)
|
||||
}
|
||||
}()
|
||||
return fn()
|
||||
}
|
||||
|
||||
func logPanic(log *zap.Logger, name string, r any) {
|
||||
if log == nil {
|
||||
fmt.Fprintf(os.Stderr, "background task panicked: task=%s panic=%v\n%s\n", name, r, debug.Stack())
|
||||
return
|
||||
}
|
||||
log.Error("background task panicked",
|
||||
zap.String("task", name),
|
||||
zap.Any("panic", r),
|
||||
zap.ByteString("stack", debug.Stack()),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
func newObservedLogger(t *testing.T) (*zap.Logger, *observer.ObservedLogs) {
|
||||
t.Helper()
|
||||
core, logs := observer.New(zap.ErrorLevel)
|
||||
return zap.New(core), logs
|
||||
}
|
||||
|
||||
func waitForLogs(t *testing.T, logs *observer.ObservedLogs, n int) []observer.LoggedEntry {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if entries := logs.All(); len(entries) >= n {
|
||||
return entries
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %d log entries, got %d", n, logs.Len())
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRunRecoversPanic(t *testing.T) {
|
||||
log, logs := newObservedLogger(t)
|
||||
ran := false
|
||||
Run(log, "unit.panic", func() {
|
||||
ran = true
|
||||
panic("boom")
|
||||
})
|
||||
if !ran {
|
||||
t.Fatal("fn should have run before panicking")
|
||||
}
|
||||
entries := waitForLogs(t, logs, 1)
|
||||
if entries[0].Message != "background task panicked" {
|
||||
t.Fatalf("unexpected message: %s", entries[0].Message)
|
||||
}
|
||||
found := false
|
||||
for _, f := range entries[0].Context {
|
||||
if f.Key == "task" && f.String == "unit.panic" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected task name in log context: %v", entries[0].Context)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoPanicNoLog(t *testing.T) {
|
||||
log, logs := newObservedLogger(t)
|
||||
Run(log, "unit.ok", func() {})
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
if logs.Len() != 0 {
|
||||
t.Fatalf("expected no error log, got %d", logs.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverConvertsPanicToError(t *testing.T) {
|
||||
log, _ := newObservedLogger(t)
|
||||
err := Recover(log, "unit.recover", func() error {
|
||||
panic("kaboom")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from recovered panic")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "kaboom") {
|
||||
t.Fatalf("panic value should be in error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverReturnsFnError(t *testing.T) {
|
||||
sentinel := errors.New("plain failure")
|
||||
err := Recover(nil, "unit.err", func() error { return sentinel })
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("expected fn error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWithNilLoggerDoesNotCrash(t *testing.T) {
|
||||
Run(nil, "unit.nillog", func() { panic("still caught") })
|
||||
}
|
||||
|
||||
func TestGoLogsPanicFromSpawnedGoroutine(t *testing.T) {
|
||||
log, logs := newObservedLogger(t)
|
||||
Go(log, "unit.go", func() { panic("async boom") })
|
||||
waitForLogs(t, logs, 1)
|
||||
}
|
||||
@@ -3,10 +3,12 @@ package model
|
||||
import "time"
|
||||
|
||||
// PlaybackHistory 记录当前播放位置以支持续播。
|
||||
// (user_id, media_id) 唯一:播放进度每几秒上报一次,唯一索引保证并发上报
|
||||
// 不会插入重复行(否则续播列表会出现重复卡片),也让 upsert 单语句完成。
|
||||
type PlaybackHistory struct {
|
||||
Base
|
||||
UserID string `gorm:"index;size:36;not null" json:"user_id"`
|
||||
MediaID string `gorm:"index;size:128;not null" json:"media_id"`
|
||||
UserID string `gorm:"index;size:36;not null;uniqueIndex:uniq_user_history" json:"user_id"`
|
||||
MediaID string `gorm:"index;size:128;not null;uniqueIndex:uniq_user_history" json:"media_id"`
|
||||
PositionMs int64 `json:"position_ms"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
WatchedAt time.Time `json:"watched_at"`
|
||||
|
||||
@@ -125,3 +125,16 @@ func (r *EmbyMountRepository) DeleteByAccountID(ctx context.Context, accountID s
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
// DeleteOrphans 清理账号已不存在的挂载(老版本删除账号未级联的历史残留)。
|
||||
func (r *EmbyMountRepository) DeleteOrphans(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).
|
||||
Where("account_id NOT IN (SELECT id FROM strm_accounts)").
|
||||
Delete(&model.EmbyMount{})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
@@ -13,25 +13,24 @@ import (
|
||||
// upserts on (UserID, MediaID) so resume always reads the latest position.
|
||||
type HistoryRepository struct{ db *gorm.DB }
|
||||
|
||||
// Upsert atomically inserts/updates the resume position.
|
||||
// Upsert atomically inserts/updates the resume position in a single statement,
|
||||
// relying on the uniq_user_history composite unique index. Concurrent progress
|
||||
// reports for the same (user, media) can no longer double-insert.
|
||||
func (r *HistoryRepository) Upsert(ctx context.Context, h *model.PlaybackHistory) error {
|
||||
var existing model.PlaybackHistory
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND media_id = ?", h.UserID, h.MediaID).
|
||||
First(&existing).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return r.db.WithContext(ctx).Create(h).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing.PositionMs = h.PositionMs
|
||||
if h.DurationMs > 0 {
|
||||
existing.DurationMs = h.DurationMs
|
||||
}
|
||||
existing.WatchedAt = h.WatchedAt
|
||||
existing.Completed = h.Completed
|
||||
return r.db.WithContext(ctx).Save(&existing).Error
|
||||
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "media_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{
|
||||
"position_ms": h.PositionMs,
|
||||
// 沿用旧语义:未知时长(0)不覆盖已记录的时长。
|
||||
"duration_ms": gorm.Expr(
|
||||
"CASE WHEN ? > 0 THEN ? ELSE playback_histories.duration_ms END",
|
||||
h.DurationMs, h.DurationMs,
|
||||
),
|
||||
"watched_at": h.WatchedAt,
|
||||
"completed": h.Completed,
|
||||
"deleted_at": nil,
|
||||
}),
|
||||
}).Create(h).Error
|
||||
}
|
||||
|
||||
// ListByUser returns the most recent history rows for the user.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/database"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func TestHistoryUpsertSingleRowPerUserMedia(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := New(db)
|
||||
ctx := t.Context()
|
||||
watched := time.Now()
|
||||
|
||||
first := &model.PlaybackHistory{UserID: "u-1", MediaID: "m-1", PositionMs: 30_000, DurationMs: 0, WatchedAt: watched, Completed: false}
|
||||
if err := repos.History.Upsert(ctx, first); err != nil {
|
||||
t.Fatalf("first upsert: %v", err)
|
||||
}
|
||||
second := &model.PlaybackHistory{UserID: "u-1", MediaID: "m-1", PositionMs: 90_000, DurationMs: 120_000, WatchedAt: watched.Add(time.Minute), Completed: true}
|
||||
if err := repos.History.Upsert(ctx, second); err != nil {
|
||||
t.Fatalf("second upsert: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&model.PlaybackHistory{}).Where("user_id = ? AND media_id = ?", "u-1", "m-1").Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected 1 history row after upserts, got %d", count)
|
||||
}
|
||||
var got model.PlaybackHistory
|
||||
if err := db.Where("user_id = ? AND media_id = ?", "u-1", "m-1").First(&got).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.PositionMs != 90_000 || !got.Completed {
|
||||
t.Fatalf("position/completion not updated: %#v", got)
|
||||
}
|
||||
if got.DurationMs != 120_000 {
|
||||
t.Fatalf("duration should update when known, got %d", got.DurationMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryUpsertKeepsDurationWhenUnknown(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repos := New(db)
|
||||
ctx := t.Context()
|
||||
watched := time.Now()
|
||||
|
||||
if err := repos.History.Upsert(ctx, &model.PlaybackHistory{UserID: "u-1", MediaID: "m-2", PositionMs: 10, DurationMs: 600_000, WatchedAt: watched}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.History.Upsert(ctx, &model.PlaybackHistory{UserID: "u-1", MediaID: "m-2", PositionMs: 20, DurationMs: 0, WatchedAt: watched.Add(time.Second)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got model.PlaybackHistory
|
||||
if err := db.Where("user_id = ? AND media_id = ?", "u-1", "m-2").First(&got).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.DurationMs != 600_000 {
|
||||
t.Fatalf("duration_ms=0 upsert must not clear stored duration, got %d", got.DurationMs)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
@@ -50,6 +51,8 @@ func (a *AuditService) RecordBestEffort(userID, action, target, ip, detail strin
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
a.Record(ctx, userID, action, target, ip, detail)
|
||||
helper.Run(a.log, "audit.record", func() {
|
||||
a.Record(ctx, userID, action, target, ip, detail)
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
@@ -176,9 +177,11 @@ func (s *AuthService) touchLoginBestEffort(userID string) {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := s.repo.User.TouchLogin(ctx, userID); err != nil && s.log != nil {
|
||||
s.log.Debug("touch login delayed", zap.String("user_id", userID), zap.Error(err))
|
||||
}
|
||||
helper.Run(s.log, "auth.touchLogin", func() {
|
||||
if err := s.repo.User.TouchLogin(ctx, userID); err != nil && s.log != nil {
|
||||
s.log.Debug("touch login delayed", zap.String("user_id", userID), zap.Error(err))
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ func (e *EmbyService) countVisibleSeries(ctx context.Context, userID string) (in
|
||||
for i := range rows {
|
||||
key := strings.TrimSpace(rows[i].SeriesID)
|
||||
if key == "" {
|
||||
key = stableEmbyID(embyVirtualSeriesPrefix, rows[i].LibraryID, e.seriesNameForMedia(&rows[i]))
|
||||
key = stableEmbyID(embyVirtualSeriesPrefix, rows[i].LibraryID, e.seriesNameForMedia(ctx, &rows[i]))
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,8 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
||||
pos = h.PositionMs
|
||||
}
|
||||
}
|
||||
return e.itemPayload(ctx, m, fav, pos), nil
|
||||
// 单条目 payload 内部对库类型/series 标题有多次查找,挂请求级缓存合并。
|
||||
return e.itemPayload(e.withPayloadCache(ctx), m, fav, pos), nil
|
||||
}
|
||||
|
||||
// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)直接透传远程。
|
||||
@@ -177,7 +178,7 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l
|
||||
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups := e.seriesGroupsFromMedia(rows)
|
||||
groups := e.seriesGroupsFromMedia(ctx, rows)
|
||||
sortSeriesGroups(groups, ItemsParams{SortBy: "premieredate", SortOrder: "Descending"})
|
||||
if len(groups) > limit {
|
||||
groups = groups[:limit]
|
||||
@@ -415,9 +416,9 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
|
||||
seasonID := ""
|
||||
if e.mediaShouldBeEpisode(ctx, m) {
|
||||
itemType = "Episode"
|
||||
seriesID = e.seriesIDForMedia(m)
|
||||
seriesName = e.seriesNameForMedia(m)
|
||||
seasonID = e.seasonIDForMedia(m)
|
||||
seriesID = e.seriesIDForMedia(ctx, m)
|
||||
seriesName = e.seriesNameForMedia(ctx, m)
|
||||
seasonID = e.seasonIDForMedia(ctx, m)
|
||||
parentID = seasonID
|
||||
episodeTitle := strings.TrimSpace(m.EpisodeTitle)
|
||||
if episodeTitle != "" {
|
||||
|
||||
@@ -63,7 +63,7 @@ func primarySupportedEmbySort(sortBy string, resumeFilter bool) string {
|
||||
for _, part := range strings.Split(sortBy, ",") {
|
||||
key := strings.ToLower(strings.TrimSpace(part))
|
||||
switch key {
|
||||
case "sortname", "name", "premieredate", "productionyear", "datecreated", "communityrating":
|
||||
case "sortname", "name", "premieredate", "productionyear", "datecreated", "datelastmediaadded", "datelastcontentadded", "communityrating":
|
||||
return key
|
||||
case "dateplayed":
|
||||
if resumeFilter {
|
||||
|
||||
@@ -75,9 +75,9 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
|
||||
orderIncludesDirection = false
|
||||
case "premieredate", "productionyear":
|
||||
order = mediaReleaseOrderSQL(desc)
|
||||
case "datecreated":
|
||||
order = "media.created_at"
|
||||
orderIncludesDirection = false
|
||||
case "datecreated", "datelastmediaadded", "datelastcontentadded":
|
||||
order = "media.created_at"
|
||||
orderIncludesDirection = false
|
||||
case "dateplayed":
|
||||
order = "resume.watched_at"
|
||||
orderIncludesDirection = false
|
||||
@@ -149,6 +149,9 @@ func (e *EmbyService) episodeItems(ctx context.Context, rows []model.Media, p It
|
||||
}
|
||||
|
||||
func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media, userID string) ([]map[string]any, error) {
|
||||
// 请求级缓存:库类型与 series 标题整页只查一次,消除逐条目 N+1。
|
||||
ctx = e.withPayloadCache(ctx)
|
||||
e.prefetchPayloadCache(ctx, rows)
|
||||
rows = e.collapseMediaVersionRows(ctx, rows)
|
||||
userFavs := map[string]bool{}
|
||||
userPos := map[string]int64{}
|
||||
@@ -240,7 +243,7 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin
|
||||
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups := e.seriesGroupsFromMedia(rows)
|
||||
groups := e.seriesGroupsFromMedia(ctx, rows)
|
||||
sortSeriesGroups(groups, p)
|
||||
total := len(groups)
|
||||
items := make([]map[string]any, 0, minInt(p.Limit, len(groups)))
|
||||
|
||||
@@ -65,7 +65,7 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
seriesGroups := e.seriesGroupsFromMedia(episodicRows)
|
||||
seriesGroups := e.seriesGroupsFromMedia(ctx, episodicRows)
|
||||
|
||||
// 真正的电影 -> Movie 项(剔除剧集结构行)。
|
||||
movieQ := apply(e.repo.DB.WithContext(ctx).Model(&model.Media{}))
|
||||
@@ -135,10 +135,11 @@ func (e *EmbyService) libraryIsEpisodic(ctx context.Context, libraryID string) (
|
||||
if strings.TrimSpace(libraryID) == "" {
|
||||
return false, nil
|
||||
}
|
||||
if lib, err := e.repo.Library.FindByID(ctx, libraryID); err != nil {
|
||||
// 走请求级缓存(若有),避免同一请求内对同一库重复查表。
|
||||
if typ, ok, err := e.payloadLibraryType(ctx, libraryID); err != nil {
|
||||
return false, err
|
||||
} else if lib != nil {
|
||||
return embyLibraryTypeIsEpisodic(lib.Type), nil
|
||||
} else if ok {
|
||||
return embyLibraryTypeIsEpisodic(typ), nil
|
||||
}
|
||||
var count int64
|
||||
err := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
@@ -151,11 +152,11 @@ func (e *EmbyService) mediaBelongsToEpisodicLibrary(ctx context.Context, m *mode
|
||||
if e == nil || m == nil || strings.TrimSpace(m.LibraryID) == "" {
|
||||
return false
|
||||
}
|
||||
lib, err := e.repo.Library.FindByID(ctx, m.LibraryID)
|
||||
if err != nil || lib == nil {
|
||||
typ, ok, err := e.payloadLibraryType(ctx, m.LibraryID)
|
||||
if err != nil || !ok {
|
||||
return false
|
||||
}
|
||||
return embyLibraryTypeIsEpisodic(lib.Type)
|
||||
return embyLibraryTypeIsEpisodic(typ)
|
||||
}
|
||||
|
||||
func (e *EmbyService) mediaShouldBeEpisode(ctx context.Context, m *model.Media) bool {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 请求级 payload 构建缓存:/Items 列表为每行构建 payload 时,
|
||||
// mediaShouldBeEpisode 需要库类型、剧集 payload 需要 series 标题。
|
||||
// 一次页面请求内这些值高度重复(同一库、同一部剧),挂在 ctx 上的
|
||||
// 小缓存可以把每条目 2-3 次 DB 查询降为整个请求各 1 次预取。
|
||||
|
||||
type embyPayloadCacheKey struct{}
|
||||
|
||||
type embyLibraryTypeEntry struct {
|
||||
typ string
|
||||
found bool // 库不存在时 found=false,调用方可退回计数启发式
|
||||
}
|
||||
|
||||
type embyPayloadCache struct {
|
||||
mu sync.Mutex
|
||||
libTypes map[string]embyLibraryTypeEntry
|
||||
series map[string]string // series_id -> title("" 表示不存在/无标题)
|
||||
}
|
||||
|
||||
func (c *embyPayloadCache) libraryType(id string) (embyLibraryTypeEntry, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
entry, ok := c.libTypes[id]
|
||||
return entry, ok
|
||||
}
|
||||
|
||||
func (c *embyPayloadCache) setLibraryType(id string, entry embyLibraryTypeEntry) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.libTypes[id] = entry
|
||||
}
|
||||
|
||||
func (c *embyPayloadCache) seriesTitle(id string) (string, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
title, ok := c.series[id]
|
||||
return title, ok
|
||||
}
|
||||
|
||||
func (c *embyPayloadCache) setSeriesTitle(id, title string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.series[id] = title
|
||||
}
|
||||
|
||||
// withPayloadCache attaches a fresh request-scoped cache if none exists yet.
|
||||
func (e *EmbyService) withPayloadCache(ctx context.Context) context.Context {
|
||||
if e == nil || e.repo == nil {
|
||||
return ctx
|
||||
}
|
||||
if ctx.Value(embyPayloadCacheKey{}) != nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, embyPayloadCacheKey{}, &embyPayloadCache{
|
||||
libTypes: map[string]embyLibraryTypeEntry{},
|
||||
series: map[string]string{},
|
||||
})
|
||||
}
|
||||
|
||||
// prefetchPayloadCache warms the cache for the given media rows with two bulk
|
||||
// queries (library types, series titles) instead of per-item lookups.
|
||||
func (e *EmbyService) prefetchPayloadCache(ctx context.Context, rows []model.Media) {
|
||||
cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache)
|
||||
if !ok || len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
libIDs := make([]string, 0, 8)
|
||||
seriesIDs := make([]string, 0, 8)
|
||||
seenLib := map[string]struct{}{}
|
||||
seenSeries := map[string]struct{}{}
|
||||
for i := range rows {
|
||||
row := &rows[i]
|
||||
if id := strings.TrimSpace(row.LibraryID); id != "" {
|
||||
if _, done := seenLib[id]; !done {
|
||||
// 已在缓存中的库不必再查。
|
||||
if _, hit := cache.libraryType(id); !hit {
|
||||
seenLib[id] = struct{}{}
|
||||
libIDs = append(libIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if id := strings.TrimSpace(row.SeriesID); id != "" {
|
||||
if _, done := seenSeries[id]; !done {
|
||||
if _, hit := cache.seriesTitle(id); !hit {
|
||||
seenSeries[id] = struct{}{}
|
||||
seriesIDs = append(seriesIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(libIDs) > 0 {
|
||||
var libs []model.Library
|
||||
if err := e.repo.DB.WithContext(ctx).Select("id, type").Where("id IN ?", libIDs).Find(&libs).Error; err == nil {
|
||||
found := map[string]string{}
|
||||
for _, lib := range libs {
|
||||
found[lib.ID] = lib.Type
|
||||
}
|
||||
for _, id := range libIDs {
|
||||
typ, ok := found[id]
|
||||
cache.setLibraryType(id, embyLibraryTypeEntry{typ: typ, found: ok})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(seriesIDs) > 0 {
|
||||
var series []model.Series
|
||||
if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id IN ?", seriesIDs).Find(&series).Error; err == nil {
|
||||
for _, s := range series {
|
||||
cache.setSeriesTitle(s.ID, s.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// payloadLibraryType resolves a library type through the request cache,
|
||||
// falling back to a direct lookup when no cache is attached. found=false
|
||||
// means the library row does not exist (soft-deleted or orphaned id).
|
||||
func (e *EmbyService) payloadLibraryType(ctx context.Context, libraryID string) (typ string, found bool, err error) {
|
||||
if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok {
|
||||
if entry, hit := cache.libraryType(libraryID); hit {
|
||||
return entry.typ, entry.found, nil
|
||||
}
|
||||
var lib model.Library
|
||||
if dbErr := e.repo.DB.WithContext(ctx).Select("id, type").Where("id = ?", libraryID).First(&lib).Error; dbErr != nil {
|
||||
cache.setLibraryType(libraryID, embyLibraryTypeEntry{})
|
||||
return "", false, nil
|
||||
}
|
||||
cache.setLibraryType(lib.ID, embyLibraryTypeEntry{typ: lib.Type, found: true})
|
||||
return lib.Type, true, nil
|
||||
}
|
||||
var lib model.Library
|
||||
if err = e.repo.DB.WithContext(ctx).Select("id, type").Where("id = ?", libraryID).First(&lib).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
return lib.Type, true, nil
|
||||
}
|
||||
|
||||
// payloadSeriesTitle resolves a series title through the request cache,
|
||||
// falling back to a direct lookup when no cache is attached.
|
||||
func (e *EmbyService) payloadSeriesTitle(ctx context.Context, seriesID string) (string, bool, error) {
|
||||
if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok {
|
||||
if title, hit := cache.seriesTitle(seriesID); hit {
|
||||
return title, true, nil
|
||||
}
|
||||
var s model.Series
|
||||
if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id = ?", seriesID).First(&s).Error; err != nil {
|
||||
cache.setSeriesTitle(seriesID, "")
|
||||
return "", true, nil
|
||||
}
|
||||
cache.setSeriesTitle(s.ID, s.Title)
|
||||
return s.Title, true, nil
|
||||
}
|
||||
series, err := e.repo.Series.FindByID(ctx, seriesID)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if series == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
return series.Title, true, nil
|
||||
}
|
||||
@@ -316,6 +316,71 @@ func (r *EmbyRemoteService) AutoSeedMounts(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// remoteConfigWithToken 解密账号配置并确保已有可用凭据(首次请求自动认证并
|
||||
// 回写 token 与 remote_user_id,等价于管理端「测试连接」),保证后续构造的
|
||||
// /Users/{userId} 路径使用远程真实用户 GUID,而不是未认证兜底的 "0"。
|
||||
func (r *EmbyRemoteService) remoteConfigWithToken(ctx context.Context, acct *model.StrmAccount) (*EmbyRemoteConfig, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.ensureToken(ctx, acct, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// api_key 直连(未配用户名/密码)的账号认证步骤不会回填用户 ID;此时用
|
||||
// api_key 拉一次用户列表取真实用户 ID 并回写,避免 /Users/{uid} 请求路径
|
||||
// 落回兜底 "0" 被远程 Emby 拒绝(Unrecognized Guid format)。
|
||||
if strings.TrimSpace(cfg.RemoteUserID) == "" {
|
||||
r.resolveRemoteUserID(ctx, acct, cfg)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// resolveRemoteUserID 用已有 api_key 拉远程用户列表,把首个用户 ID 回写账号
|
||||
// 配置(取不到时静默跳过,保持兜底行为不变)。
|
||||
func (r *EmbyRemoteService) resolveRemoteUserID(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig) {
|
||||
if acct == nil || cfg == nil || strings.TrimSpace(cfg.Token) == "" || strings.TrimSpace(cfg.RemoteUserID) != "" {
|
||||
return
|
||||
}
|
||||
q := url.Values{"api_key": {cfg.Token}}
|
||||
var users []map[string]any
|
||||
if err := r.doGet(ctx, acct, cfg, "/Users", q, &users); err != nil || len(users) == 0 {
|
||||
return
|
||||
}
|
||||
uid := strings.TrimSpace(remoteItemString(users[0], "Id"))
|
||||
if uid == "" {
|
||||
return
|
||||
}
|
||||
cfg.RemoteUserID = uid
|
||||
raw := map[string]string{}
|
||||
_ = json.Unmarshal([]byte(acct.Config), &raw)
|
||||
raw["remote_user_id"] = uid
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
acct.Config = string(data)
|
||||
_ = r.repo.StrmAccount.Update(ctx, acct)
|
||||
}
|
||||
|
||||
// CleanupOrphanMounts 清理账号已删除的残留挂载(老版本删除账号未级联),
|
||||
// 避免挂载计数/列表出现永远清不掉的孤儿数据。
|
||||
func (r *EmbyRemoteService) CleanupOrphanMounts(ctx context.Context) {
|
||||
n, err := r.repo.EmbyMount.DeleteOrphans(ctx)
|
||||
if err != nil {
|
||||
if r.log != nil {
|
||||
r.log.Warn("cleanup orphan emby mounts failed", zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
r.invalidateRemoteMediaCache(ctx)
|
||||
if r.log != nil {
|
||||
r.log.Info("cleaned up orphan emby mounts", zap.Int64("mounts", n))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// configOf 解密账号配置。
|
||||
func (r *EmbyRemoteService) configOf(acct *model.StrmAccount) (*EmbyRemoteConfig, error) {
|
||||
raw := map[string]string{}
|
||||
@@ -469,6 +534,10 @@ func (r *EmbyRemoteService) doGet(ctx context.Context, acct *model.StrmAccount,
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
if r.log != nil && acct != nil {
|
||||
r.log.Warn("remote emby request failed",
|
||||
zap.String("account", acct.Name), zap.String("path", path), zap.Error(lastErr))
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
return errors.New("远程 Emby 请求失败")
|
||||
@@ -553,7 +622,7 @@ func (r *EmbyRemoteService) ProxyPlayOf(acct *model.StrmAccount) (bool, error) {
|
||||
|
||||
// RemoteViews 拉取远程媒体库(View)列表,返回远程原始 view map(未重写)。
|
||||
func (r *EmbyRemoteService) RemoteViews(ctx context.Context, acct *model.StrmAccount) ([]map[string]any, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -588,7 +657,7 @@ func (r *EmbyRemoteService) remoteUserID(cfg *EmbyRemoteConfig) string {
|
||||
// RemoteItems 向远程 Emby 转发 /Items 浏览/搜索请求,返回重写后的响应载荷。
|
||||
// p 的分页/排序/过滤参数原样转发,分页语义完全由远程承接。
|
||||
func (r *EmbyRemoteService) RemoteItems(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, p ItemsParams) (map[string]any, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -633,7 +702,7 @@ func (r *EmbyRemoteService) RemoteItems(ctx context.Context, mount *model.EmbyMo
|
||||
// RemoteSearchMount 对单个挂载的媒体库执行全局搜索(ParentId=挂载的远程库,
|
||||
// Recursive 返回库内全部命中),结果归属明确可直接伪装。
|
||||
func (r *EmbyRemoteService) RemoteSearchMount(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, p ItemsParams) (map[string]any, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -666,7 +735,7 @@ func (r *EmbyRemoteService) RemoteSearchMount(ctx context.Context, mount *model.
|
||||
|
||||
// RemoteItem 拉取远程单条目详情(含响应的重写)。
|
||||
func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (map[string]any, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -681,7 +750,7 @@ func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMou
|
||||
|
||||
// RemoteLatest 拉取远程「最近添加」(用于 /Items/Latest 聚合)。
|
||||
func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, parentID string, limit int) ([]map[string]any, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -702,7 +771,7 @@ func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyM
|
||||
// 播放 URL:不代理=指向远程绝对地址(播放字节不过 MeBox);代理=指向 MeBox
|
||||
// 本地 /Videos/{encodedID} 端点(由 ProxyVideoStream 反代)。
|
||||
func (r *EmbyRemoteService) RemotePlaybackInfo(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID, userID string) (map[string]any, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
@@ -57,7 +58,7 @@ func (r *EmbyRemoteService) RemoteLibraries(ctx context.Context) ([]RemoteLibrar
|
||||
acctData[m.AccountID] = nil
|
||||
continue
|
||||
}
|
||||
cfg, cfgErr := r.configOf(acct)
|
||||
cfg, cfgErr := r.remoteConfigWithToken(ctx, acct)
|
||||
if cfgErr != nil {
|
||||
acctData[m.AccountID] = nil
|
||||
continue
|
||||
@@ -198,6 +199,9 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
|
||||
media.CreatedAt = date
|
||||
media.UpdatedAt = date
|
||||
}
|
||||
if date, ok := parseEmbyRemoteDate(remoteItemString(item, "DateLastMediaAdded")); ok {
|
||||
media.UpdatedAt = date
|
||||
}
|
||||
// 只有远程明确存在图片标签才下发图片 URL。
|
||||
if remoteItemHasImageTag(item, "Primary") {
|
||||
media.PosterURL = r.remoteItemImageURL(cfg, remoteID, "Primary")
|
||||
@@ -301,28 +305,28 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
|
||||
media.SeasonNum = 0
|
||||
media.EpisodeNum = 0
|
||||
}
|
||||
if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" {
|
||||
libID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
|
||||
media.DisplayLibraryID = libID
|
||||
media.LibraryID = libID
|
||||
libName := strings.TrimSpace(mount.Name)
|
||||
if libName == "" {
|
||||
libName = strings.TrimSpace(mount.RemoteViewName)
|
||||
}
|
||||
if libName == "" && acct != nil {
|
||||
libName = acct.Name
|
||||
} else if acct != nil && acct.Name != "" && !strings.Contains(libName, acct.Name) {
|
||||
libName = acct.Name + " · " + libName
|
||||
}
|
||||
media.LibraryName = libName
|
||||
media.DisplayLibraryName = libName
|
||||
if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" {
|
||||
libID := EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)
|
||||
media.DisplayLibraryID = libID
|
||||
media.LibraryID = libID
|
||||
libName := strings.TrimSpace(mount.Name)
|
||||
if libName == "" {
|
||||
libName = strings.TrimSpace(mount.RemoteViewName)
|
||||
}
|
||||
if libName == "" && acct != nil {
|
||||
libName = acct.Name
|
||||
} else if acct != nil && acct.Name != "" && !strings.Contains(libName, acct.Name) {
|
||||
libName = acct.Name + " · " + libName
|
||||
}
|
||||
media.LibraryName = libName
|
||||
media.DisplayLibraryName = libName
|
||||
}
|
||||
return media
|
||||
}
|
||||
|
||||
// RemoteLibraryMedia 拉远程库直属条目(电影库=Movie,剧集库=Series),映射分页。
|
||||
func (r *EmbyRemoteService) RemoteLibraryMedia(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, itemTypes string, offset, limit int) ([]model.Media, int64, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -367,7 +371,7 @@ func (r *EmbyRemoteService) RemoteLibraryMedia(ctx context.Context, mount *model
|
||||
|
||||
// RemoteMediaDetail 拉远程单条目映射为 Media(网页详情页)。
|
||||
func (r *EmbyRemoteService) RemoteMediaDetail(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (*model.Media, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -423,7 +427,7 @@ func (r *EmbyRemoteService) RemoteEpisodes(ctx context.Context, mount *model.Emb
|
||||
}
|
||||
|
||||
func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, parentID string) ([]model.Media, int64, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -451,8 +455,14 @@ func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, mount *model.E
|
||||
}
|
||||
|
||||
// RemoteSeriesCards 远程剧集库的系列卡片(ChildCount 作为集数)。
|
||||
//
|
||||
// 远程 Emby 的 Series DTO 不会返回 DateLastMediaAdded 字段(即使请求 Fields
|
||||
// 也缺失),但其服务端排序支持 SortBy=DateLastContentAdded——即客户端"上次
|
||||
// 添加集日期"排序。因此这里直接按该键倒序分页拉全量,返回的卡片顺序与对方
|
||||
// Emby 客户端选择"上次添加集日期"完全一致;LastAddedAt 在远程提供字段时
|
||||
// 才填充,否则保持 nil(前端对无该值的卡片维持服务器顺序,不再回退加入日期)。
|
||||
func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string) ([]SeriesCard, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -465,28 +475,44 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
|
||||
q.Set("ParentId", remoteViewID)
|
||||
q.Set("IncludeItemTypes", "Series")
|
||||
q.Set("Recursive", "false")
|
||||
q.Set("StartIndex", "0")
|
||||
q.Set("SortBy", "DateLastContentAdded")
|
||||
q.Set("SortOrder", "Descending")
|
||||
q.Set("Limit", "1000")
|
||||
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating")
|
||||
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
|
||||
var body struct {
|
||||
Items []map[string]any `json:"Items"`
|
||||
Items []map[string]any `json:"Items"`
|
||||
TotalRecordCount int64 `json:"TotalRecordCount"`
|
||||
}
|
||||
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cards := make([]SeriesCard, 0, len(body.Items))
|
||||
for _, it := range body.Items {
|
||||
RewriteEmbyRemoteIDs(it, mount.ID)
|
||||
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
|
||||
// 集数优先用递归条目数(ChildCount 只算直属 Season 文件夹数)。
|
||||
count := remoteItemInt(it, "RecursiveItemCount")
|
||||
if count == 0 {
|
||||
count = remoteItemInt(it, "ChildCount")
|
||||
cards := make([]SeriesCard, 0)
|
||||
for startIndex := 0; ; startIndex += 1000 {
|
||||
q.Set("StartIndex", strconv.Itoa(startIndex))
|
||||
body.Items = nil
|
||||
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count == 0 {
|
||||
count = 1
|
||||
if len(body.Items) == 0 {
|
||||
break
|
||||
}
|
||||
for _, it := range body.Items {
|
||||
RewriteEmbyRemoteIDs(it, mount.ID)
|
||||
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
|
||||
// 集数优先用递归条目数(ChildCount 只算直属 Season 文件夹数)。
|
||||
count := remoteItemInt(it, "RecursiveItemCount")
|
||||
if count == 0 {
|
||||
count = remoteItemInt(it, "ChildCount")
|
||||
}
|
||||
if count == 0 {
|
||||
count = 1
|
||||
}
|
||||
var lastAdded *time.Time
|
||||
if date, ok := parseEmbyRemoteDate(remoteItemString(it, "DateLastMediaAdded")); ok {
|
||||
lastAdded = &date
|
||||
}
|
||||
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded})
|
||||
}
|
||||
if int64(len(cards)) >= body.TotalRecordCount || len(body.Items) < 1000 {
|
||||
break
|
||||
}
|
||||
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count})
|
||||
}
|
||||
if r.cache != nil {
|
||||
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
|
||||
@@ -496,7 +522,7 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
|
||||
|
||||
// RemoteLatestCards 远程库最新条目(首页预览卡片),映射 SeriesCard。
|
||||
func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]SeriesCard, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -512,13 +538,21 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
|
||||
cards := make([]SeriesCard, 0, len(items))
|
||||
for _, it := range items {
|
||||
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
|
||||
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: 0})
|
||||
}
|
||||
if r.cache != nil {
|
||||
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
|
||||
var lastAdded *time.Time
|
||||
if !m.UpdatedAt.IsZero() {
|
||||
t := m.UpdatedAt
|
||||
lastAdded = &t
|
||||
} else if !m.CreatedAt.IsZero() {
|
||||
t := m.CreatedAt
|
||||
lastAdded = &t
|
||||
}
|
||||
return cards, nil
|
||||
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: 0, LastAddedAt: lastAdded})
|
||||
}
|
||||
if r.cache != nil {
|
||||
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
// RemoteSearchMedia 在全部启用的挂载库中并发搜索影视条目(Movie,Series),
|
||||
// 并将远程结果映射为 model.Media。遵循当前用户的 MediaVisibility 权限规则。
|
||||
@@ -579,7 +613,7 @@ func (r *EmbyRemoteService) RemoteSearchMedia(ctx context.Context, query string,
|
||||
if acct == nil {
|
||||
continue
|
||||
}
|
||||
cfg, cfgErr := r.configOf(acct)
|
||||
cfg, cfgErr := r.remoteConfigWithToken(ctx, acct)
|
||||
if cfgErr != nil {
|
||||
continue
|
||||
}
|
||||
@@ -609,32 +643,34 @@ func (r *EmbyRemoteService) RemoteSearchMedia(ctx context.Context, query string,
|
||||
return
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("ParentId", target.mount.RemoteViewID)
|
||||
q.Set("Recursive", "true")
|
||||
q.Set("SearchTerm", query)
|
||||
q.Set("IncludeItemTypes", "Movie,Series")
|
||||
q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating")
|
||||
q.Set("Limit", strconv.Itoa(limit))
|
||||
q.Set("StartIndex", "0")
|
||||
helper.Run(r.log, "emby.remoteSearch", func() {
|
||||
q := url.Values{}
|
||||
q.Set("ParentId", target.mount.RemoteViewID)
|
||||
q.Set("Recursive", "true")
|
||||
q.Set("SearchTerm", query)
|
||||
q.Set("IncludeItemTypes", "Movie,Series")
|
||||
q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
|
||||
q.Set("Limit", strconv.Itoa(limit))
|
||||
q.Set("StartIndex", "0")
|
||||
|
||||
var body struct {
|
||||
Items []map[string]any `json:"Items"`
|
||||
}
|
||||
if err := r.doGet(searchCtx, target.acct, target.cfg, "/Users/"+url.PathEscape(r.remoteUserID(target.cfg))+"/Items", q, &body); err != nil {
|
||||
if r.log != nil {
|
||||
r.log.Warn("remote search failed",
|
||||
zap.String("mount", target.mount.RemoteViewName), zap.Error(err))
|
||||
var body struct {
|
||||
Items []map[string]any `json:"Items"`
|
||||
}
|
||||
return
|
||||
}
|
||||
medias := make([]model.Media, 0, len(body.Items))
|
||||
for _, it := range body.Items {
|
||||
RewriteEmbyRemoteIDs(it, target.mount.ID)
|
||||
m := r.MapRemoteItemToMedia(searchCtx, &target.mount, target.acct, target.cfg, it)
|
||||
medias = append(medias, m)
|
||||
}
|
||||
results[idx] = searchResult{items: medias}
|
||||
if err := r.doGet(searchCtx, target.acct, target.cfg, "/Users/"+url.PathEscape(r.remoteUserID(target.cfg))+"/Items", q, &body); err != nil {
|
||||
if r.log != nil {
|
||||
r.log.Warn("remote search failed",
|
||||
zap.String("mount", target.mount.RemoteViewName), zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
medias := make([]model.Media, 0, len(body.Items))
|
||||
for _, it := range body.Items {
|
||||
RewriteEmbyRemoteIDs(it, target.mount.ID)
|
||||
m := r.MapRemoteItemToMedia(searchCtx, &target.mount, target.acct, target.cfg, it)
|
||||
medias = append(medias, m)
|
||||
}
|
||||
results[idx] = searchResult{items: medias}
|
||||
})
|
||||
}(i, t)
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -661,7 +697,7 @@ func (r *EmbyRemoteService) WebStreamURL(ctx context.Context, acct *model.StrmAc
|
||||
|
||||
// remoteItemType 轻量查询远程条目 Type(避免依赖映射载荷)。
|
||||
func (r *EmbyRemoteService) remoteItemType(ctx context.Context, acct *model.StrmAccount, remoteID string) string {
|
||||
cfg, err := r.configOf(acct)
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -674,7 +710,7 @@ func (r *EmbyRemoteService) remoteItemType(ctx context.Context, acct *model.Strm
|
||||
|
||||
// remoteItemSeriesID 轻量查询 Episode 的 SeriesId。
|
||||
func (r *EmbyRemoteService) remoteItemSeriesID(ctx context.Context, acct *model.StrmAccount, remoteID string) string {
|
||||
cfg, err := r.configOf(acct)
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -68,10 +70,227 @@ func TestMapRemoteItemToMediaCriticRatingFallback(t *testing.T) {
|
||||
if media.Rating != 9.2 {
|
||||
t.Fatalf("Rating = %f, want 9.2 from CriticRating", media.Rating)
|
||||
}
|
||||
if media.Year != 2022 {
|
||||
t.Fatalf("Year = %d, want 2022 from PremiereDate", media.Year)
|
||||
}
|
||||
if media.Year != 2022 {
|
||||
t.Fatalf("Year = %d, want 2022 from PremiereDate", media.Year)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapRemoteItemToMediaDateLastMediaAdded(t *testing.T) {
|
||||
svc := &EmbyRemoteService{}
|
||||
mount := &model.EmbyMount{Base: model.Base{ID: "mount-1"}}
|
||||
acct := &model.StrmAccount{Base: model.Base{ID: "acct-1"}}
|
||||
cfg := &EmbyRemoteConfig{BaseURL: "http://localhost:8096"}
|
||||
|
||||
item := map[string]any{
|
||||
"Id": "series-1",
|
||||
"Name": "测试剧集",
|
||||
"DateCreated": "2023-01-01T00:00:00.0000000Z",
|
||||
"DateLastMediaAdded": "2024-05-20T10:00:00.0000000Z",
|
||||
}
|
||||
|
||||
media := svc.MapRemoteItemToMedia(context.Background(), mount, acct, cfg, item)
|
||||
expectedCreated, _ := time.Parse(time.RFC3339, "2023-01-01T00:00:00Z")
|
||||
expectedLastAdded, _ := time.Parse(time.RFC3339, "2024-05-20T10:00:00Z")
|
||||
if !media.CreatedAt.Equal(expectedCreated) {
|
||||
t.Fatalf("CreatedAt = %v, want %v", media.CreatedAt, expectedCreated)
|
||||
}
|
||||
if !media.UpdatedAt.Equal(expectedLastAdded) {
|
||||
t.Fatalf("UpdatedAt = %v, want %v", media.UpdatedAt, expectedLastAdded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSeriesCardsAutoAuthOnFirstBrowse(t *testing.T) {
|
||||
// 模拟远程 Emby:未认证兜底用户 ID "0" 被拒绝(与真实服务器一致),
|
||||
// 只有认证拿到的真实用户 GUID 才能浏览。
|
||||
var zeroUserHits atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/AuthenticateByName") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"AccessToken": "real-token",
|
||||
"User": map[string]any{"Id": "real-user-guid"},
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/emby/Users/0/Items" {
|
||||
zeroUserHits.Add(1)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("Unrecognized Guid format."))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/emby/Users/real-user-guid/Items" {
|
||||
q := r.URL.Query()
|
||||
if q.Get("ParentId") != "view-1" || q.Get("IncludeItemTypes") != "Series" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"TotalRecordCount": 1,
|
||||
"Items": []map[string]any{
|
||||
{
|
||||
"Id": "series-100",
|
||||
"Name": "测试剧",
|
||||
"Type": "Series",
|
||||
"ProductionYear": 2024,
|
||||
"RecursiveItemCount": 12,
|
||||
"ChildCount": 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
|
||||
// 账号只配置了用户名/密码,从未「测试连接」:无 api_key、无 remote_user_id。
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": server.URL,
|
||||
"username": "user",
|
||||
"password": "pass",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-1"},
|
||||
Name: "test-emby",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
mount := &model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-1"},
|
||||
AccountID: acct.ID,
|
||||
RemoteViewID: "view-1",
|
||||
RemoteViewName: "剧集库",
|
||||
CollectionType: "tvshows",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
|
||||
t.Fatalf("create mount: %v", err)
|
||||
}
|
||||
|
||||
cards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, "view-1")
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteSeriesCards on first browse failed: %v", err)
|
||||
}
|
||||
if len(cards) != 1 {
|
||||
t.Fatalf("cards = %d, want 1", len(cards))
|
||||
}
|
||||
if cards[0].Rep.Title != "测试剧" {
|
||||
t.Fatalf("title = %q, want 测试剧", cards[0].Rep.Title)
|
||||
}
|
||||
if cards[0].Count != 12 {
|
||||
t.Fatalf("count = %d, want 12 (RecursiveItemCount)", cards[0].Count)
|
||||
}
|
||||
if n := zeroUserHits.Load(); n != 0 {
|
||||
t.Fatalf("request hit /Users/0/Items %d time(s), want 0 (must use real user id)", n)
|
||||
}
|
||||
|
||||
// 首次浏览自动认证应把 token 与 remote_user_id 回写账号配置(等价于测试连接)。
|
||||
stored := map[string]string{}
|
||||
if err := json.Unmarshal([]byte(acct.Config), &stored); err != nil {
|
||||
t.Fatalf("decode account config: %v", err)
|
||||
}
|
||||
if stored["api_key"] == "" {
|
||||
t.Fatalf("account config missing api_key after first browse: %v", stored)
|
||||
}
|
||||
if stored["remote_user_id"] != "real-user-guid" {
|
||||
t.Fatalf("remote_user_id = %q, want real-user-guid (config %v)", stored["remote_user_id"], stored)
|
||||
}
|
||||
|
||||
// 第二次浏览不再需要认证步骤,直接命中真实用户 ID。
|
||||
if _, err := svc.RemoteSeriesCards(t.Context(), mount, acct, "view-1"); err != nil {
|
||||
t.Fatalf("RemoteSeriesCards second browse failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSeriesCardsResolveUserIDFromAPIKey(t *testing.T) {
|
||||
// api_key 直连场景:账号只填了 token(无用户名/密码),从未回写过
|
||||
// remote_user_id。首次浏览应通过 /Users 列表解析出真实用户 GUID。
|
||||
var zeroUserHits atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/emby/Users" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode([]map[string]any{
|
||||
{"Id": "real-user-guid", "Name": "admin"},
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/emby/Users/0/Items" {
|
||||
zeroUserHits.Add(1)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte("Unrecognized Guid format."))
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/emby/Users/real-user-guid/Items" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"TotalRecordCount": 1,
|
||||
"Items": []map[string]any{
|
||||
{"Id": "series-200", "Name": "API剧", "Type": "Series", "RecursiveItemCount": 8},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": server.URL,
|
||||
"token": "api-key-only",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-2"},
|
||||
Name: "api-key-emby",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
mount := &model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-2"},
|
||||
AccountID: acct.ID,
|
||||
RemoteViewID: "view-2",
|
||||
RemoteViewName: "剧集库",
|
||||
CollectionType: "tvshows",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
|
||||
t.Fatalf("create mount: %v", err)
|
||||
}
|
||||
|
||||
cards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, "view-2")
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteSeriesCards with api_key only failed: %v", err)
|
||||
}
|
||||
if len(cards) != 1 || cards[0].Rep.Title != "API剧" {
|
||||
t.Fatalf("cards = %#v, want 1 card 测试剧", cards)
|
||||
}
|
||||
if n := zeroUserHits.Load(); n != 0 {
|
||||
t.Fatalf("request hit /Users/0/Items %d time(s), want 0", n)
|
||||
}
|
||||
stored := map[string]string{}
|
||||
if err := json.Unmarshal([]byte(acct.Config), &stored); err != nil {
|
||||
t.Fatalf("decode account config: %v", err)
|
||||
}
|
||||
if stored["remote_user_id"] != "real-user-guid" {
|
||||
t.Fatalf("remote_user_id = %q, want real-user-guid (config %v)", stored["remote_user_id"], stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSearchMedia(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -10,19 +10,20 @@ import (
|
||||
)
|
||||
|
||||
type embySeriesGroup struct {
|
||||
ID string
|
||||
LibraryID string
|
||||
Name string
|
||||
PosterURL string
|
||||
BackdropURL string
|
||||
Overview string
|
||||
Rating float32
|
||||
Year int
|
||||
ReleaseDate string
|
||||
TMDbID int
|
||||
BangumiID int
|
||||
CreatedAt time.Time
|
||||
Episodes []model.Media
|
||||
ID string
|
||||
LibraryID string
|
||||
Name string
|
||||
PosterURL string
|
||||
BackdropURL string
|
||||
Overview string
|
||||
Rating float32
|
||||
Year int
|
||||
ReleaseDate string
|
||||
TMDbID int
|
||||
BangumiID int
|
||||
CreatedAt time.Time
|
||||
DateLastMediaAdded time.Time
|
||||
Episodes []model.Media
|
||||
}
|
||||
|
||||
type embySeasonGroup struct {
|
||||
@@ -49,11 +50,15 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
if !strings.HasPrefix(id, embyVirtualSeriesPrefix) {
|
||||
q = q.Where("series_id = ?", id)
|
||||
} else {
|
||||
// 虚拟 series ID 只可能来自 series_id 为空的媒体:
|
||||
// 有 series_id 时分组 key 就是 series_id 本身(UUID,不带虚拟前缀)。
|
||||
q = q.Where("series_id IS NULL OR series_id = ''")
|
||||
}
|
||||
if err := q.Order("media.season_num asc, media.episode_num asc, media.created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
|
||||
return embySeriesGroup{}, false, err
|
||||
}
|
||||
for _, group := range e.seriesGroupsFromMedia(rows) {
|
||||
for _, group := range e.seriesGroupsFromMedia(ctx, rows) {
|
||||
if group.ID == id {
|
||||
e.rememberSeriesGroup(group)
|
||||
return group, true, nil
|
||||
@@ -63,19 +68,20 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e
|
||||
if series, err := e.repo.Series.FindByID(ctx, id); err != nil {
|
||||
return embySeriesGroup{}, false, err
|
||||
} else if series != nil {
|
||||
return embySeriesGroup{
|
||||
ID: series.ID,
|
||||
LibraryID: series.LibraryID,
|
||||
Name: series.Title,
|
||||
PosterURL: series.PosterURL,
|
||||
BackdropURL: series.BackdropURL,
|
||||
Overview: series.Overview,
|
||||
Rating: series.Rating,
|
||||
Year: series.Year,
|
||||
TMDbID: series.TMDbID,
|
||||
BangumiID: series.BangumiID,
|
||||
CreatedAt: series.CreatedAt,
|
||||
}, true, nil
|
||||
return embySeriesGroup{
|
||||
ID: series.ID,
|
||||
LibraryID: series.LibraryID,
|
||||
Name: series.Title,
|
||||
PosterURL: series.PosterURL,
|
||||
BackdropURL: series.BackdropURL,
|
||||
Overview: series.Overview,
|
||||
Rating: series.Rating,
|
||||
Year: series.Year,
|
||||
TMDbID: series.TMDbID,
|
||||
BangumiID: series.BangumiID,
|
||||
CreatedAt: series.CreatedAt,
|
||||
DateLastMediaAdded: series.CreatedAt,
|
||||
}, true, nil
|
||||
}
|
||||
}
|
||||
return embySeriesGroup{}, false, nil
|
||||
@@ -88,9 +94,18 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
|
||||
if season, ok := e.cachedSeasonGroup(id); ok {
|
||||
return season, true, nil
|
||||
}
|
||||
// 虚拟 Season ID 是 hash(seriesKey, seasonNum),无法反解出 series。
|
||||
// 常见情况(已刮削、series_id 非空)先用一条小型 DISTINCT 查询枚举候选对,
|
||||
// 在内存中算哈希匹配,命中后只加载该一部剧的剧集行,避免整库扫描。
|
||||
if season, ok, err := e.findSeasonGroupBySeriesCandidates(ctx, id, userID); err != nil {
|
||||
return embySeasonGroup{}, false, err
|
||||
} else if ok {
|
||||
return season, true, nil
|
||||
}
|
||||
// 回退:未刮削(series_id 为空,虚拟 key 由库名+名称派生)的媒体只能全量分组。
|
||||
var rows []model.Media
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("season_num > 0 OR episode_num > 0")
|
||||
Where("(series_id IS NULL OR series_id = '') AND (season_num > 0 OR episode_num > 0)")
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
if err := q.
|
||||
Order("media.season_num asc, media.episode_num asc, media.created_at asc").
|
||||
@@ -98,7 +113,7 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
|
||||
Find(&rows).Error; err != nil {
|
||||
return embySeasonGroup{}, false, err
|
||||
}
|
||||
for _, series := range e.seriesGroupsFromMedia(rows) {
|
||||
for _, series := range e.seriesGroupsFromMedia(ctx, rows) {
|
||||
for _, season := range e.seasonsForSeries(series) {
|
||||
if season.ID == id {
|
||||
e.rememberSeriesGroup(series)
|
||||
@@ -109,30 +124,93 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
|
||||
return embySeasonGroup{}, false, nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) seriesGroupsFromMedia(rows []model.Media) []embySeriesGroup {
|
||||
// findSeasonGroupBySeriesCandidates resolves virtual season IDs for media that
|
||||
// carry a real series_id: enumerate distinct (series_id, season_num) pairs via
|
||||
// SQL, hash each candidate to find the matching season, then load only that
|
||||
// one series' episodes.
|
||||
func (e *EmbyService) findSeasonGroupBySeriesCandidates(ctx context.Context, id, userID string) (embySeasonGroup, bool, error) {
|
||||
type seasonCandidate struct {
|
||||
SeriesID string
|
||||
SeasonNum int
|
||||
}
|
||||
var candidates []seasonCandidate
|
||||
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Select("DISTINCT series_id, season_num").
|
||||
Where("series_id <> '' AND (season_num > 0 OR episode_num > 0)")
|
||||
q = e.applyUserMediaVisibility(ctx, q, userID)
|
||||
if err := q.Find(&candidates).Error; err != nil {
|
||||
return embySeasonGroup{}, false, err
|
||||
}
|
||||
matched := make([]string, 0, 1)
|
||||
for _, cand := range candidates {
|
||||
if seasonID(cand.SeriesID, cand.SeasonNum) == id {
|
||||
matched = append(matched, cand.SeriesID)
|
||||
}
|
||||
}
|
||||
for _, matchedSeries := range matched {
|
||||
season, ok, err := e.seasonGroupForSeries(ctx, id, matchedSeries, userID)
|
||||
if err != nil || ok {
|
||||
return season, ok, err
|
||||
}
|
||||
}
|
||||
return embySeasonGroup{}, false, nil
|
||||
}
|
||||
|
||||
// seasonGroupForSeries rebuilds the season groups of one series (small row
|
||||
// set) and returns the one matching the virtual season id.
|
||||
func (e *EmbyService) seasonGroupForSeries(ctx context.Context, id, seriesID, userID string) (embySeasonGroup, bool, error) {
|
||||
var rows []model.Media
|
||||
rq := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("series_id = ? AND (season_num > 0 OR episode_num > 0)", seriesID)
|
||||
rq = e.applyUserMediaVisibility(ctx, rq, userID)
|
||||
if err := rq.
|
||||
Order("media.season_num asc, media.episode_num asc, media.created_at asc").
|
||||
Limit(embySeriesGroupingLimit).
|
||||
Find(&rows).Error; err != nil {
|
||||
return embySeasonGroup{}, false, err
|
||||
}
|
||||
for _, series := range e.seriesGroupsFromMedia(ctx, rows) {
|
||||
if series.ID != seriesID {
|
||||
continue
|
||||
}
|
||||
for _, season := range e.seasonsForSeries(series) {
|
||||
if season.ID == id {
|
||||
e.rememberSeriesGroup(series)
|
||||
return season, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return embySeasonGroup{}, false, nil
|
||||
}
|
||||
|
||||
func (e *EmbyService) seriesGroupsFromMedia(ctx context.Context, rows []model.Media) []embySeriesGroup {
|
||||
byID := map[string]*embySeriesGroup{}
|
||||
order := []string{}
|
||||
for _, row := range rows {
|
||||
row := row
|
||||
seriesID := e.seriesIDForMedia(&row)
|
||||
seriesID := e.seriesIDForMedia(ctx, &row)
|
||||
group, ok := byID[seriesID]
|
||||
if !ok {
|
||||
group = &embySeriesGroup{
|
||||
ID: seriesID,
|
||||
LibraryID: row.LibraryID,
|
||||
Name: e.seriesNameForMedia(&row),
|
||||
Year: row.Year,
|
||||
ReleaseDate: row.ReleaseDate,
|
||||
TMDbID: row.TMDbID,
|
||||
BangumiID: row.BangumiID,
|
||||
CreatedAt: row.CreatedAt,
|
||||
group = &embySeriesGroup{
|
||||
ID: seriesID,
|
||||
LibraryID: row.LibraryID,
|
||||
Name: e.seriesNameForMedia(ctx, &row),
|
||||
Year: row.Year,
|
||||
ReleaseDate: row.ReleaseDate,
|
||||
TMDbID: row.TMDbID,
|
||||
BangumiID: row.BangumiID,
|
||||
CreatedAt: row.CreatedAt,
|
||||
DateLastMediaAdded: row.CreatedAt,
|
||||
}
|
||||
byID[seriesID] = group
|
||||
order = append(order, seriesID)
|
||||
}
|
||||
if row.CreatedAt.Before(group.CreatedAt) || group.CreatedAt.IsZero() {
|
||||
group.CreatedAt = row.CreatedAt
|
||||
}
|
||||
if row.CreatedAt.After(group.DateLastMediaAdded) {
|
||||
group.DateLastMediaAdded = row.CreatedAt
|
||||
}
|
||||
byID[seriesID] = group
|
||||
order = append(order, seriesID)
|
||||
}
|
||||
if row.CreatedAt.After(group.CreatedAt) {
|
||||
group.CreatedAt = row.CreatedAt
|
||||
}
|
||||
if strings.TrimSpace(row.ReleaseDate) != "" && mediaReleaseSortTime(row).After(embySeriesReleaseSortTime(*group)) {
|
||||
group.ReleaseDate = row.ReleaseDate
|
||||
if row.Year > 0 {
|
||||
|
||||
@@ -429,10 +429,87 @@ func TestInferSeriesNameFromPath(t *testing.T) {
|
||||
want: "间谍过家家",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := inferSeriesNameFromPath(tc.path)
|
||||
if got != tc.want {
|
||||
t.Errorf("inferSeriesNameFromPath(%q) = %q, want %q", tc.path, got, tc.want)
|
||||
for _, tc := range tests {
|
||||
got := inferSeriesNameFromPath(tc.path)
|
||||
if got != tc.want {
|
||||
t.Errorf("inferSeriesNameFromPath(%q) = %q, want %q", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbySeriesSortByDateLastMediaAdded(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
lib := model.Library{Name: "测试剧库", Path: `/media/tv`, Type: "tv", Enabled: true}
|
||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
t0 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
tOld := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
tNew := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// Series A: 较早创建,但最近添加了新一集 (Last episode at tNew)
|
||||
// Series B: 较晚创建,但最后一集在 tOld
|
||||
rows := []model.Media{
|
||||
{
|
||||
Base: model.Base{ID: "showA-ep01", CreatedAt: t0, UpdatedAt: t0},
|
||||
LibraryID: lib.ID,
|
||||
Title: "剧集A",
|
||||
Path: `/media/tv/剧集A/Season 01/剧集A.S01E01.mkv`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "showA-ep02", CreatedAt: tNew, UpdatedAt: tNew},
|
||||
LibraryID: lib.ID,
|
||||
Title: "剧集A",
|
||||
Path: `/media/tv/剧集A/Season 01/剧集A.S01E02.mkv`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 2,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "showB-ep01", CreatedAt: tOld.Add(-24 * time.Hour), UpdatedAt: tOld.Add(-24 * time.Hour)},
|
||||
LibraryID: lib.ID,
|
||||
Title: "剧集B",
|
||||
Path: `/media/tv/剧集B/Season 01/剧集B.S01E01.mkv`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
},
|
||||
{
|
||||
Base: model.Base{ID: "showB-ep02", CreatedAt: tOld, UpdatedAt: tOld},
|
||||
LibraryID: lib.ID,
|
||||
Title: "剧集B",
|
||||
Path: `/media/tv/剧集B/Season 01/剧集B.S01E02.mkv`,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 2,
|
||||
},
|
||||
}
|
||||
for _, m := range rows {
|
||||
if err := svc.repo.DB.Create(&m).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 降序排序:剧集A最后一集在 tNew,剧集B最后一集在 tOld,剧集A应排在第一位
|
||||
res, err := svc.Items(t.Context(), ItemsParams{
|
||||
ParentID: lib.ID,
|
||||
SortBy: "DateLastMediaAdded",
|
||||
SortOrder: "Descending",
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("items DateLastMediaAdded: %v", err)
|
||||
}
|
||||
items := res["Items"].([]map[string]any)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items count = %d, want 2", len(items))
|
||||
}
|
||||
if items[0]["Name"] != "剧集A" {
|
||||
t.Fatalf("first item = %v, want 剧集A (last episode at tNew)", items[0]["Name"])
|
||||
}
|
||||
if items[1]["Name"] != "剧集B" {
|
||||
t.Fatalf("second item = %v, want 剧集B", items[1]["Name"])
|
||||
}
|
||||
if items[0]["DateLastMediaAdded"] != tNew {
|
||||
t.Fatalf("DateLastMediaAdded = %v, want %v", items[0]["DateLastMediaAdded"], tNew)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,21 +14,22 @@ import (
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func (e *EmbyService) seriesIDForMedia(m *model.Media) string {
|
||||
func (e *EmbyService) seriesIDForMedia(ctx context.Context, m *model.Media) string {
|
||||
if strings.TrimSpace(m.SeriesID) != "" {
|
||||
return m.SeriesID
|
||||
}
|
||||
return stableEmbyID(embyVirtualSeriesPrefix, m.LibraryID, e.seriesNameForMedia(m))
|
||||
return stableEmbyID(embyVirtualSeriesPrefix, m.LibraryID, e.seriesNameForMedia(ctx, m))
|
||||
}
|
||||
|
||||
func (e *EmbyService) seasonIDForMedia(m *model.Media) string {
|
||||
return seasonID(e.seriesIDForMedia(m), m.SeasonNum)
|
||||
func (e *EmbyService) seasonIDForMedia(ctx context.Context, m *model.Media) string {
|
||||
return seasonID(e.seriesIDForMedia(ctx, m), m.SeasonNum)
|
||||
}
|
||||
|
||||
func (e *EmbyService) seriesNameForMedia(m *model.Media) string {
|
||||
func (e *EmbyService) seriesNameForMedia(ctx context.Context, m *model.Media) string {
|
||||
if strings.TrimSpace(m.SeriesID) != "" {
|
||||
if series, err := e.repo.Series.FindByID(context.Background(), m.SeriesID); err == nil && series != nil && strings.TrimSpace(series.Title) != "" {
|
||||
return series.Title
|
||||
// 走请求级缓存;无缓存 ctx 时退化为单次查询。
|
||||
if title, ok, err := e.payloadSeriesTitle(ctx, m.SeriesID); err == nil && ok && strings.TrimSpace(title) != "" {
|
||||
return title
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(m.ScrapeStatus), "matched") && strings.TrimSpace(m.Title) != "" {
|
||||
@@ -125,13 +126,28 @@ func sortSeriesGroups(groups []embySeriesGroup, p ItemsParams) {
|
||||
}
|
||||
return groups[i].Name < groups[j].Name
|
||||
})
|
||||
case "datecreated":
|
||||
sort.SliceStable(groups, func(i, j int) bool {
|
||||
if strings.EqualFold(p.SortOrder, "Ascending") {
|
||||
return groups[i].CreatedAt.Before(groups[j].CreatedAt)
|
||||
}
|
||||
return groups[i].CreatedAt.After(groups[j].CreatedAt)
|
||||
})
|
||||
case "datecreated":
|
||||
sort.SliceStable(groups, func(i, j int) bool {
|
||||
if strings.EqualFold(p.SortOrder, "Ascending") {
|
||||
return groups[i].CreatedAt.Before(groups[j].CreatedAt)
|
||||
}
|
||||
return groups[i].CreatedAt.After(groups[j].CreatedAt)
|
||||
})
|
||||
case "datelastmediaadded", "datelastcontentadded":
|
||||
sort.SliceStable(groups, func(i, j int) bool {
|
||||
tI := groups[i].DateLastMediaAdded
|
||||
if tI.IsZero() {
|
||||
tI = groups[i].CreatedAt
|
||||
}
|
||||
tJ := groups[j].DateLastMediaAdded
|
||||
if tJ.IsZero() {
|
||||
tJ = groups[j].CreatedAt
|
||||
}
|
||||
if strings.EqualFold(p.SortOrder, "Ascending") {
|
||||
return tI.Before(tJ)
|
||||
}
|
||||
return tI.After(tJ)
|
||||
})
|
||||
default:
|
||||
sort.SliceStable(groups, func(i, j int) bool {
|
||||
if strings.EqualFold(p.SortOrder, "Ascending") {
|
||||
|
||||
@@ -10,22 +10,27 @@ func (e *EmbyService) seriesPayload(group embySeriesGroup) map[string]any {
|
||||
if group.BackdropURL != "" {
|
||||
backdropTags = append(backdropTags, group.ID+"-bd")
|
||||
}
|
||||
item := map[string]any{
|
||||
"Id": group.ID,
|
||||
"Name": group.Name,
|
||||
"ServerId": embyServerID,
|
||||
"Type": "Series",
|
||||
"MediaType": "Video",
|
||||
"IsFolder": true,
|
||||
"ParentId": group.LibraryID,
|
||||
"ProductionYear": group.Year,
|
||||
"Overview": group.Overview,
|
||||
"CommunityRating": group.Rating,
|
||||
"RecursiveItemCount": len(group.Episodes),
|
||||
"ChildCount": len(e.seasonsForSeries(group)),
|
||||
"DateCreated": group.CreatedAt,
|
||||
"ImageTags": imageTags,
|
||||
"BackdropImageTags": backdropTags,
|
||||
lastMediaAdded := group.DateLastMediaAdded
|
||||
if lastMediaAdded.IsZero() {
|
||||
lastMediaAdded = group.CreatedAt
|
||||
}
|
||||
item := map[string]any{
|
||||
"Id": group.ID,
|
||||
"Name": group.Name,
|
||||
"ServerId": embyServerID,
|
||||
"Type": "Series",
|
||||
"MediaType": "Video",
|
||||
"IsFolder": true,
|
||||
"ParentId": group.LibraryID,
|
||||
"ProductionYear": group.Year,
|
||||
"Overview": group.Overview,
|
||||
"CommunityRating": group.Rating,
|
||||
"RecursiveItemCount": len(group.Episodes),
|
||||
"ChildCount": len(e.seasonsForSeries(group)),
|
||||
"DateCreated": group.CreatedAt,
|
||||
"DateLastMediaAdded": lastMediaAdded,
|
||||
"ImageTags": imageTags,
|
||||
"BackdropImageTags": backdropTags,
|
||||
"ProviderIds": map[string]string{
|
||||
"Tmdb": intToStr(group.TMDbID),
|
||||
"Bangumi": intToStr(group.BangumiID),
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
@@ -152,7 +153,7 @@ func (s *FFmpegToolsService) StartInstall(ctx context.Context) error {
|
||||
s.mu.Unlock()
|
||||
|
||||
s.setMessage("准备下载…")
|
||||
go s.runInstall()
|
||||
helper.Go(s.log, "ffmpeg.install", s.runInstall)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -27,10 +27,11 @@ type libraryRowsCacheValue struct {
|
||||
}
|
||||
|
||||
type SeriesCard struct {
|
||||
Key string `json:"key"`
|
||||
Rep model.Media `json:"rep"`
|
||||
LinkMedia model.Media `json:"linkMedia"`
|
||||
Count int `json:"count"`
|
||||
Key string `json:"key"`
|
||||
Rep model.Media `json:"rep"`
|
||||
LinkMedia model.Media `json:"linkMedia"`
|
||||
Count int `json:"count"`
|
||||
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
|
||||
}
|
||||
|
||||
type seriesCardGroup struct {
|
||||
@@ -189,19 +190,40 @@ func (s *MediaService) ListMediaEpisodes(ctx context.Context, mediaID string, vi
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有聚合到多集,尝试同父目录匹配
|
||||
if len(out) <= 1 && target.Path != "" {
|
||||
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
|
||||
dirMatches := make([]model.Media, 0)
|
||||
for _, row := range rows {
|
||||
if row.Path != "" && filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) == targetDir {
|
||||
dirMatches = append(dirMatches, row)
|
||||
// 如果没有聚合到多集,尝试同父目录匹配(排除合集目录和公共分类目录,且同目录文件不能是互不相同的独立电影)
|
||||
if len(out) <= 1 && target.Path != "" {
|
||||
targetDir := filepath.Dir(strings.ReplaceAll(target.Path, "\\", "/"))
|
||||
parentBase := filepath.Base(targetDir)
|
||||
if !mediaParentLooksLikeCollection(target.Path) && !seriesTitleIsGenericContainer(parentBase, *target) {
|
||||
targetTitleNorm := normalizeSeriesTitle(target.Title)
|
||||
targetDirNorm := normalizeSeriesTitle(parentBase)
|
||||
dirMatches := make([]model.Media, 0)
|
||||
for _, row := range rows {
|
||||
if row.Path == "" || filepath.Dir(strings.ReplaceAll(row.Path, "\\", "/")) != targetDir {
|
||||
continue
|
||||
}
|
||||
if row.ID == target.ID {
|
||||
dirMatches = append(dirMatches, row)
|
||||
continue
|
||||
}
|
||||
rowTitleNorm := normalizeSeriesTitle(row.Title)
|
||||
allowMatch := false
|
||||
if isGenericMovieTitle(rowTitleNorm) || isGenericMovieTitle(targetTitleNorm) {
|
||||
allowMatch = true
|
||||
} else if rowTitleNorm != "" && rowTitleNorm == targetTitleNorm {
|
||||
allowMatch = true
|
||||
} else if rowTitleNorm != "" && targetDirNorm != "" && rowTitleNorm == targetDirNorm {
|
||||
allowMatch = true
|
||||
}
|
||||
if allowMatch {
|
||||
dirMatches = append(dirMatches, row)
|
||||
}
|
||||
}
|
||||
if len(dirMatches) > 1 {
|
||||
out = dirMatches
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(dirMatches) > 1 {
|
||||
out = dirMatches
|
||||
}
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
out = []model.Media{*target}
|
||||
@@ -256,12 +278,20 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
itemAdded := item.CreatedAt
|
||||
if itemAdded.IsZero() {
|
||||
itemAdded = item.UpdatedAt
|
||||
}
|
||||
if idx, ok := byKey[key]; ok {
|
||||
group := &groups[idx]
|
||||
if latest := seriesMediaTime(item); latest.After(group.latest) {
|
||||
group.latest = latest
|
||||
}
|
||||
card := &group.card
|
||||
if card.LastAddedAt == nil || (!itemAdded.IsZero() && itemAdded.After(*card.LastAddedAt)) {
|
||||
t := itemAdded
|
||||
card.LastAddedAt = &t
|
||||
}
|
||||
// A shared external ID means duplicate encodes/locations for movies,
|
||||
// not multiple episodes. Keep a single movie card without presenting
|
||||
// its versions as an "N episodes" collection.
|
||||
@@ -284,9 +314,14 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard {
|
||||
}
|
||||
continue
|
||||
}
|
||||
var initialLastAdded *time.Time
|
||||
if !itemAdded.IsZero() {
|
||||
t := itemAdded
|
||||
initialLastAdded = &t
|
||||
}
|
||||
byKey[key] = len(groups)
|
||||
groups = append(groups, seriesCardGroup{
|
||||
card: SeriesCard{Key: key, Rep: item, LinkMedia: item, Count: 1},
|
||||
card: SeriesCard{Key: key, Rep: item, LinkMedia: item, Count: 1, LastAddedAt: initialLastAdded},
|
||||
latest: seriesMediaTime(item),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,6 +11,16 @@ import (
|
||||
|
||||
var episodicPathRE = regexp.MustCompile(`(?i)[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]`)
|
||||
|
||||
var genericMovieTitleRE = regexp.MustCompile(`(?i)^(?:cd\s*\d+|part\s*\d+|disc\s*\d+|disk\s*\d+|dvd\s*\d+|movie|film|video|main|feature|track\s*\d+|preview|sample|trailer|\d{3,4}p|4k|2160p|1080p|720p)$`)
|
||||
|
||||
func isGenericMovieTitle(title string) bool {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return true
|
||||
}
|
||||
return genericMovieTitleRE.MatchString(title)
|
||||
}
|
||||
|
||||
func mediaSeriesKey(media model.Media) string {
|
||||
return compactSeriesKey(mediaSeriesRawKey(media))
|
||||
}
|
||||
@@ -54,7 +64,11 @@ func mediaSeriesRawKey(media model.Media) string {
|
||||
return seriesFingerprint("movie-external", fmt.Sprintf("bgm:%d", media.BangumiID))
|
||||
}
|
||||
if fromPath != "" && !mediaParentLooksLikeCollection(media.Path) {
|
||||
return seriesFingerprint("library-path", media.LibraryID, fromPath)
|
||||
titleNorm := normalizeSeriesTitle(media.Title)
|
||||
fromPathNorm := normalizeSeriesTitle(fromPath)
|
||||
if titleNorm == "" || isGenericMovieTitle(titleNorm) || titleNorm == fromPathNorm {
|
||||
return seriesFingerprint("library-path", media.LibraryID, fromPath)
|
||||
}
|
||||
}
|
||||
return seriesFingerprint("library-title", media.LibraryID, normalizeSeriesTitle(media.Title))
|
||||
}
|
||||
|
||||
@@ -47,10 +47,14 @@ func TestListRecentSeriesCardsCountsAllEpisodesInSeries(t *testing.T) {
|
||||
if len(cards) != 1 {
|
||||
t.Fatalf("recent cards = %#v, want one series card", cards)
|
||||
}
|
||||
if cards[0].Count != 40 {
|
||||
t.Fatalf("recent series count = %d, want full 40 episodes", cards[0].Count)
|
||||
if cards[0].Count != 40 {
|
||||
t.Fatalf("recent series count = %d, want full 40 episodes", cards[0].Count)
|
||||
}
|
||||
expectedLastAdded := now.Add(40 * time.Minute)
|
||||
if cards[0].LastAddedAt == nil || !cards[0].LastAddedAt.Equal(expectedLastAdded) {
|
||||
t.Fatalf("recent series LastAddedAt = %v, want %v", cards[0].LastAddedAt, expectedLastAdded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaSeriesKeyCollapsesNestedSpecialFolders(t *testing.T) {
|
||||
main := model.Media{
|
||||
@@ -445,3 +449,67 @@ func TestGroupMediaSeriesCardsBridgesReleaseFoldersByMatchedSeriesTitle(t *testi
|
||||
t.Fatalf("cards=%#v, want one series bridged by matched title", cards)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMediaSeriesCardsKeepsIndependentMoviesSeparateInSharedSubdirectory(t *testing.T) {
|
||||
// 同一分类子目录下存放多部不同标题的独立电影,不应被强制折叠成 1 部
|
||||
items := []model.Media{
|
||||
{LibraryID: "movies", Title: "老师2024偷窥篇", Path: `/media/小姐姐/国产/nana/老师2024偷窥篇.strm`},
|
||||
{LibraryID: "movies", Title: "紫光灯下的肉体诱惑", Path: `/media/小姐姐/国产/nana/紫光灯下的肉体诱惑.strm`},
|
||||
{LibraryID: "movies", Title: "修洗衣机", Path: `/media/小姐姐/国产/nana/修洗衣机.strm`},
|
||||
}
|
||||
cards := groupMediaSeriesCards(items)
|
||||
if len(cards) != 3 {
|
||||
t.Fatalf("got %d cards, want 3 independent movie cards", len(cards))
|
||||
}
|
||||
|
||||
// 但同一部电影的 CD1 和 CD2 仍应正确折叠为 1 部
|
||||
cdItems := []model.Media{
|
||||
{LibraryID: "movies", Title: "cd1", Path: `/media/电影/指环王 (2001)/cd1.mkv`},
|
||||
{LibraryID: "movies", Title: "cd2", Path: `/media/电影/指环王 (2001)/cd2.mkv`},
|
||||
}
|
||||
cdCards := groupMediaSeriesCards(cdItems)
|
||||
if len(cdCards) != 1 {
|
||||
t.Fatalf("got %d cards for cd1/cd2, want 1 folded movie card", len(cdCards))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMediaEpisodesKeepsIndependentMoviesSeparate(t *testing.T) {
|
||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||
repos := repository.New(db)
|
||||
lib := model.Library{Base: model.Base{ID: "lib-movies"}, Name: "电影", Type: "movies", Enabled: true}
|
||||
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m1 := model.Media{
|
||||
Base: model.Base{ID: "m1"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "老师2024偷窥篇",
|
||||
Path: `/media/小姐姐/国产/nana/老师2024偷窥篇.strm`,
|
||||
}
|
||||
m2 := model.Media{
|
||||
Base: model.Base{ID: "m2"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "紫光灯下的肉体诱惑",
|
||||
Path: `/media/小姐姐/国产/nana/紫光灯下的肉体诱惑.strm`,
|
||||
}
|
||||
m3 := model.Media{
|
||||
Base: model.Base{ID: "m3"},
|
||||
LibraryID: lib.ID,
|
||||
Title: "修洗衣机",
|
||||
Path: `/media/小姐姐/国产/nana/修洗衣机.strm`,
|
||||
}
|
||||
if err := repos.DB.Create(&[]model.Media{m1, m2, m3}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
|
||||
eps, err := svc.ListMediaEpisodes(t.Context(), "m1", MediaVisibility{IncludeNSFW: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(eps) != 1 || eps[0].ID != "m1" {
|
||||
t.Fatalf("ListMediaEpisodes got %#v, want exactly m1", eps)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
@@ -45,7 +46,7 @@ func (s *ScannerService) startLocalMediaProbeWorkers() {
|
||||
s.localMediaProbeOnce.Do(func() {
|
||||
workers := s.ffprobeWorkerCount()
|
||||
for i := 0; i < workers; i++ {
|
||||
go s.localMediaProbeWorker()
|
||||
helper.Go(s.log, "scanner.probeWorker", s.localMediaProbeWorker)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
)
|
||||
|
||||
func (s *ScannerService) invalidateMediaCache(ctx context.Context) {
|
||||
@@ -18,10 +20,12 @@ func (s *ScannerService) startAutoScrape(ctx context.Context, libraryID string)
|
||||
scrapeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Minute)
|
||||
go func() {
|
||||
defer cancel()
|
||||
_, err := s.scraper.EnrichLibraryDetailedWithOptions(scrapeCtx, libraryID, skipEpisodeArtworkOptions(false))
|
||||
if err != nil {
|
||||
s.log.Warn("scraper enrich failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
// 扫描触发的后台刮削与请求线程无关,panic 只记日志,不能带崩进程。
|
||||
helper.Run(s.log, "scanner.autoScrape", func() {
|
||||
_, err := s.scraper.EnrichLibraryDetailedWithOptions(scrapeCtx, libraryID, skipEpisodeArtworkOptions(false))
|
||||
if err != nil {
|
||||
s.log.Warn("scraper enrich failed", zap.Error(err))
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
@@ -139,7 +140,7 @@ func (s *SchedulerService) Start(ctx context.Context) {
|
||||
// 首轮等满一个完整周期再跑,平时节奏不变。
|
||||
initialDelay = j.interval
|
||||
}
|
||||
go s.loopWithInitialDelay(ctx, j, initialDelay)
|
||||
helper.Go(s.log, "scheduler.loop."+j.name, func() { s.loopWithInitialDelay(ctx, j, initialDelay) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
)
|
||||
|
||||
// JobStatus is a snapshot suitable for the admin UI.
|
||||
@@ -142,7 +144,8 @@ func (s *SchedulerService) beginRun(j *scheduledJob) error {
|
||||
}
|
||||
|
||||
func (s *SchedulerService) runReserved(ctx context.Context, j *scheduledJob) error {
|
||||
err := j.run(ctx)
|
||||
// 任务 panic 转为 error,保证下方 running/lastErr 状态照常清理、调度循环存活。
|
||||
err := helper.Recover(s.log, "scheduler.job."+j.name, func() error { return j.run(ctx) })
|
||||
s.mu.Lock()
|
||||
j.lastRun = s.currentTime()
|
||||
if err != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
@@ -73,7 +74,10 @@ func (s *ScraperService) queueWorker(ctx context.Context) {
|
||||
}
|
||||
defer func() { <-sem }()
|
||||
|
||||
s.processScrapeTask(ctx, t)
|
||||
// 刮削要解析远端元数据响应,单个任务 panic 不应拖垮队列 worker。
|
||||
helper.Run(s.log, "scraper.task", func() {
|
||||
s.processScrapeTask(ctx, t)
|
||||
})
|
||||
}(&tasks[i])
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
@@ -96,13 +97,14 @@ func (c *Container) Boot() {
|
||||
if err := c.APIConfig.SeedDefaults(c.stopCtx); err != nil {
|
||||
c.Log.Warn("api config seed failed", zap.Error(err))
|
||||
}
|
||||
go c.warmMediaSearchIndex(c.stopCtx)
|
||||
helper.Go(c.Log, "service.warmMediaSearchIndex", func() { c.warmMediaSearchIndex(c.stopCtx) })
|
||||
|
||||
// 启动调度器定时任务
|
||||
c.Scheduler.Start(c.stopCtx)
|
||||
|
||||
// 远程 Emby 挂载兼容迁移:旧账号无挂载时自动全量挂载
|
||||
// 远程 Emby 挂载兼容迁移:清理已删账号的残留挂载;旧账号无挂载时自动全量挂载
|
||||
if c.EmbyRemote != nil {
|
||||
c.EmbyRemote.CleanupOrphanMounts(c.stopCtx)
|
||||
c.EmbyRemote.AutoSeedMounts(c.stopCtx)
|
||||
}
|
||||
|
||||
@@ -119,7 +121,7 @@ func (c *Container) Boot() {
|
||||
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
|
||||
// 每天触发一次评估;规则里的窗口可随机,不固定。
|
||||
if c.Device != nil {
|
||||
go c.runInactivitySweeper(c.stopCtx)
|
||||
helper.Go(c.Log, "service.inactivitySweeper", func() { c.runInactivitySweeper(c.stopCtx) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
@@ -47,12 +48,12 @@ func newServiceContainer(cfg *config.Config, log *zap.Logger, repos *repository.
|
||||
|
||||
func (b *serviceContainerBuilder) startRealtimeServices() {
|
||||
b.c.WSHub = NewHub(b.log)
|
||||
go b.c.WSHub.Run()
|
||||
helper.Go(b.log, "ws.hub", b.c.WSHub.Run)
|
||||
b.c.Tasks = NewTaskTrackerService(b.log, b.c.WSHub)
|
||||
b.c.SystemUpdate = NewSystemUpdateService(b.cfg, b.log, b.repos, b.c.Tasks, b.version)
|
||||
|
||||
b.c.SSEHub = NewSSEHub(b.log)
|
||||
go b.c.SSEHub.Run()
|
||||
helper.Go(b.log, "sse.hub", b.c.SSEHub.Run)
|
||||
}
|
||||
|
||||
func (b *serviceContainerBuilder) initProviderServices() {
|
||||
|
||||
@@ -5,7 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestStrmAccountConfigPreviewOf(t *testing.T) {
|
||||
@@ -67,6 +71,117 @@ func TestUpdateStrmAccountMergesConfigWithoutClearingSecrets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteStrmAccountCascadesEmbyMounts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}, &model.StrmSyncPath{})
|
||||
repos := repository.New(db)
|
||||
svc := NewStrmService(nil, zap.NewNop(), repos, nil)
|
||||
|
||||
createEmbyAcct := func(id, name string) *model.StrmAccount {
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: id},
|
||||
Name: name,
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(ctx, acct); err != nil {
|
||||
t.Fatalf("create account %s: %v", id, err)
|
||||
}
|
||||
return acct
|
||||
}
|
||||
createMounts := func(accountID string, viewIDs ...string) {
|
||||
for _, vid := range viewIDs {
|
||||
m := &model.EmbyMount{
|
||||
AccountID: accountID,
|
||||
RemoteViewID: vid,
|
||||
RemoteViewName: "库-" + vid,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(ctx, m); err != nil {
|
||||
t.Fatalf("create mount %s: %v", vid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gone := createEmbyAcct("acct-gone", "要删除的账号")
|
||||
createMounts(gone.ID, "view-1", "view-2", "view-3")
|
||||
keep := createEmbyAcct("acct-keep", "保留的账号")
|
||||
createMounts(keep.ID, "view-a")
|
||||
|
||||
if err := svc.DeleteStrmAccount(ctx, gone.ID); err != nil {
|
||||
t.Fatalf("DeleteStrmAccount failed: %v", err)
|
||||
}
|
||||
|
||||
remaining, err := repos.StrmAccount.FindByID(ctx, gone.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("find account: %v", err)
|
||||
}
|
||||
if remaining != nil {
|
||||
t.Fatalf("account %s should be deleted", gone.ID)
|
||||
}
|
||||
goneMounts, err := repos.EmbyMount.ListByAccountID(ctx, gone.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list mounts: %v", err)
|
||||
}
|
||||
if len(goneMounts) != 0 {
|
||||
t.Fatalf("deleted account still has %d mounts (orphans)", len(goneMounts))
|
||||
}
|
||||
keepMounts, err := repos.EmbyMount.ListByAccountID(ctx, keep.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list mounts: %v", err)
|
||||
}
|
||||
if len(keepMounts) != 1 || keepMounts[0].RemoteViewID != "view-a" {
|
||||
t.Fatalf("keep account mounts = %#v, want 1 (view-a)", keepMounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupOrphanMountsRemovesStaleRows(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, nil)
|
||||
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-1"},
|
||||
Name: "emby",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(ctx, acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
for i, vid := range []string{"v1", "v2", "v-orphan-1", "v-orphan-2"} {
|
||||
m := &model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-" + vid},
|
||||
AccountID: acct.ID,
|
||||
RemoteViewID: vid,
|
||||
Enabled: true,
|
||||
}
|
||||
if i >= 2 {
|
||||
// 模拟历史残留:挂载归属不存在的账号
|
||||
m.AccountID = "no-such-account"
|
||||
}
|
||||
if err := repos.EmbyMount.Create(ctx, m); err != nil {
|
||||
t.Fatalf("create mount %s: %v", vid, err)
|
||||
}
|
||||
}
|
||||
|
||||
svc.CleanupOrphanMounts(ctx)
|
||||
|
||||
left, err := repos.EmbyMount.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list mounts: %v", err)
|
||||
}
|
||||
if len(left) != 2 {
|
||||
t.Fatalf("mounts after cleanup = %d, want 2 (orphans removed)", len(left))
|
||||
}
|
||||
for _, m := range left {
|
||||
if m.AccountID != acct.ID {
|
||||
t.Fatalf("mount %s still orphan (account %s)", m.ID, m.AccountID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(v any) string {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||
@@ -66,7 +67,10 @@ func (s *StrmService) downloadWorker(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
defer s.releaseDownloadSlot(task.Provider)
|
||||
s.processDownloadTask(ctx, task)
|
||||
// 单个任务 panic 不应拖垮整个下载 worker。
|
||||
helper.Run(s.log, "strm.downloadTask", func() {
|
||||
s.processDownloadTask(ctx, task)
|
||||
})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud"
|
||||
@@ -91,8 +92,8 @@ type StrmService struct {
|
||||
oauthSessions map[string]*strm115AuthSession
|
||||
wafUntil time.Time // 115 风控/限流熔断截止时间(由 mu 保护)
|
||||
|
||||
downloadSem115 chan struct{} // 115 换直链+下载并发上限(风控兜底)
|
||||
downloadSemDAV chan struct{} // WebDAV/OpenList/CloudDrive2 元数据下载并发上限
|
||||
downloadSem115 chan struct{} // 115 换直链+下载并发上限(风控兜底)
|
||||
downloadSemDAV chan struct{} // WebDAV/OpenList/CloudDrive2 元数据下载并发上限
|
||||
downloadSemOnce sync.Once
|
||||
}
|
||||
|
||||
@@ -186,14 +187,14 @@ func (s *StrmService) Start(ctx context.Context) {
|
||||
uploadThreads = 4
|
||||
}
|
||||
for i := 0; i < downloadThreads; i++ {
|
||||
go s.downloadWorker(ctx)
|
||||
helper.Go(s.log, "strm.downloadWorker", func() { s.downloadWorker(ctx) })
|
||||
}
|
||||
for i := 0; i < uploadThreads; i++ {
|
||||
go s.uploadWorker(ctx)
|
||||
helper.Go(s.log, "strm.uploadWorker", func() { s.uploadWorker(ctx) })
|
||||
}
|
||||
go s.cronLoop(ctx)
|
||||
go s.queueCleanupLoop(ctx)
|
||||
go s.refresh115TokensLoop(ctx)
|
||||
helper.Go(s.log, "strm.cronLoop", func() { s.cronLoop(ctx) })
|
||||
helper.Go(s.log, "strm.queueCleanupLoop", func() { s.queueCleanupLoop(ctx) })
|
||||
helper.Go(s.log, "strm.refresh115TokensLoop", func() { s.refresh115TokensLoop(ctx) })
|
||||
s.log.Info("strm service started",
|
||||
zap.Int("download_threads", downloadThreads),
|
||||
zap.Int("upload_threads", uploadThreads))
|
||||
@@ -378,13 +379,13 @@ func (s *StrmService) UpdateStrmAccount(ctx context.Context, id, name string, en
|
||||
if enabled != nil {
|
||||
acct.Enabled = *enabled
|
||||
}
|
||||
if len(config) > 0 {
|
||||
enc, err := s.mergeStrmAccountConfig(acct.Config, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
acct.Config = enc
|
||||
if len(config) > 0 {
|
||||
enc, err := s.mergeStrmAccountConfig(acct.Config, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
acct.Config = enc
|
||||
}
|
||||
if err := s.repo.StrmAccount.Update(ctx, acct); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -405,6 +406,11 @@ func (s *StrmService) DeleteStrmAccount(ctx context.Context, id string) error {
|
||||
if err := s.repo.StrmAccount.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
// 级联清理远程 Emby 挂载:否则留下孤儿挂载,挂载计数/列表仍会显示。
|
||||
// 账号已删,挂载清理失败只记日志,不让删除请求报错。
|
||||
if _, err := s.repo.EmbyMount.DeleteByAccountID(ctx, id); err != nil && s.log != nil {
|
||||
s.log.Warn("delete emby mounts for account failed", zap.String("account", id), zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||
@@ -105,7 +106,7 @@ func (s *StrmService) StartSync(ctx context.Context, pathID string, syncType ...
|
||||
p.LastSyncMessage = "同步进行中"
|
||||
_ = s.repo.StrmSyncPath.Update(ctx, p)
|
||||
|
||||
go s.runSync(runCtx, p, rec)
|
||||
helper.Go(s.log, "strm.sync", func() { s.runSync(runCtx, p, rec) })
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -361,38 +362,45 @@ func (st *strmSyncState) walkRemote() error {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for task := range queue {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
entries, err := st.provider.List(ctx, task.id)
|
||||
if err != nil {
|
||||
errMu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("列出远端目录 %s 失败:%w", task.id, err)
|
||||
// worker 解析远端响应 panic 时取消整个同步,让 closer 与其余
|
||||
// worker 正常收尾,避免队列与 pending 计数卡死;正常退出不取消。
|
||||
if err := helper.Recover(st.s.log, "strm.sync.walkRemote", func() error {
|
||||
for task := range queue {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
errMu.Unlock()
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
cleanName := cleanEntryName(entry.Name, entry.IsDir)
|
||||
rel := cleanName
|
||||
if task.rel != "" {
|
||||
rel = task.rel + "/" + cleanName
|
||||
}
|
||||
if entry.IsDir {
|
||||
pending.Add(1)
|
||||
select {
|
||||
case queue <- dirTask{id: entry.ID, rel: rel}:
|
||||
case <-ctx.Done():
|
||||
pending.Add(-1)
|
||||
entries, err := st.provider.List(ctx, task.id)
|
||||
if err != nil {
|
||||
errMu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("列出远端目录 %s 失败:%w", task.id, err)
|
||||
}
|
||||
} else {
|
||||
st.processRemoteFile(entry, rel)
|
||||
errMu.Unlock()
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
for _, entry := range entries {
|
||||
cleanName := cleanEntryName(entry.Name, entry.IsDir)
|
||||
rel := cleanName
|
||||
if task.rel != "" {
|
||||
rel = task.rel + "/" + cleanName
|
||||
}
|
||||
if entry.IsDir {
|
||||
pending.Add(1)
|
||||
select {
|
||||
case queue <- dirTask{id: entry.ID, rel: rel}:
|
||||
case <-ctx.Done():
|
||||
pending.Add(-1)
|
||||
}
|
||||
} else {
|
||||
st.processRemoteFile(entry, rel)
|
||||
}
|
||||
}
|
||||
pending.Add(-1)
|
||||
}
|
||||
pending.Add(-1)
|
||||
return nil
|
||||
}); err != nil {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -560,22 +568,28 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for t := range taskCh {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
files, _, err := open115.GetFsListFlat(ctx, rootCID, t.offset, pageSize)
|
||||
if err != nil {
|
||||
errMu.Lock()
|
||||
if fetchErr == nil {
|
||||
fetchErr = err
|
||||
// 分页拉取 panic 时取消整个同步;正常退出不取消。
|
||||
if err := helper.Recover(st.s.log, "strm.sync.walk115.page", func() error {
|
||||
for t := range taskCh {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
errMu.Unlock()
|
||||
return
|
||||
files, _, err := open115.GetFsListFlat(ctx, rootCID, t.offset, pageSize)
|
||||
if err != nil {
|
||||
errMu.Lock()
|
||||
if fetchErr == nil {
|
||||
fetchErr = err
|
||||
}
|
||||
errMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
filesMu.Lock()
|
||||
allFiles = append(allFiles, files...)
|
||||
filesMu.Unlock()
|
||||
}
|
||||
filesMu.Lock()
|
||||
allFiles = append(allFiles, files...)
|
||||
filesMu.Unlock()
|
||||
return nil
|
||||
}); err != nil {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -632,63 +646,70 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
|
||||
pwg.Add(1)
|
||||
go func() {
|
||||
defer pwg.Done()
|
||||
for pid := range pidCh {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if _, loaded := st.dirCache.Load(pid); loaded {
|
||||
if n := doneDirs.Add(1); n%20 == 0 || n == int64(totalDirs) {
|
||||
// 解析目录详情 panic 时中止整个同步(避免带着损坏的相对路径
|
||||
// 继续执行);正常退出不取消。
|
||||
if err := helper.Recover(st.s.log, "strm.sync.walk115.dirTree", func() error {
|
||||
for pid := range pidCh {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if _, loaded := st.dirCache.Load(pid); loaded {
|
||||
if n := doneDirs.Add(1); n%20 == 0 || n == int64(totalDirs) {
|
||||
st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (%d/%d)...", n, totalDirs))
|
||||
}
|
||||
continue
|
||||
}
|
||||
detail, err := open115.GetFsDetailByCid(ctx, pid)
|
||||
if err != nil {
|
||||
// 目录详情解析失败会导致下游文件 rel 无法还原真实父路径,
|
||||
// seen key 与磁盘路径对不上:增量 prune 会误删本地文件、上传会
|
||||
// 误传本地未变文件、下载会重复下载。这里不是降级容错,而是
|
||||
// 直接中止整个同步——宁可本次同步失败,也不带着损坏的相对路径
|
||||
// 继续执行造成大规模误删/误传/重下(参考用户反馈"云盘没动却重下重传")。
|
||||
errMu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("115: 解析目录树失败(file_id=%s):%w", pid, err)
|
||||
}
|
||||
errMu.Unlock()
|
||||
st.scanIncomplete.Store(true)
|
||||
cancel()
|
||||
return nil
|
||||
} else if detail != nil {
|
||||
// 解析相对路径
|
||||
relPath := cleanDirRel(detail.RelativePath(rootCID))
|
||||
st.dirCache.Store(pid, relPath)
|
||||
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, pid, relPath)
|
||||
|
||||
// 顺便解析并缓存 detail.Paths 中包含的中间各层级目录
|
||||
for _, ancestor := range detail.Paths {
|
||||
if ancestor.FileId == "0" || ancestor.FileId == rootCID {
|
||||
continue
|
||||
}
|
||||
if _, loaded := st.dirCache.Load(ancestor.FileId); !loaded {
|
||||
subDetail := &cloud115.RemoteFileDetail{
|
||||
FileId: ancestor.FileId,
|
||||
FileName: ancestor.Name,
|
||||
Paths: nil,
|
||||
}
|
||||
for _, p := range detail.Paths {
|
||||
subDetail.Paths = append(subDetail.Paths, p)
|
||||
if p.FileId == ancestor.FileId {
|
||||
break
|
||||
}
|
||||
}
|
||||
ancestorRel := cleanDirRel(subDetail.RelativePath(rootCID))
|
||||
st.dirCache.Store(ancestor.FileId, ancestorRel)
|
||||
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, ancestor.FileId, ancestorRel)
|
||||
}
|
||||
}
|
||||
}
|
||||
if n := doneDirs.Add(1); n%10 == 0 || n == int64(totalDirs) {
|
||||
st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (%d/%d)...", n, totalDirs))
|
||||
}
|
||||
continue
|
||||
}
|
||||
detail, err := open115.GetFsDetailByCid(ctx, pid)
|
||||
if err != nil {
|
||||
// 目录详情解析失败会导致下游文件 rel 无法还原真实父路径,
|
||||
// seen key 与磁盘路径对不上:增量 prune 会误删本地文件、上传会
|
||||
// 误传本地未变文件、下载会重复下载。这里不是降级容错,而是
|
||||
// 直接中止整个同步——宁可本次同步失败,也不带着损坏的相对路径
|
||||
// 继续执行造成大规模误删/误传/重下(参考用户反馈"云盘没动却重下重传")。
|
||||
errMu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("115: 解析目录树失败(file_id=%s):%w", pid, err)
|
||||
}
|
||||
errMu.Unlock()
|
||||
st.scanIncomplete.Store(true)
|
||||
cancel()
|
||||
return
|
||||
} else if detail != nil {
|
||||
// 解析相对路径
|
||||
relPath := cleanDirRel(detail.RelativePath(rootCID))
|
||||
st.dirCache.Store(pid, relPath)
|
||||
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, pid, relPath)
|
||||
|
||||
// 顺便解析并缓存 detail.Paths 中包含的中间各层级目录
|
||||
for _, ancestor := range detail.Paths {
|
||||
if ancestor.FileId == "0" || ancestor.FileId == rootCID {
|
||||
continue
|
||||
}
|
||||
if _, loaded := st.dirCache.Load(ancestor.FileId); !loaded {
|
||||
subDetail := &cloud115.RemoteFileDetail{
|
||||
FileId: ancestor.FileId,
|
||||
FileName: ancestor.Name,
|
||||
Paths: nil,
|
||||
}
|
||||
for _, p := range detail.Paths {
|
||||
subDetail.Paths = append(subDetail.Paths, p)
|
||||
if p.FileId == ancestor.FileId {
|
||||
break
|
||||
}
|
||||
}
|
||||
ancestorRel := cleanDirRel(subDetail.RelativePath(rootCID))
|
||||
st.dirCache.Store(ancestor.FileId, ancestorRel)
|
||||
_ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, ancestor.FileId, ancestorRel)
|
||||
}
|
||||
}
|
||||
}
|
||||
if n := doneDirs.Add(1); n%10 == 0 || n == int64(totalDirs) {
|
||||
st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (%d/%d)...", n, totalDirs))
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -35,6 +37,21 @@ type SubtitleService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
cfg *config.Config
|
||||
|
||||
// 目录发现是 Emby 条目列表的热路径(每个媒体源一次 DB 查询 + 最多 5 次
|
||||
// os.ReadDir),而字幕文件极少变化:按 media_id 做短 TTL 缓存。
|
||||
cacheMu sync.Mutex
|
||||
discovery map[string]subtitleDiscoveryEntry
|
||||
}
|
||||
|
||||
const (
|
||||
subtitleDiscoveryTTL = 2 * time.Minute
|
||||
subtitleDiscoveryCacheCap = 4096
|
||||
)
|
||||
|
||||
type subtitleDiscoveryEntry struct {
|
||||
tracks []SubtitleTrack
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// NewSubtitleService is the constructor.
|
||||
@@ -73,6 +90,50 @@ func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID stri
|
||||
}
|
||||
|
||||
func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
if tracks, ok := s.cachedDiscovery(mediaID); ok {
|
||||
return tracks, nil
|
||||
}
|
||||
tracks, err := s.discoverUncached(ctx, mediaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.rememberDiscovery(mediaID, tracks)
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
func (s *SubtitleService) cachedDiscovery(mediaID string) ([]SubtitleTrack, bool) {
|
||||
now := time.Now()
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
entry, ok := s.discovery[mediaID]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(s.discovery, mediaID)
|
||||
return nil, false
|
||||
}
|
||||
// 返回副本,避免调用方修改缓存内容。
|
||||
return append([]SubtitleTrack(nil), entry.tracks...), true
|
||||
}
|
||||
|
||||
func (s *SubtitleService) rememberDiscovery(mediaID string, tracks []SubtitleTrack) {
|
||||
now := time.Now()
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
if s.discovery == nil {
|
||||
s.discovery = make(map[string]subtitleDiscoveryEntry)
|
||||
}
|
||||
if len(s.discovery) >= subtitleDiscoveryCacheCap {
|
||||
s.discovery = make(map[string]subtitleDiscoveryEntry)
|
||||
}
|
||||
s.discovery[mediaID] = subtitleDiscoveryEntry{
|
||||
tracks: append([]SubtitleTrack(nil), tracks...),
|
||||
expiresAt: now.Add(subtitleDiscoveryTTL),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
|
||||
m, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
@@ -132,7 +133,7 @@ func (s *SystemUpdateService) Apply(ctx context.Context) (SystemUpdateStatus, er
|
||||
s.last = &status
|
||||
s.mu.Unlock()
|
||||
|
||||
go s.runUpdate(context.Background(), status, task)
|
||||
helper.Go(s.log, "system.update", func() { s.runUpdate(context.Background(), status, task) })
|
||||
return status, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
@@ -26,11 +27,14 @@ func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash string, exp
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
done <- s.storeRefreshToken(ctx, &model.RefreshToken{
|
||||
UserID: userID,
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: expiresAt,
|
||||
err := helper.Recover(s.log, "token.storeRefreshToken", func() error {
|
||||
return s.storeRefreshToken(ctx, &model.RefreshToken{
|
||||
UserID: userID,
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
})
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
|
||||
@@ -33,6 +33,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
@@ -143,7 +144,7 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
||||
t.jobs[mediaID] = job
|
||||
t.mu.Unlock()
|
||||
|
||||
go t.monitorIdle(jobCtx, job)
|
||||
go t.runFFmpeg(jobCtx, job, m.Path)
|
||||
helper.Go(t.log, "transcoder.monitorIdle", func() { t.monitorIdle(jobCtx, job) })
|
||||
helper.Go(t.log, "transcoder.ffmpeg", func() { t.runFFmpeg(jobCtx, job, m.Path) })
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
@@ -69,8 +70,8 @@ func (w *WatcherService) Start(ctx context.Context) error {
|
||||
if err := w.Refresh(ctx); err != nil {
|
||||
w.log.Warn("watcher refresh failed", zap.Error(err))
|
||||
}
|
||||
go w.loop(ctx)
|
||||
go w.debouncer(ctx)
|
||||
helper.Go(w.log, "watcher.loop", func() { w.loop(ctx) })
|
||||
helper.Go(w.log, "watcher.debouncer", func() { w.debouncer(ctx) })
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,8 @@
|
||||
<link rel="icon" type="image/png" href="/brand/logo-64.png" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/brand/logo-192.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Cabinet+Grotesk:wght@400;500;600;700;800&family=Geist:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<!-- 字体已自托管(见 src/index.css 的 @font-face),不再使用 Google Fonts 外链:
|
||||
自托管环境常无法访问 Google,渲染阻塞外链会拖慢首屏。 -->
|
||||
<title>MeBox</title>
|
||||
</head>
|
||||
<body class="font-body antialiased">
|
||||
|
||||
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 408 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
@@ -87,17 +87,31 @@ export interface LibraryWithPreview extends Library {
|
||||
}
|
||||
|
||||
export const libraryAPI = {
|
||||
list: (options?: { includeHidden?: boolean; withPreview?: boolean; previewLimit?: number }) =>
|
||||
list: (options?: { includeHidden?: boolean; withPreview?: boolean; previewLimit?: number; ids?: string[] }) =>
|
||||
api
|
||||
.get<LibraryWithPreview[]>('/libraries', {
|
||||
params: {
|
||||
...(options?.includeHidden ? { include_hidden: 1 } : {}),
|
||||
...(options?.withPreview ? { with_preview: 1 } : {}),
|
||||
...(options?.previewLimit ? { preview_limit: options.previewLimit } : {}),
|
||||
...(options?.ids && options.ids.length > 0 ? { ids: options.ids.join(',') } : {}),
|
||||
},
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
listPreviews: (ids: string[], previewLimit = 10) => {
|
||||
if (ids.length === 0) return Promise.resolve<LibraryWithPreview[]>([])
|
||||
return api
|
||||
.get<LibraryWithPreview[]>('/libraries', {
|
||||
params: {
|
||||
with_preview: 1,
|
||||
preview_limit: previewLimit,
|
||||
ids: ids.join(','),
|
||||
},
|
||||
})
|
||||
.then((r) => r.data)
|
||||
},
|
||||
|
||||
get: (id: string, options?: { includeHidden?: boolean }) =>
|
||||
api
|
||||
.get<Library>(`/libraries/${id}`, {
|
||||
|
||||
@@ -2,25 +2,34 @@
|
||||
import { lazy, type ReactElement } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
|
||||
const HomePage = lazy(() => import('./pages/HomePage').then((m) => ({ default: m.HomePage })))
|
||||
const LibraryPage = lazy(() => import('./pages/LibraryPage').then((m) => ({ default: m.LibraryPage })))
|
||||
const LibrariesPage = lazy(() => import('./pages/LibrariesPage').then((m) => ({ default: m.LibrariesPage })))
|
||||
const FavouritesPage = lazy(() => import('./pages/FavouritesPage').then((m) => ({ default: m.FavouritesPage })))
|
||||
const PlaylistsPage = lazy(() => import('./pages/PlaylistsPage').then((m) => ({ default: m.PlaylistsPage })))
|
||||
const PlaylistDetailPage = lazy(() =>
|
||||
import('./pages/PlaylistDetailPage').then((m) => ({ default: m.PlaylistDetailPage })),
|
||||
)
|
||||
const MediaDetailPage = lazy(() => import('./pages/MediaDetailPage').then((m) => ({ default: m.MediaDetailPage })))
|
||||
const PlayerPage = lazy(() => import('./pages/PlayerPage').then((m) => ({ default: m.PlayerPage })))
|
||||
const HomePageLoader = () => import('./pages/HomePage').then((m) => ({ default: m.HomePage }))
|
||||
const LibraryPageLoader = () => import('./pages/LibraryPage').then((m) => ({ default: m.LibraryPage }))
|
||||
const LibrariesPageLoader = () => import('./pages/LibrariesPage').then((m) => ({ default: m.LibrariesPage }))
|
||||
const FavouritesPageLoader = () => import('./pages/FavouritesPage').then((m) => ({ default: m.FavouritesPage }))
|
||||
const PlaylistsPageLoader = () => import('./pages/PlaylistsPage').then((m) => ({ default: m.PlaylistsPage }))
|
||||
const PlaylistDetailPageLoader = () =>
|
||||
import('./pages/PlaylistDetailPage').then((m) => ({ default: m.PlaylistDetailPage }))
|
||||
const MediaDetailPageLoader = () =>
|
||||
import('./pages/MediaDetailPage').then((m) => ({ default: m.MediaDetailPage }))
|
||||
const PlayerPageLoader = () => import('./pages/PlayerPage').then((m) => ({ default: m.PlayerPage }))
|
||||
const WatchHistoryPageLoader = () =>
|
||||
import('./pages/WatchHistoryPage').then((m) => ({ default: m.WatchHistoryPage }))
|
||||
|
||||
const HomePage = lazy(HomePageLoader)
|
||||
const LibraryPage = lazy(LibraryPageLoader)
|
||||
const LibrariesPage = lazy(LibrariesPageLoader)
|
||||
const FavouritesPage = lazy(FavouritesPageLoader)
|
||||
const PlaylistsPage = lazy(PlaylistsPageLoader)
|
||||
const PlaylistDetailPage = lazy(PlaylistDetailPageLoader)
|
||||
const MediaDetailPage = lazy(MediaDetailPageLoader)
|
||||
const PlayerPage = lazy(PlayerPageLoader)
|
||||
const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage })))
|
||||
const ProfilePage = lazy(() => import('./pages/ProfilePage').then((m) => ({ default: m.ProfilePage })))
|
||||
const DlnaPage = lazy(() => import('./pages/DlnaPage').then((m) => ({ default: m.DlnaPage })))
|
||||
const FileManagerPage = lazy(() =>
|
||||
import('./pages/FileManagerPage').then((m) => ({ default: m.FileManagerPage })),
|
||||
)
|
||||
const WatchHistoryPage = lazy(() =>
|
||||
import('./pages/WatchHistoryPage').then((m) => ({ default: m.WatchHistoryPage })),
|
||||
)
|
||||
const WatchHistoryPage = lazy(WatchHistoryPageLoader)
|
||||
const PosterWallPage = lazy(() => import('./pages/PosterWallPage').then((m) => ({ default: m.PosterWallPage })))
|
||||
const ProfileManagementPage = lazy(() =>
|
||||
import('./pages/ProfileManagementPage').then((m) => ({ default: m.ProfileManagementPage })),
|
||||
@@ -37,6 +46,34 @@ const StrmUploadQueuePage = lazy(() =>
|
||||
const ScraperQueuePage = lazy(() => import('./pages/ScraperQueuePage').then((m) => ({ default: m.ScraperQueuePage })))
|
||||
const TaskQueuePage = lazy(() => import('./pages/TaskQueuePage').then((m) => ({ default: m.TaskQueuePage })))
|
||||
|
||||
// 常用页面的路由 chunk 空闲预取:应用加载完成后浏览器一空闲就把浏览路径
|
||||
// (库列表/库详情/媒体详情/播放/收藏/历史等)的 chunk 拉下来,
|
||||
// 首次点击进入时不再出现"加载中…"等 chunk 下载。失败静默(导航时会重试)。
|
||||
let prefetchStarted = false
|
||||
export function prefetchCommonRouteChunks() {
|
||||
if (prefetchStarted || typeof window === 'undefined') return
|
||||
prefetchStarted = true
|
||||
const loaders = [
|
||||
LibrariesPageLoader,
|
||||
LibraryPageLoader,
|
||||
MediaDetailPageLoader,
|
||||
PlayerPageLoader,
|
||||
FavouritesPageLoader,
|
||||
WatchHistoryPageLoader,
|
||||
PlaylistsPageLoader,
|
||||
]
|
||||
const run = () => {
|
||||
for (const load of loaders) {
|
||||
load().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
requestIdleCallback(run, { timeout: 5000 })
|
||||
} else {
|
||||
window.setTimeout(run, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
export type AppRoute = {
|
||||
path?: string
|
||||
index?: boolean
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { prefetchCommonRouteChunks } from '../appRoutes'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { usePlayProfileStore } from '../stores/playProfile'
|
||||
import {
|
||||
@@ -37,6 +39,13 @@ function isMediaView(pathname: string, search: string): boolean {
|
||||
export function Layout() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
// 登录后的外壳挂载即开始空闲预取常用页面的路由 chunk,
|
||||
// 让首次点击进入各页面时不出现"加载中…"等 chunk 下载。
|
||||
useEffect(() => {
|
||||
prefetchCommonRouteChunks()
|
||||
}, [])
|
||||
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const activeProfileId = usePlayProfileStore((s) => s.activeProfileId)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-rea
|
||||
import { imageURL } from '../api/client'
|
||||
import { mediaAPI } from '../api/library'
|
||||
import type { Media, PlayProfile, User } from '../types'
|
||||
import { favouriteMediaLink } from '../utils/mediaNavigation'
|
||||
import { resolveHeaderBack } from './layoutNavigation'
|
||||
import { LayoutThemeToggle } from './LayoutThemeToggle'
|
||||
import { LayoutUserMenu } from './LayoutUserMenu'
|
||||
@@ -149,17 +150,17 @@ function LayoutHeaderSearch() {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handleSelect = (mediaId: string) => {
|
||||
const handleSelect = (item: Media) => {
|
||||
setIsOpen(false)
|
||||
setQuery('')
|
||||
navigate(`/media/${mediaId}`)
|
||||
navigate(favouriteMediaLink(item))
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
} else if (e.key === 'Enter' && results.length > 0) {
|
||||
handleSelect(results[0].id)
|
||||
handleSelect(results[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +221,7 @@ function LayoutHeaderSearch() {
|
||||
{results.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelect(item.id)}
|
||||
onClick={() => handleSelect(item)}
|
||||
className="flex w-full items-center gap-3 rounded-xl p-2 text-left transition-colors hover:bg-[var(--app-hover)] group"
|
||||
>
|
||||
<div className="relative h-12 w-9 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
|
||||
|
||||
@@ -202,7 +202,7 @@ function ManualScrapeCandidateRow({
|
||||
<div className="flex flex-col gap-4 rounded-xl border border-sand-200 bg-white p-3 shadow-sm sm:flex-row">
|
||||
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-lg bg-sand-100">
|
||||
{item.poster_url ? (
|
||||
<img src={imageURL(item.poster_url)} alt={item.title} className="h-full w-full object-cover" referrerPolicy="no-referrer" />
|
||||
<img src={imageURL(item.poster_url)} alt={item.title} loading="lazy" decoding="async" className="h-full w-full object-cover" referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-xs text-sand-500">无海报</div>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { memo, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Film, Play, Layers, Star } from 'lucide-react'
|
||||
import { imageURL } from '../api/client'
|
||||
import type { Media } from '../types'
|
||||
|
||||
export const MediaCard = ({
|
||||
media, progress, count, rating, linkTo, onClick, actions, compact,
|
||||
// memo:父级状态变化(如轮播切图、其它卡片操作)不再级联重渲染所有卡片。
|
||||
// 注意 actions/renderActions 必须引用稳定(用 renderActions 传函数)memo 才生效。
|
||||
export const MediaCard = memo(function MediaCard({
|
||||
media, progress, count, rating, linkTo, onClick, actions, renderActions, compact,
|
||||
}: {
|
||||
media: Media
|
||||
progress?: number
|
||||
@@ -15,25 +16,26 @@ export const MediaCard = ({
|
||||
linkTo?: string
|
||||
onClick?: () => void
|
||||
actions?: ReactNode
|
||||
renderActions?: (media: Media) => ReactNode
|
||||
compact?: boolean
|
||||
}) => {
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const href = linkTo ?? `/media/${media.id}`
|
||||
const [posterFit, setPosterFit] = useState<'cover' | 'contain'>('cover')
|
||||
const posterSrc = imageURL(media.poster_url, media.updated_at)
|
||||
const displayRating = rating ?? media.rating
|
||||
const versionCount = media.versions?.length ?? 0
|
||||
// renderActions 延迟到卡片自身渲染时才调用,保证 memo 生效
|
||||
const actionContent = actions ?? renderActions?.(media)
|
||||
|
||||
useEffect(() => {
|
||||
setPosterFit('cover')
|
||||
}, [media.poster_url, media.updated_at])
|
||||
|
||||
const card = (
|
||||
<motion.div
|
||||
<div
|
||||
ref={ref}
|
||||
whileHover={{ scale: 1.04, y: -6 }}
|
||||
transition={{ type: 'spring', stiffness: 280, damping: 22 }}
|
||||
className="relative overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:border-brand-500/40 hover:shadow-[0_12px_32px_var(--app-shadow)]"
|
||||
className="relative overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:scale-[1.04] hover:-translate-y-1.5 hover:border-brand-500/40 hover:shadow-[0_12px_32px_var(--app-shadow)]"
|
||||
>
|
||||
{/* Poster Wrapper */}
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-[var(--app-panel-soft)]">
|
||||
@@ -95,16 +97,11 @@ export const MediaCard = ({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Premium Hover Overlay */}
|
||||
{/* Premium Hover Overlay(CSS 过渡替代 framer-motion 逐卡动画实例) */}
|
||||
<div className={`absolute inset-0 bg-gradient-to-t from-[#111827]/90 via-[#111827]/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end ${
|
||||
compact ? 'p-3' : 'p-4'
|
||||
}`}>
|
||||
<motion.div
|
||||
initial={{ y: 15, opacity: 0 }}
|
||||
whileHover={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="space-y-2 translate-y-4 transition-transform duration-200 group-hover:translate-y-0">
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-xl bg-brand-500 px-4 py-2 font-bold text-white shadow-md shadow-brand-500/20 ${
|
||||
compact ? 'py-1.5 text-[11px]' : 'text-xs py-2'
|
||||
}`}>
|
||||
@@ -116,15 +113,15 @@ export const MediaCard = ({
|
||||
{media.overview || "暂无简介内容"}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar overlay */}
|
||||
{progress !== undefined && progress > 0 && progress < 1 && (
|
||||
<div className="absolute inset-x-0 bottom-0 h-1.5 bg-[var(--app-hover)]">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-brand-400 to-brand-500 rounded-r-full transition-all duration-300"
|
||||
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-brand-400 to-brand-500 rounded-r-full transition-all duration-300"
|
||||
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -148,7 +145,7 @@ export const MediaCard = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (onClick) {
|
||||
@@ -157,23 +154,23 @@ export const MediaCard = ({
|
||||
<button type="button" onClick={onClick} className="block w-full text-left">
|
||||
{card}
|
||||
</button>
|
||||
{actions && (
|
||||
{actionContent && (
|
||||
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
{actions}
|
||||
{actionContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (actions) {
|
||||
if (actionContent) {
|
||||
return (
|
||||
<div className="group relative block">
|
||||
<Link to={href} className="block">
|
||||
{card}
|
||||
</Link>
|
||||
<div className="absolute right-2 top-2 z-20 flex flex-wrap justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
{actions}
|
||||
{actionContent}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -184,4 +181,4 @@ export const MediaCard = ({
|
||||
{card}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
import { usePermissionStore } from '../stores/permissions'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
interface PermissionGuardProps {
|
||||
permission: string
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
requireSuperUser?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* PermissionGuard 组件用于根据用户权限控制内容显示。
|
||||
*
|
||||
* @param permission - 需要的权限键
|
||||
* @param children - 有权限时显示的内容
|
||||
* @param fallback - 无权限时显示的内容(可选,默认不显示)
|
||||
* @param requireSuperUser - 是否要求超级用户(admin/plus)绕过权限检查
|
||||
*/
|
||||
export function PermissionGuard({
|
||||
permission,
|
||||
children,
|
||||
fallback = null,
|
||||
requireSuperUser = false,
|
||||
}: PermissionGuardProps) {
|
||||
const { hasPermission, isSuper, permissions, isLoading } = usePermissionStore()
|
||||
const tier = useAuthStore((state) => state.tier)
|
||||
const role = useAuthStore((state) => state.user?.role)
|
||||
|
||||
// 超级用户(admin 或 plus)默认有所有权限
|
||||
if (isSuper || tier === 'plus' || role === 'admin') {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// 如果 requireSuperUser 为 true 且用户不是超级用户,则不显示
|
||||
if (requireSuperUser && !isSuper) {
|
||||
return <>{fallback}</>
|
||||
}
|
||||
|
||||
// 加载中时显示 fallback
|
||||
if (isLoading && Object.keys(permissions).length === 0) {
|
||||
return <>{fallback}</>
|
||||
}
|
||||
|
||||
// 检查具体权限
|
||||
if (hasPermission(permission)) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return <>{fallback}</>
|
||||
}
|
||||
@@ -28,18 +28,25 @@ export function usePermission(
|
||||
options: { autoFetch?: boolean } = {}
|
||||
): boolean {
|
||||
const { autoFetch = true } = options
|
||||
const { hasPermission, isSuper, permissions, isLoading, fetchPermissions } = usePermissionStore()
|
||||
// selector 订阅:store 任何无关字段变化不会触发本组件重渲染
|
||||
const hasPermission = usePermissionStore((s) => s.hasPermission)
|
||||
const isSuper = usePermissionStore((s) => s.isSuper)
|
||||
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
|
||||
const tier = useAuthStore((state) => state.tier)
|
||||
const role = useAuthStore((state) => state.user?.role)
|
||||
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
||||
const hasSuperAccess = isSuper || tier === 'plus' || role === 'admin'
|
||||
|
||||
// 权限未加载时自动获取
|
||||
// 权限未加载时自动获取;用 getState() 读最新快照而不是渲染闭包,
|
||||
// 同一次 commit 内挂载的多个消费方也只会有一个发出请求(store 内还有 inflight 去重兜底)。
|
||||
useEffect(() => {
|
||||
if (!hasSuperAccess && isAuthenticated && autoFetch && Object.keys(permissions).length === 0 && !isLoading) {
|
||||
fetchPermissions()
|
||||
if (!hasSuperAccess && isAuthenticated && autoFetch) {
|
||||
const { permissions, isLoading } = usePermissionStore.getState()
|
||||
if (Object.keys(permissions).length === 0 && !isLoading) {
|
||||
fetchPermissions()
|
||||
}
|
||||
}
|
||||
}, [autoFetch, fetchPermissions, hasSuperAccess, isAuthenticated, isLoading, permissions])
|
||||
}, [autoFetch, fetchPermissions, hasSuperAccess, isAuthenticated])
|
||||
|
||||
// 超级用户有所有权限
|
||||
if (hasSuperAccess) {
|
||||
@@ -78,7 +85,11 @@ export function usePermission(
|
||||
* ```
|
||||
*/
|
||||
export function usePermissions() {
|
||||
const { permissions, isSuper, isLoading, fetchPermissions } = usePermissionStore()
|
||||
// selector 订阅:只关注 permissions/isSuper/isLoading 变化
|
||||
const permissions = usePermissionStore((s) => s.permissions)
|
||||
const isSuper = usePermissionStore((s) => s.isSuper)
|
||||
const isLoading = usePermissionStore((s) => s.isLoading)
|
||||
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
|
||||
const tier = useAuthStore((state) => state.tier)
|
||||
const role = useAuthStore((state) => state.user?.role)
|
||||
const isAuthenticated = useAuthStore((state) => state.token !== null)
|
||||
|
||||
@@ -2,6 +2,74 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ── 自托管 Web 字体(OFL 许可,latin 子集,public/fonts/)──
|
||||
取代原先 fonts.googleapis.com 的渲染阻塞外链:自托管服务常部署在
|
||||
无法访问 Google 的网络,外链会拖住首屏渲染直到超时。 */
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url('/fonts/geist-300.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/geist-400.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/fonts/geist-500.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/fonts/geist-600.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/fonts/geist-700.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/jetbrainsmono-400.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/fonts/jetbrainsmono-500.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/fonts/jetbrainsmono-600.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
html, body, #root { height: 100%; }
|
||||
|
||||
:root {
|
||||
|
||||
@@ -169,7 +169,7 @@ function LibraryTableRow({ library, dragging, dragOver, onDragStart, onDragOver,
|
||||
</td>
|
||||
<td className="py-2 pr-3 font-medium text-ink-600">
|
||||
<div className="flex items-center gap-2">
|
||||
{library.cover_url && <img src={library.cover_url} alt="" className="h-10 w-8 rounded object-cover" />}
|
||||
{library.cover_url && <img src={library.cover_url} alt="" loading="lazy" decoding="async" className="h-10 w-8 rounded object-cover" />}
|
||||
<span>{library.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -31,7 +31,10 @@ export function AdminUsersPanel() {
|
||||
}
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
const timer = window.setInterval(() => refresh().catch(() => undefined), 10000)
|
||||
const timer = window.setInterval(() => {
|
||||
if (document.hidden) return
|
||||
refresh().catch(() => undefined)
|
||||
}, 10000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { libraryAPI, type LibraryWithPreview } from '../api/library'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { historyAPI } from '../api/history'
|
||||
import type { HistoryItem } from '../api/playback'
|
||||
import type { Library, Media } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import { fetchLibraries, peekLibraries } from '../utils/libraryCache'
|
||||
import { usePinnedLibraries } from '../hooks/usePinnedLibraries'
|
||||
import { sortByPinnedIds } from '../utils/pinnedLibraries'
|
||||
import {
|
||||
@@ -18,7 +19,6 @@ import {
|
||||
} from './HomePageSections'
|
||||
|
||||
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
|
||||
const asArray = <T,>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : [])
|
||||
|
||||
export function HomePage() {
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
@@ -30,26 +30,24 @@ export function HomePage() {
|
||||
const [historyLoading, setHistoryLoading] = useState(true)
|
||||
const { pinnedIds } = usePinnedLibraries()
|
||||
|
||||
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。
|
||||
// 会话内已有缓存则先用缓存立即渲染,后台仍刷新一次兜底。
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
libraryAPI
|
||||
.list({ withPreview: true, previewLimit: 10 })
|
||||
.then((rows) => asArray<LibraryWithPreview>(rows))
|
||||
.catch(() => [] as LibraryWithPreview[])
|
||||
const cached = peekLibraries()
|
||||
if (cached && cached.length > 0) {
|
||||
setLibraries(cached)
|
||||
setLibrariesLoading(false)
|
||||
}
|
||||
|
||||
fetchLibraries()
|
||||
.then((rows) => (Array.isArray(rows) ? rows : []))
|
||||
.catch(() => [] as Library[])
|
||||
.then((libs) => {
|
||||
if (cancelled) return
|
||||
setLibraries(libs)
|
||||
|
||||
const mapData: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }> = {}
|
||||
for (const lib of libs) {
|
||||
mapData[lib.id] = {
|
||||
cards: lib.cards ?? [],
|
||||
items: [],
|
||||
total: lib.total ?? 0,
|
||||
}
|
||||
}
|
||||
setLibraryData(mapData)
|
||||
// 失败兜底返回空数组时,不要覆盖已先行渲染的缓存数据
|
||||
setLibraries((prev) => (libs.length === 0 && prev.length > 0 ? prev : libs))
|
||||
setLibrariesLoading(false)
|
||||
})
|
||||
|
||||
@@ -58,6 +56,7 @@ export function HomePage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 2. 继续观看记录独立渐进加载
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
@@ -85,6 +84,123 @@ export function HomePage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
|
||||
|
||||
// 按需拉取卡片预览管理
|
||||
const fetchedLibIdsRef = useRef<Set<string>>(new Set())
|
||||
const fetchingRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const fetchPreviews = useCallback(async (ids: string[]) => {
|
||||
const targets = ids.filter((id) => !fetchedLibIdsRef.current.has(id) && !fetchingRef.current.has(id))
|
||||
if (targets.length === 0) return
|
||||
targets.forEach((id) => fetchingRef.current.add(id))
|
||||
|
||||
try {
|
||||
const rows = await libraryAPI.listPreviews(targets, 10)
|
||||
setLibraryData((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const row of rows) {
|
||||
next[row.id] = {
|
||||
cards: row.cards ?? [],
|
||||
items: [],
|
||||
total: row.total ?? 0,
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
} catch {
|
||||
// 容错
|
||||
} finally {
|
||||
targets.forEach((id) => {
|
||||
fetchedLibIdsRef.current.add(id)
|
||||
fetchingRef.current.delete(id)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 3. 首屏优先加载:轮播图库 + 媒体库卡片区前 20 个库 + 首屏前 3 个内容行
|
||||
useEffect(() => {
|
||||
if (sortedLibraries.length === 0) return
|
||||
const carouselLibIds = sortedLibraries
|
||||
.filter((l) => l.carousel_enabled === true)
|
||||
.map((l) => l.id)
|
||||
const topGridLibIds = sortedLibraries.slice(0, 20).map((l) => l.id)
|
||||
const topRowLibIds = sortedLibraries.slice(0, 3).map((l) => l.id)
|
||||
const initialTargets = Array.from(new Set([...carouselLibIds, ...topGridLibIds, ...topRowLibIds]))
|
||||
void fetchPreviews(initialTargets)
|
||||
}, [sortedLibraries, fetchPreviews])
|
||||
|
||||
// 4. 媒体库展示行渐进流式加载:默认先检视前 3 个库,随向下滚动逐步检视后续库
|
||||
const INITIAL_ROWS = 3
|
||||
const STEP_ROWS = 2
|
||||
const [visibleTargetCount, setVisibleTargetCount] = useState(INITIAL_ROWS)
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// 随 visibleTargetCount 增加,按需触发后续库的预览加载
|
||||
useEffect(() => {
|
||||
if (sortedLibraries.length === 0) return
|
||||
const currentTargets = sortedLibraries.slice(0, visibleTargetCount).map((l) => l.id)
|
||||
void fetchPreviews(currentTargets)
|
||||
}, [sortedLibraries, visibleTargetCount, fetchPreviews])
|
||||
|
||||
// 当前已拉取并确认有内容的媒体库行
|
||||
const visibleLibraries = useMemo(() => {
|
||||
return sortedLibraries
|
||||
.slice(0, visibleTargetCount)
|
||||
.filter((lib) => (libraryData[lib.id]?.cards?.length ?? 0) > 0)
|
||||
}, [sortedLibraries, visibleTargetCount, libraryData])
|
||||
|
||||
const hasMoreLibraries = visibleTargetCount < sortedLibraries.length
|
||||
|
||||
// 底部哨兵监听与滚动双保险(触底解锁后续媒体库行)
|
||||
useEffect(() => {
|
||||
const scrollParent = document.getElementById('app-main-scroll')
|
||||
if (!scrollParent) return
|
||||
|
||||
const handleCheckBottom = () => {
|
||||
const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight
|
||||
if (remaining < 600) {
|
||||
setVisibleTargetCount((prev) => {
|
||||
if (prev >= sortedLibraries.length) return prev
|
||||
return Math.min(prev + STEP_ROWS, sortedLibraries.length)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
scrollParent.addEventListener('scroll', handleCheckBottom, { passive: true })
|
||||
|
||||
const sentinel = sentinelRef.current
|
||||
let observer: IntersectionObserver | null = null
|
||||
if (sentinel) {
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [entry] = entries
|
||||
if (entry?.isIntersecting) {
|
||||
setVisibleTargetCount((prev) => {
|
||||
if (prev >= sortedLibraries.length) return prev
|
||||
return Math.min(prev + STEP_ROWS, sortedLibraries.length)
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollParent,
|
||||
rootMargin: '600px 0px',
|
||||
threshold: 0,
|
||||
},
|
||||
)
|
||||
observer.observe(sentinel)
|
||||
}
|
||||
|
||||
handleCheckBottom()
|
||||
|
||||
return () => {
|
||||
scrollParent.removeEventListener('scroll', handleCheckBottom)
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
}
|
||||
}
|
||||
}, [visibleLibraries.length, hasMoreLibraries, sortedLibraries.length])
|
||||
|
||||
// Quick lookup map for libraries
|
||||
const libraryMap = useMemo(() => {
|
||||
const map = new Map<string, Library>()
|
||||
@@ -138,52 +254,6 @@ export function HomePage() {
|
||||
return candidateMedia.slice(0, 10)
|
||||
}, [libraries, libraryData])
|
||||
|
||||
const sortedLibraries = useMemo(() => sortByPinnedIds(libraries, pinnedIds), [libraries, pinnedIds])
|
||||
|
||||
// 过滤出有内容的媒体库,避免空库空占名额
|
||||
const activeLibraries = useMemo(() => {
|
||||
return sortedLibraries.filter((lib) => (libraryData[lib.id]?.cards?.length ?? 0) > 0)
|
||||
}, [sortedLibraries, libraryData])
|
||||
|
||||
// 媒体库展示行支持向下滑动渐进流式加载:默认先展示前 3 个库,
|
||||
// 随着用户向下滑动接近底部,通过 IntersectionObserver 动态解锁后续媒体库。
|
||||
const INITIAL_ROWS = 3
|
||||
const STEP_ROWS = 2
|
||||
const [visibleRowCount, setVisibleRowCount] = useState(INITIAL_ROWS)
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current
|
||||
if (!sentinel) return
|
||||
|
||||
const scrollParent = document.getElementById('app-main-scroll')
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [entry] = entries
|
||||
if (entry?.isIntersecting) {
|
||||
setVisibleRowCount((prev) => {
|
||||
if (prev >= activeLibraries.length) return prev
|
||||
return Math.min(prev + STEP_ROWS, activeLibraries.length)
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollParent,
|
||||
rootMargin: '400px 0px',
|
||||
threshold: 0,
|
||||
},
|
||||
)
|
||||
|
||||
observer.observe(sentinel)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [activeLibraries.length])
|
||||
|
||||
const visibleLibraries = useMemo(() => {
|
||||
return activeLibraries.slice(0, visibleRowCount)
|
||||
}, [activeLibraries, visibleRowCount])
|
||||
|
||||
// 库列表还没回来先展示整页 loading;库为空时再等一下播放记录,
|
||||
// 以免在"空站点"和"有观看记录"两个终态之间闪空白。
|
||||
if (librariesLoading || (libraries.length === 0 && historyLoading)) {
|
||||
@@ -209,16 +279,17 @@ export function HomePage() {
|
||||
{historyLoading && <ContinueWatchingSkeleton />}
|
||||
{!historyLoading && history.length > 0 && <ContinueWatchingSection history={history} />}
|
||||
|
||||
{/* 3. 媒体库卡片区 */}
|
||||
{/* 3. 媒体库卡片区(每页展示 20 个媒体库) */}
|
||||
{sortedLibraries.length > 0 && (
|
||||
<HomeLibrariesSection
|
||||
libraries={sortedLibraries}
|
||||
libraryData={libraryData}
|
||||
libraryCounts={libraryCounts}
|
||||
onNeedPreviews={fetchPreviews}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 4. 各媒体库内容展示行(向下滑动渐进流式加载) */}
|
||||
{/* 4. 各媒体库内容展示行(向下滑动渐进流式加载,不受上方20个分页限制) */}
|
||||
{visibleLibraries.length > 0 && (
|
||||
<div className="space-y-10">
|
||||
{visibleLibraries.map((lib) => {
|
||||
@@ -231,7 +302,7 @@ export function HomePage() {
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{visibleRowCount < activeLibraries.length && (
|
||||
{hasMoreLibraries && (
|
||||
<div ref={sentinelRef} className="flex h-10 w-full items-center justify-center py-2 opacity-60">
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--app-muted)]">
|
||||
<div className="h-1.5 w-1.5 animate-ping rounded-full bg-brand-500" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import {
|
||||
@@ -366,11 +366,28 @@ export function HomeLibrariesSection({
|
||||
libraries,
|
||||
libraryData,
|
||||
libraryCounts,
|
||||
onNeedPreviews,
|
||||
}: {
|
||||
libraries: Library[]
|
||||
libraryData?: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }>
|
||||
libraryCounts: Record<string, number>
|
||||
onNeedPreviews?: (ids: string[]) => void
|
||||
}) {
|
||||
const PAGE_SIZE = 20
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const totalPages = Math.max(1, Math.ceil(libraries.length / PAGE_SIZE))
|
||||
const effectivePage = Math.min(currentPage, totalPages)
|
||||
|
||||
const pagedLibraries = useMemo<Library[]>(() => {
|
||||
const start = (effectivePage - 1) * PAGE_SIZE
|
||||
return libraries.slice(start, start + PAGE_SIZE)
|
||||
}, [libraries, effectivePage])
|
||||
|
||||
useEffect(() => {
|
||||
const ids = pagedLibraries.map((l) => l.id)
|
||||
onNeedPreviews?.(ids)
|
||||
}, [pagedLibraries, onNeedPreviews])
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--app-border)] pb-3">
|
||||
@@ -379,27 +396,62 @@ export function HomeLibrariesSection({
|
||||
<LibraryIcon size={18} />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">
|
||||
媒体库
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">
|
||||
媒体库
|
||||
</h2>
|
||||
{libraries.length > PAGE_SIZE && (
|
||||
<span className="rounded-md border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[11px] font-semibold text-[var(--app-muted)]">
|
||||
共 {libraries.length} 个 · 每页 {PAGE_SIZE} 个
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--app-muted)]">
|
||||
点击卡片浏览对应媒体库精选内容
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/libraries"
|
||||
className="group inline-flex items-center gap-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:text-brand-500"
|
||||
>
|
||||
<span>全部媒体库</span>
|
||||
<ArrowRight size={14} className="transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2 py-1 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={effectivePage <= 1}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-[var(--app-muted)] transition-colors"
|
||||
title="上一页"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<span className="font-mono text-xs font-semibold text-[var(--app-subtle)]">
|
||||
{effectivePage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={effectivePage >= totalPages}
|
||||
className="rounded-lg p-1 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-[var(--app-muted)] transition-colors"
|
||||
title="下一页"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to="/libraries"
|
||||
className="group inline-flex items-center gap-1 text-xs font-bold text-[var(--app-subtle)] transition-colors hover:text-brand-500"
|
||||
>
|
||||
<span>全部媒体库</span>
|
||||
<ArrowRight size={14} className="transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Libraries Grid */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-6">
|
||||
{libraries.map((lib) => {
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-6">
|
||||
{pagedLibraries.map((lib) => {
|
||||
const count = libraryCounts[lib.id] ?? 0
|
||||
const cards = libraryData?.[lib.id]?.cards || []
|
||||
const artwork = getLibraryArtworks(lib, cards)
|
||||
@@ -409,6 +461,7 @@ export function HomeLibrariesSection({
|
||||
key={lib.id}
|
||||
to={`/library/${lib.id}`}
|
||||
className="group relative flex flex-col justify-between overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3 transition-all duration-300 hover:-translate-y-1 hover:border-brand-500/50 hover:bg-[var(--app-hover)]/40 hover:shadow-lg hover:shadow-brand-500/10"
|
||||
title={lib.name}
|
||||
>
|
||||
{/* 封面图片展示区:和媒体库页面一样,显示设置好的或生成的图片 */}
|
||||
<div
|
||||
@@ -444,7 +497,10 @@ export function HomeLibrariesSection({
|
||||
|
||||
{/* 媒体库信息 */}
|
||||
<div className="mt-3 flex flex-col justify-between">
|
||||
<h3 className="truncate font-display text-sm font-bold text-[var(--app-text)] group-hover:text-brand-500">
|
||||
<h3
|
||||
className="line-clamp-2 break-words font-display text-sm font-bold text-[var(--app-text)] group-hover:text-brand-500"
|
||||
title={lib.name}
|
||||
>
|
||||
{lib.name}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-[var(--app-muted)]">
|
||||
@@ -490,7 +546,7 @@ export function HomeLibraryRowSection({
|
||||
{TYPE_ICONS[library.type] || <FolderOpen size={18} />}
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]">
|
||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-[var(--app-text)]" title={library.name}>
|
||||
{library.name}
|
||||
</h2>
|
||||
<span className="text-xs text-[var(--app-muted)]">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import { toolsAPI } from '../api/tools'
|
||||
@@ -10,32 +10,70 @@ import {
|
||||
LibrariesHeader,
|
||||
} from './LibrariesPageSections'
|
||||
import type { LibraryPreview } from './librariesPageModel'
|
||||
import type { Library } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import { fetchLibraries, invalidateLibraries, peekLibraries } from '../utils/libraryCache'
|
||||
import { sortLibraryPreviews } from '../utils/pinnedLibraries'
|
||||
|
||||
export function LibrariesPage() {
|
||||
const [previews, setPreviews] = useState<LibraryPreview[]>([])
|
||||
const [libraries, setLibraries] = useState<Library[]>([])
|
||||
const [libraryData, setLibraryData] = useState<Record<string, { cards: SeriesCard[]; total: number }>>({})
|
||||
const { pinnedIds, loading: pinnedLoading, togglePin } = usePinnedLibraries()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [repairing, setRepairing] = useState(false)
|
||||
const [repairEpisodeArtwork, setRepairEpisodeArtwork] = useState(false)
|
||||
const [repairMsg, setRepairMsg] = useState('')
|
||||
|
||||
const loadLibraries = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const fetchedLibIdsRef = useRef<Set<string>>(new Set())
|
||||
const fetchingRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const fetchPreviews = useCallback(async (ids: string[]) => {
|
||||
const targets = ids.filter((id) => !fetchedLibIdsRef.current.has(id) && !fetchingRef.current.has(id))
|
||||
if (targets.length === 0) return
|
||||
targets.forEach((id) => fetchingRef.current.add(id))
|
||||
|
||||
try {
|
||||
const libs = await libraryAPI.list({ withPreview: true })
|
||||
setPreviews(
|
||||
libs.map((library) => ({
|
||||
library,
|
||||
items: [],
|
||||
total: library.total ?? 0,
|
||||
cards: library.cards ?? [],
|
||||
})),
|
||||
)
|
||||
const rows = await libraryAPI.listPreviews(targets, 10)
|
||||
setLibraryData((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const row of rows) {
|
||||
next[row.id] = {
|
||||
cards: row.cards ?? [],
|
||||
total: row.total ?? 0,
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
} catch {
|
||||
// 容错
|
||||
} finally {
|
||||
targets.forEach((id) => {
|
||||
fetchedLibIdsRef.current.add(id)
|
||||
fetchingRef.current.delete(id)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadLibraries = useCallback(async (options?: { force?: boolean }) => {
|
||||
if (options?.force) invalidateLibraries()
|
||||
// 会话缓存命中时先行渲染,避免每次进入都白等一轮请求
|
||||
const cached = peekLibraries()
|
||||
if (cached) {
|
||||
setLibraries(cached)
|
||||
setLoading(false)
|
||||
} else {
|
||||
setLoading(true)
|
||||
}
|
||||
try {
|
||||
const libs = await fetchLibraries()
|
||||
setLibraries(libs)
|
||||
// 优先拉取入口卡片网格当前页(前 20 个库)的预览
|
||||
const topIds = libs.slice(0, 20).map((l) => l.id)
|
||||
void fetchPreviews(topIds)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [fetchPreviews])
|
||||
|
||||
async function handleRepairRescrape() {
|
||||
if (repairing) return
|
||||
@@ -53,13 +91,25 @@ export function LibrariesPage() {
|
||||
|
||||
const handleManageLibraries = async () => {
|
||||
await openManageLibrariesDialog()
|
||||
await loadLibraries()
|
||||
await loadLibraries({ force: true })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadLibraries().catch(() => undefined)
|
||||
}, [loadLibraries])
|
||||
|
||||
const previews: LibraryPreview[] = useMemo(() => {
|
||||
return libraries.map((library) => {
|
||||
const data = libraryData[library.id]
|
||||
return {
|
||||
library,
|
||||
items: [],
|
||||
total: data?.total ?? 0,
|
||||
cards: data?.cards ?? [],
|
||||
}
|
||||
})
|
||||
}, [libraries, libraryData])
|
||||
|
||||
const sortedPreviews = useMemo(() => sortLibraryPreviews(previews, pinnedIds), [previews, pinnedIds])
|
||||
|
||||
const handleTogglePin = useCallback((libraryId: string) => {
|
||||
@@ -88,7 +138,12 @@ export function LibrariesPage() {
|
||||
{previews.length === 0 ? (
|
||||
<LibrariesEmptyState />
|
||||
) : (
|
||||
<LibrariesContent previews={sortedPreviews} pinnedIds={pinnedIds} onTogglePin={handleTogglePin} />
|
||||
<LibrariesContent
|
||||
previews={sortedPreviews}
|
||||
pinnedIds={pinnedIds}
|
||||
onTogglePin={handleTogglePin}
|
||||
onNeedPreviews={fetchPreviews}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, Pin, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react'
|
||||
import { ArrowRight, ChevronLeft, ChevronRight, Film, FolderOpen, Library as LibraryIcon, Music, Pin, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||
@@ -107,10 +107,12 @@ export function LibrariesContent({
|
||||
previews,
|
||||
pinnedIds,
|
||||
onTogglePin,
|
||||
onNeedPreviews,
|
||||
}: {
|
||||
previews: LibraryPreview[]
|
||||
pinnedIds: string[]
|
||||
onTogglePin: (libraryId: string) => void
|
||||
onNeedPreviews?: (ids: string[]) => void
|
||||
}) {
|
||||
const pinnedCount = previews.filter((preview) => isLibraryPinned(preview.library.id, pinnedIds)).length
|
||||
|
||||
@@ -122,49 +124,124 @@ export function LibrariesContent({
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current
|
||||
if (!sentinel) return
|
||||
const currentTargets = previews.slice(0, visibleCount).map((p) => p.library.id)
|
||||
onNeedPreviews?.(currentTargets)
|
||||
}, [previews, visibleCount, onNeedPreviews])
|
||||
|
||||
// 底部哨兵监听与滚动双保险(触底解锁后续媒体库货架)
|
||||
useEffect(() => {
|
||||
const scrollParent = document.getElementById('app-main-scroll')
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [entry] = entries
|
||||
if (entry?.isIntersecting) {
|
||||
setVisibleCount((prev) => {
|
||||
if (prev >= previews.length) return prev
|
||||
return Math.min(prev + STEP_SHELVES, previews.length)
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollParent,
|
||||
rootMargin: '400px 0px',
|
||||
threshold: 0,
|
||||
},
|
||||
)
|
||||
if (!scrollParent) return
|
||||
|
||||
observer.observe(sentinel)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
const handleCheckBottom = () => {
|
||||
const remaining = scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight
|
||||
if (remaining < 600) {
|
||||
setVisibleCount((prev) => {
|
||||
if (prev >= previews.length) return prev
|
||||
return Math.min(prev + STEP_SHELVES, previews.length)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [previews.length])
|
||||
|
||||
scrollParent.addEventListener('scroll', handleCheckBottom, { passive: true })
|
||||
|
||||
const sentinel = sentinelRef.current
|
||||
let observer: IntersectionObserver | null = null
|
||||
if (sentinel) {
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [entry] = entries
|
||||
if (entry?.isIntersecting) {
|
||||
setVisibleCount((prev) => {
|
||||
if (prev >= previews.length) return prev
|
||||
return Math.min(prev + STEP_SHELVES, previews.length)
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollParent,
|
||||
rootMargin: '600px 0px',
|
||||
threshold: 0,
|
||||
},
|
||||
)
|
||||
observer.observe(sentinel)
|
||||
}
|
||||
|
||||
handleCheckBottom()
|
||||
|
||||
return () => {
|
||||
scrollParent.removeEventListener('scroll', handleCheckBottom)
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
}
|
||||
}
|
||||
}, [visibleCount, previews.length])
|
||||
|
||||
const visiblePreviews = useMemo(() => {
|
||||
return previews.slice(0, visibleCount)
|
||||
}, [previews, visibleCount])
|
||||
|
||||
const ENTRY_PAGE_SIZE = 20
|
||||
const [entryPage, setEntryPage] = useState(1)
|
||||
const totalEntryPages = Math.max(1, Math.ceil(previews.length / ENTRY_PAGE_SIZE))
|
||||
const effectiveEntryPage = Math.min(entryPage, totalEntryPages)
|
||||
const pagedPreviews = useMemo<LibraryPreview[]>(() => {
|
||||
const start = (effectiveEntryPage - 1) * ENTRY_PAGE_SIZE
|
||||
return previews.slice(start, start + ENTRY_PAGE_SIZE)
|
||||
}, [previews, effectiveEntryPage])
|
||||
|
||||
useEffect(() => {
|
||||
const ids = pagedPreviews.map((p) => p.library.id)
|
||||
onNeedPreviews?.(ids)
|
||||
}, [pagedPreviews, onNeedPreviews])
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h2 className="font-display text-2xl font-bold text-ink-600">媒体库入口</h2>
|
||||
<p className="text-sm text-ink-50">
|
||||
按目录进入完整媒体库;下方每个目录也会直接展示最新内容。
|
||||
{pinnedCount > 0 ? ` 已置顶 ${pinnedCount} 个媒体库。` : ' 点击卡片右上角图钉可置顶常用媒体库。'}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-display text-2xl font-bold text-ink-600">媒体库入口</h2>
|
||||
{previews.length > ENTRY_PAGE_SIZE && (
|
||||
<span className="rounded-md border border-sand-200 bg-sand-50 px-2 py-0.5 text-xs font-semibold text-sand-700">
|
||||
共 {previews.length} 个 · 每页 {ENTRY_PAGE_SIZE} 个
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-ink-50">
|
||||
按目录进入完整媒体库;下方每个目录也会直接展示最新内容。
|
||||
{pinnedCount > 0 ? ` 已置顶 ${pinnedCount} 个媒体库。` : ' 点击卡片右上角图钉可置顶常用媒体库。'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{totalEntryPages > 1 && (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-sand-200 bg-white px-2.5 py-1 text-xs shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEntryPage((p) => Math.max(1, p - 1))}
|
||||
disabled={effectiveEntryPage <= 1}
|
||||
className="rounded-lg p-1 text-ink-50 hover:bg-sand-100 hover:text-ink-600 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-ink-50 transition-colors"
|
||||
title="上一页"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<span className="font-mono text-xs font-semibold text-ink-600">
|
||||
{effectiveEntryPage} / {totalEntryPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEntryPage((p) => Math.min(totalEntryPages, p + 1))}
|
||||
disabled={effectiveEntryPage >= totalEntryPages}
|
||||
className="rounded-lg p-1 text-ink-50 hover:bg-sand-100 hover:text-ink-600 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-ink-50 transition-colors"
|
||||
title="下一页"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 xl:grid-cols-3">
|
||||
{previews.map((preview, index) => (
|
||||
{pagedPreviews.map((preview, index) => (
|
||||
<motion.div
|
||||
key={preview.library.id}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
@@ -255,6 +332,7 @@ function LibraryEntryCard({
|
||||
<Link
|
||||
to={`/library/${library.id}`}
|
||||
className="flex min-w-0 flex-1"
|
||||
title={library.name}
|
||||
>
|
||||
<div className={`grid h-20 w-24 sm:h-24 sm:w-36 shrink-0 gap-1 overflow-hidden rounded-xl sm:rounded-2xl bg-[linear-gradient(135deg,#fff7ed,#f8fafc)] ${artwork.length > 1 ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{artwork.length > 0 ? (
|
||||
@@ -285,7 +363,10 @@ function LibraryEntryCard({
|
||||
{preview.total.toLocaleString()} 个条目
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="line-clamp-2 font-display text-sm font-bold leading-tight text-ink-600 group-hover:text-brand-600 sm:truncate sm:text-xl sm:font-black">
|
||||
<h2
|
||||
className="line-clamp-2 break-words font-display text-sm font-bold leading-snug text-ink-600 group-hover:text-brand-600 sm:text-base lg:text-lg sm:font-black"
|
||||
title={library.name}
|
||||
>
|
||||
{library.name}
|
||||
{pinned ? <span className="ml-1.5 align-middle text-[10px] font-bold text-brand-600">置顶</span> : null}
|
||||
</h2>
|
||||
@@ -318,7 +399,10 @@ function LibraryShelf({ preview, pinned }: { preview: LibraryPreview; pinned?: b
|
||||
{TYPE_ICONS[library.type] ?? <LibraryIcon size={14} />}
|
||||
{TYPE_LABELS[library.type] ?? library.type}
|
||||
</div>
|
||||
<h2 className="line-clamp-2 font-display text-xl sm:text-2xl font-black text-ink-600 sm:truncate">
|
||||
<h2
|
||||
className="line-clamp-2 break-words font-display text-xl sm:text-2xl font-black text-ink-600"
|
||||
title={library.name}
|
||||
>
|
||||
{library.name}
|
||||
{pinned ? <span className="ml-2 align-middle text-xs font-bold text-brand-600">置顶</span> : null}
|
||||
</h2>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { memo, type ReactNode } from 'react'
|
||||
import { Film } from 'lucide-react'
|
||||
|
||||
import { MediaCard } from '../components/MediaCard'
|
||||
@@ -33,7 +33,9 @@ export function LibraryMediaSections({
|
||||
{!isSeries && items.length > 0 && (
|
||||
<MediaGrid count={items.length} renderItem={(index) => {
|
||||
const media = items[index]
|
||||
return <MediaCard key={media.id} media={media} actions={cardActions(media)} />
|
||||
// renderActions 传函数引用:MediaCard 重渲染时才构建操作按钮,
|
||||
// 配合 memo,父级无关状态变化不再级联重渲染全部卡片。
|
||||
return <MediaCard key={media.id} media={media} renderActions={cardActions} />
|
||||
}} />
|
||||
)}
|
||||
|
||||
@@ -45,12 +47,11 @@ export function LibraryMediaSections({
|
||||
<MediaGrid count={seriesCards.length} renderItem={(index) => {
|
||||
const series = seriesCards[index]
|
||||
return (
|
||||
<MediaCard
|
||||
<SeriesCardItem
|
||||
key={series.key}
|
||||
media={series.rep}
|
||||
count={series.count}
|
||||
actions={cardActions(series.rep)}
|
||||
onClick={() => onSeriesClick(series)}
|
||||
series={series}
|
||||
cardActions={cardActions}
|
||||
onSeriesClick={onSeriesClick}
|
||||
/>
|
||||
)
|
||||
}} />
|
||||
@@ -63,6 +64,27 @@ export function LibraryMediaSections({
|
||||
)
|
||||
}
|
||||
|
||||
// 独立 memo 组件:onClick 闭包在其内部创建,props 均为稳定引用,
|
||||
// 父级重渲染不会穿透到每张剧集卡片。
|
||||
const SeriesCardItem = memo(function SeriesCardItem({
|
||||
series,
|
||||
cardActions,
|
||||
onSeriesClick,
|
||||
}: {
|
||||
series: SeriesCard
|
||||
cardActions: (media: Media) => ReactNode
|
||||
onSeriesClick: (series: SeriesCard) => void
|
||||
}) {
|
||||
return (
|
||||
<MediaCard
|
||||
media={series.rep}
|
||||
count={series.count}
|
||||
renderActions={cardActions}
|
||||
onClick={() => onSeriesClick(series)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
function MediaGrid({ count, renderItem }: { count: number; renderItem: (index: number) => ReactNode }) {
|
||||
if (count <= VIRTUALIZE_THRESHOLD) {
|
||||
return <div className={MEDIA_GRID_CLASS}>{Array.from({ length: count }, (_, index) => renderItem(index))}</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState, Fragment, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState, Fragment, type ReactNode } from 'react'
|
||||
import { useLocation, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
@@ -175,7 +175,7 @@ export function LibraryPage() {
|
||||
setManualMovie,
|
||||
})
|
||||
|
||||
const handleToggleFavourite = async (mediaID: string) => {
|
||||
const handleToggleFavourite = useCallback(async (mediaID: string) => {
|
||||
if (!canFavorite || favouriteBusyID) return
|
||||
setFavouriteBusyID(mediaID)
|
||||
try {
|
||||
@@ -183,9 +183,11 @@ export function LibraryPage() {
|
||||
} finally {
|
||||
setFavouriteBusyID('')
|
||||
}
|
||||
}
|
||||
}, [canFavorite, favouriteBusyID, toggleFavourite])
|
||||
|
||||
const cardActions = (media: Media): ReactNode => {
|
||||
// useCallback 稳定引用:配合 MediaCard 的 memo,仅在收藏状态/操作集变化时
|
||||
// 才让卡片重渲染。
|
||||
const cardActions = useCallback((media: Media): ReactNode => {
|
||||
const actions: ReactNode[] = []
|
||||
if (canFavorite) {
|
||||
actions.push(
|
||||
@@ -206,7 +208,7 @@ export function LibraryPage() {
|
||||
}
|
||||
if (actions.length === 0) return undefined
|
||||
return <>{actions}</>
|
||||
}
|
||||
}, [canFavorite, favouriteBusyID, handleToggleFavourite, isFavourite, movieActions])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
@@ -70,6 +70,8 @@ export function LibrarySeriesDetailHeader({
|
||||
<img
|
||||
src={imageURL(series.rep.poster_url, series.rep.updated_at)}
|
||||
alt={series.rep.title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="aspect-[2/3] w-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function MediaDetailPage() {
|
||||
media={media}
|
||||
isAdmin={role === 'admin'}
|
||||
favourite={detail.favourite}
|
||||
playTargetId={detail.firstPlayableEpisode?.id}
|
||||
playTargetId={media.id || detail.firstPlayableEpisode?.id}
|
||||
scrapeEpisodeArtwork={detail.scrapeEpisodeArtwork}
|
||||
onToggleFavourite={detail.toggleFavourite}
|
||||
onScrapeEpisodeArtworkChange={detail.setScrapeEpisodeArtwork}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function MediaDetailPlaybackActions({
|
||||
onToggleFavourite,
|
||||
playTargetId,
|
||||
}: MediaDetailPlaybackActionsProps) {
|
||||
const targetId = playTargetId || media.id
|
||||
const targetId = media.id || playTargetId || ''
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link to={`/play/${targetId}`} state={{ from: `/media/${media.id}` }} className="btn-primary px-6 py-3.5 shadow-sm">
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
AlertCircle,
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
Film,
|
||||
Image as ImageIcon,
|
||||
@@ -26,6 +23,8 @@ import { imageURL } from '../api/client'
|
||||
import { scraperAPI } from '../api/scraper'
|
||||
import type { ScrapeQueueSnapshot, ScrapeTask, ScrapeTaskStatus } from '../types/scraper'
|
||||
import { apiErrorMessage, formatTime, taskStatusMeta } from './StrmManagePage'
|
||||
import { PROVIDER_LABELS, ScrapeDetailModal } from './scraper-queue/ScrapeDetailModal'
|
||||
import { copyToClipboard, useTaskSelection } from './queue-shared'
|
||||
|
||||
const FILTERS: { key: 'all' | ScrapeTaskStatus; label: string; icon: typeof Clock; color: string }[] = [
|
||||
{ key: 'all', label: '全部', icon: Sparkles, color: 'text-ink-600' },
|
||||
@@ -36,14 +35,6 @@ const FILTERS: { key: 'all' | ScrapeTaskStatus; label: string; icon: typeof Cloc
|
||||
{ key: 'canceled', label: '已取消', icon: Ban, color: 'text-amber-500' },
|
||||
]
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
tmdb: 'TheMovieDB',
|
||||
douban: '豆瓣 Douban',
|
||||
bangumi: 'Bangumi 番组计划',
|
||||
thetvdb: 'TheTVDB',
|
||||
metatube: 'MetaTube',
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<string, ReactNode> = {
|
||||
movie: <Film size={14} className="text-blue-500" />,
|
||||
tv: <Tv size={14} className="text-purple-500" />,
|
||||
@@ -51,13 +42,6 @@ const TYPE_ICONS: Record<string, ReactNode> = {
|
||||
adult: <Film size={14} className="text-rose-500" />,
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
movie: '电影',
|
||||
tv: '剧集',
|
||||
anime: '动漫',
|
||||
adult: 'Adult',
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
||||
@@ -70,7 +54,7 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
const [batchBusy, setBatchBusy] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const { selectedIds, setSelectedIds, reset: clearSelection, toggleRow: toggleSelectRow, toggleAll: toggleAllIds } = useTaskSelection()
|
||||
const [detailTask, setDetailTask] = useState<ScrapeTask | null>(null)
|
||||
|
||||
const refresh = useCallback(
|
||||
@@ -103,19 +87,17 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return
|
||||
const timer = setInterval(() => {
|
||||
if (document.hidden) return
|
||||
refresh().catch(() => undefined)
|
||||
}, 3000)
|
||||
return () => clearInterval(timer)
|
||||
}, [autoRefresh, refresh])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds(new Set())
|
||||
}, [filter, page])
|
||||
clearSelection()
|
||||
}, [filter, page, clearSelection])
|
||||
|
||||
const copyText = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.success(`已复制${label}`)
|
||||
}
|
||||
const copyText = copyToClipboard
|
||||
|
||||
// Row actions
|
||||
const cancelTask = async (task: ScrapeTask) => {
|
||||
@@ -237,22 +219,7 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
||||
const allCurrentChecked =
|
||||
filteredTasks.length > 0 && filteredTasks.every((t) => selectedIds.has(t.id))
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (allCurrentChecked) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(filteredTasks.map((t) => t.id)))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectRow = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
const toggleSelectAll = () => toggleAllIds(filteredTasks.map((t) => t.id))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -950,216 +917,3 @@ export function ScraperQueuePage({ embedded = false }: { embedded?: boolean }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrapeDetailModal({
|
||||
task,
|
||||
onClose,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onCopy,
|
||||
}: {
|
||||
task: ScrapeTask
|
||||
onClose: () => void
|
||||
onRetry: (t: ScrapeTask) => void
|
||||
onCancel: (t: ScrapeTask) => void
|
||||
onDelete: (t: ScrapeTask) => void
|
||||
onCopy: (text: string, label: string) => void
|
||||
}) {
|
||||
const status = taskStatusMeta(task.status)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-xl rounded-3xl border border-gray-200 bg-white shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={16} className="text-brand-500" />
|
||||
<h3 className="font-display text-base font-bold text-ink-600">刮削任务详情</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1 text-gray-400 hover:bg-gray-100 hover:text-ink-600 transition"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-6 max-h-[70vh] overflow-y-auto text-xs">
|
||||
{/* Matched Poster / Info Banner */}
|
||||
{task.matched_title ? (
|
||||
<div className="flex gap-4 rounded-2xl border border-brand-500/20 bg-primary-400/5 p-4">
|
||||
{task.poster_url && (
|
||||
<img
|
||||
src={imageURL(task.poster_url)}
|
||||
alt=""
|
||||
className="h-28 w-20 rounded-xl object-cover border border-brand-500/30 shadow-md shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded bg-brand-500 px-2 py-0.5 text-[10px] font-bold text-white uppercase">
|
||||
已匹配
|
||||
</span>
|
||||
{task.provider && (
|
||||
<span className="rounded border border-gray-200 bg-white px-2 py-0.5 text-[10px] font-semibold text-ink-600">
|
||||
{PROVIDER_LABELS[task.provider] ?? task.provider}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="font-display text-base font-extrabold text-ink-600 truncate">
|
||||
{task.matched_title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-3 text-sand-500 text-[11px]">
|
||||
{task.matched_year > 0 && <span>年份:{task.matched_year}</span>}
|
||||
<span>类型:{TYPE_LABELS[task.media_type] ?? task.media_type}</span>
|
||||
</div>
|
||||
{task.media_id && (
|
||||
<Link
|
||||
to={`/media/${task.media_id}`}
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-1 text-brand-500 font-semibold hover:underline pt-1"
|
||||
>
|
||||
<span>在媒体详情中查看</span>
|
||||
<ExternalLink size={11} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Media Info Box */}
|
||||
<div className="rounded-2xl border border-gray-100 bg-gray-50/70 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">原始媒体标题</span>
|
||||
<span className="font-bold text-ink-600 select-all">{task.media_title}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">所属媒体库</span>
|
||||
<span className="font-medium text-ink-100">{task.library_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">媒体库类型</span>
|
||||
<span className="font-medium text-ink-100">
|
||||
{TYPE_LABELS[task.media_type] ?? task.media_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">当前状态</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${status.cls}`}
|
||||
>
|
||||
{task.status === 'done' ? '已匹配' : task.status === 'failed' ? '未匹配' : status.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">剧照/海报刮削</span>
|
||||
<span className="font-medium text-ink-100">
|
||||
{task.episode_images ? '开启' : '关闭'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File path */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sand-500 font-medium">
|
||||
<span>磁盘文件路径</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.media_path, '文件路径')}
|
||||
className="inline-flex items-center gap-1 text-brand-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 font-mono text-[11px] text-ink-600 break-all select-all">
|
||||
{task.media_path}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message Box */}
|
||||
{task.error && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-rose-500 font-medium">
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertCircle size={13} /> 刮削未匹配 / 异常详情
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.error, '错误日志')}
|
||||
className="inline-flex items-center gap-1 text-rose-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制日志
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50/60 p-3 font-mono text-[11px] text-rose-700 break-all select-all whitespace-pre-wrap">
|
||||
{task.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-2 text-[11px] text-sand-500 border-t border-gray-100">
|
||||
<div>入队时间:{formatTime(task.created_at)}</div>
|
||||
{task.started_at && <div>开始刮削:{formatTime(task.started_at)}</div>}
|
||||
{task.finished_at && <div>完成时间:{formatTime(task.finished_at)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4 bg-gray-50/50">
|
||||
<div>
|
||||
{(task.status === 'done' ||
|
||||
task.status === 'failed' ||
|
||||
task.status === 'canceled') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(task)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-rose-200 bg-white px-3 py-2 text-xs font-semibold text-rose-500 hover:bg-rose-50 transition"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
删除记录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{(task.status === 'pending' || task.status === 'running') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCancel(task)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-amber-200 bg-white px-4 py-2 text-xs font-semibold text-amber-600 hover:bg-amber-50 transition"
|
||||
>
|
||||
<Ban size={13} />
|
||||
取消任务
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRetry(task)}
|
||||
className="neon-button !py-2 !px-4 text-xs font-semibold"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
重新刮削
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-xs font-semibold text-ink-100 hover:bg-gray-50 transition"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FormEvent, useEffect, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ChevronRight,
|
||||
Cloud,
|
||||
ExternalLink,
|
||||
FolderPlus,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
Plus,
|
||||
QrCode,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
Tv,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
import { strmAPI, type Strm115Source, type StrmRemoteEntry } from '../api/strm'
|
||||
import { strmAPI, type StrmRemoteEntry } from '../api/strm'
|
||||
import type { SettingDef } from './SettingsRow'
|
||||
import { SettingRow } from './SettingsRow'
|
||||
import type { StrmAccount, StrmSyncPath, StrmSyncPathInput, StrmProvider } from '../types/strm'
|
||||
import { STRM_PROVIDER_LABELS } from '../types/strm'
|
||||
import { apiErrorMessage } from './StrmManagePage'
|
||||
import { Strm115AuthPanel } from './strm-dialogs/Strm115AuthPanel'
|
||||
import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog'
|
||||
import {
|
||||
defaultEmbyRemoteLines,
|
||||
@@ -496,309 +492,6 @@ export function StrmAccountDialog({
|
||||
// ─── 115 登录二维码(canvas 渲染;115 返回的是扫码页面链接而非图片) ───────────
|
||||
|
||||
// QR 尺寸:物理像素 = CSS 像素(避免 canvas 被 CSS 缩放导致裁切/模糊)
|
||||
const QR_SIZE = 176
|
||||
|
||||
function QrCanvas({ content }: { content: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas || !content) return
|
||||
QRCode.toCanvas(canvas, content, {
|
||||
width: QR_SIZE,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: { dark: '#1f2937', light: '#ffffff' },
|
||||
})
|
||||
.then(() => setError(''))
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : '二维码生成失败'))
|
||||
}, [content])
|
||||
|
||||
return (
|
||||
<div className="relative h-44 w-44 shrink-0 rounded-xl border border-gray-200 bg-white">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={QR_SIZE}
|
||||
height={QR_SIZE}
|
||||
role="img"
|
||||
aria-label="115 授权二维码"
|
||||
style={{ width: QR_SIZE, height: QR_SIZE }}
|
||||
/>
|
||||
{error && (
|
||||
<div className="absolute inset-0 grid place-items-center rounded-xl bg-gray-50 p-3 text-center text-xs text-rose-500">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 115 开放平台授权面板 ─────────────────────────────────────────────────────
|
||||
|
||||
type AuthSourceKey = 'built_in_appid' | 'custom_appid' | 'built_in_relay' | 'third_party_service'
|
||||
|
||||
const AUTH_SOURCE_OPTIONS: { key: AuthSourceKey; label: string; desc: string; needsRelayKey?: boolean }[] = [
|
||||
{ key: 'built_in_appid', label: '官方应用目录', desc: '设备码扫码授权,无需回跳' },
|
||||
{ key: 'custom_appid', label: '自定义 APP ID', desc: '使用自己申请的开放平台应用' },
|
||||
{ key: 'built_in_relay', label: '中继授权', desc: 'QMediaSync / MQFamily 中继', needsRelayKey: true },
|
||||
{ key: 'third_party_service', label: '第三方服务', desc: 'MoviePilot / CloudDrive' },
|
||||
]
|
||||
|
||||
function Strm115AuthPanel({
|
||||
existing,
|
||||
accountName,
|
||||
onAuthed,
|
||||
}: {
|
||||
existing: StrmAccount | null
|
||||
accountName: string
|
||||
onAuthed: (account: StrmAccount) => void
|
||||
}) {
|
||||
const [sources, setSources] = useState<{ built_in: Strm115Source[]; relay: Strm115Source[]; third_party: Strm115Source[] } | null>(null)
|
||||
const [relayKeyConfigured, setRelayKeyConfigured] = useState(false)
|
||||
const [authSource, setAuthSource] = useState<AuthSourceKey>('built_in_appid')
|
||||
const [thirdParty, setThirdParty] = useState<'moviepilot' | 'clouddrive'>('moviepilot')
|
||||
const [relayProvider, setRelayProvider] = useState<'qmediasync' | 'mqfamily'>('qmediasync')
|
||||
const [appID, setAppID] = useState('100195125')
|
||||
const [appKeyword, setAppKeyword] = useState('')
|
||||
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [authUI, setAuthUI] = useState<{ sessionId: string; accountId: string; mode: 'qrcode' | 'url'; authUrl?: string; qrcodeUrl?: string } | null>(null)
|
||||
const [authStatus, setAuthStatus] = useState('')
|
||||
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const stopPolling = () => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
}
|
||||
useEffect(() => stopPolling, [])
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
try {
|
||||
const data = await strmAPI.list115Sources()
|
||||
setSources(data)
|
||||
const settings = await strmAPI.getSettings().catch(() => null)
|
||||
const key = settings?.['strm.115_relay_key']
|
||||
setRelayKeyConfigured(Boolean(key && key.trim()))
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const filteredApps = useMemo(
|
||||
() =>
|
||||
(sources?.built_in ?? []).filter(
|
||||
(s) => !appKeyword || s.app_name.toLowerCase().includes(appKeyword.toLowerCase()) || s.app_id.includes(appKeyword),
|
||||
),
|
||||
[appKeyword, sources],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredApps.length > 0 && !filteredApps.some((source) => source.app_id === appID)) {
|
||||
setAppID(filteredApps[0].app_id)
|
||||
}
|
||||
}, [appID, filteredApps])
|
||||
|
||||
const buildStartPayload = () => {
|
||||
switch (authSource) {
|
||||
case 'built_in_appid':
|
||||
return { auth_source: 'built_in_appid', app_id: appID }
|
||||
case 'custom_appid':
|
||||
return { auth_source: 'custom_appid', app_id: appID }
|
||||
case 'built_in_relay':
|
||||
return { auth_source: 'built_in_relay', provider: relayProvider }
|
||||
case 'third_party_service':
|
||||
return { auth_source: 'third_party_service', provider: thirdParty }
|
||||
}
|
||||
}
|
||||
|
||||
const startAuth = async () => {
|
||||
if (authSource === 'built_in_appid' && filteredApps.length === 0) {
|
||||
toast.error('未找到匹配的官方应用')
|
||||
return
|
||||
}
|
||||
|
||||
setStarting(true)
|
||||
setAuthStatus('')
|
||||
try {
|
||||
// 115 授权需要账号 ID:没有则先创建空凭据账号
|
||||
let account = existing
|
||||
if (!account) {
|
||||
account = await strmAPI.createAccount({ name: accountName || '115 网盘', provider: 'cloud115', config: {} })
|
||||
}
|
||||
const result = await strmAPI.start115OAuth(account.id, buildStartPayload())
|
||||
setAuthUI(
|
||||
result.mode === 'qrcode'
|
||||
? { sessionId: result.session_id, accountId: account.id, mode: 'qrcode', qrcodeUrl: result.qrcode?.qrcode }
|
||||
: { sessionId: result.session_id, accountId: account.id, mode: 'url', authUrl: result.auth_url },
|
||||
)
|
||||
stopPolling()
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const status = await strmAPI.poll115OAuth(account.id, result.session_id)
|
||||
setAuthStatus(status.tip)
|
||||
if (status.status === 'confirmed') {
|
||||
stopPolling()
|
||||
const updated = await strmAPI.testAccount(account.id)
|
||||
onAuthed(updated)
|
||||
}
|
||||
if (status.status === 'expired') {
|
||||
stopPolling()
|
||||
setAuthUI(null)
|
||||
toast.error('授权已过期,请重新发起')
|
||||
}
|
||||
} catch {
|
||||
/* 轮询失败等下一次 */
|
||||
}
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openAuthWindow = () => {
|
||||
if (authUI?.mode === 'url' && authUI.authUrl) {
|
||||
window.open(authUI.authUrl, '_blank', 'noopener')
|
||||
}
|
||||
}
|
||||
|
||||
const resetAuth = () => {
|
||||
stopPolling()
|
||||
setAuthUI(null)
|
||||
setAuthStatus('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl bg-gray-50 p-4">
|
||||
{existing?.has_credential && (
|
||||
<p className="rounded-xl bg-emerald-50 px-3 py-2 text-sm text-emerald-600">
|
||||
✓ 该账号已授权;重新授权会替换现有令牌
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!authUI && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{AUTH_SOURCE_OPTIONS.map((option) => {
|
||||
const active = authSource === option.key
|
||||
const disabled = Boolean(option.needsRelayKey && !relayKeyConfigured)
|
||||
return (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setAuthSource(option.key)}
|
||||
className={
|
||||
'rounded-xl border-2 p-2.5 text-left transition disabled:cursor-not-allowed disabled:opacity-40 ' +
|
||||
(active ? 'border-brand-400 bg-brand-50' : 'border-gray-100 bg-white hover:border-gray-200')
|
||||
}
|
||||
>
|
||||
<p className="text-sm font-bold text-ink-600">{option.label}</p>
|
||||
<p className="text-[11px] text-sand-500">{option.desc}</p>
|
||||
{disabled && <p className="text-[10px] text-rose-400">需在 STRM 设置配置共享密钥</p>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{authSource === 'built_in_appid' && (
|
||||
<div className="space-y-2">
|
||||
<Field label="选择官方应用" hint="应用目录来自 QMediaSync 内置;媒体播放器 / 飞牛 / 恒星等">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search size={14} className="shrink-0 text-sand-400" />
|
||||
<input className={inputCls} placeholder="搜索应用名称或 ID…" value={appKeyword} onChange={(e) => setAppKeyword(e.target.value)} />
|
||||
</div>
|
||||
<select className={inputCls} value={appID} onChange={(e) => setAppID(e.target.value)} size={5}>
|
||||
{filteredApps.map((source) => (
|
||||
<option key={source.app_id} value={source.app_id}>
|
||||
{source.display_name}({source.app_id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{filteredApps.length === 0 && <p className="text-xs text-sand-500">未找到匹配的官方应用</p>}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
{authSource === 'custom_appid' && (
|
||||
<Field label="自定义 APP ID" hint="使用自己在 115 开放平台申请的应用 ID">
|
||||
<input className={inputCls} value={appID} placeholder="100195125" onChange={(e) => setAppID(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
{authSource === 'built_in_relay' && (
|
||||
<Field label="中继服务">
|
||||
<select className={inputCls} value={relayProvider} onChange={(e) => setRelayProvider(e.target.value as typeof relayProvider)}>
|
||||
<option value="qmediasync">QMediaSync(oauth.qmediasync.cn)</option>
|
||||
<option value="mqfamily">MQFamily(api.mqfamily.top)</option>
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{authSource === 'third_party_service' && (
|
||||
<Field label="第三方授权服务">
|
||||
<select className={inputCls} value={thirdParty} onChange={(e) => setThirdParty(e.target.value as typeof thirdParty)}>
|
||||
<option value="moviepilot">MoviePilot(https://movie-pilot.org)</option>
|
||||
<option value="clouddrive">CloudDrive(redirect115.zhenyunpan.com)</option>
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={startAuth}
|
||||
disabled={starting || (authSource === 'built_in_appid' && filteredApps.length === 0)}
|
||||
className="neon-button disabled:opacity-50"
|
||||
>
|
||||
{starting ? <Loader2 size={16} className="animate-spin" /> : <QrCode size={16} />}
|
||||
{authSource === 'built_in_appid' || authSource === 'custom_appid' ? '获取登录二维码' : '获取授权链接'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authUI && (
|
||||
<div className="space-y-3">
|
||||
{authUI.mode === 'qrcode' && authUI.qrcodeUrl ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<QrCanvas content={authUI.qrcodeUrl} />
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<p className="font-medium text-ink-600">{authStatus || '等待扫码…'}</p>
|
||||
<p className="text-xs text-sand-500">请使用 115 手机客户端扫码并确认授权,5 分钟内有效</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-ink-100">请点击下方按钮在新窗口完成授权:</p>
|
||||
<button type="button" onClick={openAuthWindow} className="neon-button">
|
||||
<ExternalLink size={16} />
|
||||
打开授权页面
|
||||
</button>
|
||||
<p className="text-xs text-sand-500">完成后回到此页面等待自动确认(授权成功后可关闭弹窗)</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Loader2 size={14} className="animate-spin text-brand-500" />
|
||||
<span className="text-ink-50">{authStatus}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetAuth}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2.5 py-1.5 text-xs font-semibold text-ink-100 transition hover:bg-gray-50"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
重新发起授权
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── STRM 设置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const SETTING_DEFS: SettingDef[] = [
|
||||
{ key: 'strm.base_url', label: 'STRM 链接基础地址', type: 'text', hint: '生成的 strm 文件指向的播放地址;留空依次自动使用 app.server_url,最后回退到本机地址(http://127.0.0.1:端口)。Emby 在其他设备时请配置局域网/公网地址' },
|
||||
{ key: 'strm.video_ext', label: '视频扩展名', type: 'text', hint: '逗号分隔,命中即生成 .strm' },
|
||||
|
||||
@@ -95,7 +95,10 @@ export function StrmManagePage() {
|
||||
// 有进行中的同步时轮询刷新状态
|
||||
useEffect(() => {
|
||||
if (!paths.some((p) => p.last_sync_status === 'running')) return
|
||||
const timer = setInterval(() => refresh().catch(() => undefined), 4000)
|
||||
const timer = setInterval(() => {
|
||||
if (document.hidden) return
|
||||
refresh().catch(() => undefined)
|
||||
}, 4000)
|
||||
return () => clearInterval(timer)
|
||||
}, [paths, refresh])
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -8,12 +8,7 @@ import {
|
||||
Copy,
|
||||
Download,
|
||||
Eye,
|
||||
File,
|
||||
FileText,
|
||||
Film,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
PlayCircle,
|
||||
RefreshCw,
|
||||
Search,
|
||||
@@ -26,6 +21,8 @@ import { strmAPI } from '../api/strm'
|
||||
import type { StrmQueueSnapshot, StrmTask, StrmTaskStatus } from '../types/strm'
|
||||
import { STRM_PROVIDER_LABELS } from '../types/strm'
|
||||
import { apiErrorMessage, formatBytes, formatTime, taskStatusMeta } from './StrmManagePage'
|
||||
import { TaskDetailModal, getFileIcon } from './strm-queue/TaskDetailModal'
|
||||
import { copyToClipboard, useTaskSelection } from './queue-shared'
|
||||
|
||||
const FILTERS: { key: 'all' | StrmTaskStatus; label: string; icon: typeof Clock; color: string }[] = [
|
||||
{ key: 'all', label: '全部', icon: Clock, color: 'text-ink-600' },
|
||||
@@ -38,23 +35,6 @@ const FILTERS: { key: 'all' | StrmTaskStatus; label: string; icon: typeof Clock;
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
function getFileIcon(filename: string): ReactNode {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
||||
if (['nfo', 'txt', 'xml', 'json'].includes(ext)) {
|
||||
return <FileText size={15} className="text-amber-500 shrink-0" />
|
||||
}
|
||||
if (['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'svg'].includes(ext)) {
|
||||
return <ImageIcon size={15} className="text-blue-500 shrink-0" />
|
||||
}
|
||||
if (['srt', 'ass', 'ssa', 'sub', 'vtt'].includes(ext)) {
|
||||
return <MessageSquare size={15} className="text-purple-500 shrink-0" />
|
||||
}
|
||||
if (['mkv', 'mp4', 'avi', 'mov', 'wmv', 'ts', 'flv', 'iso', 'm4v', 'strm'].includes(ext)) {
|
||||
return <Film size={15} className="text-emerald-500 shrink-0" />
|
||||
}
|
||||
return <File size={15} className="text-gray-400 shrink-0" />
|
||||
}
|
||||
|
||||
export function StrmQueuePanel({
|
||||
kind,
|
||||
embedded = false,
|
||||
@@ -71,7 +51,7 @@ export function StrmQueuePanel({
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
const [batchBusy, setBatchBusy] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const { selectedIds, setSelectedIds, reset: clearSelection, toggleRow: toggleSelectRow, toggleAll: toggleAllIds } = useTaskSelection()
|
||||
const [detailTask, setDetailTask] = useState<StrmTask | null>(null)
|
||||
|
||||
const isDownload = kind === 'download'
|
||||
@@ -109,6 +89,7 @@ export function StrmQueuePanel({
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return
|
||||
const timer = setInterval(() => {
|
||||
if (document.hidden) return
|
||||
refresh().catch(() => undefined)
|
||||
}, 3000)
|
||||
return () => clearInterval(timer)
|
||||
@@ -116,13 +97,10 @@ export function StrmQueuePanel({
|
||||
|
||||
// Clear selections when changing filter or page
|
||||
useEffect(() => {
|
||||
setSelectedIds(new Set())
|
||||
}, [filter, page])
|
||||
clearSelection()
|
||||
}, [filter, page, clearSelection])
|
||||
|
||||
const copyText = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.success(`已复制${label}`)
|
||||
}
|
||||
const copyText = copyToClipboard
|
||||
|
||||
// Task actions
|
||||
const cancelTask = async (task: StrmTask) => {
|
||||
@@ -234,22 +212,7 @@ export function StrmQueuePanel({
|
||||
const allCurrentChecked =
|
||||
filteredTasks.length > 0 && filteredTasks.every((t) => selectedIds.has(t.id))
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (allCurrentChecked) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(filteredTasks.map((t) => t.id)))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectRow = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
const toggleSelectAll = () => toggleAllIds(filteredTasks.map((t) => t.id))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -996,201 +959,6 @@ export function StrmQueuePanel({
|
||||
)
|
||||
}
|
||||
|
||||
function TaskDetailModal({
|
||||
task,
|
||||
isDownload,
|
||||
onClose,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onCopy,
|
||||
}: {
|
||||
task: StrmTask
|
||||
isDownload: boolean
|
||||
onClose: () => void
|
||||
onRetry: (t: StrmTask) => void
|
||||
onCancel: (t: StrmTask) => void
|
||||
onDelete: (t: StrmTask) => void
|
||||
onCopy: (text: string, label: string) => void
|
||||
}) {
|
||||
const status = taskStatusMeta(task.status)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-xl rounded-3xl border border-gray-200 bg-white shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{getFileIcon(task.file_name)}
|
||||
<h3 className="font-display text-base font-bold text-ink-600">任务详情</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1 text-gray-400 hover:bg-gray-100 hover:text-ink-600 transition"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-6 max-h-[70vh] overflow-y-auto text-xs">
|
||||
{/* Main Info Box */}
|
||||
<div className="rounded-2xl border border-gray-100 bg-gray-50/70 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">任务 ID</span>
|
||||
<span className="font-mono text-ink-100 select-all">{task.id}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">文件名称</span>
|
||||
<span className="font-bold text-ink-600 select-all">{task.file_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">文件大小</span>
|
||||
<span className="font-mono text-ink-100">{formatBytes(task.size)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">当前状态</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${status.cls}`}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">云盘提供方</span>
|
||||
<span className="font-medium text-ink-100">
|
||||
{STRM_PROVIDER_LABELS[task.provider] ?? task.provider}
|
||||
</span>
|
||||
</div>
|
||||
{task.retry_count > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">已重试次数</span>
|
||||
<span className="font-bold text-amber-600">{task.retry_count} 次</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Paths */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sand-500 font-medium">
|
||||
<span>{isDownload ? '本地输出目标路径' : '本地来源路径'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.local_path, '本地路径')}
|
||||
className="inline-flex items-center gap-1 text-brand-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 font-mono text-[11px] text-ink-600 break-all select-all">
|
||||
{task.local_path}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sand-500 font-medium">
|
||||
<span>远端网盘路径</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.remote_path, '远端路径')}
|
||||
className="inline-flex items-center gap-1 text-brand-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 font-mono text-[11px] text-ink-600 break-all select-all">
|
||||
{task.remote_path}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message Box */}
|
||||
{task.error && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-rose-500 font-medium">
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertCircle size={13} /> 错误详情
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.error!, '错误信息')}
|
||||
className="inline-flex items-center gap-1 text-rose-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制错误
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50/60 p-3 font-mono text-[11px] text-rose-700 break-all select-all whitespace-pre-wrap">
|
||||
{task.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-2 text-[11px] text-sand-500 border-t border-gray-100">
|
||||
<div>创建时间:{formatTime(task.created_at)}</div>
|
||||
{task.started_at && <div>开始时间:{formatTime(task.started_at)}</div>}
|
||||
{task.finished_at && <div>结束时间:{formatTime(task.finished_at)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4 bg-gray-50/50">
|
||||
<div>
|
||||
{(task.status === 'done' ||
|
||||
task.status === 'failed' ||
|
||||
task.status === 'canceled') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(task)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-rose-200 bg-white px-3 py-2 text-xs font-semibold text-rose-500 hover:bg-rose-50 transition"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
删除记录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{(task.status === 'pending' || task.status === 'running') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCancel(task)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-amber-200 bg-white px-4 py-2 text-xs font-semibold text-amber-600 hover:bg-amber-50 transition"
|
||||
>
|
||||
<Ban size={13} />
|
||||
取消任务
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(task.status === 'failed' || task.status === 'canceled') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRetry(task)}
|
||||
className="neon-button !py-2 !px-4 text-xs font-semibold"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
重新入队
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-xs font-semibold text-ink-100 hover:bg-gray-50 transition"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StrmDownloadQueuePage() {
|
||||
return <StrmQueuePanel kind="download" />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
/** 复制文本到剪贴板并提示(Strm/Scraper 队列页共用)。 */
|
||||
export function copyToClipboard(text: string, label: string) {
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.success(`已复制${label}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务列表多选状态:StrmQueuePage 与 ScraperQueuePage 共用。
|
||||
* allIds 变化(翻页/筛选)时调用 reset() 清空选中。
|
||||
*/
|
||||
export function useTaskSelection() {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const reset = useCallback(() => setSelectedIds(new Set()), [])
|
||||
|
||||
const toggleRow = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleAll = useCallback((visibleIds: string[]) => {
|
||||
setSelectedIds((prev) => {
|
||||
if (visibleIds.length > 0 && visibleIds.every((id) => prev.has(id))) {
|
||||
return new Set()
|
||||
}
|
||||
return new Set(visibleIds)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return { selectedIds, setSelectedIds, reset, toggleRow, toggleAll }
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { AlertCircle, Ban, Copy, ExternalLink, RefreshCw, Sparkles, Trash2, X } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../../api/client'
|
||||
import type { ScrapeTask } from '../../types/scraper'
|
||||
import { formatTime, taskStatusMeta } from '../StrmManagePage'
|
||||
|
||||
export const PROVIDER_LABELS: Record<string, string> = {
|
||||
tmdb: 'TheMovieDB',
|
||||
douban: '豆瓣 Douban',
|
||||
bangumi: 'Bangumi 番组计划',
|
||||
thetvdb: 'TheTVDB',
|
||||
metatube: 'MetaTube',
|
||||
}
|
||||
|
||||
export const TYPE_LABELS: Record<string, string> = {
|
||||
movie: '电影',
|
||||
tv: '剧集',
|
||||
anime: '动漫',
|
||||
adult: 'Adult',
|
||||
}
|
||||
|
||||
export function ScrapeDetailModal({
|
||||
task,
|
||||
onClose,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onCopy,
|
||||
}: {
|
||||
task: ScrapeTask
|
||||
onClose: () => void
|
||||
onRetry: (t: ScrapeTask) => void
|
||||
onCancel: (t: ScrapeTask) => void
|
||||
onDelete: (t: ScrapeTask) => void
|
||||
onCopy: (text: string, label: string) => void
|
||||
}) {
|
||||
const status = taskStatusMeta(task.status)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-xl rounded-3xl border border-gray-200 bg-white shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={16} className="text-brand-500" />
|
||||
<h3 className="font-display text-base font-bold text-ink-600">刮削任务详情</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1 text-gray-400 hover:bg-gray-100 hover:text-ink-600 transition"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-6 max-h-[70vh] overflow-y-auto text-xs">
|
||||
{/* Matched Poster / Info Banner */}
|
||||
{task.matched_title ? (
|
||||
<div className="flex gap-4 rounded-2xl border border-brand-500/20 bg-primary-400/5 p-4">
|
||||
{task.poster_url && (
|
||||
<img
|
||||
src={imageURL(task.poster_url)}
|
||||
alt=""
|
||||
className="h-28 w-20 rounded-xl object-cover border border-brand-500/30 shadow-md shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded bg-brand-500 px-2 py-0.5 text-[10px] font-bold text-white uppercase">
|
||||
已匹配
|
||||
</span>
|
||||
{task.provider && (
|
||||
<span className="rounded border border-gray-200 bg-white px-2 py-0.5 text-[10px] font-semibold text-ink-600">
|
||||
{PROVIDER_LABELS[task.provider] ?? task.provider}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="font-display text-base font-extrabold text-ink-600 truncate">
|
||||
{task.matched_title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-3 text-sand-500 text-[11px]">
|
||||
{task.matched_year > 0 && <span>年份:{task.matched_year}</span>}
|
||||
<span>类型:{TYPE_LABELS[task.media_type] ?? task.media_type}</span>
|
||||
</div>
|
||||
{task.media_id && (
|
||||
<Link
|
||||
to={`/media/${task.media_id}`}
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-1 text-brand-500 font-semibold hover:underline pt-1"
|
||||
>
|
||||
<span>在媒体详情中查看</span>
|
||||
<ExternalLink size={11} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Media Info Box */}
|
||||
<div className="rounded-2xl border border-gray-100 bg-gray-50/70 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">原始媒体标题</span>
|
||||
<span className="font-bold text-ink-600 select-all">{task.media_title}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">所属媒体库</span>
|
||||
<span className="font-medium text-ink-100">{task.library_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">媒体库类型</span>
|
||||
<span className="font-medium text-ink-100">
|
||||
{TYPE_LABELS[task.media_type] ?? task.media_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">当前状态</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${status.cls}`}
|
||||
>
|
||||
{task.status === 'done' ? '已匹配' : task.status === 'failed' ? '未匹配' : status.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">剧照/海报刮削</span>
|
||||
<span className="font-medium text-ink-100">
|
||||
{task.episode_images ? '开启' : '关闭'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File path */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sand-500 font-medium">
|
||||
<span>磁盘文件路径</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.media_path, '文件路径')}
|
||||
className="inline-flex items-center gap-1 text-brand-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 font-mono text-[11px] text-ink-600 break-all select-all">
|
||||
{task.media_path}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message Box */}
|
||||
{task.error && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-rose-500 font-medium">
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertCircle size={13} /> 刮削未匹配 / 异常详情
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.error, '错误日志')}
|
||||
className="inline-flex items-center gap-1 text-rose-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制日志
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50/60 p-3 font-mono text-[11px] text-rose-700 break-all select-all whitespace-pre-wrap">
|
||||
{task.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-2 text-[11px] text-sand-500 border-t border-gray-100">
|
||||
<div>入队时间:{formatTime(task.created_at)}</div>
|
||||
{task.started_at && <div>开始刮削:{formatTime(task.started_at)}</div>}
|
||||
{task.finished_at && <div>完成时间:{formatTime(task.finished_at)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4 bg-gray-50/50">
|
||||
<div>
|
||||
{(task.status === 'done' ||
|
||||
task.status === 'failed' ||
|
||||
task.status === 'canceled') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(task)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-rose-200 bg-white px-3 py-2 text-xs font-semibold text-rose-500 hover:bg-rose-50 transition"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
删除记录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{(task.status === 'pending' || task.status === 'running') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCancel(task)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-amber-200 bg-white px-4 py-2 text-xs font-semibold text-amber-600 hover:bg-amber-50 transition"
|
||||
>
|
||||
<Ban size={13} />
|
||||
取消任务
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRetry(task)}
|
||||
className="neon-button !py-2 !px-4 text-xs font-semibold"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
重新刮削
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-xs font-semibold text-ink-100 hover:bg-gray-50 transition"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { ExternalLink, Loader2, QrCode, RefreshCw, Search } from 'lucide-react'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
import { strmAPI, type Strm115Source } from '../../api/strm'
|
||||
import type { StrmAccount } from '../../types/strm'
|
||||
import { apiErrorMessage } from '../StrmManagePage'
|
||||
|
||||
const QR_SIZE = 176
|
||||
|
||||
function QrCanvas({ content }: { content: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas || !content) return
|
||||
QRCode.toCanvas(canvas, content, {
|
||||
width: QR_SIZE,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: { dark: '#1f2937', light: '#ffffff' },
|
||||
})
|
||||
.then(() => setError(''))
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : '二维码生成失败'))
|
||||
}, [content])
|
||||
|
||||
return (
|
||||
<div className="relative h-44 w-44 shrink-0 rounded-xl border border-gray-200 bg-white">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={QR_SIZE}
|
||||
height={QR_SIZE}
|
||||
role="img"
|
||||
aria-label="115 授权二维码"
|
||||
style={{ width: QR_SIZE, height: QR_SIZE }}
|
||||
/>
|
||||
{error && (
|
||||
<div className="absolute inset-0 grid place-items-center rounded-xl bg-gray-50 p-3 text-center text-xs text-rose-500">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type AuthSourceKey = 'built_in_appid' | 'custom_appid' | 'built_in_relay' | 'third_party_service'
|
||||
|
||||
const AUTH_SOURCE_OPTIONS: { key: AuthSourceKey; label: string; desc: string; needsRelayKey?: boolean }[] = [
|
||||
{ key: 'built_in_appid', label: '官方应用目录', desc: '设备码扫码授权,无需回跳' },
|
||||
{ key: 'custom_appid', label: '自定义 APP ID', desc: '使用自己申请的开放平台应用' },
|
||||
{ key: 'built_in_relay', label: '中继授权', desc: 'QMediaSync / MQFamily 中继', needsRelayKey: true },
|
||||
{ key: 'third_party_service', label: '第三方服务', desc: 'MoviePilot / CloudDrive' },
|
||||
]
|
||||
|
||||
const inputCls = 'input-base w-full'
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block space-y-1">
|
||||
<span className="text-sm font-medium text-ink-100">{label}</span>
|
||||
{children}
|
||||
{hint && <span className="block text-xs text-sand-500">{hint}</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function Strm115AuthPanel({
|
||||
existing,
|
||||
accountName,
|
||||
onAuthed,
|
||||
}: {
|
||||
existing: StrmAccount | null
|
||||
accountName: string
|
||||
onAuthed: (account: StrmAccount) => void
|
||||
}) {
|
||||
const [sources, setSources] = useState<{ built_in: Strm115Source[]; relay: Strm115Source[]; third_party: Strm115Source[] } | null>(null)
|
||||
const [relayKeyConfigured, setRelayKeyConfigured] = useState(false)
|
||||
const [authSource, setAuthSource] = useState<AuthSourceKey>('built_in_appid')
|
||||
const [thirdParty, setThirdParty] = useState<'moviepilot' | 'clouddrive'>('moviepilot')
|
||||
const [relayProvider, setRelayProvider] = useState<'qmediasync' | 'mqfamily'>('qmediasync')
|
||||
const [appID, setAppID] = useState('100195125')
|
||||
const [appKeyword, setAppKeyword] = useState('')
|
||||
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [authUI, setAuthUI] = useState<{ sessionId: string; accountId: string; mode: 'qrcode' | 'url'; authUrl?: string; qrcodeUrl?: string } | null>(null)
|
||||
const [authStatus, setAuthStatus] = useState('')
|
||||
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const stopPolling = () => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
}
|
||||
useEffect(() => stopPolling, [])
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
try {
|
||||
const data = await strmAPI.list115Sources()
|
||||
setSources(data)
|
||||
const settings = await strmAPI.getSettings().catch(() => null)
|
||||
const key = settings?.['strm.115_relay_key']
|
||||
setRelayKeyConfigured(Boolean(key && key.trim()))
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const filteredApps = useMemo(
|
||||
() =>
|
||||
(sources?.built_in ?? []).filter(
|
||||
(s) => !appKeyword || s.app_name.toLowerCase().includes(appKeyword.toLowerCase()) || s.app_id.includes(appKeyword),
|
||||
),
|
||||
[appKeyword, sources],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredApps.length > 0 && !filteredApps.some((source) => source.app_id === appID)) {
|
||||
setAppID(filteredApps[0].app_id)
|
||||
}
|
||||
}, [appID, filteredApps])
|
||||
|
||||
const buildStartPayload = () => {
|
||||
switch (authSource) {
|
||||
case 'built_in_appid':
|
||||
return { auth_source: 'built_in_appid', app_id: appID }
|
||||
case 'custom_appid':
|
||||
return { auth_source: 'custom_appid', app_id: appID }
|
||||
case 'built_in_relay':
|
||||
return { auth_source: 'built_in_relay', provider: relayProvider }
|
||||
case 'third_party_service':
|
||||
return { auth_source: 'third_party_service', provider: thirdParty }
|
||||
}
|
||||
}
|
||||
|
||||
const startAuth = async () => {
|
||||
if (authSource === 'built_in_appid' && filteredApps.length === 0) {
|
||||
toast.error('未找到匹配的官方应用')
|
||||
return
|
||||
}
|
||||
|
||||
setStarting(true)
|
||||
setAuthStatus('')
|
||||
try {
|
||||
// 115 授权需要账号 ID:没有则先创建空凭据账号
|
||||
let account = existing
|
||||
if (!account) {
|
||||
account = await strmAPI.createAccount({ name: accountName || '115 网盘', provider: 'cloud115', config: {} })
|
||||
}
|
||||
const result = await strmAPI.start115OAuth(account.id, buildStartPayload())
|
||||
setAuthUI(
|
||||
result.mode === 'qrcode'
|
||||
? { sessionId: result.session_id, accountId: account.id, mode: 'qrcode', qrcodeUrl: result.qrcode?.qrcode }
|
||||
: { sessionId: result.session_id, accountId: account.id, mode: 'url', authUrl: result.auth_url },
|
||||
)
|
||||
stopPolling()
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const status = await strmAPI.poll115OAuth(account.id, result.session_id)
|
||||
setAuthStatus(status.tip)
|
||||
if (status.status === 'confirmed') {
|
||||
stopPolling()
|
||||
const updated = await strmAPI.testAccount(account.id)
|
||||
onAuthed(updated)
|
||||
}
|
||||
if (status.status === 'expired') {
|
||||
stopPolling()
|
||||
setAuthUI(null)
|
||||
toast.error('授权已过期,请重新发起')
|
||||
}
|
||||
} catch {
|
||||
/* 轮询失败等下一次 */
|
||||
}
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openAuthWindow = () => {
|
||||
if (authUI?.mode === 'url' && authUI.authUrl) {
|
||||
window.open(authUI.authUrl, '_blank', 'noopener')
|
||||
}
|
||||
}
|
||||
|
||||
const resetAuth = () => {
|
||||
stopPolling()
|
||||
setAuthUI(null)
|
||||
setAuthStatus('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl bg-gray-50 p-4">
|
||||
{existing?.has_credential && (
|
||||
<p className="rounded-xl bg-emerald-50 px-3 py-2 text-sm text-emerald-600">
|
||||
✓ 该账号已授权;重新授权会替换现有令牌
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!authUI && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{AUTH_SOURCE_OPTIONS.map((option) => {
|
||||
const active = authSource === option.key
|
||||
const disabled = Boolean(option.needsRelayKey && !relayKeyConfigured)
|
||||
return (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setAuthSource(option.key)}
|
||||
className={
|
||||
'rounded-xl border-2 p-2.5 text-left transition disabled:cursor-not-allowed disabled:opacity-40 ' +
|
||||
(active ? 'border-brand-400 bg-brand-50' : 'border-gray-100 bg-white hover:border-gray-200')
|
||||
}
|
||||
>
|
||||
<p className="text-sm font-bold text-ink-600">{option.label}</p>
|
||||
<p className="text-[11px] text-sand-500">{option.desc}</p>
|
||||
{disabled && <p className="text-[10px] text-rose-400">需在 STRM 设置配置共享密钥</p>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{authSource === 'built_in_appid' && (
|
||||
<div className="space-y-2">
|
||||
<Field label="选择官方应用" hint="应用目录来自 QMediaSync 内置;媒体播放器 / 飞牛 / 恒星等">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search size={14} className="shrink-0 text-sand-400" />
|
||||
<input className={inputCls} placeholder="搜索应用名称或 ID…" value={appKeyword} onChange={(e) => setAppKeyword(e.target.value)} />
|
||||
</div>
|
||||
<select className={inputCls} value={appID} onChange={(e) => setAppID(e.target.value)} size={5}>
|
||||
{filteredApps.map((source) => (
|
||||
<option key={source.app_id} value={source.app_id}>
|
||||
{source.display_name}({source.app_id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{filteredApps.length === 0 && <p className="text-xs text-sand-500">未找到匹配的官方应用</p>}
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
{authSource === 'custom_appid' && (
|
||||
<Field label="自定义 APP ID" hint="使用自己在 115 开放平台申请的应用 ID">
|
||||
<input className={inputCls} value={appID} placeholder="100195125" onChange={(e) => setAppID(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
{authSource === 'built_in_relay' && (
|
||||
<Field label="中继服务">
|
||||
<select className={inputCls} value={relayProvider} onChange={(e) => setRelayProvider(e.target.value as typeof relayProvider)}>
|
||||
<option value="qmediasync">QMediaSync(oauth.qmediasync.cn)</option>
|
||||
<option value="mqfamily">MQFamily(api.mqfamily.top)</option>
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{authSource === 'third_party_service' && (
|
||||
<Field label="第三方授权服务">
|
||||
<select className={inputCls} value={thirdParty} onChange={(e) => setThirdParty(e.target.value as typeof thirdParty)}>
|
||||
<option value="moviepilot">MoviePilot(https://movie-pilot.org)</option>
|
||||
<option value="clouddrive">CloudDrive(redirect115.zhenyunpan.com)</option>
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={startAuth}
|
||||
disabled={starting || (authSource === 'built_in_appid' && filteredApps.length === 0)}
|
||||
className="neon-button disabled:opacity-50"
|
||||
>
|
||||
{starting ? <Loader2 size={16} className="animate-spin" /> : <QrCode size={16} />}
|
||||
{authSource === 'built_in_appid' || authSource === 'custom_appid' ? '获取登录二维码' : '获取授权链接'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authUI && (
|
||||
<div className="space-y-3">
|
||||
{authUI.mode === 'qrcode' && authUI.qrcodeUrl ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<QrCanvas content={authUI.qrcodeUrl} />
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<p className="font-medium text-ink-600">{authStatus || '等待扫码…'}</p>
|
||||
<p className="text-xs text-sand-500">请使用 115 手机客户端扫码并确认授权,5 分钟内有效</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-ink-100">请点击下方按钮在新窗口完成授权:</p>
|
||||
<button type="button" onClick={openAuthWindow} className="neon-button">
|
||||
<ExternalLink size={16} />
|
||||
打开授权页面
|
||||
</button>
|
||||
<p className="text-xs text-sand-500">完成后回到此页面等待自动确认(授权成功后可关闭弹窗)</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Loader2 size={14} className="animate-spin text-brand-500" />
|
||||
<span className="text-ink-50">{authStatus}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetAuth}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2.5 py-1.5 text-xs font-semibold text-ink-100 transition hover:bg-gray-50"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
重新发起授权
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertCircle, Ban, Copy, FileText, Film, Image as ImageIcon, File, MessageSquare, RefreshCw, Trash2, X } from 'lucide-react'
|
||||
|
||||
import type { StrmTask } from '../../types/strm'
|
||||
import { STRM_PROVIDER_LABELS } from '../../types/strm'
|
||||
import { formatBytes, formatTime, taskStatusMeta } from '../StrmManagePage'
|
||||
|
||||
export function getFileIcon(filename: string): ReactNode {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
||||
if (['nfo', 'txt', 'xml', 'json'].includes(ext)) {
|
||||
return <FileText size={15} className="text-amber-500 shrink-0" />
|
||||
}
|
||||
if (['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'svg'].includes(ext)) {
|
||||
return <ImageIcon size={15} className="text-blue-500 shrink-0" />
|
||||
}
|
||||
if (['srt', 'ass', 'ssa', 'sub', 'vtt'].includes(ext)) {
|
||||
return <MessageSquare size={15} className="text-purple-500 shrink-0" />
|
||||
}
|
||||
if (['mkv', 'mp4', 'avi', 'mov', 'wmv', 'ts', 'flv', 'iso', 'm4v', 'strm'].includes(ext)) {
|
||||
return <Film size={15} className="text-emerald-500 shrink-0" />
|
||||
}
|
||||
return <File size={15} className="text-gray-400 shrink-0" />
|
||||
}
|
||||
|
||||
export function TaskDetailModal({
|
||||
task,
|
||||
isDownload,
|
||||
onClose,
|
||||
onRetry,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onCopy,
|
||||
}: {
|
||||
task: StrmTask
|
||||
isDownload: boolean
|
||||
onClose: () => void
|
||||
onRetry: (t: StrmTask) => void
|
||||
onCancel: (t: StrmTask) => void
|
||||
onDelete: (t: StrmTask) => void
|
||||
onCopy: (text: string, label: string) => void
|
||||
}) {
|
||||
const status = taskStatusMeta(task.status)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-xl rounded-3xl border border-gray-200 bg-white shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{getFileIcon(task.file_name)}
|
||||
<h3 className="font-display text-base font-bold text-ink-600">任务详情</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1 text-gray-400 hover:bg-gray-100 hover:text-ink-600 transition"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-6 max-h-[70vh] overflow-y-auto text-xs">
|
||||
{/* Main Info Box */}
|
||||
<div className="rounded-2xl border border-gray-100 bg-gray-50/70 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">任务 ID</span>
|
||||
<span className="font-mono text-ink-100 select-all">{task.id}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">文件名称</span>
|
||||
<span className="font-bold text-ink-600 select-all">{task.file_name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">文件大小</span>
|
||||
<span className="font-mono text-ink-100">{formatBytes(task.size)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">当前状态</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold ${status.cls}`}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">云盘提供方</span>
|
||||
<span className="font-medium text-ink-100">
|
||||
{STRM_PROVIDER_LABELS[task.provider] ?? task.provider}
|
||||
</span>
|
||||
</div>
|
||||
{task.retry_count > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sand-500 font-medium">已重试次数</span>
|
||||
<span className="font-bold text-amber-600">{task.retry_count} 次</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Paths */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sand-500 font-medium">
|
||||
<span>{isDownload ? '本地输出目标路径' : '本地来源路径'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.local_path, '本地路径')}
|
||||
className="inline-flex items-center gap-1 text-brand-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 font-mono text-[11px] text-ink-600 break-all select-all">
|
||||
{task.local_path}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sand-500 font-medium">
|
||||
<span>远端网盘路径</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.remote_path, '远端路径')}
|
||||
className="inline-flex items-center gap-1 text-brand-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-3 font-mono text-[11px] text-ink-600 break-all select-all">
|
||||
{task.remote_path}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message Box */}
|
||||
{task.error && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-rose-500 font-medium">
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertCircle size={13} /> 错误详情
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(task.error!, '错误信息')}
|
||||
className="inline-flex items-center gap-1 text-rose-500 hover:underline"
|
||||
>
|
||||
<Copy size={11} /> 复制错误
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50/60 p-3 font-mono text-[11px] text-rose-700 break-all select-all whitespace-pre-wrap">
|
||||
{task.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-2 text-[11px] text-sand-500 border-t border-gray-100">
|
||||
<div>创建时间:{formatTime(task.created_at)}</div>
|
||||
{task.started_at && <div>开始时间:{formatTime(task.started_at)}</div>}
|
||||
{task.finished_at && <div>结束时间:{formatTime(task.finished_at)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4 bg-gray-50/50">
|
||||
<div>
|
||||
{(task.status === 'done' ||
|
||||
task.status === 'failed' ||
|
||||
task.status === 'canceled') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(task)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-rose-200 bg-white px-3 py-2 text-xs font-semibold text-rose-500 hover:bg-rose-50 transition"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
删除记录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{(task.status === 'pending' || task.status === 'running') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCancel(task)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-amber-200 bg-white px-4 py-2 text-xs font-semibold text-amber-600 hover:bg-amber-50 transition"
|
||||
>
|
||||
<Ban size={13} />
|
||||
取消任务
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(task.status === 'failed' || task.status === 'canceled') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRetry(task)}
|
||||
className="neon-button !py-2 !px-4 text-xs font-semibold"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
重新入队
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-xs font-semibold text-ink-100 hover:bg-gray-50 transition"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import toast from 'react-hot-toast'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library, LibraryRoot } from '../types'
|
||||
import { invalidateLibraries } from '../utils/libraryCache'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
import { apiErrorMessage, createRootPayload, displayLibraryRootName, displayLibraryRootPath, emptyRootDraft, rootDraftKey, type RootDraft } from './adminLibraryPanelModel'
|
||||
|
||||
@@ -18,10 +19,14 @@ export function useAdminLibraryPanel() {
|
||||
|
||||
function useAdminLibraryList() {
|
||||
const [libs, setLibs] = useState<Library[]>([])
|
||||
const refresh = () =>
|
||||
libraryAPI
|
||||
const refresh = () => {
|
||||
// 后台任何库变更都会走到这里;顺带清掉前台会话缓存,
|
||||
// 避免返回首页/媒体库页后 30 秒 TTL 内还显示旧列表。
|
||||
invalidateLibraries()
|
||||
return libraryAPI
|
||||
.list({ includeHidden: true })
|
||||
.then((libs) => setLibs(libs.filter((l) => !l.is_remote_emby))) // 远程挂载库只读,不在后台管理列表内
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
|
||||
@@ -3,6 +3,7 @@ import toast from 'react-hot-toast'
|
||||
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library, Media } from '../types'
|
||||
import { peekLibrary, resolveLibrary } from '../utils/libraryCache'
|
||||
import { groupSeries, isEpisodeLike, type SeriesCard } from '../utils/groupSeries'
|
||||
|
||||
export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | null) {
|
||||
@@ -25,74 +26,79 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
|
||||
return groupSeries(items)
|
||||
}, [isSeries, isSeriesLibrary, items, serverSeriesCards])
|
||||
|
||||
// reloadCurrentLibrary 通过自增 tick 重跑加载(原实现靠克隆 library 对象
|
||||
// 触发第二个 effect,这里合并成单个 bootstrap effect 后改用显式信号)。
|
||||
const [reloadTick, setReloadTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryID) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setLoadingAll(false)
|
||||
setLibrary(null)
|
||||
setItems([])
|
||||
setServerSeriesCards([])
|
||||
setSeriesEpisodeItems([])
|
||||
libraryAPI.get(libraryID)
|
||||
.then((lib) => {
|
||||
if (!cancelled) setLibrary(lib)
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
const bootstrap = async () => {
|
||||
// 库信息先查会话缓存(首页/全部媒体库页已拉过全量列表),命中则
|
||||
// 同步就绪,内容请求在挂载当帧即发出;未命中才退回单独请求,
|
||||
// 消除原先"先等库信息、再等内容"的两段串行首屏等待。
|
||||
let lib = peekLibrary(libraryID)
|
||||
if (lib) setLibrary(lib)
|
||||
try {
|
||||
const resolved = await resolveLibrary(libraryID)
|
||||
if (cancelled) return
|
||||
if (!lib) {
|
||||
lib = resolved
|
||||
setLibrary(lib)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setLibrary(null)
|
||||
setLoading(false)
|
||||
toast.error('媒体库不存在或无权限')
|
||||
}
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [libraryID])
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryID || !library) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setLoadingAll(true)
|
||||
setItems([])
|
||||
setServerSeriesCards([])
|
||||
setSeriesEpisodeItems([])
|
||||
|
||||
const loadAll = async () => {
|
||||
if (isSeriesLibrary) {
|
||||
const collected = await loadAllSeriesCards(libraryID, library.is_remote_emby, (next) => {
|
||||
if (cancelled) return
|
||||
setTotal(next.total)
|
||||
if (next.firstPage) {
|
||||
setServerSeriesCards(next.items)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
if (!cancelled) setServerSeriesCards(collected.items)
|
||||
return
|
||||
}
|
||||
|
||||
const collected = await loadAllMedia(libraryID, library.is_remote_emby, (next) => {
|
||||
if (cancelled) return
|
||||
setTotal(next.total)
|
||||
if (next.firstPage) {
|
||||
setItems(next.items)
|
||||
setLoading(false)
|
||||
const seriesLibrary = isSeriesLibraryType(lib.type)
|
||||
setLoadingAll(true)
|
||||
try {
|
||||
if (seriesLibrary) {
|
||||
const collected = await loadAllSeriesCards(libraryID, lib.is_remote_emby, (next) => {
|
||||
if (cancelled) return
|
||||
setTotal(next.total)
|
||||
if (next.firstPage) {
|
||||
setServerSeriesCards(next.items)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
if (!cancelled) setServerSeriesCards(collected.items)
|
||||
return
|
||||
}
|
||||
})
|
||||
if (!cancelled) setItems(collected.items)
|
||||
}
|
||||
|
||||
loadAll()
|
||||
.catch(() => {
|
||||
const collected = await loadAllMedia(libraryID, lib.is_remote_emby, (next) => {
|
||||
if (cancelled) return
|
||||
setTotal(next.total)
|
||||
if (next.firstPage) {
|
||||
setItems(next.items)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
if (!cancelled) setItems(collected.items)
|
||||
} catch {
|
||||
if (!cancelled) toast.error('媒体库加载失败')
|
||||
})
|
||||
.finally(() => {
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
setLoadingAll(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap()
|
||||
return () => { cancelled = true }
|
||||
}, [libraryID, library, isSeriesLibrary])
|
||||
}, [libraryID, reloadTick])
|
||||
|
||||
useEffect(() => {
|
||||
if (!libraryID || !isSeriesLibrary || !selectedSeries) {
|
||||
@@ -117,7 +123,7 @@ export function useLibraryData(libraryID: string, selectedSeries: SeriesCard | n
|
||||
}, [libraryID, isSeriesLibrary, selectedSeries])
|
||||
|
||||
const reloadCurrentLibrary = useCallback(() => {
|
||||
setLibrary((current) => (current ? { ...current } : current))
|
||||
setReloadTick((tick) => tick + 1)
|
||||
}, [])
|
||||
|
||||
const loadingAllText = loadingAll && !loading && (isSeriesLibrary ? total > serverSeriesCards.length : total > items.length)
|
||||
|
||||
@@ -143,21 +143,27 @@ function useMediaDetailRefresh({
|
||||
setLoading(true)
|
||||
setLoadingEpisodes(true)
|
||||
try {
|
||||
const nextMedia = await mediaAPI.get(id)
|
||||
// 三个请求并行发出;详情一到就解锁整页渲染,收藏状态与分集列表
|
||||
// 到达后各自补齐(原先完全串行,首屏要排队等满三个往返)。
|
||||
const nextMediaPromise = mediaAPI.get(id)
|
||||
const favouritesPromise = playbackAPI.listFavourites().catch(() => [])
|
||||
const episodesPromise = mediaAPI
|
||||
.getEpisodes(id)
|
||||
.then((r) => r.items ?? [])
|
||||
.catch(() => [])
|
||||
|
||||
const nextMedia = await nextMediaPromise
|
||||
setMedia(nextMedia)
|
||||
const favourites = await playbackAPI.listFavourites().catch(() => [])
|
||||
setLoading(false)
|
||||
|
||||
const favourites = await favouritesPromise
|
||||
setFavourite(favourites.some((item) => item.id === nextMedia.id))
|
||||
// 异步加载分集列表
|
||||
try {
|
||||
const epRes = await mediaAPI.getEpisodes(id)
|
||||
setEpisodes(epRes.items ?? [])
|
||||
} catch {
|
||||
setEpisodes([])
|
||||
} finally {
|
||||
setLoadingEpisodes(false)
|
||||
}
|
||||
|
||||
const episodes = await episodesPromise
|
||||
setEpisodes(episodes)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoadingEpisodes(false)
|
||||
}
|
||||
}, [id, setFavourite, setLoading, setMedia, setEpisodes, setLoadingEpisodes])
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ interface PermissionState {
|
||||
clearPermissions: () => void
|
||||
}
|
||||
|
||||
// 进行中的请求共享同一个 Promise:同一次 commit 内挂载的多个消费方
|
||||
// 同时触发 fetchPermissions 时只会发出一次 /permissions 请求。
|
||||
let inflight: Promise<void> | null = null
|
||||
|
||||
export const usePermissionStore = create<PermissionState>((set, get) => ({
|
||||
permissions: {},
|
||||
role: '',
|
||||
@@ -23,22 +27,28 @@ export const usePermissionStore = create<PermissionState>((set, get) => ({
|
||||
error: null,
|
||||
|
||||
fetchPermissions: async () => {
|
||||
if (inflight) return inflight
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const result = await getMyPermissions()
|
||||
set({
|
||||
permissions: result.permissions ?? {},
|
||||
role: result.role ?? '',
|
||||
tier: result.tier ?? 'free',
|
||||
isSuper: result.is_super ?? false,
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (err) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to fetch permissions',
|
||||
})
|
||||
}
|
||||
inflight = (async () => {
|
||||
try {
|
||||
const result = await getMyPermissions()
|
||||
set({
|
||||
permissions: result.permissions ?? {},
|
||||
role: result.role ?? '',
|
||||
tier: result.tier ?? 'free',
|
||||
isSuper: result.is_super ?? false,
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (err) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to fetch permissions',
|
||||
})
|
||||
} finally {
|
||||
inflight = null
|
||||
}
|
||||
})()
|
||||
return inflight
|
||||
},
|
||||
|
||||
hasPermission: (key: string) => {
|
||||
|
||||
@@ -30,7 +30,13 @@ import type { Media } from '../types'
|
||||
*
|
||||
* 同一组内取最早 created_at 的那条作为代表卡片,并带 count 表示集数。
|
||||
*/
|
||||
export type SeriesCard = { key: string; rep: Media; linkMedia: Media; count: number }
|
||||
export type SeriesCard = {
|
||||
key: string
|
||||
rep: Media
|
||||
linkMedia: Media
|
||||
count: number
|
||||
last_added_at?: string
|
||||
}
|
||||
|
||||
export function getSeriesKey(media: Media): string {
|
||||
return compactSeriesKey(getSeriesRawKey(media))
|
||||
@@ -327,10 +333,22 @@ export function groupSeries(items: Media[] = []): SeriesCard[] {
|
||||
? compactSeriesKey(externalKey)
|
||||
: getSeriesKey(m)
|
||||
|
||||
const mAddedAt = m.created_at || m.updated_at || ''
|
||||
const g = groups.get(key)
|
||||
if (!g) {
|
||||
groups.set(key, { key, rep: m, linkMedia: m, count: 1 })
|
||||
groups.set(key, { key, rep: m, linkMedia: m, count: 1, last_added_at: mAddedAt })
|
||||
} else {
|
||||
if (mAddedAt) {
|
||||
if (!g.last_added_at) {
|
||||
g.last_added_at = mAddedAt
|
||||
} else {
|
||||
const prevTime = new Date(g.last_added_at).getTime()
|
||||
const curTime = new Date(mAddedAt).getTime()
|
||||
if (!isNaN(curTime) && (isNaN(prevTime) || curTime > prevTime)) {
|
||||
g.last_added_at = mAddedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
// Repeated movie IDs represent alternate locations/encodes, not
|
||||
// episodes. Fold the versions but keep the card in movie mode.
|
||||
if (isEpisodeLike(m) || pathLooksEpisodic(m) || isEpisodeLike(g.linkMedia) || pathLooksEpisodic(g.linkMedia)) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library } from '../types'
|
||||
|
||||
// 会话级媒体库元数据缓存(不落 localStorage)。
|
||||
// 首页 / 媒体库页 / 媒体库详情页都要先拿库列表或库信息才能渲染,
|
||||
// 同一会话内反复进出这些页面时直接命中缓存,避免每次都等一整轮请求。
|
||||
// TTL 内直接返回缓存;过期后重新请求并刷新。并发调用共享同一个在途请求。
|
||||
|
||||
const LIST_TTL_MS = 30_000
|
||||
const LIBRARY_TTL_MS = 30_000
|
||||
|
||||
let listData: Library[] | null = null
|
||||
let listAt = 0
|
||||
let listInflight: Promise<Library[]> | null = null
|
||||
|
||||
const byId = new Map<string, { at: number; data: Library }>()
|
||||
const byIdInflight = new Map<string, Promise<Library>>()
|
||||
|
||||
function fresh(at: number, ttl: number): boolean {
|
||||
return Date.now() - at < ttl
|
||||
}
|
||||
|
||||
function rememberList(data: Library[]) {
|
||||
listData = data
|
||||
listAt = Date.now()
|
||||
for (const lib of data) {
|
||||
byId.set(lib.id, { at: listAt, data: lib })
|
||||
}
|
||||
}
|
||||
|
||||
// fetchLibraries 拉取库列表:TTL 内命中缓存立即返回(含 peek 先行渲染),
|
||||
// 过期或未缓存时发起请求,并发调用共享在途请求。
|
||||
export function fetchLibraries(): Promise<Library[]> {
|
||||
if (listData && fresh(listAt, LIST_TTL_MS)) return Promise.resolve(listData)
|
||||
if (!listInflight) {
|
||||
listInflight = libraryAPI
|
||||
.list()
|
||||
.then((rows) => {
|
||||
const data = Array.isArray(rows) ? rows : []
|
||||
rememberList(data)
|
||||
return data
|
||||
})
|
||||
.finally(() => {
|
||||
listInflight = null
|
||||
})
|
||||
}
|
||||
return listInflight
|
||||
}
|
||||
|
||||
// peekLibraries 同步返回当前缓存的库列表(可能过期),没有则 null。
|
||||
// 页面用它先行渲染上一次的数据,再由 fetchLibraries 在后台补一次刷新。
|
||||
export function peekLibraries(): Library[] | null {
|
||||
return listData
|
||||
}
|
||||
|
||||
// peekLibrary 同步返回单库缓存(库列表缓存也会回填单库索引)。
|
||||
export function peekLibrary(id: string): Library | null {
|
||||
const entry = byId.get(id)
|
||||
return entry?.data ?? null
|
||||
}
|
||||
|
||||
// resolveLibrary 拿单个库信息:命中缓存直接返回;未命中走 /libraries/:id
|
||||
// 并回填缓存。远程 Emby 挂载库的详情接口同样返回 is_remote_emby,缓存可用。
|
||||
export function resolveLibrary(id: string): Promise<Library> {
|
||||
const entry = byId.get(id)
|
||||
if (entry && fresh(entry.at, LIBRARY_TTL_MS)) return Promise.resolve(entry.data)
|
||||
const inflight = byIdInflight.get(id)
|
||||
if (inflight) return inflight
|
||||
const request = libraryAPI
|
||||
.get(id)
|
||||
.then((lib) => {
|
||||
byId.set(id, { at: Date.now(), data: lib })
|
||||
return lib
|
||||
})
|
||||
.finally(() => {
|
||||
byIdInflight.delete(id)
|
||||
})
|
||||
byIdInflight.set(id, request)
|
||||
return request
|
||||
}
|
||||
|
||||
// invalidate 清空缓存。管理页对库做过增删改后调用,确保下次拉到新数据。
|
||||
export function invalidateLibraries() {
|
||||
listData = null
|
||||
listAt = 0
|
||||
listInflight = null
|
||||
byId.clear()
|
||||
byIdInflight.clear()
|
||||
}
|
||||
@@ -114,7 +114,7 @@ export function sortMediaList(
|
||||
case 'created_at':
|
||||
return compareDates(a.created_at, b.created_at, order) || compareStrings(a.title, b.title, 'asc')
|
||||
case 'updated_at':
|
||||
return compareDates(a.updated_at || a.created_at, b.updated_at || b.created_at, order) || compareStrings(a.title, b.title, 'asc')
|
||||
return compareDates(a.created_at || a.updated_at, b.created_at || b.updated_at, order) || compareStrings(a.title, b.title, 'asc')
|
||||
case 'rating':
|
||||
case 'imdb_rating': {
|
||||
const cmp = compareNumbers(a.rating || 0, b.rating || 0, order)
|
||||
@@ -175,8 +175,12 @@ export function sortSeriesList(
|
||||
case 'created_at':
|
||||
return compareDates(repA.created_at, repB.created_at, order) || compareStrings(titleA, titleB, 'asc')
|
||||
case 'updated_at': {
|
||||
const updateA = repA.updated_at || repA.created_at
|
||||
const updateB = repB.updated_at || repB.created_at
|
||||
// 远程挂载库的卡片由服务器按 DateLastContentAdded(上次添加集日期)
|
||||
// 倒序返回且不回传日期值:缺失时保持服务器顺序,避免错误回退成加入日期。
|
||||
// 本地库卡片(groupSeries)始终带 last_added_at,按真实值排序。
|
||||
if (!a.last_added_at && !b.last_added_at) return 0
|
||||
const updateA = a.last_added_at || ''
|
||||
const updateB = b.last_added_at || ''
|
||||
return compareDates(updateA, updateB, order) || compareStrings(titleA, titleB, 'asc')
|
||||
}
|
||||
case 'rating':
|
||||
|
||||
@@ -24,5 +24,25 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// 只拆哈希长期稳定的核心库:业务代码发版后用户只需重新下载变小的
|
||||
// 业务 chunk,核心 vendor 走浏览器长缓存。其余依赖保持 Vite 默认
|
||||
// 分块,避免把 hls.js 等懒加载库拖进首屏。
|
||||
manualChunks(id) {
|
||||
if (!id.includes('node_modules')) return undefined
|
||||
if (/[\\/]node_modules[\\/](react|react-dom|react-router|react-router-dom|scheduler)[\\/]/.test(id)) {
|
||||
return 'vendor-react'
|
||||
}
|
||||
if (/[\\/]node_modules[\\/](axios|zustand)[\\/]/.test(id)) {
|
||||
return 'vendor-data'
|
||||
}
|
||||
if (/[\\/]node_modules[\\/]framer-motion[\\/]/.test(id)) {
|
||||
return 'vendor-motion'
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||