mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
优化
This commit is contained in:
@@ -20,6 +20,24 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
|
||||
registerAdminAPIConfigRoutes(admin, svc)
|
||||
registerAdminRecognitionWordRoutes(admin, svc)
|
||||
registerAdminStrmRoutes(admin, svc)
|
||||
registerAdminScraperRoutes(admin, svc)
|
||||
}
|
||||
|
||||
func registerAdminScraperRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
admin.GET("/scraper/queue", listScrapeQueueHandler(svc))
|
||||
admin.POST("/scraper/queue/:id/cancel", cancelScrapeTaskHandler(svc))
|
||||
admin.POST("/scraper/queue/:id/retry", retryScrapeTaskHandler(svc))
|
||||
admin.DELETE("/scraper/queue/:id", deleteScrapeTaskHandler(svc))
|
||||
admin.POST("/scraper/queue/batch", batchActionScrapeTasksHandler(svc))
|
||||
admin.POST("/scraper/queue/clear-done", clearDoneScrapeTasksHandler(svc))
|
||||
admin.POST("/scraper/queue/clear-finished", clearFinishedScrapeTasksHandler(svc))
|
||||
admin.POST("/scraper/queue/clear-canceled", clearCanceledScrapeTasksHandler(svc))
|
||||
admin.POST("/scraper/queue/retry-failed", retryAllFailedScrapeTasksHandler(svc))
|
||||
admin.POST("/scraper/queue/cancel-pending", cancelPendingScrapeTasksHandler(svc))
|
||||
admin.POST("/scraper/queue/enqueue-library/:id", enqueueLibraryScrapeHandler(svc))
|
||||
admin.POST("/scraper/queue/enqueue-all", enqueueAllScrapeHandler(svc))
|
||||
admin.POST("/media/repair-rescrape", enqueueAllScrapeHandler(svc))
|
||||
admin.POST("/libraries/:id/repair-rescrape", enqueueLibraryScrapeHandler(svc))
|
||||
}
|
||||
|
||||
func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
func listScrapeQueueHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||
snap, err := svc.Scraper.ScrapeQueueSnapshot(c.Request.Context(), c.Query("status"), page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, snap)
|
||||
}
|
||||
}
|
||||
|
||||
func cancelScrapeTaskHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Scraper.CancelScrapeTask(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func retryScrapeTaskHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Scraper.RetryScrapeTask(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func deleteScrapeTaskHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.Scraper.DeleteScrapeTask(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
func batchActionScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req queueBatchReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
n, err := svc.Scraper.BatchActionScrapeTasks(c.Request.Context(), req.Action, req.IDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"affected": n, "action": req.Action})
|
||||
}
|
||||
}
|
||||
|
||||
func clearDoneScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
n, err := svc.Scraper.ClearDoneScrapeTasks(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": n})
|
||||
}
|
||||
}
|
||||
|
||||
func clearFinishedScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
n, err := svc.Scraper.ClearFinishedScrapeTasks(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": n})
|
||||
}
|
||||
}
|
||||
|
||||
func clearCanceledScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
n, err := svc.Scraper.ClearCanceledScrapeTasks(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": n})
|
||||
}
|
||||
}
|
||||
|
||||
func retryAllFailedScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
n, err := svc.Scraper.RetryAllFailedScrapeTasks(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"retried": n})
|
||||
}
|
||||
}
|
||||
|
||||
func cancelPendingScrapeTasksHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
n, err := svc.Scraper.CancelPendingScrapeTasks(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"canceled": n})
|
||||
}
|
||||
}
|
||||
|
||||
type enqueueScrapeReq struct {
|
||||
EpisodeImages bool `json:"episode_images"`
|
||||
EpisodeArtwork bool `json:"episode_artwork"`
|
||||
RefreshMatched bool `json:"refresh_matched"`
|
||||
IncludeMatched bool `json:"include_matched"`
|
||||
}
|
||||
|
||||
func (r enqueueScrapeReq) toOptions() service.ScrapeOptions {
|
||||
epArtwork := r.EpisodeImages || r.EpisodeArtwork
|
||||
return service.ScrapeOptions{
|
||||
EpisodeArtwork: &epArtwork,
|
||||
IncludeMatched: r.IncludeMatched || r.RefreshMatched,
|
||||
RetryNoMatch: true,
|
||||
}
|
||||
}
|
||||
|
||||
func enqueueLibraryScrapeHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req enqueueScrapeReq
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
libID := c.Param("id")
|
||||
n, err := svc.Scraper.EnqueueLibrary(c.Request.Context(), libID, req.toOptions())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"enqueued": n})
|
||||
}
|
||||
}
|
||||
|
||||
func enqueueAllScrapeHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req enqueueScrapeReq
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
n, err := svc.Scraper.EnqueueAll(c.Request.Context(), req.toOptions())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"enqueued": n})
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -135,28 +134,12 @@ func scrapeOneHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
options.IncludeMatched = true
|
||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || m == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
task := startScrapeHTTPTask(svc, "手动刮削媒体", m.Title, m.Path)
|
||||
if err := svc.Scraper.EnrichOneWithOptions(c.Request.Context(), m, options); err != nil {
|
||||
finishHTTPTask(task, err, "scrape", "手动刮削媒体失败", nil, nil)
|
||||
task, err := svc.Scraper.EnqueueMedia(c.Request.Context(), c.Param("id"), options)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
reclassified := reclassifyMediaAfterScrape(c.Request.Context(), svc, m.ID)
|
||||
refreshed, _ := svc.Repo.Media.FindByID(c.Request.Context(), m.ID)
|
||||
metrics := map[string]int64{"processed": 1}
|
||||
if refreshed != nil && refreshed.ScrapeStatus == "matched" {
|
||||
metrics["matched"] = 1
|
||||
}
|
||||
if reclassified > 0 {
|
||||
metrics["reclassified"] = int64(reclassified)
|
||||
}
|
||||
finishHTTPTask(task, nil, "completed", "手动刮削媒体结束", metrics, nil)
|
||||
c.JSON(http.StatusOK, refreshed)
|
||||
c.JSON(http.StatusOK, task)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,40 +153,12 @@ func scrapeLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
options.IncludeMatched = true
|
||||
var task *service.TaskHandle
|
||||
if lib, err := svc.Repo.Library.FindByID(c.Request.Context(), libID); err == nil && lib != nil {
|
||||
task = startScrapeHTTPTask(svc, "手动刮削媒体库", lib.Name, lib.Path)
|
||||
} else {
|
||||
task = startScrapeHTTPTask(svc, "手动刮削媒体库", libID, "")
|
||||
n, err := svc.Scraper.EnqueueLibrary(c.Request.Context(), libID, options)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Run in the background so HTTP returns instantly; the WS hub
|
||||
// pushes per-item progress on the "scrape" topic.
|
||||
go func(libID string, task *service.TaskHandle, options service.ScrapeOptions) {
|
||||
result, err := svc.Scraper.EnrichLibraryDetailedWithOptions(context.Background(), libID, options)
|
||||
reclassified := 0
|
||||
if result.Processed > 0 {
|
||||
reclassified = reclassifyLibraryAfterScrape(context.Background(), svc, libID)
|
||||
}
|
||||
metrics := map[string]int64{
|
||||
"matched": int64(result.Matched),
|
||||
"processed": int64(result.Processed),
|
||||
"candidates": int64(result.Candidates),
|
||||
}
|
||||
if reclassified > 0 {
|
||||
metrics["reclassified"] = int64(reclassified)
|
||||
}
|
||||
if result.Failed > 0 {
|
||||
metrics["errors"] = int64(result.Failed)
|
||||
}
|
||||
stage := "completed"
|
||||
message := "手动刮削媒体库结束"
|
||||
if err != nil {
|
||||
stage = "scrape"
|
||||
message = "手动刮削媒体库失败"
|
||||
}
|
||||
finishHTTPTask(task, err, stage, message, metrics, nil)
|
||||
}(libID, task, options)
|
||||
c.JSON(http.StatusAccepted, gin.H{"status": "scraping"})
|
||||
c.JSON(http.StatusOK, gin.H{"status": "queued", "enqueued": n})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ type Library struct {
|
||||
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
SortOrder int `gorm:"index;default:0" json:"sort_order"` // 手动拖拽排序用,越小越靠前
|
||||
CarouselEnabled bool `gorm:"default:true" json:"carousel_enabled"` // 是否参与首页海报轮播
|
||||
CarouselEnabled bool `gorm:"default:false" json:"carousel_enabled"` // 是否参与首页海报轮播(默认不参与)
|
||||
Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -57,5 +57,6 @@ func AllModels() []interface{} {
|
||||
&StrmDownloadTask{},
|
||||
&StrmUploadTask{},
|
||||
&StrmDirCache{},
|
||||
&ScrapeTask{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
ScrapeTaskPending = "pending"
|
||||
ScrapeTaskRunning = "running"
|
||||
ScrapeTaskDone = "done"
|
||||
ScrapeTaskFailed = "failed"
|
||||
ScrapeTaskCanceled = "canceled"
|
||||
)
|
||||
|
||||
// ScrapeTask 表示一条持久化的媒体刮削任务。
|
||||
type ScrapeTask struct {
|
||||
Base
|
||||
MediaID string `gorm:"index;size:36" json:"media_id"`
|
||||
LibraryID string `gorm:"index;size:36" json:"library_id"`
|
||||
LibraryName string `gorm:"size:128" json:"library_name"`
|
||||
MediaTitle string `gorm:"size:255;not null" json:"media_title"`
|
||||
MediaPath string `gorm:"size:1024;not null" json:"media_path"`
|
||||
MediaType string `gorm:"size:16" json:"media_type"` // movie / tv / anime / adult
|
||||
Provider string `gorm:"size:32" json:"provider"` // tmdb / douban / bangumi / thetvdb / metatube
|
||||
MatchedTitle string `gorm:"size:255" json:"matched_title"`
|
||||
MatchedYear int `json:"matched_year"`
|
||||
PosterURL string `gorm:"size:1024" json:"poster_url"`
|
||||
BackdropURL string `gorm:"size:1024" json:"backdrop_url"`
|
||||
Status string `gorm:"index;size:16;default:pending" json:"status"` // pending / running / done / failed / canceled
|
||||
Error string `gorm:"type:text" json:"error"`
|
||||
RetryCount int `gorm:"default:0" json:"retry_count"`
|
||||
EpisodeImages bool `gorm:"default:true" json:"episode_images"`
|
||||
RefreshMatched bool `gorm:"default:false" json:"refresh_matched"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
}
|
||||
@@ -15,6 +15,11 @@ type LibraryRepository struct{ db *gorm.DB }
|
||||
|
||||
// Create persists a new library row.
|
||||
func (r *LibraryRepository) Create(ctx context.Context, l *model.Library) error {
|
||||
if l != nil && l.SortOrder == 0 {
|
||||
var maxSort int
|
||||
_ = r.db.WithContext(ctx).Model(&model.Library{}).Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort)
|
||||
l.SortOrder = maxSort + 1
|
||||
}
|
||||
return r.db.WithContext(ctx).Create(l).Error
|
||||
}
|
||||
|
||||
@@ -23,6 +28,11 @@ func (r *LibraryRepository) CreateWithRoots(ctx context.Context, l *model.Librar
|
||||
return r.Create(ctx, l)
|
||||
}
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if l != nil && l.SortOrder == 0 {
|
||||
var maxSort int
|
||||
_ = tx.Model(&model.Library{}).Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort)
|
||||
l.SortOrder = maxSort + 1
|
||||
}
|
||||
if err := tx.Create(l).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -30,9 +30,10 @@ type Container struct {
|
||||
StrmSyncPath *StrmSyncPathRepository
|
||||
StrmSyncRecord *StrmSyncRecordRepository
|
||||
StrmDownload *StrmDownloadTaskRepository
|
||||
StrmUpload *StrmUploadTaskRepository
|
||||
StrmDirCache *StrmDirCacheRepository
|
||||
}
|
||||
StrmUpload *StrmUploadTaskRepository
|
||||
StrmDirCache *StrmDirCacheRepository
|
||||
ScrapeTask *ScrapeTaskRepository
|
||||
}
|
||||
|
||||
// New 将每个 repository 连接到单个 *gorm.DB。
|
||||
func New(db *gorm.DB) *Container {
|
||||
@@ -60,5 +61,6 @@ func New(db *gorm.DB) *Container {
|
||||
StrmDownload: &StrmDownloadTaskRepository{db: db},
|
||||
StrmUpload: &StrmUploadTaskRepository{db: db},
|
||||
StrmDirCache: &StrmDirCacheRepository{db: db},
|
||||
ScrapeTask: &ScrapeTaskRepository{db: db},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
var scrapeClaimMu sync.Mutex
|
||||
|
||||
// ScrapeTaskRepository persists model.ScrapeTask.
|
||||
type ScrapeTaskRepository struct{ db *gorm.DB }
|
||||
|
||||
func (r *ScrapeTaskRepository) Create(ctx context.Context, t *model.ScrapeTask) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Create(t).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) CreateBatch(ctx context.Context, tasks []model.ScrapeTask) error {
|
||||
if len(tasks) == 0 {
|
||||
return nil
|
||||
}
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).CreateInBatches(tasks, 100).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) FindByID(ctx context.Context, id string) (*model.ScrapeTask, error) {
|
||||
var t model.ScrapeTask
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&t).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &t, err
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) FindActiveByMediaID(ctx context.Context, mediaID string) (*model.ScrapeTask, error) {
|
||||
var t model.ScrapeTask
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("media_id = ? AND status IN ?", mediaID, []string{model.ScrapeTaskPending, model.ScrapeTaskRunning}).
|
||||
First(&t).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &t, err
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) List(ctx context.Context, status string, page, pageSize int) ([]model.ScrapeTask, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 200 {
|
||||
pageSize = 50
|
||||
}
|
||||
q := r.db.WithContext(ctx).Model(&model.ScrapeTask{})
|
||||
if strings.TrimSpace(status) != "" && status != "all" {
|
||||
q = q.Where("status = ?", strings.TrimSpace(status))
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var rows []model.ScrapeTask
|
||||
err := q.Order("created_at desc").
|
||||
Offset((page - 1) * pageSize).
|
||||
Limit(pageSize).
|
||||
Find(&rows).Error
|
||||
return rows, total, err
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) CountByStatus(ctx context.Context) (map[string]int64, error) {
|
||||
var rows []struct {
|
||||
Status string
|
||||
Count int64
|
||||
}
|
||||
err := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
|
||||
Select("status, count(*) as count").
|
||||
Group("status").Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]int64{}
|
||||
for _, row := range rows {
|
||||
out[row.Status] = row.Count
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ClaimPending picks pending scrape tasks and marks them running.
|
||||
func (r *ScrapeTaskRepository) ClaimPending(ctx context.Context, limit int) ([]model.ScrapeTask, error) {
|
||||
scrapeClaimMu.Lock()
|
||||
defer scrapeClaimMu.Unlock()
|
||||
|
||||
var rows []model.ScrapeTask
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("status = ?", model.ScrapeTaskPending).
|
||||
Order("created_at asc").Limit(limit).Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(rows))
|
||||
now := time.Now()
|
||||
for i := range rows {
|
||||
ids = append(ids, rows[i].ID)
|
||||
rows[i].Status = model.ScrapeTaskRunning
|
||||
rows[i].StartedAt = &now
|
||||
}
|
||||
return tx.Model(&model.ScrapeTask{}).Where("id IN ?", ids).
|
||||
Updates(map[string]any{"status": model.ScrapeTaskRunning, "started_at": now}).Error
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) Update(ctx context.Context, t *model.ScrapeTask) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Model(&model.ScrapeTask{}).Where("id = ?", t.ID).Updates(map[string]any{
|
||||
"status": t.Status,
|
||||
"error": t.Error,
|
||||
"provider": t.Provider,
|
||||
"matched_title": t.MatchedTitle,
|
||||
"matched_year": t.MatchedYear,
|
||||
"poster_url": t.PosterURL,
|
||||
"backdrop_url": t.BackdropURL,
|
||||
"retry_count": t.RetryCount,
|
||||
"started_at": t.StartedAt,
|
||||
"finished_at": t.FinishedAt,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) Delete(ctx context.Context, id string) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.ScrapeTask{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) DeleteBatch(ctx context.Context, ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Delete(&model.ScrapeTask{})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) RetryBatch(ctx context.Context, ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
|
||||
Where("id IN ? AND status IN ?", ids, []string{model.ScrapeTaskFailed, model.ScrapeTaskCanceled}).
|
||||
Updates(map[string]any{
|
||||
"status": model.ScrapeTaskPending,
|
||||
"error": "",
|
||||
"retry_count": 0,
|
||||
"started_at": nil,
|
||||
"finished_at": nil,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) CancelBatch(ctx context.Context, ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
now := time.Now()
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
|
||||
Where("id IN ? AND status IN ?", ids, []string{model.ScrapeTaskPending, model.ScrapeTaskRunning}).
|
||||
Updates(map[string]any{
|
||||
"status": model.ScrapeTaskCanceled,
|
||||
"error": "已批量取消",
|
||||
"finished_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) ClearDone(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.ScrapeTaskDone).Delete(&model.ScrapeTask{})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) ClearFinished(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Unscoped().Where("status IN ?", []string{model.ScrapeTaskDone, model.ScrapeTaskFailed, model.ScrapeTaskCanceled}).
|
||||
Delete(&model.ScrapeTask{})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) ClearCanceled(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Unscoped().Where("status = ?", model.ScrapeTaskCanceled).Delete(&model.ScrapeTask{})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) RetryAllFailed(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
|
||||
Where("status = ?", model.ScrapeTaskFailed).
|
||||
Updates(map[string]any{
|
||||
"status": model.ScrapeTaskPending,
|
||||
"error": "",
|
||||
"retry_count": 0,
|
||||
"started_at": nil,
|
||||
"finished_at": nil,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *ScrapeTaskRepository) CancelPending(ctx context.Context) (int64, error) {
|
||||
now := time.Now()
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.ScrapeTask{}).
|
||||
Where("status IN ?", []string{model.ScrapeTaskPending, model.ScrapeTaskRunning}).
|
||||
Updates(map[string]any{
|
||||
"status": model.ScrapeTaskCanceled,
|
||||
"error": "已批量取消",
|
||||
"finished_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
@@ -32,18 +32,21 @@ func (s *ScraperService) manualTMDbCandidates(ctx context.Context, query string,
|
||||
for _, typ := range manualTMDbSearchTypes(mediaType) {
|
||||
switch typ {
|
||||
case "movie":
|
||||
if matches, err := s.tmdb.SearchMovieCandidates(ctx, query, year); err == nil {
|
||||
if matches, err := s.tmdb.SearchMovieCandidates(ctx, query, year); err == nil && len(matches) > 0 {
|
||||
for _, match := range matches {
|
||||
out = append(out, manualTMDbCandidate{MediaType: "movie", Match: match})
|
||||
}
|
||||
}
|
||||
case "tv":
|
||||
if matches, err := s.tmdb.SearchTVCandidates(ctx, query, year); err == nil {
|
||||
if matches, err := s.tmdb.SearchTVCandidates(ctx, query, year); err == nil && len(matches) > 0 {
|
||||
for _, match := range matches {
|
||||
out = append(out, manualTMDbCandidate{MediaType: "tv", Match: match})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -70,15 +73,15 @@ func manualTMDbIDSearchTypes(mediaType string) []string {
|
||||
|
||||
func manualTMDbSearchTypes(mediaType string) []string {
|
||||
if strings.TrimSpace(mediaType) == "" {
|
||||
return []string{"movie", "tv"}
|
||||
return []string{"tv", "movie"}
|
||||
}
|
||||
switch normalizeMediaType(mediaType, "", "") {
|
||||
case "tv", "anime", "variety":
|
||||
return []string{"tv", "movie"}
|
||||
case "movie", "adult":
|
||||
return []string{"movie"}
|
||||
default:
|
||||
return []string{"movie", "tv"}
|
||||
default:
|
||||
return []string{"tv", "movie"}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -179,9 +179,9 @@ func TestManualSearchFallsBackToMovieFolderForGenericQuery(t *testing.T) {
|
||||
if len(results) != 1 || results[0].TMDbID != 27205 {
|
||||
t.Fatalf("manual search results=%#v, want folder fallback candidate; queries=%v", results, queries)
|
||||
}
|
||||
if len(queries) < 2 || queries[0] != "00000" || queries[1] != "inception" {
|
||||
t.Fatalf("manual search queries=%v, want explicit query then folder fallback", queries)
|
||||
}
|
||||
if len(queries) < 2 || queries[0] != "00000" || queries[len(queries)-1] != "inception" {
|
||||
t.Fatalf("manual search queries=%v, want explicit query then folder fallback", queries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualSearchReturnsMovieFallbackForTVTypedTMDbSearch(t *testing.T) {
|
||||
|
||||
@@ -52,7 +52,14 @@ func (s *MediaService) CreateLibraryWithRootsAndCover(ctx context.Context, name,
|
||||
s.invalidateMediaCache(ctx)
|
||||
return lib, nil
|
||||
}
|
||||
lib := &model.Library{Name: strings.TrimSpace(name), Path: roots[0].Path, Type: kind, CoverURL: strings.TrimSpace(coverURL), Enabled: true}
|
||||
lib := &model.Library{
|
||||
Name: strings.TrimSpace(name),
|
||||
Path: roots[0].Path,
|
||||
Type: kind,
|
||||
CoverURL: strings.TrimSpace(coverURL),
|
||||
Enabled: true,
|
||||
CarouselEnabled: false,
|
||||
}
|
||||
if err := s.repo.Library.CreateWithRoots(ctx, lib, roots); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
type ScrapeQueueCounts struct {
|
||||
Pending int64 `json:"pending"`
|
||||
Running int64 `json:"running"`
|
||||
Done int64 `json:"done"`
|
||||
Failed int64 `json:"failed"`
|
||||
Canceled int64 `json:"canceled"`
|
||||
}
|
||||
|
||||
type ScrapeQueueSnapshot struct {
|
||||
Counts ScrapeQueueCounts `json:"counts"`
|
||||
Tasks []model.ScrapeTask `json:"tasks"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// Start 启动刮削任务队列的后台消费者。
|
||||
func (s *ScraperService) Start(ctx context.Context) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
go s.queueWorker(ctx)
|
||||
}
|
||||
|
||||
func (s *ScraperService) queueWorker(ctx context.Context) {
|
||||
const claimBatch = 4
|
||||
sem := make(chan struct{}, 2) // 最大并发刮削数:2
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
tasks, err := s.repo.ScrapeTask.ClaimPending(ctx, claimBatch)
|
||||
if err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Warn("claim pending scrape task failed", zap.Error(err))
|
||||
}
|
||||
sleepContext(ctx, 3*time.Second)
|
||||
continue
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
sleepContext(ctx, 2*time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := range tasks {
|
||||
wg.Add(1)
|
||||
go func(t *model.ScrapeTask) {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case sem <- struct{}{}:
|
||||
}
|
||||
defer func() { <-sem }()
|
||||
|
||||
s.processScrapeTask(ctx, t)
|
||||
}(&tasks[i])
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScraperService) processScrapeTask(ctx context.Context, task *model.ScrapeTask) {
|
||||
media, err := s.repo.Media.FindByID(ctx, task.MediaID)
|
||||
if err != nil || media == nil {
|
||||
now := time.Now()
|
||||
task.Status = model.ScrapeTaskFailed
|
||||
task.Error = "媒体项已不存在或被删除"
|
||||
task.FinishedAt = &now
|
||||
_ = s.repo.ScrapeTask.Update(ctx, task)
|
||||
return
|
||||
}
|
||||
|
||||
epArtwork := task.EpisodeImages
|
||||
options := ScrapeOptions{
|
||||
EpisodeArtwork: &epArtwork,
|
||||
IncludeMatched: task.RefreshMatched,
|
||||
RetryNoMatch: true,
|
||||
}
|
||||
|
||||
enrichErr := s.EnrichOneWithOptions(ctx, media, options)
|
||||
now := time.Now()
|
||||
task.FinishedAt = &now
|
||||
|
||||
refreshed, _ := s.repo.Media.FindByID(ctx, media.ID)
|
||||
if refreshed != nil && refreshed.ScrapeStatus == "matched" {
|
||||
task.Status = model.ScrapeTaskDone
|
||||
task.Error = ""
|
||||
task.MatchedTitle = refreshed.Title
|
||||
task.MatchedYear = refreshed.Year
|
||||
task.PosterURL = refreshed.PosterURL
|
||||
task.BackdropURL = refreshed.BackdropURL
|
||||
if refreshed.TMDbID > 0 {
|
||||
task.Provider = "tmdb"
|
||||
} else if strings.TrimSpace(refreshed.DoubanID) != "" {
|
||||
task.Provider = "douban"
|
||||
} else if refreshed.BangumiID > 0 {
|
||||
task.Provider = "bangumi"
|
||||
} else if strings.TrimSpace(refreshed.TheTVDBID) != "" {
|
||||
task.Provider = "thetvdb"
|
||||
} else {
|
||||
task.Provider = "metatube"
|
||||
}
|
||||
} else {
|
||||
task.Status = model.ScrapeTaskFailed
|
||||
if enrichErr != nil {
|
||||
task.Error = enrichErr.Error()
|
||||
} else if refreshed != nil && refreshed.ScrapeStatus == "no_match" {
|
||||
task.Error = "未搜索到匹配的元数据"
|
||||
} else {
|
||||
task.Error = "刮削未完成匹配"
|
||||
}
|
||||
}
|
||||
|
||||
_ = s.repo.ScrapeTask.Update(ctx, task)
|
||||
|
||||
if s.hub != nil {
|
||||
s.hub.Publish("scraper_queue", map[string]any{
|
||||
"task_id": task.ID,
|
||||
"status": task.Status,
|
||||
"title": task.MediaTitle,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScraperService) mediaKind(m *model.Media, lib *model.Library) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if lib != nil && lib.Type != "" {
|
||||
return lib.Type
|
||||
}
|
||||
if mediaIsEpisodic(m, lib) {
|
||||
return "tv"
|
||||
}
|
||||
return "movie"
|
||||
}
|
||||
|
||||
// EnqueueMedia 把单个媒体项放入刮削队列。
|
||||
func (s *ScraperService) EnqueueMedia(ctx context.Context, mediaID string, options ScrapeOptions) (*model.ScrapeTask, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return nil, errors.New("scraper service not initialized")
|
||||
}
|
||||
media, err := s.repo.Media.FindByID(ctx, mediaID)
|
||||
if err != nil || media == nil {
|
||||
return nil, errors.New("media not found")
|
||||
}
|
||||
|
||||
if active, _ := s.repo.ScrapeTask.FindActiveByMediaID(ctx, mediaID); active != nil {
|
||||
return active, nil
|
||||
}
|
||||
|
||||
libName := ""
|
||||
var lib *model.Library
|
||||
if strings.TrimSpace(media.LibraryID) != "" {
|
||||
lib, _ = s.repo.Library.FindByID(ctx, media.LibraryID)
|
||||
if lib != nil {
|
||||
libName = lib.Name
|
||||
}
|
||||
}
|
||||
|
||||
task := &model.ScrapeTask{
|
||||
MediaID: media.ID,
|
||||
LibraryID: media.LibraryID,
|
||||
LibraryName: libName,
|
||||
MediaTitle: media.Title,
|
||||
MediaPath: media.Path,
|
||||
MediaType: s.mediaKind(media, lib),
|
||||
Status: model.ScrapeTaskPending,
|
||||
EpisodeImages: options.episodeArtworkEnabled(),
|
||||
RefreshMatched: options.IncludeMatched || options.RefreshWeakMatched,
|
||||
}
|
||||
|
||||
if err := s.repo.ScrapeTask.Create(ctx, task); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// EnqueueLibrary 把指定媒体库内的所有候选媒体批量推入刮削队列。
|
||||
func (s *ScraperService) EnqueueLibrary(ctx context.Context, libraryID string, options ScrapeOptions) (int, error) {
|
||||
if s == nil || s.repo == nil {
|
||||
return 0, errors.New("scraper service not initialized")
|
||||
}
|
||||
|
||||
lib, err := s.repo.Library.FindByID(ctx, libraryID)
|
||||
if err != nil || lib == nil {
|
||||
return 0, errors.New("library not found")
|
||||
}
|
||||
|
||||
rows, err := s.scrapeCandidateRows(ctx, libraryID, options)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
tasks := make([]model.ScrapeTask, 0, len(rows))
|
||||
for _, m := range rows {
|
||||
tasks = append(tasks, model.ScrapeTask{
|
||||
MediaID: m.ID,
|
||||
LibraryID: lib.ID,
|
||||
LibraryName: lib.Name,
|
||||
MediaTitle: m.Title,
|
||||
MediaPath: m.Path,
|
||||
MediaType: s.mediaKind(&m, lib),
|
||||
Status: model.ScrapeTaskPending,
|
||||
EpisodeImages: options.episodeArtworkEnabled(),
|
||||
RefreshMatched: options.IncludeMatched || options.RefreshWeakMatched,
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.repo.ScrapeTask.CreateBatch(ctx, tasks); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(tasks), nil
|
||||
}
|
||||
|
||||
// EnqueueAll 把所有已启用媒体库的媒体推入刮削队列。
|
||||
func (s *ScraperService) EnqueueAll(ctx context.Context, options ScrapeOptions) (int, error) {
|
||||
libs, err := s.repo.Library.List(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total := 0
|
||||
for _, lib := range libs {
|
||||
if !lib.Enabled {
|
||||
continue
|
||||
}
|
||||
n, err := s.EnqueueLibrary(ctx, lib.ID, options)
|
||||
if err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Warn("enqueue library for scrape failed", zap.String("library", lib.ID), zap.Error(err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
total += n
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (s *ScraperService) ScrapeQueueSnapshot(ctx context.Context, status string, page, pageSize int) (*ScrapeQueueSnapshot, error) {
|
||||
tasks, total, err := s.repo.ScrapeTask.List(ctx, status, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
countsMap, err := s.repo.ScrapeTask.CountByStatus(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap := &ScrapeQueueSnapshot{
|
||||
Counts: ScrapeQueueCounts{
|
||||
Pending: countsMap[model.ScrapeTaskPending],
|
||||
Running: countsMap[model.ScrapeTaskRunning],
|
||||
Done: countsMap[model.ScrapeTaskDone],
|
||||
Failed: countsMap[model.ScrapeTaskFailed],
|
||||
Canceled: countsMap[model.ScrapeTaskCanceled],
|
||||
},
|
||||
Tasks: tasks,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
func (s *ScraperService) CancelScrapeTask(ctx context.Context, id string) error {
|
||||
task, err := s.repo.ScrapeTask.FindByID(ctx, id)
|
||||
if err != nil || task == nil {
|
||||
return errors.New("刮削任务不存在")
|
||||
}
|
||||
if task.Status != model.ScrapeTaskPending && task.Status != model.ScrapeTaskRunning {
|
||||
return errors.New("任务已完成或已终止,无法取消")
|
||||
}
|
||||
now := time.Now()
|
||||
task.Status = model.ScrapeTaskCanceled
|
||||
task.Error = "已取消"
|
||||
task.FinishedAt = &now
|
||||
return s.repo.ScrapeTask.Update(ctx, task)
|
||||
}
|
||||
|
||||
func (s *ScraperService) RetryScrapeTask(ctx context.Context, id string) error {
|
||||
task, err := s.repo.ScrapeTask.FindByID(ctx, id)
|
||||
if err != nil || task == nil {
|
||||
return errors.New("刮削任务不存在")
|
||||
}
|
||||
if task.Status != model.ScrapeTaskFailed && task.Status != model.ScrapeTaskCanceled {
|
||||
return errors.New("只有失败或已取消的任务可以重试")
|
||||
}
|
||||
task.Status = model.ScrapeTaskPending
|
||||
task.Error = ""
|
||||
task.RetryCount = 0
|
||||
task.StartedAt = nil
|
||||
task.FinishedAt = nil
|
||||
return s.repo.ScrapeTask.Update(ctx, task)
|
||||
}
|
||||
|
||||
func (s *ScraperService) DeleteScrapeTask(ctx context.Context, id string) error {
|
||||
return s.repo.ScrapeTask.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func (s *ScraperService) BatchActionScrapeTasks(ctx context.Context, action string, ids []string) (int64, error) {
|
||||
switch action {
|
||||
case "delete":
|
||||
return s.repo.ScrapeTask.DeleteBatch(ctx, ids)
|
||||
case "retry":
|
||||
return s.repo.ScrapeTask.RetryBatch(ctx, ids)
|
||||
case "cancel":
|
||||
return s.repo.ScrapeTask.CancelBatch(ctx, ids)
|
||||
default:
|
||||
return 0, fmt.Errorf("不支持的操作: %s", action)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScraperService) ClearDoneScrapeTasks(ctx context.Context) (int64, error) {
|
||||
return s.repo.ScrapeTask.ClearDone(ctx)
|
||||
}
|
||||
|
||||
func (s *ScraperService) ClearFinishedScrapeTasks(ctx context.Context) (int64, error) {
|
||||
return s.repo.ScrapeTask.ClearFinished(ctx)
|
||||
}
|
||||
|
||||
func (s *ScraperService) ClearCanceledScrapeTasks(ctx context.Context) (int64, error) {
|
||||
return s.repo.ScrapeTask.ClearCanceled(ctx)
|
||||
}
|
||||
|
||||
func (s *ScraperService) RetryAllFailedScrapeTasks(ctx context.Context) (int64, error) {
|
||||
return s.repo.ScrapeTask.RetryAllFailed(ctx)
|
||||
}
|
||||
|
||||
func (s *ScraperService) CancelPendingScrapeTasks(ctx context.Context) (int64, error) {
|
||||
return s.repo.ScrapeTask.CancelPending(ctx)
|
||||
}
|
||||
@@ -102,7 +102,12 @@ func (c *Container) Boot() {
|
||||
c.Strm.Start(c.stopCtx)
|
||||
}
|
||||
|
||||
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
|
||||
// 启动刮削队列后台消费者
|
||||
if c.Scraper != nil {
|
||||
c.Scraper.Start(c.stopCtx)
|
||||
}
|
||||
|
||||
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
|
||||
// 每天触发一次评估;规则里的窗口可随机,不固定。
|
||||
if c.Device != nil {
|
||||
go c.runInactivitySweeper(c.stopCtx)
|
||||
|
||||
@@ -57,7 +57,7 @@ func (t *TMDbProvider) searchMovieCandidates(ctx context.Context, query string,
|
||||
|
||||
apiKey := t.resolveAPIKey(ctx)
|
||||
if apiKey == "" {
|
||||
return nil, nil
|
||||
return nil, errors.New("TMDb API Key 未配置,请先在「系统设置 → API配置」中填写")
|
||||
}
|
||||
base := t.resolveBaseURL(ctx)
|
||||
|
||||
@@ -65,7 +65,7 @@ func (t *TMDbProvider) searchMovieCandidates(ctx context.Context, query string,
|
||||
q.Set("api_key", apiKey)
|
||||
q.Set("query", query)
|
||||
q.Set("language", language)
|
||||
q.Set("include_adult", "false")
|
||||
q.Set("include_adult", "true")
|
||||
if year > 0 {
|
||||
q.Set("year", fmt.Sprintf("%d", year))
|
||||
}
|
||||
@@ -79,6 +79,12 @@ func (t *TMDbProvider) searchMovieCandidates(ctx context.Context, query string,
|
||||
if err := t.getJSON(ctx, u, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(p.Results) == 0 && year > 0 {
|
||||
// 移除年份限制重试一次,避免年份微小差异(如 2019 vs 2020)导致无搜索结果
|
||||
q.Del("year")
|
||||
u = base + "/search/movie?" + q.Encode()
|
||||
_ = t.getJSON(ctx, u, &p)
|
||||
}
|
||||
if len(p.Results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -137,7 +143,7 @@ func (t *TMDbProvider) searchTVCandidates(ctx context.Context, query string, yea
|
||||
|
||||
apiKey := t.resolveAPIKey(ctx)
|
||||
if apiKey == "" {
|
||||
return nil, nil
|
||||
return nil, errors.New("TMDb API Key 未配置,请先在「系统设置 → API配置」中填写")
|
||||
}
|
||||
base := t.resolveBaseURL(ctx)
|
||||
|
||||
@@ -145,7 +151,7 @@ func (t *TMDbProvider) searchTVCandidates(ctx context.Context, query string, yea
|
||||
q.Set("api_key", apiKey)
|
||||
q.Set("query", query)
|
||||
q.Set("language", language)
|
||||
q.Set("include_adult", "false")
|
||||
q.Set("include_adult", "true")
|
||||
if year > 0 {
|
||||
q.Set("first_air_date_year", fmt.Sprintf("%d", year))
|
||||
}
|
||||
@@ -159,6 +165,12 @@ func (t *TMDbProvider) searchTVCandidates(ctx context.Context, query string, yea
|
||||
if err := t.getJSON(ctx, u, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(p.Results) == 0 && year > 0 {
|
||||
// 移除年份限制重试一次,避免年份微小差异导致无搜索结果
|
||||
q.Del("first_air_date_year")
|
||||
u = base + "/search/tv?" + q.Encode()
|
||||
_ = t.getJSON(ctx, u, &p)
|
||||
}
|
||||
if len(p.Results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { api } from './client'
|
||||
import type { ScrapeQueueSnapshot } from '../types/scraper'
|
||||
|
||||
export interface EnqueueScrapeOptions {
|
||||
episode_images?: boolean
|
||||
episode_artwork?: boolean
|
||||
refresh_matched?: boolean
|
||||
include_matched?: boolean
|
||||
}
|
||||
|
||||
export const scraperAPI = {
|
||||
queue: (status?: string, page = 1, pageSize = 50) =>
|
||||
api
|
||||
.get<ScrapeQueueSnapshot>('/admin/scraper/queue', {
|
||||
params: { status, page, page_size: pageSize },
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
cancelTask: (id: string) =>
|
||||
api.post(`/admin/scraper/queue/${id}/cancel`).then((r) => r.data),
|
||||
|
||||
retryTask: (id: string) =>
|
||||
api.post(`/admin/scraper/queue/${id}/retry`).then((r) => r.data),
|
||||
|
||||
deleteTask: (id: string) =>
|
||||
api.delete(`/admin/scraper/queue/${id}`).then((r) => r.data),
|
||||
|
||||
batchAction: (action: 'delete' | 'retry' | 'cancel', ids: string[]) =>
|
||||
api
|
||||
.post<{ affected: number; action: string }>('/admin/scraper/queue/batch', { action, ids })
|
||||
.then((r) => r.data),
|
||||
|
||||
clearDone: () =>
|
||||
api.post<{ deleted: number }>('/admin/scraper/queue/clear-done').then((r) => r.data),
|
||||
|
||||
clearFinished: () =>
|
||||
api.post<{ deleted: number }>('/admin/scraper/queue/clear-finished').then((r) => r.data),
|
||||
|
||||
clearCanceled: () =>
|
||||
api.post<{ deleted: number }>('/admin/scraper/queue/clear-canceled').then((r) => r.data),
|
||||
|
||||
retryFailed: () =>
|
||||
api.post<{ retried: number }>('/admin/scraper/queue/retry-failed').then((r) => r.data),
|
||||
|
||||
cancelPending: () =>
|
||||
api.post<{ canceled: number }>('/admin/scraper/queue/cancel-pending').then((r) => r.data),
|
||||
|
||||
enqueueLibrary: (libraryId: string, options?: EnqueueScrapeOptions) =>
|
||||
api
|
||||
.post<{ enqueued: number }>(`/admin/scraper/queue/enqueue-library/${libraryId}`, options ?? {})
|
||||
.then((r) => r.data),
|
||||
|
||||
enqueueAll: (options?: EnqueueScrapeOptions) =>
|
||||
api
|
||||
.post<{ enqueued: number }>('/admin/scraper/queue/enqueue-all', options ?? {})
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -33,6 +33,9 @@ const StrmDownloadQueuePage = lazy(() =>
|
||||
const StrmUploadQueuePage = lazy(() =>
|
||||
import('./pages/StrmQueuePage').then((m) => ({ default: m.StrmUploadQueuePage })),
|
||||
)
|
||||
const ScraperQueuePage = lazy(() =>
|
||||
import('./pages/ScraperQueuePage').then((m) => ({ default: m.ScraperQueuePage })),
|
||||
)
|
||||
|
||||
export type AppRoute = {
|
||||
path?: string
|
||||
@@ -62,5 +65,6 @@ export const appRoutes: AppRoute[] = [
|
||||
{ path: 'strm', element: <StrmManagePage />, adminOnly: true },
|
||||
{ path: 'strm/downloads', element: <StrmDownloadQueuePage />, adminOnly: true },
|
||||
{ path: 'strm/uploads', element: <StrmUploadQueuePage />, adminOnly: true },
|
||||
{ path: 'scraper/queue', element: <ScraperQueuePage />, adminOnly: true },
|
||||
{ path: 'admin', element: <AdminPage />, adminOnly: true },
|
||||
]
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
FolderOpen,
|
||||
Library,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Upload,
|
||||
User,
|
||||
Users,
|
||||
@@ -22,6 +23,7 @@ export type LayoutNavItem = {
|
||||
export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
|
||||
{ to: '/profile', label: '个人资料', icon: User },
|
||||
{ to: '/libraries?from=admin', label: '媒体库', icon: Library },
|
||||
{ to: '/scraper/queue', label: '刮削队列', icon: Sparkles, adminOnly: true },
|
||||
{ to: '/admin', label: '用户管理', icon: Users, adminOnly: true },
|
||||
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
|
||||
{ to: '/settings', label: '系统设置', icon: Settings, adminOnly: true },
|
||||
|
||||
@@ -188,7 +188,7 @@ function LibraryTableRow({ library, dragging, dragOver, onDragStart, onDragOver,
|
||||
}
|
||||
|
||||
function CarouselToggle({ library, onToggleCarousel }: { library: Library; onToggleCarousel: (library: Library) => void }) {
|
||||
const on = library.carousel_enabled ?? true
|
||||
const on = Boolean(library.carousel_enabled)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -198,7 +198,7 @@ function CarouselToggle({ library, onToggleCarousel }: { library: Library; onTog
|
||||
? 'border-brand-500/50 bg-brand-500/10 text-brand-500'
|
||||
: 'border-gray-300 bg-white text-ink-50 hover:border-gray-400'
|
||||
}`}
|
||||
title={on ? '参与首页海报轮播(点击关闭)' : '未参与首页海报轮播(点击开启)'}
|
||||
title={on ? '已开启首页海报轮播(点击关闭)' : '未开启首页海报轮播(点击开启)'}
|
||||
>
|
||||
<span className={`h-3.5 w-3.5 rounded-full ${on ? 'bg-brand-500' : 'bg-gray-300'}`} />
|
||||
{on ? '参与轮播' : '未参与'}
|
||||
|
||||
@@ -117,7 +117,7 @@ export function HomePage() {
|
||||
const carouselItems = useMemo(() => {
|
||||
const candidateMedia: Media[] = []
|
||||
const effectiveSelectedIds = libraries
|
||||
.filter((l) => l.carousel_enabled !== false)
|
||||
.filter((l) => l.carousel_enabled === true)
|
||||
.map((l) => l.id)
|
||||
|
||||
for (const libId of effectiveSelectedIds) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, PlayCircle, RefreshCw, Tv } from 'lucide-react'
|
||||
import { ArrowRight, Film, FolderOpen, Library as LibraryIcon, Music, PlayCircle, RefreshCw, Sparkles, Tv } from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||
@@ -73,6 +73,10 @@ export function LibrariesHeader({
|
||||
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
|
||||
{repairing ? '正在启动…' : '全库修复+重刮'}
|
||||
</button>
|
||||
<Link to="/scraper/queue" className="btn-outline inline-flex items-center gap-1.5" title="查看正在进行的刮削任务与进度">
|
||||
<Sparkles size={14} className="text-brand-500" />
|
||||
<span>刮削队列</span>
|
||||
</Link>
|
||||
<button type="button" onClick={onManageLibraries} className="btn-outline">
|
||||
管理媒体库
|
||||
</button>
|
||||
|
||||
@@ -113,21 +113,23 @@ export function LibraryPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<LibraryPageHeader
|
||||
library={library}
|
||||
itemCount={isSeries ? seriesCards.length : total}
|
||||
loadingAllText={loadingAllText}
|
||||
scanProgress={scanProgress}
|
||||
isAdmin={role === 'admin'}
|
||||
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
|
||||
scanning={scanning}
|
||||
scraping={scraping}
|
||||
repairing={repairing}
|
||||
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
|
||||
onScan={handleScan}
|
||||
onScrape={() => setScrapeDialogOpen(true)}
|
||||
onRepairRescrape={handleRepairRescrape}
|
||||
/>
|
||||
{!selectedSeries && (
|
||||
<LibraryPageHeader
|
||||
library={library}
|
||||
itemCount={isSeries ? seriesCards.length : total}
|
||||
loadingAllText={loadingAllText}
|
||||
scanProgress={scanProgress}
|
||||
isAdmin={role === 'admin'}
|
||||
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
|
||||
scanning={scanning}
|
||||
scraping={scraping}
|
||||
repairing={repairing}
|
||||
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
|
||||
onScan={handleScan}
|
||||
onScrape={() => setScrapeDialogOpen(true)}
|
||||
onRepairRescrape={handleRepairRescrape}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LibraryMediaSections
|
||||
isSeries={isSeries}
|
||||
|
||||
@@ -55,10 +55,10 @@ export function LibraryPageHeader({
|
||||
className="h-10"
|
||||
/>
|
||||
<button onClick={onScan} disabled={scanning} className="btn-outline">
|
||||
{scanning ? '扫描中…' : '立即扫描'}
|
||||
{scanning ? '扫描中…' : '扫描媒体库'}
|
||||
</button>
|
||||
<button onClick={onScrape} disabled={scraping} className="btn-outline">
|
||||
{scraping ? '刮削中…' : '刮削元数据'}
|
||||
<button onClick={onScrape} disabled={scraping} className="btn-outline" title="对整个媒体库执行刮削元数据">
|
||||
{scraping ? '刮削中…' : '整库刮削元数据'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onRepairRescrape}
|
||||
@@ -66,7 +66,7 @@ export function LibraryPageHeader({
|
||||
className="btn-outline"
|
||||
title="回填本库占位符外部 ID 并重刮,修正空 ID / 拆集问题"
|
||||
>
|
||||
{repairing ? '修复中…' : '修复+重刮本库'}
|
||||
{repairing ? '修复中…' : '修复+重刮整库'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,972 @@
|
||||
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,
|
||||
Layers,
|
||||
Loader2,
|
||||
PlayCircle,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Tv,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { imageURL } from '../api/client'
|
||||
import { scraperAPI } from '../api/scraper'
|
||||
import type { ScrapeQueueSnapshot, ScrapeTask, ScrapeTaskStatus } from '../types/scraper'
|
||||
import { apiErrorMessage, formatTime, taskStatusMeta } from './StrmManagePage'
|
||||
|
||||
const FILTERS: { key: 'all' | ScrapeTaskStatus; label: string; icon: typeof Clock; color: string }[] = [
|
||||
{ key: 'all', label: '全部', icon: Sparkles, color: 'text-ink-600' },
|
||||
{ key: 'pending', label: '排队中', icon: Clock, color: 'text-gray-500' },
|
||||
{ key: 'running', label: '刮削中', icon: PlayCircle, color: 'text-brand-500' },
|
||||
{ key: 'done', label: '已匹配', icon: CheckCircle2, color: 'text-emerald-500' },
|
||||
{ key: 'failed', label: '未匹配/失败', icon: AlertCircle, color: 'text-rose-500' },
|
||||
{ key: 'canceled', label: '已取消', icon: Ban, color: 'text-amber-500' },
|
||||
]
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
tmdb: 'TheMovieDB',
|
||||
douban: '豆瓣 Douban',
|
||||
bangumi: 'Bangumi 番组计划',
|
||||
thetvdb: 'TheTVDB',
|
||||
metatube: 'MetaTube',
|
||||
}
|
||||
|
||||
const TYPE_ICONS: Record<string, ReactNode> = {
|
||||
movie: <Film size={14} className="text-blue-500" />,
|
||||
tv: <Tv size={14} className="text-purple-500" />,
|
||||
anime: <Layers size={14} className="text-emerald-500" />,
|
||||
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() {
|
||||
const [snapshot, setSnapshot] = useState<ScrapeQueueSnapshot | null>(null)
|
||||
const [filter, setFilter] = useState<'all' | ScrapeTaskStatus>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
const [batchBusy, setBatchBusy] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [detailTask, setDetailTask] = useState<ScrapeTask | null>(null)
|
||||
|
||||
const refresh = useCallback(
|
||||
async (showLoading = false) => {
|
||||
if (showLoading) setIsRefreshing(true)
|
||||
try {
|
||||
const status = filter === 'all' ? undefined : filter
|
||||
const data = await scraperAPI.queue(status, page, PAGE_SIZE)
|
||||
const tp = Math.max(1, Math.ceil((data.total ?? data.tasks.length) / PAGE_SIZE))
|
||||
if (page > tp) {
|
||||
setPage(tp)
|
||||
return
|
||||
}
|
||||
setTotalPages(tp)
|
||||
setSnapshot(data)
|
||||
} catch {
|
||||
/* keep existing */
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (showLoading) setIsRefreshing(false)
|
||||
}
|
||||
},
|
||||
[filter, page],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, [refresh])
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return
|
||||
const timer = setInterval(() => {
|
||||
refresh().catch(() => undefined)
|
||||
}, 3000)
|
||||
return () => clearInterval(timer)
|
||||
}, [autoRefresh, refresh])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds(new Set())
|
||||
}, [filter, page])
|
||||
|
||||
const copyText = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.success(`已复制${label}`)
|
||||
}
|
||||
|
||||
// Row actions
|
||||
const cancelTask = async (task: ScrapeTask) => {
|
||||
try {
|
||||
await scraperAPI.cancelTask(task.id)
|
||||
toast.success('已取消刮削任务')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
const retryTask = async (task: ScrapeTask) => {
|
||||
try {
|
||||
await scraperAPI.retryTask(task.id)
|
||||
toast.success('已重新推入刮削队列')
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
const deleteTask = async (task: ScrapeTask) => {
|
||||
try {
|
||||
await scraperAPI.deleteTask(task.id)
|
||||
toast.success('已删除刮削记录')
|
||||
if (detailTask?.id === task.id) setDetailTask(null)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Batch actions
|
||||
const runSelectedBatch = async (action: 'retry' | 'cancel' | 'delete') => {
|
||||
const ids = Array.from(selectedIds)
|
||||
if (ids.length === 0) return
|
||||
|
||||
const actionText = action === 'retry' ? '重新刮削' : action === 'cancel' ? '取消' : '删除'
|
||||
if (action === 'delete' && !window.confirm(`确定删除选中的 ${ids.length} 条刮削记录?`)) return
|
||||
if (action === 'cancel' && !window.confirm(`确定取消选中的 ${ids.length} 个刮削任务?`)) return
|
||||
|
||||
setBatchBusy(true)
|
||||
try {
|
||||
const res = await scraperAPI.batchAction(action, ids)
|
||||
toast.success(`已成功${actionText} ${res.affected} 项`)
|
||||
setSelectedIds(new Set())
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setBatchBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runGlobalBatch = async (
|
||||
action: () => Promise<{ deleted?: number; retried?: number; canceled?: number }>,
|
||||
confirmMsg?: string,
|
||||
) => {
|
||||
if (confirmMsg && !window.confirm(confirmMsg)) return
|
||||
setBatchBusy(true)
|
||||
try {
|
||||
const res = await action()
|
||||
if (res.deleted !== undefined) toast.success(`已清空 ${res.deleted} 条记录`)
|
||||
else if (res.retried !== undefined) toast.success(`已重新入队 ${res.retried} 个任务`)
|
||||
else if (res.canceled !== undefined) toast.success(`已取消 ${res.canceled} 个任务`)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setBatchBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue all libraries
|
||||
const handleEnqueueAll = async () => {
|
||||
if (!window.confirm('确定将全库所有未匹配或需要更新的媒体重新推入刮削队列?')) return
|
||||
setBatchBusy(true)
|
||||
try {
|
||||
const res = await scraperAPI.enqueueAll({ include_matched: false, refresh_matched: false, episode_images: true })
|
||||
toast.success(`已将 ${res.enqueued} 个媒体项推入刮削队列`)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setBatchBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter and search
|
||||
const tasks = snapshot?.tasks ?? []
|
||||
const filteredTasks = useMemo(() => {
|
||||
let list = tasks
|
||||
if (filter !== 'all') {
|
||||
list = list.filter((t) => t.status === filter)
|
||||
}
|
||||
if (search.trim()) {
|
||||
const q = search.trim().toLowerCase()
|
||||
list = list.filter(
|
||||
(t) =>
|
||||
t.media_title.toLowerCase().includes(q) ||
|
||||
t.matched_title.toLowerCase().includes(q) ||
|
||||
t.library_name.toLowerCase().includes(q) ||
|
||||
t.media_path.toLowerCase().includes(q) ||
|
||||
(t.error && t.error.toLowerCase().includes(q)),
|
||||
)
|
||||
}
|
||||
return list
|
||||
}, [tasks, filter, search])
|
||||
|
||||
const counts = snapshot?.counts
|
||||
const activeTaskCount = (counts?.pending ?? 0) + (counts?.running ?? 0)
|
||||
const failedCount = counts?.failed ?? 0
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1. Header */}
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary-400/30 bg-primary-400/10 text-brand-500 shadow-sm">
|
||||
<Sparkles size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="font-display text-2xl font-bold text-ink-600 sm:text-3xl">刮削队列</h1>
|
||||
{autoRefresh && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-300/40 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-semibold text-emerald-600">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
|
||||
实时同步
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-sand-500 mt-0.5">
|
||||
媒体元数据在线识别与海报/剧照下载进度(TMDb / 豆瓣 / Bangumi / TheTVDB)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchBusy}
|
||||
onClick={handleEnqueueAll}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-brand-500/40 bg-brand-500/10 px-3 py-2 text-xs font-semibold text-brand-500 shadow-sm transition hover:bg-brand-500/20 disabled:opacity-50"
|
||||
title="将所有媒体库未刮削媒体加入队列"
|
||||
>
|
||||
<Sparkles size={13} />
|
||||
<span>全库重新刮削</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAutoRefresh((v) => !v)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-2 text-xs font-semibold transition ${
|
||||
autoRefresh
|
||||
? 'border-emerald-300/50 bg-emerald-50 text-emerald-700 hover:bg-emerald-100/70'
|
||||
: 'border-gray-200 bg-white text-ink-50 hover:bg-gray-50'
|
||||
}`}
|
||||
title={autoRefresh ? '点击暂停自动刷新' : '点击开启 3 秒自动轮询'}
|
||||
>
|
||||
<Clock size={13} />
|
||||
<span>自动刷新: {autoRefresh ? '开启' : '已暂停'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRefreshing}
|
||||
onClick={() => refresh(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-ink-100 shadow-sm transition hover:border-gray-300 hover:bg-gray-50"
|
||||
title="手动刷新"
|
||||
>
|
||||
<RefreshCw size={13} className={isRefreshing ? 'animate-spin text-brand-500' : ''} />
|
||||
<span>刷新</span>
|
||||
</button>
|
||||
|
||||
<details className="relative inline-block">
|
||||
<summary className="inline-flex cursor-pointer list-none items-center gap-1.5 rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-ink-100 shadow-sm transition hover:border-gray-300 hover:bg-gray-50 [&::-webkit-details-marker]:hidden">
|
||||
<Trash2 size={13} className="text-sand-500" />
|
||||
<span>批量操作</span>
|
||||
</summary>
|
||||
<div className="absolute right-0 top-10 z-30 min-w-44 rounded-xl border border-gray-200 bg-white p-1.5 shadow-xl backdrop-blur">
|
||||
{failedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchBusy}
|
||||
onClick={(e) => {
|
||||
e.currentTarget.closest('details')?.removeAttribute('open')
|
||||
runGlobalBatch(() => scraperAPI.retryFailed(), '确定重新入队所有失败任务?')
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-brand-500 hover:bg-brand-50"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
<span>重试所有失败 ({failedCount})</span>
|
||||
</button>
|
||||
)}
|
||||
{activeTaskCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchBusy}
|
||||
onClick={(e) => {
|
||||
e.currentTarget.closest('details')?.removeAttribute('open')
|
||||
runGlobalBatch(() => scraperAPI.cancelPending(), '确定取消所有排队及进行中的刮削任务?')
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-amber-600 hover:bg-amber-50"
|
||||
>
|
||||
<Ban size={13} />
|
||||
<span>取消所有进行中 ({activeTaskCount})</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="my-1 border-t border-gray-100" />
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchBusy}
|
||||
onClick={(e) => {
|
||||
e.currentTarget.closest('details')?.removeAttribute('open')
|
||||
runGlobalBatch(() => scraperAPI.clearDone(), '确定清空所有已匹配完成的记录?')
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-ink-100 hover:bg-gray-50"
|
||||
>
|
||||
<CheckCircle2 size={13} className="text-emerald-500" />
|
||||
<span>清空已完成记录</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchBusy}
|
||||
onClick={(e) => {
|
||||
e.currentTarget.closest('details')?.removeAttribute('open')
|
||||
runGlobalBatch(() => scraperAPI.clearCanceled(), '确定清空所有已取消的任务记录?')
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-ink-100 hover:bg-gray-50"
|
||||
>
|
||||
<Ban size={13} className="text-amber-500" />
|
||||
<span>清空已取消记录</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchBusy}
|
||||
onClick={(e) => {
|
||||
e.currentTarget.closest('details')?.removeAttribute('open')
|
||||
runGlobalBatch(() => scraperAPI.clearFinished(), '确定清空所有已完成、失败及取消的历史记录?')
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs font-medium text-rose-500 hover:bg-rose-50"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
<span>清空全部历史记录</span>
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 2. Status Cards */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{FILTERS.map((item) => {
|
||||
const count =
|
||||
item.key === 'all'
|
||||
? (counts?.pending ?? 0) +
|
||||
(counts?.running ?? 0) +
|
||||
(counts?.done ?? 0) +
|
||||
(counts?.failed ?? 0) +
|
||||
(counts?.canceled ?? 0)
|
||||
: counts?.[item.key] ?? 0
|
||||
const isActive = filter === item.key
|
||||
const ItemIcon = item.icon
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFilter(item.key)
|
||||
setPage(1)
|
||||
}}
|
||||
className={`flex flex-col justify-between rounded-2xl border p-3.5 text-left transition-all duration-200 select-none ${
|
||||
isActive
|
||||
? 'border-brand-500 bg-primary-400/10 shadow-sm ring-2 ring-brand-500/20'
|
||||
: 'border-gray-200 bg-white/80 hover:border-gray-300 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between text-xs text-sand-500">
|
||||
<span className="font-semibold">{item.label}</span>
|
||||
<ItemIcon size={14} className={item.color} />
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline gap-1">
|
||||
<span className={`font-display text-2xl font-black ${item.color}`}>{count}</span>
|
||||
<span className="text-[10px] text-sand-400 font-medium">项</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 3. Search & Batch Actions */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索媒体标题、匹配结果、媒体库或错误信息…"
|
||||
className="h-9 w-full rounded-xl border border-gray-200 bg-white pl-9 pr-8 text-xs text-ink-600 placeholder:text-gray-400 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100/60"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded p-0.5 text-gray-400 hover:text-ink-600"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-brand-500/30 bg-primary-400/10 px-3 py-1.5 text-xs animate-in fade-in zoom-in-95">
|
||||
<span className="font-bold text-brand-500">已选中 {selectedIds.size} 项</span>
|
||||
<div className="h-3.5 w-px bg-brand-300/40 mx-1" />
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchBusy}
|
||||
onClick={() => runSelectedBatch('retry')}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-brand-500/40 bg-white px-2 py-1 font-semibold text-brand-500 hover:bg-brand-50 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
重试选中
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchBusy}
|
||||
onClick={() => runSelectedBatch('cancel')}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-amber-300 bg-white px-2 py-1 font-semibold text-amber-600 hover:bg-amber-50 disabled:opacity-50"
|
||||
>
|
||||
<Ban size={12} />
|
||||
取消选中
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={batchBusy}
|
||||
onClick={() => runSelectedBatch('delete')}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-rose-300 bg-white px-2 py-1 font-semibold text-rose-600 hover:bg-rose-50 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
删除选中
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
className="p-1 text-gray-400 hover:text-ink-600"
|
||||
title="清空选择"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 4. Table */}
|
||||
<div className="glass-panel overflow-hidden !p-0 shadow-sm">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16 text-ink-50">
|
||||
<Loader2 className="animate-spin text-brand-500" size={28} />
|
||||
</div>
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<div className="py-16 text-center text-xs text-sand-500">
|
||||
{search
|
||||
? '没有找到符合搜索条件的刮削任务'
|
||||
: filter === 'all'
|
||||
? '刮削队列为空,暂无进行或排队中的任务'
|
||||
: `「${FILTERS.find((f) => f.key === filter)?.label}」状态下暂无任务`}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-gray-200/80 bg-gray-50/50 text-[11px] font-bold uppercase tracking-wider text-sand-500">
|
||||
<tr>
|
||||
<th className="w-10 px-3 py-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allCurrentChecked}
|
||||
onChange={toggleSelectAll}
|
||||
className="h-3.5 w-3.5 rounded border-gray-300 text-brand-500 focus:ring-brand-400 cursor-pointer"
|
||||
title="全选 / 反选本页"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-3 py-3">媒体文件</th>
|
||||
<th className="px-3 py-3">所属媒体库</th>
|
||||
<th className="px-3 py-3">刮削匹配结果</th>
|
||||
<th className="px-3 py-3">识别源</th>
|
||||
<th className="px-3 py-3">状态</th>
|
||||
<th className="px-3 py-3">时间</th>
|
||||
<th className="px-3 py-3 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{filteredTasks.map((task) => {
|
||||
const status = taskStatusMeta(task.status)
|
||||
const isSelected = selectedIds.has(task.id)
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={task.id}
|
||||
className={`transition-colors hover:bg-primary-400/5 ${
|
||||
isSelected ? 'bg-primary-400/10' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSelectRow(task.id)}
|
||||
className="h-3.5 w-3.5 rounded border-gray-300 text-brand-500 focus:ring-brand-400 cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* Media title & path */}
|
||||
<td className="max-w-[240px] px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{TYPE_ICONS[task.media_type] || <Film size={14} className="text-gray-400" />}
|
||||
<div className="min-w-0">
|
||||
<span
|
||||
onClick={() => setDetailTask(task)}
|
||||
className="cursor-pointer truncate font-medium text-ink-600 hover:text-brand-500 hover:underline block"
|
||||
title={task.media_title}
|
||||
>
|
||||
{task.media_title}
|
||||
</span>
|
||||
<span className="truncate font-mono text-[10px] text-gray-400 block" title={task.media_path}>
|
||||
{task.media_path}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Library */}
|
||||
<td className="px-3 py-2.5 text-xs text-ink-100 whitespace-nowrap">
|
||||
<span className="rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-[11px] font-semibold text-ink-100">
|
||||
{task.library_name || '媒体库'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Scraped matched result */}
|
||||
<td className="max-w-[240px] px-3 py-2.5">
|
||||
{task.matched_title ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{task.poster_url ? (
|
||||
<img
|
||||
src={imageURL(task.poster_url)}
|
||||
alt=""
|
||||
className="h-10 w-7 rounded object-cover border border-gray-200 shrink-0"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-7 rounded bg-gray-100 flex items-center justify-center text-gray-400 shrink-0">
|
||||
<ImageIcon size={12} />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<span className="font-bold text-ink-600 truncate block text-xs">
|
||||
{task.matched_title}
|
||||
</span>
|
||||
{task.matched_year > 0 && (
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{task.matched_year} 年
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-sand-400 font-mono">
|
||||
{task.status === 'pending' || task.status === 'running'
|
||||
? '等待识别…'
|
||||
: '未匹配到结果'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Provider */}
|
||||
<td className="px-3 py-2.5 text-xs text-ink-100 whitespace-nowrap">
|
||||
{task.provider ? (
|
||||
<span className="rounded bg-brand-500/10 border border-brand-500/20 px-1.5 py-0.5 text-[10px] font-bold text-brand-500">
|
||||
{PROVIDER_LABELS[task.provider] ?? task.provider}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-300 text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Status & Error */}
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span
|
||||
className={`inline-flex w-fit items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold ${status.cls}`}
|
||||
>
|
||||
{task.status === 'running' && (
|
||||
<Loader2 size={10} className="animate-spin" />
|
||||
)}
|
||||
{task.status === 'done'
|
||||
? '已匹配'
|
||||
: task.status === 'failed'
|
||||
? '未匹配'
|
||||
: status.label}
|
||||
</span>
|
||||
{task.error && (
|
||||
<span
|
||||
onClick={() => setDetailTask(task)}
|
||||
className="cursor-pointer truncate max-w-[180px] text-[10px] text-rose-500 hover:underline"
|
||||
title={task.error}
|
||||
>
|
||||
{task.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Time */}
|
||||
<td className="px-3 py-2.5 text-xs text-ink-50 whitespace-nowrap">
|
||||
{formatTime(task.created_at)}
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-3 py-2.5 text-right whitespace-nowrap">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailTask(task)}
|
||||
className="rounded-lg p-1.5 text-gray-400 transition hover:bg-gray-100 hover:text-ink-600"
|
||||
title="查看详情"
|
||||
>
|
||||
<Eye size={13} />
|
||||
</button>
|
||||
|
||||
{(task.status === 'pending' || task.status === 'running') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cancelTask(task)}
|
||||
className="rounded-lg border border-amber-200 px-2 py-1 text-xs font-semibold text-amber-600 transition hover:bg-amber-50"
|
||||
title="取消刮削"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(task.status === 'failed' ||
|
||||
task.status === 'canceled' ||
|
||||
task.status === 'done') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => retryTask(task)}
|
||||
className="rounded-lg border border-primary-400/50 bg-primary-400/5 px-2 py-1 text-xs font-semibold text-brand-500 transition hover:bg-primary-400/15"
|
||||
title="重新刮削"
|
||||
>
|
||||
重刮
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(task.status === 'done' ||
|
||||
task.status === 'failed' ||
|
||||
task.status === 'canceled') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteTask(task)}
|
||||
className="rounded-lg p-1.5 text-gray-400 transition hover:bg-rose-50 hover:text-rose-500"
|
||||
title="删除记录"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 5. Pagination */}
|
||||
{(snapshot?.total ?? 0) > 0 && (
|
||||
<div className="flex items-center justify-between border-t border-gray-200/80 bg-gray-50/40 px-4 py-3">
|
||||
<span className="text-xs text-sand-500">
|
||||
共 {snapshot?.total ?? 0} 条 · 第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 1 || loading}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
className="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-ink-100 transition hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= totalPages || loading}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="inline-flex items-center rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-ink-100 transition hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 6. Task Detail Modal */}
|
||||
{detailTask && (
|
||||
<ScrapeDetailModal
|
||||
task={detailTask}
|
||||
onClose={() => setDetailTask(null)}
|
||||
onRetry={retryTask}
|
||||
onCancel={cancelTask}
|
||||
onDelete={deleteTask}
|
||||
onCopy={copyText}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -165,7 +165,7 @@ function useLibraryActions(refresh: () => Promise<void>) {
|
||||
}
|
||||
|
||||
const toggleCarouselLibrary = async (library: Library) => {
|
||||
const next = !(library.carousel_enabled ?? true)
|
||||
const next = !Boolean(library.carousel_enabled)
|
||||
await libraryAPI.update(library.id, { carousel_enabled: next })
|
||||
toast.success(next ? `「${library.name}」已加入首页轮播` : `「${library.name}」已移出首页轮播`)
|
||||
await refresh()
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
export type ScrapeTaskStatus = 'pending' | 'running' | 'done' | 'failed' | 'canceled'
|
||||
|
||||
export interface ScrapeTask {
|
||||
id: string
|
||||
media_id: string
|
||||
library_id: string
|
||||
library_name: string
|
||||
media_title: string
|
||||
media_path: string
|
||||
media_type: string
|
||||
provider: string
|
||||
matched_title: string
|
||||
matched_year: number
|
||||
poster_url: string
|
||||
backdrop_url: string
|
||||
status: ScrapeTaskStatus
|
||||
error: string
|
||||
retry_count: number
|
||||
episode_images: boolean
|
||||
refresh_matched: boolean
|
||||
created_at: string
|
||||
started_at?: string | null
|
||||
finished_at?: string | null
|
||||
}
|
||||
|
||||
export interface ScrapeQueueCounts {
|
||||
pending: number
|
||||
running: number
|
||||
done: number
|
||||
failed: number
|
||||
canceled: number
|
||||
}
|
||||
|
||||
export interface ScrapeQueueSnapshot {
|
||||
counts: ScrapeQueueCounts
|
||||
tasks: ScrapeTask[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
Reference in New Issue
Block a user