mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 13:26: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)
|
registerAdminAPIConfigRoutes(admin, svc)
|
||||||
registerAdminRecognitionWordRoutes(admin, svc)
|
registerAdminRecognitionWordRoutes(admin, svc)
|
||||||
registerAdminStrmRoutes(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) {
|
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
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -135,28 +134,12 @@ func scrapeOneHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
options.IncludeMatched = true
|
options.IncludeMatched = true
|
||||||
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
|
task, err := svc.Scraper.EnqueueMedia(c.Request.Context(), c.Param("id"), options)
|
||||||
if err != nil || m == nil {
|
if err != 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)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
reclassified := reclassifyMediaAfterScrape(c.Request.Context(), svc, m.ID)
|
c.JSON(http.StatusOK, task)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,40 +153,12 @@ func scrapeLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
options.IncludeMatched = true
|
options.IncludeMatched = true
|
||||||
var task *service.TaskHandle
|
n, err := svc.Scraper.EnqueueLibrary(c.Request.Context(), libID, options)
|
||||||
if lib, err := svc.Repo.Library.FindByID(c.Request.Context(), libID); err == nil && lib != nil {
|
if err != nil {
|
||||||
task = startScrapeHTTPTask(svc, "手动刮削媒体库", lib.Name, lib.Path)
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
} else {
|
return
|
||||||
task = startScrapeHTTPTask(svc, "手动刮削媒体库", libID, "")
|
|
||||||
}
|
}
|
||||||
// Run in the background so HTTP returns instantly; the WS hub
|
c.JSON(http.StatusOK, gin.H{"status": "queued", "enqueued": n})
|
||||||
// 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"})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ type Library struct {
|
|||||||
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
|
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
|
||||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||||
SortOrder int `gorm:"index;default:0" json:"sort_order"` // 手动拖拽排序用,越小越靠前
|
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"`
|
Roots []LibraryRoot `gorm:"foreignKey:LibraryID" json:"roots,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,5 +57,6 @@ func AllModels() []interface{} {
|
|||||||
&StrmDownloadTask{},
|
&StrmDownloadTask{},
|
||||||
&StrmUploadTask{},
|
&StrmUploadTask{},
|
||||||
&StrmDirCache{},
|
&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.
|
// Create persists a new library row.
|
||||||
func (r *LibraryRepository) Create(ctx context.Context, l *model.Library) error {
|
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
|
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.Create(ctx, l)
|
||||||
}
|
}
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
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 {
|
if err := tx.Create(l).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,10 @@ type Container struct {
|
|||||||
StrmSyncPath *StrmSyncPathRepository
|
StrmSyncPath *StrmSyncPathRepository
|
||||||
StrmSyncRecord *StrmSyncRecordRepository
|
StrmSyncRecord *StrmSyncRecordRepository
|
||||||
StrmDownload *StrmDownloadTaskRepository
|
StrmDownload *StrmDownloadTaskRepository
|
||||||
StrmUpload *StrmUploadTaskRepository
|
StrmUpload *StrmUploadTaskRepository
|
||||||
StrmDirCache *StrmDirCacheRepository
|
StrmDirCache *StrmDirCacheRepository
|
||||||
}
|
ScrapeTask *ScrapeTaskRepository
|
||||||
|
}
|
||||||
|
|
||||||
// New 将每个 repository 连接到单个 *gorm.DB。
|
// New 将每个 repository 连接到单个 *gorm.DB。
|
||||||
func New(db *gorm.DB) *Container {
|
func New(db *gorm.DB) *Container {
|
||||||
@@ -60,5 +61,6 @@ func New(db *gorm.DB) *Container {
|
|||||||
StrmDownload: &StrmDownloadTaskRepository{db: db},
|
StrmDownload: &StrmDownloadTaskRepository{db: db},
|
||||||
StrmUpload: &StrmUploadTaskRepository{db: db},
|
StrmUpload: &StrmUploadTaskRepository{db: db},
|
||||||
StrmDirCache: &StrmDirCacheRepository{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) {
|
for _, typ := range manualTMDbSearchTypes(mediaType) {
|
||||||
switch typ {
|
switch typ {
|
||||||
case "movie":
|
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 {
|
for _, match := range matches {
|
||||||
out = append(out, manualTMDbCandidate{MediaType: "movie", Match: match})
|
out = append(out, manualTMDbCandidate{MediaType: "movie", Match: match})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "tv":
|
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 {
|
for _, match := range matches {
|
||||||
out = append(out, manualTMDbCandidate{MediaType: "tv", Match: match})
|
out = append(out, manualTMDbCandidate{MediaType: "tv", Match: match})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(out) > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -70,15 +73,15 @@ func manualTMDbIDSearchTypes(mediaType string) []string {
|
|||||||
|
|
||||||
func manualTMDbSearchTypes(mediaType string) []string {
|
func manualTMDbSearchTypes(mediaType string) []string {
|
||||||
if strings.TrimSpace(mediaType) == "" {
|
if strings.TrimSpace(mediaType) == "" {
|
||||||
return []string{"movie", "tv"}
|
return []string{"tv", "movie"}
|
||||||
}
|
}
|
||||||
switch normalizeMediaType(mediaType, "", "") {
|
switch normalizeMediaType(mediaType, "", "") {
|
||||||
case "tv", "anime", "variety":
|
case "tv", "anime", "variety":
|
||||||
return []string{"tv", "movie"}
|
return []string{"tv", "movie"}
|
||||||
case "movie", "adult":
|
case "movie", "adult":
|
||||||
return []string{"movie"}
|
|
||||||
default:
|
|
||||||
return []string{"movie", "tv"}
|
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 {
|
if len(results) != 1 || results[0].TMDbID != 27205 {
|
||||||
t.Fatalf("manual search results=%#v, want folder fallback candidate; queries=%v", results, queries)
|
t.Fatalf("manual search results=%#v, want folder fallback candidate; queries=%v", results, queries)
|
||||||
}
|
}
|
||||||
if len(queries) < 2 || queries[0] != "00000" || queries[1] != "inception" {
|
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)
|
t.Fatalf("manual search queries=%v, want explicit query then folder fallback", queries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManualSearchReturnsMovieFallbackForTVTypedTMDbSearch(t *testing.T) {
|
func TestManualSearchReturnsMovieFallbackForTVTypedTMDbSearch(t *testing.T) {
|
||||||
|
|||||||
@@ -52,7 +52,14 @@ func (s *MediaService) CreateLibraryWithRootsAndCover(ctx context.Context, name,
|
|||||||
s.invalidateMediaCache(ctx)
|
s.invalidateMediaCache(ctx)
|
||||||
return lib, nil
|
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 {
|
if err := s.repo.Library.CreateWithRoots(ctx, lib, roots); err != nil {
|
||||||
return nil, err
|
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)
|
c.Strm.Start(c.stopCtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
|
// 启动刮削队列后台消费者
|
||||||
|
if c.Scraper != nil {
|
||||||
|
c.Scraper.Start(c.stopCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。
|
||||||
// 每天触发一次评估;规则里的窗口可随机,不固定。
|
// 每天触发一次评估;规则里的窗口可随机,不固定。
|
||||||
if c.Device != nil {
|
if c.Device != nil {
|
||||||
go c.runInactivitySweeper(c.stopCtx)
|
go c.runInactivitySweeper(c.stopCtx)
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func (t *TMDbProvider) searchMovieCandidates(ctx context.Context, query string,
|
|||||||
|
|
||||||
apiKey := t.resolveAPIKey(ctx)
|
apiKey := t.resolveAPIKey(ctx)
|
||||||
if apiKey == "" {
|
if apiKey == "" {
|
||||||
return nil, nil
|
return nil, errors.New("TMDb API Key 未配置,请先在「系统设置 → API配置」中填写")
|
||||||
}
|
}
|
||||||
base := t.resolveBaseURL(ctx)
|
base := t.resolveBaseURL(ctx)
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ func (t *TMDbProvider) searchMovieCandidates(ctx context.Context, query string,
|
|||||||
q.Set("api_key", apiKey)
|
q.Set("api_key", apiKey)
|
||||||
q.Set("query", query)
|
q.Set("query", query)
|
||||||
q.Set("language", language)
|
q.Set("language", language)
|
||||||
q.Set("include_adult", "false")
|
q.Set("include_adult", "true")
|
||||||
if year > 0 {
|
if year > 0 {
|
||||||
q.Set("year", fmt.Sprintf("%d", year))
|
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 {
|
if err := t.getJSON(ctx, u, &p); err != nil {
|
||||||
return nil, err
|
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 {
|
if len(p.Results) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -137,7 +143,7 @@ func (t *TMDbProvider) searchTVCandidates(ctx context.Context, query string, yea
|
|||||||
|
|
||||||
apiKey := t.resolveAPIKey(ctx)
|
apiKey := t.resolveAPIKey(ctx)
|
||||||
if apiKey == "" {
|
if apiKey == "" {
|
||||||
return nil, nil
|
return nil, errors.New("TMDb API Key 未配置,请先在「系统设置 → API配置」中填写")
|
||||||
}
|
}
|
||||||
base := t.resolveBaseURL(ctx)
|
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("api_key", apiKey)
|
||||||
q.Set("query", query)
|
q.Set("query", query)
|
||||||
q.Set("language", language)
|
q.Set("language", language)
|
||||||
q.Set("include_adult", "false")
|
q.Set("include_adult", "true")
|
||||||
if year > 0 {
|
if year > 0 {
|
||||||
q.Set("first_air_date_year", fmt.Sprintf("%d", year))
|
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 {
|
if err := t.getJSON(ctx, u, &p); err != nil {
|
||||||
return nil, err
|
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 {
|
if len(p.Results) == 0 {
|
||||||
return nil, nil
|
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(() =>
|
const StrmUploadQueuePage = lazy(() =>
|
||||||
import('./pages/StrmQueuePage').then((m) => ({ default: m.StrmUploadQueuePage })),
|
import('./pages/StrmQueuePage').then((m) => ({ default: m.StrmUploadQueuePage })),
|
||||||
)
|
)
|
||||||
|
const ScraperQueuePage = lazy(() =>
|
||||||
|
import('./pages/ScraperQueuePage').then((m) => ({ default: m.ScraperQueuePage })),
|
||||||
|
)
|
||||||
|
|
||||||
export type AppRoute = {
|
export type AppRoute = {
|
||||||
path?: string
|
path?: string
|
||||||
@@ -62,5 +65,6 @@ export const appRoutes: AppRoute[] = [
|
|||||||
{ path: 'strm', element: <StrmManagePage />, adminOnly: true },
|
{ path: 'strm', element: <StrmManagePage />, adminOnly: true },
|
||||||
{ path: 'strm/downloads', element: <StrmDownloadQueuePage />, adminOnly: true },
|
{ path: 'strm/downloads', element: <StrmDownloadQueuePage />, adminOnly: true },
|
||||||
{ path: 'strm/uploads', element: <StrmUploadQueuePage />, adminOnly: true },
|
{ path: 'strm/uploads', element: <StrmUploadQueuePage />, adminOnly: true },
|
||||||
|
{ path: 'scraper/queue', element: <ScraperQueuePage />, adminOnly: true },
|
||||||
{ path: 'admin', element: <AdminPage />, adminOnly: true },
|
{ path: 'admin', element: <AdminPage />, adminOnly: true },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
Library,
|
Library,
|
||||||
Settings,
|
Settings,
|
||||||
|
Sparkles,
|
||||||
Upload,
|
Upload,
|
||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
@@ -22,6 +23,7 @@ export type LayoutNavItem = {
|
|||||||
export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
|
export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
|
||||||
{ to: '/profile', label: '个人资料', icon: User },
|
{ to: '/profile', label: '个人资料', icon: User },
|
||||||
{ to: '/libraries?from=admin', label: '媒体库', icon: Library },
|
{ to: '/libraries?from=admin', label: '媒体库', icon: Library },
|
||||||
|
{ to: '/scraper/queue', label: '刮削队列', icon: Sparkles, adminOnly: true },
|
||||||
{ to: '/admin', label: '用户管理', icon: Users, adminOnly: true },
|
{ to: '/admin', label: '用户管理', icon: Users, adminOnly: true },
|
||||||
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
|
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
|
||||||
{ to: '/settings', label: '系统设置', icon: Settings, 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 }) {
|
function CarouselToggle({ library, onToggleCarousel }: { library: Library; onToggleCarousel: (library: Library) => void }) {
|
||||||
const on = library.carousel_enabled ?? true
|
const on = Boolean(library.carousel_enabled)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="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-brand-500/50 bg-brand-500/10 text-brand-500'
|
||||||
: 'border-gray-300 bg-white text-ink-50 hover:border-gray-400'
|
: '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'}`} />
|
<span className={`h-3.5 w-3.5 rounded-full ${on ? 'bg-brand-500' : 'bg-gray-300'}`} />
|
||||||
{on ? '参与轮播' : '未参与'}
|
{on ? '参与轮播' : '未参与'}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function HomePage() {
|
|||||||
const carouselItems = useMemo(() => {
|
const carouselItems = useMemo(() => {
|
||||||
const candidateMedia: Media[] = []
|
const candidateMedia: Media[] = []
|
||||||
const effectiveSelectedIds = libraries
|
const effectiveSelectedIds = libraries
|
||||||
.filter((l) => l.carousel_enabled !== false)
|
.filter((l) => l.carousel_enabled === true)
|
||||||
.map((l) => l.id)
|
.map((l) => l.id)
|
||||||
|
|
||||||
for (const libId of effectiveSelectedIds) {
|
for (const libId of effectiveSelectedIds) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { motion } from 'framer-motion'
|
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 { imageURL } from '../api/client'
|
||||||
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
|
||||||
@@ -73,6 +73,10 @@ export function LibrariesHeader({
|
|||||||
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
|
<RefreshCw size={14} className={repairing ? 'animate-spin' : ''} />
|
||||||
{repairing ? '正在启动…' : '全库修复+重刮'}
|
{repairing ? '正在启动…' : '全库修复+重刮'}
|
||||||
</button>
|
</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 type="button" onClick={onManageLibraries} className="btn-outline">
|
||||||
管理媒体库
|
管理媒体库
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -113,21 +113,23 @@ export function LibraryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<LibraryPageHeader
|
{!selectedSeries && (
|
||||||
library={library}
|
<LibraryPageHeader
|
||||||
itemCount={isSeries ? seriesCards.length : total}
|
library={library}
|
||||||
loadingAllText={loadingAllText}
|
itemCount={isSeries ? seriesCards.length : total}
|
||||||
scanProgress={scanProgress}
|
loadingAllText={loadingAllText}
|
||||||
isAdmin={role === 'admin'}
|
scanProgress={scanProgress}
|
||||||
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
|
isAdmin={role === 'admin'}
|
||||||
scanning={scanning}
|
scrapeEpisodeArtwork={scrapeEpisodeArtwork}
|
||||||
scraping={scraping}
|
scanning={scanning}
|
||||||
repairing={repairing}
|
scraping={scraping}
|
||||||
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
|
repairing={repairing}
|
||||||
onScan={handleScan}
|
onScrapeEpisodeArtworkChange={setScrapeEpisodeArtwork}
|
||||||
onScrape={() => setScrapeDialogOpen(true)}
|
onScan={handleScan}
|
||||||
onRepairRescrape={handleRepairRescrape}
|
onScrape={() => setScrapeDialogOpen(true)}
|
||||||
/>
|
onRepairRescrape={handleRepairRescrape}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<LibraryMediaSections
|
<LibraryMediaSections
|
||||||
isSeries={isSeries}
|
isSeries={isSeries}
|
||||||
|
|||||||
@@ -55,10 +55,10 @@ export function LibraryPageHeader({
|
|||||||
className="h-10"
|
className="h-10"
|
||||||
/>
|
/>
|
||||||
<button onClick={onScan} disabled={scanning} className="btn-outline">
|
<button onClick={onScan} disabled={scanning} className="btn-outline">
|
||||||
{scanning ? '扫描中…' : '立即扫描'}
|
{scanning ? '扫描中…' : '扫描媒体库'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onScrape} disabled={scraping} className="btn-outline">
|
<button onClick={onScrape} disabled={scraping} className="btn-outline" title="对整个媒体库执行刮削元数据">
|
||||||
{scraping ? '刮削中…' : '刮削元数据'}
|
{scraping ? '刮削中…' : '整库刮削元数据'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onRepairRescrape}
|
onClick={onRepairRescrape}
|
||||||
@@ -66,7 +66,7 @@ export function LibraryPageHeader({
|
|||||||
className="btn-outline"
|
className="btn-outline"
|
||||||
title="回填本库占位符外部 ID 并重刮,修正空 ID / 拆集问题"
|
title="回填本库占位符外部 ID 并重刮,修正空 ID / 拆集问题"
|
||||||
>
|
>
|
||||||
{repairing ? '修复中…' : '修复+重刮本库'}
|
{repairing ? '修复中…' : '修复+重刮整库'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 toggleCarouselLibrary = async (library: Library) => {
|
||||||
const next = !(library.carousel_enabled ?? true)
|
const next = !Boolean(library.carousel_enabled)
|
||||||
await libraryAPI.update(library.id, { carousel_enabled: next })
|
await libraryAPI.update(library.id, { carousel_enabled: next })
|
||||||
toast.success(next ? `「${library.name}」已加入首页轮播` : `「${library.name}」已移出首页轮播`)
|
toast.success(next ? `「${library.name}」已加入首页轮播` : `「${library.name}」已移出首页轮播`)
|
||||||
await refresh()
|
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