This commit is contained in:
truewhile
2026-09-04 11:56:29 +08:00
parent cc40169616
commit 203abd106a
72 changed files with 1979 additions and 1155 deletions
+2 -1
View File
@@ -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)
+26
View File
@@ -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")
}
}
+3
View File
@@ -11,6 +11,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
@@ -164,6 +165,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
case <-ctx.Done():
return
}
helper.Run(svc.Log, "media.remotePreview", func() {
acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID)
if acct == nil {
return
@@ -176,6 +178,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
remotePayloads[i].Cards = cards
}
})
}()
}
wg.Wait()
+13 -2
View File
@@ -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()
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)
})
}()
}
+53
View File
@@ -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()),
)
}
+95
View File
@@ -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)
}
+4 -2
View File
@@ -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"`
+18 -19
View File
@@ -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)
}
}
+3
View File
@@ -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()
helper.Run(a.log, "audit.record", func() {
a.Record(ctx, userID, action, target, ip, detail)
})
}()
}
+3
View File
@@ -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()
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))
}
})
}()
}
+1 -1
View File
@@ -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{}{}
}
+6 -5
View File
@@ -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 != "" {
+4 -1
View File
@@ -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)))
+8 -7
View File
@@ -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 {
+175
View File
@@ -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
}
+3
View File
@@ -18,6 +18,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
)
@@ -609,6 +610,7 @@ func (r *EmbyRemoteService) RemoteSearchMedia(ctx context.Context, query string,
return
}
helper.Run(r.log, "emby.remoteSearch", func() {
q := url.Values{}
q.Set("ParentId", target.mount.RemoteViewID)
q.Set("Recursive", "true")
@@ -635,6 +637,7 @@ func (r *EmbyRemoteService) RemoteSearchMedia(ctx context.Context, query string,
medias = append(medias, m)
}
results[idx] = searchResult{items: medias}
})
}(i, t)
}
wg.Wait()
+78 -6
View File
@@ -49,11 +49,15 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e
q = e.applyUserMediaVisibility(ctx, q, userID)
if !strings.HasPrefix(id, embyVirtualSeriesPrefix) {
q = q.Where("series_id = ?", id)
} else {
// 虚拟 series ID 只可能来自 series_id 为空的媒体:
// 有 series_id 时分组 key 就是 series_id 本身(UUID,不带虚拟前缀)。
q = q.Where("series_id IS NULL OR series_id = ''")
}
if err := q.Order("media.season_num asc, media.episode_num asc, media.created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil {
return embySeriesGroup{}, false, err
}
for _, group := range e.seriesGroupsFromMedia(rows) {
for _, group := range e.seriesGroupsFromMedia(ctx, rows) {
if group.ID == id {
e.rememberSeriesGroup(group)
return group, true, nil
@@ -88,9 +92,18 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
if season, ok := e.cachedSeasonGroup(id); ok {
return season, true, nil
}
// 虚拟 Season ID 是 hash(seriesKey, seasonNum),无法反解出 series。
// 常见情况(已刮削、series_id 非空)先用一条小型 DISTINCT 查询枚举候选对,
// 在内存中算哈希匹配,命中后只加载该一部剧的剧集行,避免整库扫描。
if season, ok, err := e.findSeasonGroupBySeriesCandidates(ctx, id, userID); err != nil {
return embySeasonGroup{}, false, err
} else if ok {
return season, true, nil
}
// 回退:未刮削(series_id 为空,虚拟 key 由库名+名称派生)的媒体只能全量分组。
var rows []model.Media
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("season_num > 0 OR episode_num > 0")
Where("(series_id IS NULL OR series_id = '') AND (season_num > 0 OR episode_num > 0)")
q = e.applyUserMediaVisibility(ctx, q, userID)
if err := q.
Order("media.season_num asc, media.episode_num asc, media.created_at asc").
@@ -98,7 +111,7 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
Find(&rows).Error; err != nil {
return embySeasonGroup{}, false, err
}
for _, series := range e.seriesGroupsFromMedia(rows) {
for _, series := range e.seriesGroupsFromMedia(ctx, rows) {
for _, season := range e.seasonsForSeries(series) {
if season.ID == id {
e.rememberSeriesGroup(series)
@@ -109,18 +122,77 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e
return embySeasonGroup{}, false, nil
}
func (e *EmbyService) seriesGroupsFromMedia(rows []model.Media) []embySeriesGroup {
// findSeasonGroupBySeriesCandidates resolves virtual season IDs for media that
// carry a real series_id: enumerate distinct (series_id, season_num) pairs via
// SQL, hash each candidate to find the matching season, then load only that
// one series' episodes.
func (e *EmbyService) findSeasonGroupBySeriesCandidates(ctx context.Context, id, userID string) (embySeasonGroup, bool, error) {
type seasonCandidate struct {
SeriesID string
SeasonNum int
}
var candidates []seasonCandidate
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Select("DISTINCT series_id, season_num").
Where("series_id <> '' AND (season_num > 0 OR episode_num > 0)")
q = e.applyUserMediaVisibility(ctx, q, userID)
if err := q.Find(&candidates).Error; err != nil {
return embySeasonGroup{}, false, err
}
matched := make([]string, 0, 1)
for _, cand := range candidates {
if seasonID(cand.SeriesID, cand.SeasonNum) == id {
matched = append(matched, cand.SeriesID)
}
}
for _, matchedSeries := range matched {
season, ok, err := e.seasonGroupForSeries(ctx, id, matchedSeries, userID)
if err != nil || ok {
return season, ok, err
}
}
return embySeasonGroup{}, false, nil
}
// seasonGroupForSeries rebuilds the season groups of one series (small row
// set) and returns the one matching the virtual season id.
func (e *EmbyService) seasonGroupForSeries(ctx context.Context, id, seriesID, userID string) (embySeasonGroup, bool, error) {
var rows []model.Media
rq := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("series_id = ? AND (season_num > 0 OR episode_num > 0)", seriesID)
rq = e.applyUserMediaVisibility(ctx, rq, userID)
if err := rq.
Order("media.season_num asc, media.episode_num asc, media.created_at asc").
Limit(embySeriesGroupingLimit).
Find(&rows).Error; err != nil {
return embySeasonGroup{}, false, err
}
for _, series := range e.seriesGroupsFromMedia(ctx, rows) {
if series.ID != seriesID {
continue
}
for _, season := range e.seasonsForSeries(series) {
if season.ID == id {
e.rememberSeriesGroup(series)
return season, true, nil
}
}
}
return embySeasonGroup{}, false, nil
}
func (e *EmbyService) seriesGroupsFromMedia(ctx context.Context, rows []model.Media) []embySeriesGroup {
byID := map[string]*embySeriesGroup{}
order := []string{}
for _, row := range rows {
row := row
seriesID := e.seriesIDForMedia(&row)
seriesID := e.seriesIDForMedia(ctx, &row)
group, ok := byID[seriesID]
if !ok {
group = &embySeriesGroup{
ID: seriesID,
LibraryID: row.LibraryID,
Name: e.seriesNameForMedia(&row),
Name: e.seriesNameForMedia(ctx, &row),
Year: row.Year,
ReleaseDate: row.ReleaseDate,
TMDbID: row.TMDbID,
+8 -7
View File
@@ -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) != "" {
+2 -1
View File
@@ -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
}
@@ -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 -1
View File
@@ -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()
// 扫描触发的后台刮削与请求线程无关,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))
return
}
})
}()
}
+2 -1
View File
@@ -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) })
}
}
+4 -1
View File
@@ -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 {
+4
View File
@@ -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 }()
// 刮削要解析远端元数据响应,单个任务 panic 不应拖垮队列 worker。
helper.Run(s.log, "scraper.task", func() {
s.processScrapeTask(ctx, t)
})
}(&tasks[i])
}
wg.Wait()
+3 -2
View File
@@ -10,6 +10,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
)
@@ -96,7 +97,7 @@ func (c *Container) Boot() {
if err := c.APIConfig.SeedDefaults(c.stopCtx); err != nil {
c.Log.Warn("api config seed failed", zap.Error(err))
}
go c.warmMediaSearchIndex(c.stopCtx)
helper.Go(c.Log, "service.warmMediaSearchIndex", func() { c.warmMediaSearchIndex(c.stopCtx) })
// 启动调度器定时任务
c.Scheduler.Start(c.stopCtx)
@@ -119,7 +120,7 @@ func (c *Container) Boot() {
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
// 每天触发一次评估;规则里的窗口可随机,不固定。
if c.Device != nil {
go c.runInactivitySweeper(c.stopCtx)
helper.Go(c.Log, "service.inactivitySweeper", func() { c.runInactivitySweeper(c.stopCtx) })
}
}
+3 -2
View File
@@ -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() {
+4
View File
@@ -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)
// 单个任务 panic 不应拖垮整个下载 worker。
helper.Run(s.log, "strm.downloadTask", func() {
s.processDownloadTask(ctx, task)
})
}(i)
}
wg.Wait()
+6 -5
View File
@@ -24,6 +24,7 @@ import (
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/cloud"
@@ -186,14 +187,14 @@ func (s *StrmService) Start(ctx context.Context) {
uploadThreads = 4
}
for i := 0; i < downloadThreads; i++ {
go s.downloadWorker(ctx)
helper.Go(s.log, "strm.downloadWorker", func() { s.downloadWorker(ctx) })
}
for i := 0; i < uploadThreads; i++ {
go s.uploadWorker(ctx)
helper.Go(s.log, "strm.uploadWorker", func() { s.uploadWorker(ctx) })
}
go s.cronLoop(ctx)
go s.queueCleanupLoop(ctx)
go s.refresh115TokensLoop(ctx)
helper.Go(s.log, "strm.cronLoop", func() { s.cronLoop(ctx) })
helper.Go(s.log, "strm.queueCleanupLoop", func() { s.queueCleanupLoop(ctx) })
helper.Go(s.log, "strm.refresh115TokensLoop", func() { s.refresh115TokensLoop(ctx) })
s.log.Info("strm service started",
zap.Int("download_threads", downloadThreads),
zap.Int("upload_threads", uploadThreads))
+28 -7
View File
@@ -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,9 +362,12 @@ func (st *strmSyncState) walkRemote() error {
wg.Add(1)
go func() {
defer wg.Done()
// 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
return nil
}
entries, err := st.provider.List(ctx, task.id)
if err != nil {
@@ -373,7 +377,7 @@ func (st *strmSyncState) walkRemote() error {
}
errMu.Unlock()
cancel()
return
return nil
}
for _, entry := range entries {
cleanName := cleanEntryName(entry.Name, entry.IsDir)
@@ -394,6 +398,10 @@ func (st *strmSyncState) walkRemote() error {
}
pending.Add(-1)
}
return nil
}); err != nil {
cancel()
}
}()
}
wg.Wait()
@@ -560,9 +568,11 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
wg.Add(1)
go func() {
defer wg.Done()
// 分页拉取 panic 时取消整个同步;正常退出不取消。
if err := helper.Recover(st.s.log, "strm.sync.walk115.page", func() error {
for t := range taskCh {
if ctx.Err() != nil {
return
return nil
}
files, _, err := open115.GetFsListFlat(ctx, rootCID, t.offset, pageSize)
if err != nil {
@@ -571,12 +581,16 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
fetchErr = err
}
errMu.Unlock()
return
return nil
}
filesMu.Lock()
allFiles = append(allFiles, files...)
filesMu.Unlock()
}
return nil
}); err != nil {
cancel()
}
}()
}
wg.Wait()
@@ -632,9 +646,12 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
pwg.Add(1)
go func() {
defer pwg.Done()
// 解析目录详情 panic 时中止整个同步(避免带着损坏的相对路径
// 继续执行);正常退出不取消。
if err := helper.Recover(st.s.log, "strm.sync.walk115.dirTree", func() error {
for pid := range pidCh {
if ctx.Err() != nil {
return
return nil
}
if _, loaded := st.dirCache.Load(pid); loaded {
if n := doneDirs.Add(1); n%20 == 0 || n == int64(totalDirs) {
@@ -656,7 +673,7 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
errMu.Unlock()
st.scanIncomplete.Store(true)
cancel()
return
return nil
} else if detail != nil {
// 解析相对路径
relPath := cleanDirRel(detail.RelativePath(rootCID))
@@ -690,6 +707,10 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error {
st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (%d/%d)...", n, totalDirs))
}
}
return nil
}); err != nil {
cancel()
}
}()
}
pwg.Wait()
+61
View File
@@ -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
+2 -1
View File
@@ -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
}
+5 -1
View File
@@ -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{
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:
+3 -2
View File
@@ -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
}
+3 -2
View File
@@ -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
}
+2 -6
View File
@@ -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">
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

-1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 199 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 81 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 81 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
)}
+20 -23
View File
@@ -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,7 +113,7 @@ export const MediaCard = ({
{media.overview || "暂无简介内容"}
</p>
)}
</motion.div>
</div>
</div>
{/* Progress Bar overlay */}
@@ -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>
)
}
})
-52
View File
@@ -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}</>
}
+16 -5
View File
@@ -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) {
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)
+68
View File
@@ -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 {
+1 -1
View File
@@ -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>
+4 -1
View File
@@ -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)
}, [])
+29 -7
View File
@@ -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>
+7 -5
View File
@@ -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"
/>
+8 -254
View File
@@ -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>
)
}
+3 -310
View File
@@ -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' },
+4 -1
View File
@@ -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])
+9 -241
View File
@@ -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" />
}
+38
View File
@@ -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>
)
}
+10
View File
@@ -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,7 +27,9 @@ export const usePermissionStore = create<PermissionState>((set, get) => ({
error: null,
fetchPermissions: async () => {
if (inflight) return inflight
set({ isLoading: true, error: null })
inflight = (async () => {
try {
const result = await getMyPermissions()
set({
@@ -38,7 +44,11 @@ export const usePermissionStore = create<PermissionState>((set, get) => ({
isLoading: false,
error: err instanceof Error ? err.message : 'Failed to fetch permissions',
})
} finally {
inflight = null
}
})()
return inflight
},
hasPermission: (key: string) => {
+20
View File
@@ -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
},
},
},
},
})