mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Unify organize ingest pipeline
This commit is contained in:
@@ -9,6 +9,11 @@ docs/
|
|||||||
data/
|
data/
|
||||||
cache/
|
cache/
|
||||||
logs/
|
logs/
|
||||||
|
verify-data/
|
||||||
|
verify-cache/
|
||||||
|
verify-media/
|
||||||
|
verify-downloads/
|
||||||
|
.codex-*
|
||||||
.tmp_*
|
.tmp_*
|
||||||
.tmp-deploy-*
|
.tmp-deploy-*
|
||||||
.tmp-deploy-data/
|
.tmp-deploy-data/
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ config.yaml
|
|||||||
# Runtime / local-only artifacts (清理补充)
|
# Runtime / local-only artifacts (清理补充)
|
||||||
.tmp-live-backups/
|
.tmp-live-backups/
|
||||||
.tmp-*
|
.tmp-*
|
||||||
|
.codex-*
|
||||||
downloads/
|
downloads/
|
||||||
media/
|
media/
|
||||||
*.pid
|
*.pid
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ func downloadOrganizeOneHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
// existing OrganizerService for each library that contains those files.
|
// existing OrganizerService for each library that contains those files.
|
||||||
func downloadOrganizeAllHandler(svc *service.Container) gin.HandlerFunc {
|
func downloadOrganizeAllHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// Walk each library and re-organize. The OrganizerService is
|
// Walk each library through the unified organize pipeline so rename,
|
||||||
// idempotent so this is safe to run repeatedly.
|
// scan, scrape and task reporting stay identical to manual organize.
|
||||||
libs, err := svc.Repo.Library.List(c.Request.Context())
|
libs, err := svc.Repo.Library.List(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
@@ -80,15 +80,17 @@ func downloadOrganizeAllHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
results := make([]any, 0, len(libs))
|
results := make([]any, 0, len(libs))
|
||||||
for _, l := range libs {
|
for _, l := range libs {
|
||||||
res, err := svc.Organizer.OrganizeLibrary(c.Request.Context(), l.ID)
|
resp, err := organizePipeline(svc).Run(c.Request.Context(), service.OrganizePipelineRequest{
|
||||||
|
Scope: service.OrganizeScopeLibrary,
|
||||||
|
Trigger: service.OrganizeTriggerManual,
|
||||||
|
TaskName: "批量整理媒体库:" + l.Name,
|
||||||
|
LibraryID: l.ID,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
results = append(results, gin.H{"library": l.Name, "error": err.Error()})
|
results = append(results, gin.H{"library": l.Name, "error": err.Error()})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if svc.Scan != nil && res != nil && !res.DryRun {
|
results = append(results, gin.H{"library": l.Name, "result": resp.Result})
|
||||||
res.Scans, res.Scrapes = scanAndScrapeAfterOrganize(c, svc, res.DestPath, l.ID, nil)
|
|
||||||
}
|
|
||||||
results = append(results, gin.H{"library": l.Name, "result": res})
|
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"results": results})
|
c.JSON(http.StatusOK, gin.H{"results": results})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,15 +129,17 @@ func organizeBulkHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
out := make([]any, 0, len(libs))
|
out := make([]any, 0, len(libs))
|
||||||
for _, l := range libs {
|
for _, l := range libs {
|
||||||
res, err := svc.Organizer.OrganizeLibrary(c.Request.Context(), l.ID)
|
resp, err := organizePipeline(svc).Run(c.Request.Context(), service.OrganizePipelineRequest{
|
||||||
|
Scope: service.OrganizeScopeLibrary,
|
||||||
|
Trigger: service.OrganizeTriggerManual,
|
||||||
|
TaskName: "批量整理媒体库:" + l.Name,
|
||||||
|
LibraryID: l.ID,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out = append(out, gin.H{"library": l.Name, "error": err.Error()})
|
out = append(out, gin.H{"library": l.Name, "error": err.Error()})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if svc.Scan != nil && res != nil && !res.DryRun {
|
out = append(out, gin.H{"library": l.Name, "result": resp.Result})
|
||||||
res.Scans, res.Scrapes = scanAndScrapeAfterOrganize(c, svc, res.DestPath, l.ID, nil)
|
|
||||||
}
|
|
||||||
out = append(out, gin.H{"library": l.Name, "result": res})
|
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"results": out})
|
c.JSON(http.StatusOK, gin.H{"results": out})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,22 +57,18 @@ func organizeMediaHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req organizeReq
|
var req organizeReq
|
||||||
_ = c.ShouldBindJSON(&req)
|
_ = c.ShouldBindJSON(&req)
|
||||||
opts := organizeOptionsFromReq(req)
|
runReq := organizePipelineRequestFromReq(req, service.OrganizeScopeMedia, "手动整理媒体")
|
||||||
task := startOrganizeHTTPTask(svc, "手动整理媒体", opts)
|
runReq.MediaID = c.Param("id")
|
||||||
dst, err := svc.Organizer.OrganizeMediaWithOptions(c.Request.Context(), c.Param("id"), opts)
|
resp, err := organizePipeline(svc).Run(c.Request.Context(), runReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
finishHTTPTask(task, err, "organize", "手动整理媒体失败", nil, nil)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
payload := gin.H{"path": dst}
|
payload := gin.H{"path": resp.Path}
|
||||||
if organizeScanAfter(req.ScanAfter) && !req.DryRun && svc.Scan != nil {
|
if resp.Result != nil {
|
||||||
updateHTTPTask(task, "scan_scrape", "正在扫描入库并按设置刮削", nil, nil)
|
payload["scans"] = resp.Result.Scans
|
||||||
scans, scrapes := scanAndScrapeAfterOrganize(c, svc, dst, strings.TrimSpace(req.LibraryID), req.ScrapeAfter)
|
payload["scrapes"] = resp.Result.Scrapes
|
||||||
payload["scans"] = scans
|
|
||||||
payload["scrapes"] = scrapes
|
|
||||||
}
|
}
|
||||||
finishHTTPTask(task, nil, "completed", "手动整理媒体结束", nil, nil)
|
|
||||||
c.JSON(http.StatusOK, payload)
|
c.JSON(http.StatusOK, payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,21 +77,14 @@ func organizeLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req organizeReq
|
var req organizeReq
|
||||||
_ = c.ShouldBindJSON(&req)
|
_ = c.ShouldBindJSON(&req)
|
||||||
opts := organizeOptionsFromReq(req)
|
runReq := organizePipelineRequestFromReq(req, service.OrganizeScopeLibrary, "手动整理媒体库")
|
||||||
task := startOrganizeHTTPTask(svc, "手动整理媒体库", opts)
|
runReq.LibraryID = c.Param("id")
|
||||||
res, err := svc.Organizer.OrganizeLibraryWithOptions(c.Request.Context(), c.Param("id"), opts)
|
resp, err := organizePipeline(svc).Run(c.Request.Context(), runReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
finishHTTPTask(task, err, "organize", "手动整理媒体库失败", nil, nil)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
details := service.OrganizeTaskDetails(res, 8)
|
c.JSON(http.StatusOK, resp.Result)
|
||||||
if organizeScanAfter(req.ScanAfter) && !req.DryRun && svc.Scan != nil {
|
|
||||||
updateHTTPTask(task, "scan_scrape", "正在扫描入库并按设置刮削", service.OrganizeTaskMetrics(res), details)
|
|
||||||
res.Scans, res.Scrapes = scanAndScrapeAfterOrganize(c, svc, res.DestPath, c.Param("id"), req.ScrapeAfter)
|
|
||||||
}
|
|
||||||
finishHTTPTask(task, nil, "completed", "手动整理媒体库结束", service.OrganizeTaskMetrics(res), details)
|
|
||||||
c.JSON(http.StatusOK, res)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,63 +102,40 @@ func organizeDirectoryHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req organizeReq
|
var req organizeReq
|
||||||
_ = c.ShouldBindJSON(&req)
|
_ = c.ShouldBindJSON(&req)
|
||||||
opts := organizeOptionsFromReq(req)
|
runReq := organizePipelineRequestFromReq(req, service.OrganizeScopeDirectory, "手动整理入库")
|
||||||
task := startOrganizeHTTPTask(svc, "手动整理入库", opts)
|
runReq.PreferredLibraryID = strings.TrimSpace(req.LibraryID)
|
||||||
res, err := svc.Organizer.OrganizeDirectory(c.Request.Context(), opts)
|
resp, err := organizePipeline(svc).Run(c.Request.Context(), runReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
finishHTTPTask(task, err, "organize", "手动整理入库失败", nil, nil)
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
details := service.OrganizeTaskDetails(res, 8)
|
c.JSON(http.StatusOK, resp.Result)
|
||||||
updateHTTPTask(task, "organize", "手动整理完成,准备扫描入库", service.OrganizeTaskMetrics(res), details)
|
|
||||||
if organizeScanAfter(req.ScanAfter) && !req.DryRun && svc.Scan != nil {
|
|
||||||
updateHTTPTask(task, "scan_scrape", "正在扫描入库并按设置刮削", service.OrganizeTaskMetrics(res), details)
|
|
||||||
res.Scans, res.Scrapes = scanAndScrapeAfterOrganize(c, svc, res.DestPath, strings.TrimSpace(req.LibraryID), req.ScrapeAfter)
|
|
||||||
}
|
|
||||||
finishHTTPTask(task, nil, "completed", "手动整理入库结束", service.OrganizeTaskMetrics(res), details)
|
|
||||||
c.JSON(http.StatusOK, res)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startOrganizeHTTPTask(svc *service.Container, name string, opts service.OrganizeOptions) *service.TaskHandle {
|
func organizePipelineRequestFromReq(req organizeReq, scope service.OrganizeScope, taskName string) service.OrganizePipelineRequest {
|
||||||
if svc == nil || svc.Tasks == nil {
|
dest := strings.TrimSpace(req.DestPath)
|
||||||
return nil
|
if dest == "" {
|
||||||
|
dest = strings.TrimSpace(req.TargetPath)
|
||||||
}
|
}
|
||||||
message := "正在整理/重命名/入库"
|
return service.OrganizePipelineRequest{
|
||||||
if opts.DryRun {
|
Scope: scope,
|
||||||
message = "正在预览整理/重命名"
|
Trigger: service.OrganizeTriggerManual,
|
||||||
|
TaskName: taskName,
|
||||||
|
SourcePath: strings.TrimSpace(req.SourcePath),
|
||||||
|
DestPath: dest,
|
||||||
|
TransferMode: strings.TrimSpace(req.TransferMode),
|
||||||
|
MediaType: strings.TrimSpace(req.MediaType),
|
||||||
|
MediaCategory: strings.TrimSpace(req.MediaCategory),
|
||||||
|
ScanAfter: req.ScanAfter,
|
||||||
|
ScrapeAfter: req.ScrapeAfter,
|
||||||
|
DryRun: req.DryRun,
|
||||||
}
|
}
|
||||||
return svc.Tasks.Start(service.TaskKindOrganize, name, service.TaskUpdate{
|
|
||||||
Stage: "organize",
|
|
||||||
SourcePath: opts.SourcePath,
|
|
||||||
DestPath: opts.DestPath,
|
|
||||||
Message: message,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateHTTPTask(task *service.TaskHandle, stage, message string, metrics map[string]int64, details []string) {
|
func organizePipeline(svc *service.Container) *service.OrganizePipelineService {
|
||||||
if task == nil {
|
if svc.OrganizePipeline != nil {
|
||||||
return
|
return svc.OrganizePipeline
|
||||||
}
|
}
|
||||||
task.Update(service.TaskUpdate{Stage: stage, Message: message, Metrics: metrics, Details: details})
|
return service.NewOrganizePipelineService(svc.Log, svc.Repo, svc.Organizer, svc.Scan, svc.Tasks)
|
||||||
}
|
|
||||||
|
|
||||||
func finishHTTPTask(task *service.TaskHandle, err error, stage, message string, metrics map[string]int64, details []string) {
|
|
||||||
if task == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
task.Finish(err, service.TaskUpdate{Stage: stage, Message: message, Metrics: metrics, Details: details})
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanAndScrapeAfterOrganize(c *gin.Context, svc *service.Container, destRoot, preferredLibraryID string, scrapeOverride *bool) ([]service.OrganizeScanSummary, []service.OrganizeScrapeSummary) {
|
|
||||||
scrapeAfter := service.OrganizeScrapeAfterEnabled(c.Request.Context(), svc.Repo)
|
|
||||||
if scrapeOverride != nil {
|
|
||||||
scrapeAfter = *scrapeOverride
|
|
||||||
}
|
|
||||||
return svc.Scan.ScanAndScrapeLibrariesForPath(c.Request.Context(), destRoot, preferredLibraryID, scrapeAfter)
|
|
||||||
}
|
|
||||||
|
|
||||||
func organizeScanAfter(value *bool) bool {
|
|
||||||
return value == nil || *value
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc *
|
|||||||
|
|
||||||
// Subscriptions.
|
// Subscriptions.
|
||||||
authed.GET("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), listSubscriptionsHandler(svc))
|
authed.GET("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), listSubscriptionsHandler(svc))
|
||||||
|
authed.GET("/subscriptions/history", requirePermission(svc, "can_manage_subscriptions"), listSubscriptionHistoryHandler(svc))
|
||||||
authed.POST("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), createSubscriptionHandler(svc))
|
authed.POST("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), createSubscriptionHandler(svc))
|
||||||
authed.DELETE("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), deleteSubscriptionHandler(svc))
|
authed.DELETE("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), deleteSubscriptionHandler(svc))
|
||||||
authed.POST("/subscriptions/:id/run", requirePermission(svc, "can_manage_subscriptions"), runSubscriptionHandler(svc))
|
authed.POST("/subscriptions/:id/run", requirePermission(svc, "can_manage_subscriptions"), runSubscriptionHandler(svc))
|
||||||
|
|||||||
@@ -98,6 +98,18 @@ func listSubscriptionsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func listSubscriptionHistoryHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
items, err := svc.Subscription.History(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
service.EnrichSubscriptionProgress(c.Request.Context(), svc.Repo, items)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func deleteSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
|
func deleteSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
if err := svc.Subscription.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
if err := svc.Subscription.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
||||||
|
|||||||
@@ -76,30 +76,6 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
|
|||||||
{"key": "ffprobe.max_concurrent", "type": "number", "label": "FFprobe 最大并发"},
|
{"key": "ffprobe.max_concurrent", "type": "number", "label": "FFprobe 最大并发"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"key": "organize",
|
|
||||||
"label": "整理 & 刮削",
|
|
||||||
"items": []gin.H{
|
|
||||||
{"key": "organize.auto", "type": "toggle", "label": "整理源目录定时自动整理"},
|
|
||||||
{"key": "organizer.auto_after_download", "type": "toggle"},
|
|
||||||
{"key": "organize.scrape_after", "type": "toggle", "label": "整理后自动刮削"},
|
|
||||||
{"key": "downloads.smart_classify", "type": "toggle"},
|
|
||||||
{"key": "organizer.smart_classify", "type": "toggle"},
|
|
||||||
{"key": "organize.source_dir", "type": "text", "label": "整理源目录"},
|
|
||||||
{"key": "organize.target_dir", "type": "text", "label": "整理目的地目录"},
|
|
||||||
{"key": "organize.transfer_mode", "type": "select", "label": "默认转移方式"},
|
|
||||||
{"key": "organize.interval_seconds", "type": "number", "label": "自动整理间隔秒数"},
|
|
||||||
{"key": "organize.keep_seeding", "type": "toggle", "label": "保种"},
|
|
||||||
{"key": "organize.movie_format", "type": "text"},
|
|
||||||
{"key": "organize.tv_format", "type": "text"},
|
|
||||||
{"key": "organize.anime_format", "type": "text"},
|
|
||||||
{"key": "scrape.auto_on_scan", "type": "toggle"},
|
|
||||||
{"key": "scrape.providers", "type": "text"},
|
|
||||||
{"key": "scrape.language", "type": "text"},
|
|
||||||
{"key": "scrape.delay_min_ms", "type": "number", "label": "刮削最小间隔毫秒"},
|
|
||||||
{"key": "scrape.delay_max_ms", "type": "number", "label": "刮削最大间隔毫秒"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"key": "cloud-upload",
|
"key": "cloud-upload",
|
||||||
"label": "网盘转存",
|
"label": "网盘转存",
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import "github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
|
|
||||||
|
func finishHTTPTask(task *service.TaskHandle, err error, stage, message string, metrics map[string]int64, details []string) {
|
||||||
|
if task == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task.Finish(err, service.TaskUpdate{Stage: stage, Message: message, Metrics: metrics, Details: details})
|
||||||
|
}
|
||||||
@@ -238,6 +238,8 @@ type Subscription struct {
|
|||||||
Priority int `gorm:"default:50" json:"priority,omitempty"` // lower is earlier when schedulers sort later
|
Priority int `gorm:"default:50" json:"priority,omitempty"` // lower is earlier when schedulers sort later
|
||||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||||
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
||||||
|
ArchivedAt *time.Time `gorm:"index" json:"archived_at,omitempty"`
|
||||||
|
ArchiveReason string `gorm:"size:255" json:"archive_reason,omitempty"`
|
||||||
|
|
||||||
DownloadedEpisodes int `gorm:"-" json:"downloaded_episodes,omitempty"`
|
DownloadedEpisodes int `gorm:"-" json:"downloaded_episodes,omitempty"`
|
||||||
LocalMediaCount int `gorm:"-" json:"local_media_count,omitempty"`
|
LocalMediaCount int `gorm:"-" json:"local_media_count,omitempty"`
|
||||||
|
|||||||
@@ -809,13 +809,33 @@ func (r *SubscriptionRepository) Create(ctx context.Context, s *model.Subscripti
|
|||||||
return r.db.WithContext(ctx).Select("*").Omit("DeletedAt").Create(s).Error
|
return r.db.WithContext(ctx).Select("*").Omit("DeletedAt").Create(s).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns all subscription rules.
|
// List returns active subscription rules. Archived rows live in history and are
|
||||||
|
// intentionally excluded from scheduler polling and the active management list.
|
||||||
func (r *SubscriptionRepository) List(ctx context.Context) ([]model.Subscription, error) {
|
func (r *SubscriptionRepository) List(ctx context.Context) ([]model.Subscription, error) {
|
||||||
var rows []model.Subscription
|
var rows []model.Subscription
|
||||||
err := r.db.WithContext(ctx).Order("created_at desc").Find(&rows).Error
|
err := r.db.WithContext(ctx).Where("archived_at IS NULL").Order("created_at desc").Find(&rows).Error
|
||||||
return rows, err
|
return rows, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// History returns archived subscription rules.
|
||||||
|
func (r *SubscriptionRepository) History(ctx context.Context) ([]model.Subscription, error) {
|
||||||
|
var rows []model.Subscription
|
||||||
|
err := r.db.WithContext(ctx).Where("archived_at IS NOT NULL").Order("archived_at desc, updated_at desc").Find(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive moves a completed subscription out of the active list without
|
||||||
|
// deleting its rule details, so users can audit completed subscriptions later.
|
||||||
|
func (r *SubscriptionRepository) Archive(ctx context.Context, id, reason string, archivedAt time.Time) error {
|
||||||
|
return r.db.WithContext(ctx).Model(&model.Subscription{}).
|
||||||
|
Where("id = ? AND archived_at IS NULL", id).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"enabled": false,
|
||||||
|
"archived_at": &archivedAt,
|
||||||
|
"archive_reason": reason,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Setting ─────────────────────────────────────────────────────────────────
|
// ─── Setting ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// SettingRepository persists key/value preferences.
|
// SettingRepository persists key/value preferences.
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ func (s *DownloadClientService) Create(ctx context.Context, in DownloadClientInp
|
|||||||
IsDefault: normalized.IsDefault,
|
IsDefault: normalized.IsDefault,
|
||||||
Enabled: normalized.Enabled,
|
Enabled: normalized.Enabled,
|
||||||
}
|
}
|
||||||
|
if !c.IsDefault && c.Enabled {
|
||||||
|
if currentDefault, err := s.repo.DownloadClient.FindDefault(ctx); err == nil && currentDefault == nil {
|
||||||
|
if enabled, err := s.repo.DownloadClient.ListEnabled(ctx); err == nil && len(enabled) == 0 {
|
||||||
|
c.IsDefault = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if normalized.IsDefault {
|
if normalized.IsDefault {
|
||||||
_ = s.repo.DownloadClient.ClearDefault(ctx)
|
_ = s.repo.DownloadClient.ClearDefault(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,31 @@ func TestDownloadClientCreateNormalizesHostAndClearsDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDownloadClientCreateMakesFirstEnabledClientDefault(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.DownloadClient{}, &model.Setting{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
svc := NewDownloadClientService(zap.NewNop(), repos)
|
||||||
|
|
||||||
|
client, err := svc.Create(t.Context(), DownloadClientInput{
|
||||||
|
Name: "qB",
|
||||||
|
Type: "qbittorrent",
|
||||||
|
Host: "127.0.0.1:8080",
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !client.IsDefault {
|
||||||
|
t.Fatalf("first enabled client should become default: %#v", client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDownloadClientRejectsUnsupportedHostScheme(t *testing.T) {
|
func TestDownloadClientRejectsUnsupportedHostScheme(t *testing.T) {
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+139
-80
@@ -39,14 +39,15 @@ import (
|
|||||||
|
|
||||||
// DownloadService is the single download orchestrator.
|
// DownloadService is the single download orchestrator.
|
||||||
type DownloadService struct {
|
type DownloadService struct {
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
repo *repository.Container
|
repo *repository.Container
|
||||||
hub *Hub
|
hub *Hub
|
||||||
qb *QBitClient
|
qb *QBitClient
|
||||||
organizer *OrganizerService
|
organizer *OrganizerService
|
||||||
scanner *ScannerService
|
organizePipeline *OrganizePipelineService
|
||||||
site *SiteService
|
scanner *ScannerService
|
||||||
tasks *TaskTrackerService
|
site *SiteService
|
||||||
|
tasks *TaskTrackerService
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
@@ -62,6 +63,10 @@ func (d *DownloadService) SetScanner(scanner *ScannerService) {
|
|||||||
d.scanner = scanner
|
d.scanner = scanner
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *DownloadService) SetOrganizePipeline(pipeline *OrganizePipelineService) {
|
||||||
|
d.organizePipeline = pipeline
|
||||||
|
}
|
||||||
|
|
||||||
func (d *DownloadService) SetTaskTracker(tasks *TaskTrackerService) {
|
func (d *DownloadService) SetTaskTracker(tasks *TaskTrackerService) {
|
||||||
d.tasks = tasks
|
d.tasks = tasks
|
||||||
}
|
}
|
||||||
@@ -203,6 +208,10 @@ func (d *DownloadService) ReloadConfig(ctx context.Context) error {
|
|||||||
cfg.BaseURL = strings.TrimRight(c.Host, "/")
|
cfg.BaseURL = strings.TrimRight(c.Host, "/")
|
||||||
cfg.Username = c.Username
|
cfg.Username = c.Username
|
||||||
cfg.Password = c.Password
|
cfg.Password = c.Password
|
||||||
|
} else if c, err := d.soleEnabledQBitClient(ctx); err == nil && c != nil {
|
||||||
|
cfg.BaseURL = strings.TrimRight(c.Host, "/")
|
||||||
|
cfg.Username = c.Username
|
||||||
|
cfg.Password = c.Password
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if d.repo.Setting != nil {
|
if d.repo.Setting != nil {
|
||||||
@@ -228,6 +237,28 @@ func (d *DownloadService) ReloadConfig(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *DownloadService) soleEnabledQBitClient(ctx context.Context) (*model.DownloadClient, error) {
|
||||||
|
if d == nil || d.repo == nil || d.repo.DownloadClient == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := d.repo.DownloadClient.ListEnabled(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var selected *model.DownloadClient
|
||||||
|
for i := range rows {
|
||||||
|
if rows[i].Type != "qbittorrent" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if selected != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
row := rows[i]
|
||||||
|
selected = &row
|
||||||
|
}
|
||||||
|
return selected, nil
|
||||||
|
}
|
||||||
|
|
||||||
// AddDownload accepts a magnet URL / HTTP URL and persists a tracking row.
|
// AddDownload accepts a magnet URL / HTTP URL and persists a tracking row.
|
||||||
func (d *DownloadService) AddDownload(ctx context.Context, userID, urlStr, savePath string) (*model.DownloadTask, error) {
|
func (d *DownloadService) AddDownload(ctx context.Context, userID, urlStr, savePath string) (*model.DownloadTask, error) {
|
||||||
return d.AddDownloadWithMeta(ctx, userID, urlStr, savePath, DownloadTaskMeta{})
|
return d.AddDownloadWithMeta(ctx, userID, urlStr, savePath, DownloadTaskMeta{})
|
||||||
@@ -313,7 +344,7 @@ func (d *DownloadService) resolveDownloadSavePath(ctx context.Context, explicitS
|
|||||||
if !autoClassify || category == "" {
|
if !autoClassify || category == "" {
|
||||||
return base, ""
|
return base, ""
|
||||||
}
|
}
|
||||||
return categoryRoot(base, sanitizeFilename(category)), category
|
return downloadSavePathCategoryRoot(base, sanitizeFilename(category)), category
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DownloadService) localMediaAlreadyExists(ctx context.Context, title string) bool {
|
func (d *DownloadService) localMediaAlreadyExists(ctx context.Context, title string) bool {
|
||||||
@@ -738,7 +769,7 @@ func (d *DownloadService) Delete(ctx context.Context, hash string, withFiles boo
|
|||||||
if err := d.qb.Delete(ctx, hash, withFiles); err != nil {
|
if err := d.qb.Delete(ctx, hash, withFiles); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
d.markDownloadTaskDeleted(ctx, torrentName)
|
d.markDownloadTaskDeleted(ctx, hash, torrentName)
|
||||||
stateKey := strings.ToLower(hash)
|
stateKey := strings.ToLower(hash)
|
||||||
d.mu.Lock()
|
d.mu.Lock()
|
||||||
delete(d.prevStates, stateKey)
|
delete(d.prevStates, stateKey)
|
||||||
@@ -747,16 +778,28 @@ func (d *DownloadService) Delete(ctx context.Context, hash string, withFiles boo
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DownloadService) markDownloadTaskDeleted(ctx context.Context, torrentName string) {
|
func (d *DownloadService) markDownloadTaskDeleted(ctx context.Context, hash, torrentName string) {
|
||||||
if d == nil || d.repo == nil || d.repo.DB == nil || strings.TrimSpace(torrentName) == "" {
|
if d == nil || d.repo == nil || d.repo.DB == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := d.repo.Download.List(ctx)
|
rows, err := d.repo.Download.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
taskByKey := tasksByIdentity(rows)
|
if matched, ok := findDownloadTaskByHash(rows, hash); ok {
|
||||||
matched, ok := findMatchingTaskByIdentity(torrentName, taskByKey)
|
_ = d.repo.DB.WithContext(ctx).Model(&model.DownloadTask{}).
|
||||||
|
Where("id = ?", matched.ID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"status": "deleted",
|
||||||
|
"progress": matched.Progress,
|
||||||
|
}).Error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(torrentName) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskByKey := tasksByTorrentIdentity(rows)
|
||||||
|
matched, ok := findMatchingTaskByTorrentIdentity(torrentName, taskByKey)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -768,6 +811,19 @@ func (d *DownloadService) markDownloadTaskDeleted(ctx context.Context, torrentNa
|
|||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func findDownloadTaskByHash(rows []model.DownloadTask, hash string) (model.DownloadTask, bool) {
|
||||||
|
hash = strings.ToLower(strings.TrimSpace(hash))
|
||||||
|
if hash == "" {
|
||||||
|
return model.DownloadTask{}, false
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if strings.Contains(strings.ToLower(row.URL), hash) {
|
||||||
|
return row, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return model.DownloadTask{}, false
|
||||||
|
}
|
||||||
|
|
||||||
// RelocateTorrent moves a torrent's data to a new save directory while keeping
|
// RelocateTorrent moves a torrent's data to a new save directory while keeping
|
||||||
// it seeding (qBittorrent performs the physical move and resumes seeding).
|
// it seeding (qBittorrent performs the physical move and resumes seeding).
|
||||||
// 用于「移动 PT 种子文件且转移后继续做种上传」的整盘迁移场景。
|
// 用于「移动 PT 种子文件且转移后继续做种上传」的整盘迁移场景。
|
||||||
@@ -803,7 +859,7 @@ func (d *DownloadService) poll(ctx context.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rows, _ := d.repo.Download.List(ctx)
|
rows, _ := d.repo.Download.List(ctx)
|
||||||
taskByKey := tasksByIdentity(rows)
|
taskByKey := tasksByTorrentIdentity(rows)
|
||||||
d.processDownloadSnapshot(ctx, live, taskByKey)
|
d.processDownloadSnapshot(ctx, live, taskByKey)
|
||||||
d.hub.Publish("download", map[string]any{"torrents": live})
|
d.hub.Publish("download", map[string]any{"torrents": live})
|
||||||
}
|
}
|
||||||
@@ -823,6 +879,10 @@ func (d *DownloadService) processDownloadSnapshot(ctx context.Context, live []QB
|
|||||||
for _, torrent := range live {
|
for _, torrent := range live {
|
||||||
stateKey := completedTorrentQueueKey(torrent)
|
stateKey := completedTorrentQueueKey(torrent)
|
||||||
complete := torrent.Progress >= 1.0
|
complete := torrent.Progress >= 1.0
|
||||||
|
matchedTask, hasTask := findMatchingTaskByTorrentIdentity(torrent.Name, taskByKey)
|
||||||
|
catchupRecorded := hasTask && d.completedTorrentCatchupRecorded(ctx, torrent)
|
||||||
|
taskNeedsOrganize := hasTask && !catchupRecorded &&
|
||||||
|
(downloadTaskNeedsCompletion(matchedTask) || recentlyCompletedTorrent(torrent, time.Now()))
|
||||||
d.syncDownloadTaskProgress(ctx, torrent, taskByKey)
|
d.syncDownloadTaskProgress(ctx, torrent, taskByKey)
|
||||||
if stateKey == "" {
|
if stateKey == "" {
|
||||||
continue
|
continue
|
||||||
@@ -839,11 +899,13 @@ func (d *DownloadService) processDownloadSnapshot(ctx context.Context, live []QB
|
|||||||
// (onTorrentComplete 内部仍受 organize.auto 开关约束,且
|
// (onTorrentComplete 内部仍受 organize.auto 开关约束,且
|
||||||
// 整理对已存在的目标文件幂等跳过)。
|
// 整理对已存在的目标文件幂等跳过)。
|
||||||
d.prevStates[stateKey] = true
|
d.prevStates[stateKey] = true
|
||||||
if recentlyCompletedTorrent(torrent, time.Now()) && !d.completedTorrentCatchupRecorded(ctx, torrent) {
|
if taskNeedsOrganize {
|
||||||
shouldQueue = true
|
shouldQueue = true
|
||||||
}
|
}
|
||||||
case complete && !wasComplete:
|
case complete && !wasComplete:
|
||||||
shouldQueue = true
|
shouldQueue = true
|
||||||
|
case complete && taskNeedsOrganize:
|
||||||
|
shouldQueue = true
|
||||||
case complete:
|
case complete:
|
||||||
d.prevStates[stateKey] = true
|
d.prevStates[stateKey] = true
|
||||||
default:
|
default:
|
||||||
@@ -1018,7 +1080,7 @@ func (d *DownloadService) syncDownloadTaskProgress(ctx context.Context, torrent
|
|||||||
if d == nil || d.repo == nil || d.repo.DB == nil || strings.TrimSpace(torrent.Name) == "" {
|
if d == nil || d.repo == nil || d.repo.DB == nil || strings.TrimSpace(torrent.Name) == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
matched, ok := findMatchingTaskByIdentity(torrent.Name, taskByKey)
|
matched, ok := findMatchingTaskByTorrentIdentity(torrent.Name, taskByKey)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1053,6 +1115,17 @@ func tasksByIdentity(rows []model.DownloadTask) map[string]model.DownloadTask {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func tasksByTorrentIdentity(rows []model.DownloadTask) map[string]model.DownloadTask {
|
||||||
|
out := make(map[string]model.DownloadTask, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
key := normalizeTorrentName(row.Title)
|
||||||
|
if key != "" {
|
||||||
|
out[key] = row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func findMatchingTaskByIdentity(title string, taskByKey map[string]model.DownloadTask) (model.DownloadTask, bool) {
|
func findMatchingTaskByIdentity(title string, taskByKey map[string]model.DownloadTask) (model.DownloadTask, bool) {
|
||||||
key := downloadTaskIdentityKey(title)
|
key := downloadTaskIdentityKey(title)
|
||||||
if key == "" {
|
if key == "" {
|
||||||
@@ -1069,6 +1142,29 @@ func findMatchingTaskByIdentity(title string, taskByKey map[string]model.Downloa
|
|||||||
return model.DownloadTask{}, false
|
return model.DownloadTask{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func findMatchingTaskByTorrentIdentity(title string, taskByKey map[string]model.DownloadTask) (model.DownloadTask, bool) {
|
||||||
|
key := normalizeTorrentName(title)
|
||||||
|
if key == "" {
|
||||||
|
return model.DownloadTask{}, false
|
||||||
|
}
|
||||||
|
if row, ok := taskByKey[key]; ok {
|
||||||
|
return row, true
|
||||||
|
}
|
||||||
|
for currentKey, row := range taskByKey {
|
||||||
|
if strings.Contains(key, currentKey) || strings.Contains(currentKey, key) {
|
||||||
|
return row, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return model.DownloadTask{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadTaskNeedsCompletion(task model.DownloadTask) bool {
|
||||||
|
if task.Progress < 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.ToLower(strings.TrimSpace(task.Status)) != "completed"
|
||||||
|
}
|
||||||
|
|
||||||
// onTorrentComplete handles a torrent that just finished downloading.
|
// onTorrentComplete handles a torrent that just finished downloading.
|
||||||
// It organizes the completed torrent payload directly. Relying on existing
|
// It organizes the completed torrent payload directly. Relying on existing
|
||||||
// Media rows is too late for freshly-downloaded files: they usually have not
|
// Media rows is too late for freshly-downloaded files: they usually have not
|
||||||
@@ -1109,61 +1205,25 @@ func (d *DownloadService) onTorrentComplete(ctx context.Context, torrent QBitTor
|
|||||||
zap.String("name", torrent.Name),
|
zap.String("name", torrent.Name),
|
||||||
zap.String("source", source),
|
zap.String("source", source),
|
||||||
zap.Bool("allow_replace_existing", allowReplace))
|
zap.Bool("allow_replace_existing", allowReplace))
|
||||||
taskHandle := d.startDownloadOrganizeTask(torrent, source, allowReplace)
|
resWrap, err := d.ensureOrganizePipeline().Run(ctx, OrganizePipelineRequest{
|
||||||
res, err := d.organizer.OrganizeDirectory(ctx, OrganizeOptions{
|
Scope: OrganizeScopeDirectory,
|
||||||
SourcePath: source,
|
Trigger: OrganizeTriggerDownload,
|
||||||
MediaType: downloadTaskMediaType(taskRow),
|
TaskName: d.downloadOrganizeTaskName(torrent, allowReplace),
|
||||||
MediaCategory: firstNonEmpty(downloadTaskMediaCategory(taskRow), torrent.Category),
|
SourcePath: source,
|
||||||
AllowReplaceExisting: allowReplace,
|
MediaType: downloadTaskMediaType(taskRow),
|
||||||
|
MediaCategory: firstNonEmpty(downloadTaskMediaCategory(taskRow), torrent.Category),
|
||||||
|
AllowReplace: allowReplace,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if taskHandle != nil {
|
|
||||||
taskHandle.Finish(err, TaskUpdate{
|
|
||||||
Stage: "organize",
|
|
||||||
Message: "下载完成自动整理失败",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
d.log.Error("auto organize completed torrent failed",
|
d.log.Error("auto organize completed torrent failed",
|
||||||
zap.String("hash", torrent.Hash),
|
zap.String("hash", torrent.Hash),
|
||||||
zap.String("source", source),
|
zap.String("source", source),
|
||||||
zap.Error(err))
|
zap.Error(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if taskHandle != nil && res != nil {
|
res := resWrap.Result
|
||||||
taskHandle.Update(TaskUpdate{
|
if res == nil {
|
||||||
Stage: "organize",
|
res = &OrganizeResult{}
|
||||||
SourcePath: res.SourcePath,
|
|
||||||
DestPath: res.DestPath,
|
|
||||||
Message: "下载完成整理已完成,准备扫描入库",
|
|
||||||
Metrics: OrganizeTaskMetrics(res),
|
|
||||||
Details: OrganizeTaskDetails(res, 8),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if d.scanner != nil && res != nil && strings.TrimSpace(res.DestPath) != "" && OrganizeResultNeedsVisibilitySync(res) {
|
|
||||||
if taskHandle != nil {
|
|
||||||
taskHandle.Update(TaskUpdate{
|
|
||||||
Stage: "scan_scrape",
|
|
||||||
Message: "正在扫描入库并按设置刮削",
|
|
||||||
Metrics: OrganizeTaskMetrics(res),
|
|
||||||
Details: OrganizeTaskDetails(res, 8),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
res.Scans, res.Scrapes = d.scanner.ScanAndScrapeLibrariesForPath(ctx, res.DestPath, "", OrganizeScrapeAfterEnabled(ctx, d.repo))
|
|
||||||
} else if d.log != nil && res != nil && !OrganizeResultNeedsVisibilitySync(res) {
|
|
||||||
d.log.Info("auto organize completed torrent skipped scan; no destination changes",
|
|
||||||
zap.String("hash", torrent.Hash),
|
|
||||||
zap.String("source", source),
|
|
||||||
zap.Int("organized", res.Organized),
|
|
||||||
zap.Int("replaced", res.Replaced),
|
|
||||||
zap.Int("skipped", res.Skipped))
|
|
||||||
}
|
|
||||||
if taskHandle != nil {
|
|
||||||
taskHandle.Finish(nil, TaskUpdate{
|
|
||||||
Stage: "completed",
|
|
||||||
Message: "下载完成自动整理入库结束",
|
|
||||||
Metrics: OrganizeTaskMetrics(res),
|
|
||||||
Details: OrganizeTaskDetails(res, 8),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
d.markCompletedTorrentCatchupRecorded(context.Background(), torrent)
|
d.markCompletedTorrentCatchupRecorded(context.Background(), torrent)
|
||||||
d.log.Info("auto organize completed torrent finished",
|
d.log.Info("auto organize completed torrent finished",
|
||||||
@@ -1177,23 +1237,22 @@ func (d *DownloadService) onTorrentComplete(ctx context.Context, torrent QBitTor
|
|||||||
zap.Int("errors", len(res.Errors)))
|
zap.Int("errors", len(res.Errors)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DownloadService) startDownloadOrganizeTask(torrent QBitTorrent, source string, allowReplace bool) *TaskHandle {
|
func (d *DownloadService) downloadOrganizeTaskName(torrent QBitTorrent, allowReplace bool) string {
|
||||||
if d == nil || d.tasks == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
message := "下载完成后自动整理/重命名/入库"
|
|
||||||
if allowReplace {
|
|
||||||
message = "下载完成后自动整理/重命名/入库(允许洗版替换)"
|
|
||||||
}
|
|
||||||
name := strings.TrimSpace(torrent.Name)
|
name := strings.TrimSpace(torrent.Name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = "下载完成自动整理"
|
name = "下载完成自动整理"
|
||||||
}
|
}
|
||||||
return d.tasks.Start(TaskKindOrganize, name, TaskUpdate{
|
if allowReplace {
|
||||||
Stage: "organize",
|
name += "(允许洗版)"
|
||||||
SourcePath: source,
|
}
|
||||||
Message: message,
|
return name
|
||||||
})
|
}
|
||||||
|
|
||||||
|
func (d *DownloadService) ensureOrganizePipeline() *OrganizePipelineService {
|
||||||
|
if d.organizePipeline != nil {
|
||||||
|
return d.organizePipeline
|
||||||
|
}
|
||||||
|
return NewOrganizePipelineService(d.log, d.repo, d.organizer, d.scanner, d.tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DownloadService) completedTorrentTask(ctx context.Context, torrent QBitTorrent) (*model.DownloadTask, bool) {
|
func (d *DownloadService) completedTorrentTask(ctx context.Context, torrent QBitTorrent) (*model.DownloadTask, bool) {
|
||||||
@@ -1204,12 +1263,12 @@ func (d *DownloadService) completedTorrentTask(ctx context.Context, torrent QBit
|
|||||||
if err != nil || len(rows) == 0 {
|
if err != nil || len(rows) == 0 {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
taskByKey := tasksByIdentity(rows)
|
taskByKey := tasksByTorrentIdentity(rows)
|
||||||
if task, ok := findMatchingTaskByIdentity(torrent.Name, taskByKey); ok {
|
if task, ok := findMatchingTaskByTorrentIdentity(torrent.Name, taskByKey); ok {
|
||||||
return &task, true
|
return &task, true
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(torrent.ContentPath) != "" {
|
if strings.TrimSpace(torrent.ContentPath) != "" {
|
||||||
if task, ok := findMatchingTaskByIdentity(filepath.Base(torrent.ContentPath), taskByKey); ok {
|
if task, ok := findMatchingTaskByTorrentIdentity(filepath.Base(torrent.ContentPath), taskByKey); ok {
|
||||||
return &task, true
|
return &task, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,101 @@ func TestSyncDownloadTaskProgressSkipsUnchangedCompletedTask(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSyncDownloadTaskProgressMatchesSeasonFolderTorrentName(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.DownloadTask{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
task := &model.DownloadTask{
|
||||||
|
Source: "qbittorrent",
|
||||||
|
URL: "magnet:?xt=urn:btih:test",
|
||||||
|
Title: "The First Jasmine S01E01 1080p TX WEB-DL AAC2.0 H.264-MWeb",
|
||||||
|
SavePath: "/downloads/未分类",
|
||||||
|
Status: "queued",
|
||||||
|
Progress: 0.5,
|
||||||
|
}
|
||||||
|
if err := repos.Download.Create(t.Context(), task); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
|
||||||
|
svc.syncDownloadTaskProgress(t.Context(), QBitTorrent{
|
||||||
|
Name: "The.First.Jasmine.S01.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb",
|
||||||
|
Progress: 1,
|
||||||
|
State: "stalledUP",
|
||||||
|
}, tasksByTorrentIdentity([]model.DownloadTask{*task}))
|
||||||
|
|
||||||
|
var after model.DownloadTask
|
||||||
|
if err := db.First(&after, "id = ?", task.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if after.Status != "completed" || after.Progress != 1 {
|
||||||
|
t.Fatalf("task completion = %s/%v, want completed/1", after.Status, after.Progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessDownloadSnapshotQueuesCompletedPendingTaskOnFirstSnapshot(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.DownloadTask{}, &model.Setting{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
task := &model.DownloadTask{
|
||||||
|
Source: "qbittorrent",
|
||||||
|
URL: "magnet:?xt=urn:btih:test",
|
||||||
|
Title: "Blades of the Guardians S02E01 1080p TX WEB-DL AAC2.0 H.264-MWeb",
|
||||||
|
SavePath: "/downloads/未分类",
|
||||||
|
Status: "queued",
|
||||||
|
Progress: 0,
|
||||||
|
}
|
||||||
|
if err := repos.Download.Create(t.Context(), task); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
|
||||||
|
svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{
|
||||||
|
Hash: "quickdone",
|
||||||
|
Name: "Blades.of.the.Guardians.S02.1080p.TX.WEB-DL.AAC2.0.H.264-MWeb",
|
||||||
|
Progress: 1,
|
||||||
|
State: "stalledUP",
|
||||||
|
}}, tasksByTorrentIdentity([]model.DownloadTask{*task}))
|
||||||
|
|
||||||
|
if got := len(svc.organizeQueue); got != 1 {
|
||||||
|
t.Fatalf("queued completed organize jobs = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessDownloadSnapshotSkipsUntrackedCompletedTorrentOnFirstSnapshot(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.DownloadTask{}, &model.Setting{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
|
||||||
|
|
||||||
|
svc.processDownloadSnapshot(t.Context(), []QBitTorrent{{
|
||||||
|
Hash: "historydone",
|
||||||
|
Name: "Large.History.Pack.2026.1080p",
|
||||||
|
Progress: 1,
|
||||||
|
State: "stalledUP",
|
||||||
|
CompletionOn: time.Now().Unix(),
|
||||||
|
}}, tasksByTorrentIdentity(nil))
|
||||||
|
|
||||||
|
if got := len(svc.organizeQueue); got != 0 {
|
||||||
|
t.Fatalf("queued untracked historical torrents = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDownloadCompleteAutoOrganizesContentPath(t *testing.T) {
|
func TestDownloadCompleteAutoOrganizesContentPath(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
src := filepath.Join(root, "downloads", "国产剧", "狂飙.S01E01.2023.1080p.mkv")
|
src := filepath.Join(root, "downloads", "国产剧", "狂飙.S01E01.2023.1080p.mkv")
|
||||||
@@ -335,17 +430,31 @@ func TestDownloadPollBaselinesAlreadyCompletedTorrents(t *testing.T) {
|
|||||||
|
|
||||||
func TestDownloadPollCatchesUpRecentlyCompletedTorrents(t *testing.T) {
|
func TestDownloadPollCatchesUpRecentlyCompletedTorrents(t *testing.T) {
|
||||||
repos := newOrganizerTestRepo(t)
|
repos := newOrganizerTestRepo(t)
|
||||||
|
if err := repos.DB.AutoMigrate(&model.DownloadTask{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
task := &model.DownloadTask{
|
||||||
|
Source: "qbittorrent",
|
||||||
|
URL: "magnet:?xt=urn:btih:fresh",
|
||||||
|
Title: "Fresh Complete S01E01",
|
||||||
|
SavePath: "/downloads",
|
||||||
|
Status: "queued",
|
||||||
|
Progress: 0,
|
||||||
|
}
|
||||||
|
if err := repos.Download.Create(t.Context(), task); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
|
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
|
||||||
|
|
||||||
svc.processDownloadSnapshot(t.Context(), []QBitTorrent{
|
svc.processDownloadSnapshot(t.Context(), []QBitTorrent{
|
||||||
{Hash: "fresh-complete", Name: "Fresh Complete S01E01", Progress: 1, CompletionOn: time.Now().Add(-time.Hour).Unix()},
|
{Hash: "fresh-complete", Name: "Fresh Complete S01E01", Progress: 1, CompletionOn: time.Now().Add(-time.Hour).Unix()},
|
||||||
{Hash: "stale-complete", Name: "Stale Complete S01E01", Progress: 1, CompletionOn: time.Now().Add(-48 * time.Hour).Unix()},
|
{Hash: "stale-complete", Name: "Stale Complete S01E01", Progress: 1, CompletionOn: time.Now().Add(-48 * time.Hour).Unix()},
|
||||||
{Hash: "no-timestamp", Name: "No Timestamp S01E01", Progress: 1},
|
{Hash: "no-timestamp", Name: "No Timestamp S01E01", Progress: 1},
|
||||||
}, nil)
|
}, tasksByTorrentIdentity([]model.DownloadTask{*task}))
|
||||||
|
|
||||||
// 只有补整理时间窗内完成的种子会被补整理;无 completion_on 的保守跳过。
|
// 只有补整理时间窗内、且存在本地追踪任务的种子会被补整理;无 completion_on 的保守跳过。
|
||||||
if got := len(svc.organizeQueue); got != 1 {
|
if got := len(svc.organizeQueue); got != 1 {
|
||||||
t.Fatalf("first poll queued %d organize jobs, want 1 (recent completion only)", got)
|
t.Fatalf("first poll queued %d organize jobs, want 1 (recent tracked completion only)", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,6 +766,65 @@ func TestDeleteMarksMatchingDownloadTaskDeleted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeleteMarksMagnetTaskDeletedWhenLiveTorrentNameMissing(t *testing.T) {
|
||||||
|
const hash = "0123456789abcdef0123456789abcdef0123c0de"
|
||||||
|
var deleteCalls int32
|
||||||
|
qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v2/auth/login":
|
||||||
|
_, _ = w.Write([]byte("Ok."))
|
||||||
|
case "/api/v2/torrents/info":
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
case "/api/v2/torrents/delete":
|
||||||
|
atomic.AddInt32(&deleteCalls, 1)
|
||||||
|
_, _ = w.Write([]byte("Ok."))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer qb.Close()
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.DownloadTask{}, &model.DownloadClient{}, &model.Setting{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
configureTestDefaultQB(t, repos, qb.URL)
|
||||||
|
task := &model.DownloadTask{
|
||||||
|
UserID: "u1",
|
||||||
|
Source: "qbittorrent",
|
||||||
|
URL: "magnet:?xt=urn:btih:" + hash + "&dn=Codex.Path.Verify.S01E01.2026",
|
||||||
|
Title: "Codex Path Verify S01E01 2026",
|
||||||
|
SavePath: "/downloads/tv",
|
||||||
|
Status: "queued",
|
||||||
|
}
|
||||||
|
if err := repos.Download.Create(t.Context(), task); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
|
||||||
|
if err := svc.ReloadConfig(t.Context()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := svc.Delete(t.Context(), hash, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&deleteCalls); got != 1 {
|
||||||
|
t.Fatalf("delete calls = %d, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated model.DownloadTask
|
||||||
|
if err := db.Where("id = ?", task.ID).First(&updated).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if updated.Status != "deleted" {
|
||||||
|
t.Fatalf("status = %q, want deleted", updated.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAddDownloadWithMetaSkipsExistingLocalMovieBeforeQBAdd(t *testing.T) {
|
func TestAddDownloadWithMetaSkipsExistingLocalMovieBeforeQBAdd(t *testing.T) {
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -852,6 +1020,37 @@ func TestAddDownloadWithMetaAutoClassifiesSavePathAndQBitCategory(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDownloadSavePathCategoryRootKeepsWindowsClientSeparators(t *testing.T) {
|
||||||
|
if got := downloadSavePathCategoryRoot(`F:\downloads`, "国产剧"); got != `F:\downloads\国产剧` {
|
||||||
|
t.Fatalf("downloadSavePathCategoryRoot() = %q, want Windows qB path", got)
|
||||||
|
}
|
||||||
|
if got := downloadSavePathCategoryRoot(`F:\downloads\国产剧`, "国产剧"); got != `F:\downloads\国产剧` {
|
||||||
|
t.Fatalf("downloadSavePathCategoryRoot() duplicated category: %q", got)
|
||||||
|
}
|
||||||
|
if got := downloadSavePathCategoryRoot(`/downloads`, "国产剧"); got != filepath.Join(`/downloads`, "国产剧") {
|
||||||
|
t.Fatalf("downloadSavePathCategoryRoot() = %q, want local path", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranslateClientPathMapsWindowsQBitPathToContainerDownloadPath(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
containerDownloads := filepath.Join(root, "downloads")
|
||||||
|
want := filepath.Join(containerDownloads, "国产剧", "Show.S01E01.mkv")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(want), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(want, []byte("episode"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := translateClientPath(`F:\downloads\国产剧\Show.S01E01.mkv`, map[string]string{
|
||||||
|
`F:\downloads`: containerDownloads,
|
||||||
|
})
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("translateClientPath() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAddDownloadWithMetaCanDisableAutoClassifiedSavePath(t *testing.T) {
|
func TestAddDownloadWithMetaCanDisableAutoClassifiedSavePath(t *testing.T) {
|
||||||
var addCalls int32
|
var addCalls int32
|
||||||
var gotSavePath string
|
var gotSavePath string
|
||||||
@@ -974,6 +1173,58 @@ func TestReloadConfigDoesNotFallbackToLegacyAfterClientDisabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReloadConfigUsesSoleEnabledQBitWhenNoExplicitDefault(t *testing.T) {
|
||||||
|
var addCalls int32
|
||||||
|
qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v2/auth/login":
|
||||||
|
_, _ = w.Write([]byte("Ok."))
|
||||||
|
case "/api/v2/torrents/info":
|
||||||
|
if atomic.LoadInt32(&addCalls) > 0 {
|
||||||
|
_, _ = w.Write([]byte(`[{"hash":"sole123","name":"Movie 2026 1080p","state":"downloading","progress":0.1}]`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
case "/api/v2/torrents/add":
|
||||||
|
atomic.AddInt32(&addCalls, 1)
|
||||||
|
_, _ = w.Write([]byte("Ok."))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer qb.Close()
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.DownloadClient{}, &model.DownloadTask{}, &model.Setting{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
if err := repos.Setting.Set(t.Context(), settingDownloadClientsManaged, "true"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
client := &model.DownloadClient{Name: "qB", Type: "qbittorrent", Host: qb.URL, Username: "admin", Password: "admin", IsDefault: false, Enabled: true}
|
||||||
|
if err := repos.DownloadClient.Create(t.Context(), client); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
|
||||||
|
task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:abababababababababababababababababababab&dn=Movie+2026+1080p", "/downloads", DownloadTaskMeta{
|
||||||
|
Title: "Movie 2026 1080p",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if task == nil {
|
||||||
|
t.Fatal("expected task")
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&addCalls); got != 1 {
|
||||||
|
t.Fatalf("qb add calls = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAddDownloadWithMetaFailsClosedWhenNoDownloaderConfigured(t *testing.T) {
|
func TestAddDownloadWithMetaFailsClosedWhenNoDownloaderConfigured(t *testing.T) {
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ var (
|
|||||||
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`)
|
||||||
patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`)
|
patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`)
|
||||||
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`)
|
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`)
|
||||||
|
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`)
|
||||||
patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`)
|
patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`)
|
||||||
// patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
|
// patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
|
||||||
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
|
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -34,12 +35,11 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
|
|||||||
languages := normalizeTokens(input.Languages...)
|
languages := normalizeTokens(input.Languages...)
|
||||||
rawText := input.Title + " " + input.Category + " " + strings.Join(input.Genres, " ")
|
rawText := input.Title + " " + input.Category + " " + strings.Join(input.Genres, " ")
|
||||||
text := strings.ToLower(rawText)
|
text := strings.ToLower(rawText)
|
||||||
|
hasMetadata := len(genres) > 0 || len(countries) > 0 || len(languages) > 0
|
||||||
|
|
||||||
isChinesePlatform := containsAnyText(text,
|
isChineseByMetadata := hasAny(languages, "ZH", "ZH-CN", "ZH-TW", "CN", "BO", "ZA") || hasAny(countries, "CN", "TW", "HK", "MO")
|
||||||
"iqiyi", "qiyi", "youku", "tencent", "wetv", "mgtv", "mango", "hunantv", "cctv",
|
isChineseByText := containsHan(rawText) || containsAnyText(text, "华语", "国产", "国剧", "国漫")
|
||||||
"bilibili", "bili", "芒果tv", "腾讯视频", "优酷", "爱奇艺",
|
isChinese := isChineseByMetadata || (!hasMetadata && isChineseByText)
|
||||||
)
|
|
||||||
isChinese := hasAny(languages, "ZH", "ZH-CN", "ZH-TW", "CN") || hasAny(countries, "CN", "TW", "HK", "MO") || containsHan(rawText) || containsAnyText(text, "华语", "国产", "国剧", "国漫") || isChinesePlatform
|
|
||||||
isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || containsJapaneseKana(rawText) || strings.Contains(text, "日番")
|
isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || containsJapaneseKana(rawText) || strings.Contains(text, "日番")
|
||||||
isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP") || containsKoreanHangul(rawText)
|
isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP") || containsKoreanHangul(rawText)
|
||||||
isEastAsian := isJapanese || isKorean || hasAny(countries, "TH", "IN", "SG")
|
isEastAsian := isJapanese || isKorean || hasAny(countries, "TH", "IN", "SG")
|
||||||
@@ -47,8 +47,7 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
|
|||||||
"US", "GB", "UK", "FR", "DE", "CA", "AU", "NZ", "IE", "NL", "SE", "NO", "DK",
|
"US", "GB", "UK", "FR", "DE", "CA", "AU", "NZ", "IE", "NL", "SE", "NO", "DK",
|
||||||
"FI", "ES", "IT", "PT", "AT", "CH", "BE", "RU",
|
"FI", "ES", "IT", "PT", "AT", "CH", "BE", "RU",
|
||||||
)
|
)
|
||||||
isLatinFallback := containsLatin(rawText) && !containsHan(rawText) && !containsJapaneseKana(rawText) && !containsKoreanHangul(rawText)
|
isWestern := isWesternByMetadata
|
||||||
isWestern := isWesternByMetadata || (mediaType == "tv" && isLatinFallback)
|
|
||||||
hasAnimeText := containsAnyText(text, "动画", "动漫", "番剧", "年番", "国漫", "日番", "bangumi", "anime", "b-global", "ani-one", "crunchyroll")
|
hasAnimeText := containsAnyText(text, "动画", "动漫", "番剧", "年番", "国漫", "日番", "bangumi", "anime", "b-global", "ani-one", "crunchyroll")
|
||||||
hasVarietyText := containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show")
|
hasVarietyText := containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show")
|
||||||
hasDocumentaryText := containsAnyText(text, "纪录", "纪录片", "documentary", "docu", "national geographic", "natgeo")
|
hasDocumentaryText := containsAnyText(text, "纪录", "纪录片", "documentary", "docu", "national geographic", "natgeo")
|
||||||
@@ -74,18 +73,12 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
|
|||||||
if isAdultText {
|
if isAdultText {
|
||||||
return categoryName(categories, "adult", "成人")
|
return categoryName(categories, "adult", "成人")
|
||||||
}
|
}
|
||||||
if hasGenre("16", "ANIMATION", "动画", "动漫") {
|
if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText {
|
||||||
return categoryName(categories, "animation_movie", "动画电影")
|
return categoryName(categories, "animation_movie", "动画电影")
|
||||||
}
|
}
|
||||||
if isChinese {
|
if isChinese {
|
||||||
return categoryName(categories, "chinese_movie", "华语电影")
|
return categoryName(categories, "chinese_movie", "华语电影")
|
||||||
}
|
}
|
||||||
if isEastAsian {
|
|
||||||
return categoryName(categories, "jk_movie", "日韩电影")
|
|
||||||
}
|
|
||||||
if isWesternByMetadata {
|
|
||||||
return categoryName(categories, "euus_movie", "欧美电影")
|
|
||||||
}
|
|
||||||
return categoryName(categories, "foreign_movie", "外语电影")
|
return categoryName(categories, "foreign_movie", "外语电影")
|
||||||
case "anime":
|
case "anime":
|
||||||
if isChinese {
|
if isChinese {
|
||||||
@@ -280,6 +273,29 @@ func (o *OrganizerService) classifyMedia(ctx context.Context, m *model.Media, me
|
|||||||
func (s *SubscriptionService) classifySubscriptionItem(ctx context.Context, sub *model.Subscription, title, sourceCategory string) (string, string) {
|
func (s *SubscriptionService) classifySubscriptionItem(ctx context.Context, sub *model.Subscription, title, sourceCategory string) (string, string) {
|
||||||
mediaType := normalizeMediaType(sub.MediaType, title+" "+sub.Name+" "+sub.Filter, sourceCategory)
|
mediaType := normalizeMediaType(sub.MediaType, title+" "+sub.Name+" "+sub.Filter, sourceCategory)
|
||||||
category := strings.TrimSpace(sub.MediaCategory)
|
category := strings.TrimSpace(sub.MediaCategory)
|
||||||
|
if category == "" {
|
||||||
|
if match := s.lookupSubscriptionMetadata(ctx, mediaType, title, sub); match != nil {
|
||||||
|
category = classifyMediaCategory(mediaClassifyInput{
|
||||||
|
MediaType: mediaType,
|
||||||
|
Title: match.Title + " " + match.OriginalName,
|
||||||
|
Languages: match.Languages,
|
||||||
|
Countries: match.Countries,
|
||||||
|
Genres: match.Genres,
|
||||||
|
Category: sourceCategory,
|
||||||
|
}, s.categoryMap())
|
||||||
|
if s != nil && s.log != nil && category != "" {
|
||||||
|
s.log.Info("subscription metadata classified",
|
||||||
|
zap.String("title", title),
|
||||||
|
zap.String("matched_title", match.Title),
|
||||||
|
zap.String("media_type", mediaType),
|
||||||
|
zap.String("media_category", category),
|
||||||
|
zap.Int("tmdb_id", match.TMDbID),
|
||||||
|
zap.Int("bangumi_id", match.BangumiID),
|
||||||
|
zap.String("douban_id", match.DoubanID),
|
||||||
|
zap.String("thetvdb_id", match.TheTVDBID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if category == "" {
|
if category == "" {
|
||||||
category = classifyMediaCategory(mediaClassifyInput{
|
category = classifyMediaCategory(mediaClassifyInput{
|
||||||
MediaType: mediaType,
|
MediaType: mediaType,
|
||||||
@@ -290,6 +306,78 @@ func (s *SubscriptionService) classifySubscriptionItem(ctx context.Context, sub
|
|||||||
return mediaType, category
|
return mediaType, category
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SubscriptionService) lookupSubscriptionMetadata(ctx context.Context, mediaType, title string, sub *model.Subscription) *Match {
|
||||||
|
if s == nil || s.scraper == nil || !s.scraper.AnyEnabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
queries := subscriptionMetadataQueries(title, sub)
|
||||||
|
if len(queries) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, libType := range subscriptionMetadataLibraryTypes(mediaType, title) {
|
||||||
|
lib := &model.Library{Type: libType, Enabled: true}
|
||||||
|
for _, query := range queries {
|
||||||
|
cleaned, year := CleanQuery(query)
|
||||||
|
if cleaned == "" {
|
||||||
|
cleaned = strings.TrimSpace(query)
|
||||||
|
}
|
||||||
|
for _, candidate := range titleCandidates(cleaned) {
|
||||||
|
if candidate == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
match := s.scraper.lookup(ctx, lib, candidate, year)
|
||||||
|
if match == nil || strings.TrimSpace(match.Title) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !organizeMetadataMatchTrusted(candidate, year, match) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func subscriptionMetadataQueries(title string, sub *model.Subscription) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
out := make([]string, 0, 3)
|
||||||
|
add := func(value string) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := strings.ToLower(value)
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
add(title)
|
||||||
|
if sub != nil {
|
||||||
|
add(sub.Filter)
|
||||||
|
add(sub.Name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func subscriptionMetadataLibraryTypes(mediaType, title string) []string {
|
||||||
|
switch normalizeMediaType(mediaType, title, "") {
|
||||||
|
case "movie":
|
||||||
|
return []string{"movie"}
|
||||||
|
case "anime":
|
||||||
|
return []string{"anime", "tv"}
|
||||||
|
case "tv", "variety":
|
||||||
|
return []string{"tv", "anime"}
|
||||||
|
default:
|
||||||
|
if classifierEpisodeRE.MatchString(title) || classifierSeasonRE.MatchString(title) {
|
||||||
|
return []string{"tv", "anime"}
|
||||||
|
}
|
||||||
|
return []string{"movie", "tv", "anime"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SubscriptionService) categoryMap() map[string]string {
|
func (s *SubscriptionService) categoryMap() map[string]string {
|
||||||
if s == nil || s.cfg == nil || s.cfg.Organizer.Categories == nil {
|
if s == nil || s.cfg == nil || s.cfg.Organizer.Categories == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -311,7 +399,7 @@ func (s *SubscriptionService) resolveSubscriptionSavePath(ctx context.Context, s
|
|||||||
if !s.isSmartClassifyEnabled(ctx) || category == "" {
|
if !s.isSmartClassifyEnabled(ctx) || category == "" {
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
return categoryRoot(base, sanitizeFilename(category))
|
return downloadSavePathCategoryRoot(base, sanitizeFilename(category))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubscriptionService) isSmartClassifyEnabled(ctx context.Context) bool {
|
func (s *SubscriptionService) isSmartClassifyEnabled(ctx context.Context) bool {
|
||||||
@@ -368,3 +456,39 @@ func downloadCategoryMap(organizer *OrganizerService) map[string]string {
|
|||||||
}
|
}
|
||||||
return organizer.categoryMap()
|
return organizer.categoryMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func downloadSavePathCategoryRoot(root, category string) string {
|
||||||
|
root = strings.TrimSpace(root)
|
||||||
|
category = strings.TrimSpace(category)
|
||||||
|
if root == "" || category == "" {
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
if isWindowsStyleClientPath(root) {
|
||||||
|
cleanRoot := strings.ReplaceAll(root, "/", `\`)
|
||||||
|
cleanRoot = strings.TrimRight(cleanRoot, `\`)
|
||||||
|
if windowsPathBaseEqual(cleanRoot, category) {
|
||||||
|
return cleanRoot
|
||||||
|
}
|
||||||
|
return cleanRoot + `\` + category
|
||||||
|
}
|
||||||
|
return categoryRoot(root, category)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isWindowsStyleClientPath(path string) bool {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
return (len(path) >= 2 && isASCIIAlpha(path[0]) && path[1] == ':') ||
|
||||||
|
strings.HasPrefix(path, `\\`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowsPathBaseEqual(path, base string) bool {
|
||||||
|
path = strings.TrimRight(strings.ReplaceAll(strings.TrimSpace(path), "/", `\`), `\`)
|
||||||
|
base = strings.Trim(strings.TrimSpace(base), `\/`)
|
||||||
|
if path == "" || base == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
idx := strings.LastIndex(path, `\`)
|
||||||
|
if idx >= 0 {
|
||||||
|
path = path[idx+1:]
|
||||||
|
}
|
||||||
|
return strings.EqualFold(path, base)
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,37 @@ func TestClassifyMediaCategoryMatchesMoviePilotStyleRules(t *testing.T) {
|
|||||||
},
|
},
|
||||||
want: "动画电影",
|
want: "动画电影",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "translated tmdb animation title uses genre before title script",
|
||||||
|
input: mediaClassifyInput{
|
||||||
|
MediaType: "movie",
|
||||||
|
Title: "寻龙记",
|
||||||
|
Languages: []string{"en"},
|
||||||
|
Countries: []string{"NL"},
|
||||||
|
Genres: []string{"16"},
|
||||||
|
},
|
||||||
|
want: "动画电影",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "translated western movie title does not become chinese movie",
|
||||||
|
input: mediaClassifyInput{
|
||||||
|
MediaType: "movie",
|
||||||
|
Title: "大雄兔",
|
||||||
|
Languages: []string{"en"},
|
||||||
|
Countries: []string{"NL"},
|
||||||
|
Genres: []string{"Comedy"},
|
||||||
|
},
|
||||||
|
want: "外语电影",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "movie animation source category fallback",
|
||||||
|
input: mediaClassifyInput{
|
||||||
|
MediaType: "movie",
|
||||||
|
Title: "Sintel 2010 1080p",
|
||||||
|
Category: "动画电影",
|
||||||
|
},
|
||||||
|
want: "动画电影",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "tv variety by genre",
|
name: "tv variety by genre",
|
||||||
input: mediaClassifyInput{
|
input: mediaClassifyInput{
|
||||||
@@ -82,26 +113,27 @@ func TestClassifyMediaCategoryMatchesMoviePilotStyleRules(t *testing.T) {
|
|||||||
want: "国产剧",
|
want: "国产剧",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "latin tv title without metadata",
|
name: "latin tv title without metadata stays uncategorized",
|
||||||
input: mediaClassifyInput{
|
input: mediaClassifyInput{
|
||||||
MediaType: "tv",
|
MediaType: "tv",
|
||||||
Title: "The Last of Us S01E01 1080p",
|
Title: "The Last of Us S01E01 1080p",
|
||||||
},
|
},
|
||||||
want: "欧美剧",
|
want: "未分类",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "iqiyi romanized chinese drama",
|
name: "platform token alone does not classify romanized drama",
|
||||||
input: mediaClassifyInput{
|
input: mediaClassifyInput{
|
||||||
MediaType: "tv",
|
MediaType: "tv",
|
||||||
Title: "Motherhood.of.Taihang.S01E01.2026.1080p.iQIYI.WEB-DL",
|
Title: "Motherhood.of.Taihang.S01E01.2026.1080p.iQIYI.WEB-DL",
|
||||||
},
|
},
|
||||||
want: "国产剧",
|
want: "未分类",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "youku romanized chinese drama",
|
name: "metadata classifies romanized chinese drama",
|
||||||
input: mediaClassifyInput{
|
input: mediaClassifyInput{
|
||||||
MediaType: "tv",
|
MediaType: "tv",
|
||||||
Title: "Ashes.to.Crown.S01E15.2160p.YOUKU.WEB-DL",
|
Title: "Ashes.to.Crown.S01E15.2160p.YOUKU.WEB-DL",
|
||||||
|
Countries: []string{"CN"},
|
||||||
},
|
},
|
||||||
want: "国产剧",
|
want: "国产剧",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
// TestOrganizeNaming locks in the rename pipeline used by OrganizeDirectory:
|
// TestOrganizeNaming locks in the rename pipeline used by OrganizeDirectory:
|
||||||
// CleanQuery (title/year) + ParseEpisode (season/episode) + titleCaseWords.
|
// CleanQuery (title/year) + ParseEpisode (season/episode) + titleCaseWords.
|
||||||
@@ -24,6 +33,7 @@ func TestOrganizeNaming(t *testing.T) {
|
|||||||
{"Friends.S03E12.1994.720p.mkv", "Friends", 1994, 3, 12},
|
{"Friends.S03E12.1994.720p.mkv", "Friends", 1994, 3, 12},
|
||||||
{"Oppenheimer.2023.2160p.UHD.BluRay.mkv", "Oppenheimer", 2023, 0, 0},
|
{"Oppenheimer.2023.2160p.UHD.BluRay.mkv", "Oppenheimer", 2023, 0, 0},
|
||||||
{"Rocky.IV.1985.1080p.BluRay.mkv", "Rocky IV", 1985, 0, 0},
|
{"Rocky.IV.1985.1080p.BluRay.mkv", "Rocky IV", 1985, 0, 0},
|
||||||
|
{"Big.Buck.Bunny.2008.1080p.CodexVerify.mp4", "Big Buck Bunny", 2008, 0, 0},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.file, func(t *testing.T) {
|
t.Run(tc.file, func(t *testing.T) {
|
||||||
@@ -59,3 +69,165 @@ func TestTitleCaseWordsRomanNumerals(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOrganizeDirectoryHonorsConfiguredNamingFormats(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dest := filepath.Join(root, "media")
|
||||||
|
repos := newOrganizerTestRepo(t)
|
||||||
|
for key, value := range map[string]string{
|
||||||
|
"organize.movie_format": "Movies/{title} ({year})/{title} [{year}]",
|
||||||
|
"organize.tv_format": "Series/{title} ({year})/S{season:02}/{title}.S{season:02}E{episode:02}",
|
||||||
|
"organize.anime_format": "Bangumi/{title}/Season {season:02}/{title} - {episode:03}",
|
||||||
|
} {
|
||||||
|
if err := repos.Setting.Set(t.Context(), key, value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
|
||||||
|
|
||||||
|
movieSrc := filepath.Join(root, "downloads", "Dune.2021.2160p.mkv")
|
||||||
|
writeOrgFile(t, movieSrc, "movie")
|
||||||
|
if _, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
|
||||||
|
SourcePath: movieSrc,
|
||||||
|
DestPath: dest,
|
||||||
|
MediaType: "movie",
|
||||||
|
TransferMode: TransferCopy,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("organize movie: %v", err)
|
||||||
|
}
|
||||||
|
movieWant := filepath.Join(dest, "电影", "Movies", "Dune (2021)", "Dune [2021].mkv")
|
||||||
|
if _, err := os.Stat(movieWant); err != nil {
|
||||||
|
t.Fatalf("movie naming format not honored, want %q: %v", movieWant, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tvSrc := filepath.Join(root, "downloads", "Some.Show.S02E03.2024.mkv")
|
||||||
|
writeOrgFile(t, tvSrc, "tv")
|
||||||
|
if _, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
|
||||||
|
SourcePath: tvSrc,
|
||||||
|
DestPath: dest,
|
||||||
|
MediaType: "tv",
|
||||||
|
TransferMode: TransferCopy,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("organize tv: %v", err)
|
||||||
|
}
|
||||||
|
tvWant := filepath.Join(dest, "电视剧", "Series", "Some Show (2024)", "S02", "Some Show.S02E03.mkv")
|
||||||
|
if _, err := os.Stat(tvWant); err != nil {
|
||||||
|
t.Fatalf("tv naming format not honored, want %q: %v", tvWant, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
animeSrc := filepath.Join(root, "downloads", "Frieren.S01E01.2023.mkv")
|
||||||
|
writeOrgFile(t, animeSrc, "anime")
|
||||||
|
if _, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
|
||||||
|
SourcePath: animeSrc,
|
||||||
|
DestPath: dest,
|
||||||
|
MediaType: "anime",
|
||||||
|
MediaCategory: "日番",
|
||||||
|
TransferMode: TransferCopy,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("organize anime: %v", err)
|
||||||
|
}
|
||||||
|
animeWant := filepath.Join(dest, "电视剧", "日番", "Bangumi", "Frieren", "Season 01", "Frieren - 001.mkv")
|
||||||
|
if _, err := os.Stat(animeWant); err != nil {
|
||||||
|
t.Fatalf("anime naming format not honored, want %q: %v", animeWant, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrganizeDirectoryHonorsMoviePilotStyleNamingFormat(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dest := filepath.Join(root, "media")
|
||||||
|
repos := newOrganizerTestRepo(t)
|
||||||
|
template := "{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{% if episode %} - 第 {{episode}} 集{% endif %}{{fileExt}}"
|
||||||
|
if err := repos.Setting.Set(t.Context(), "organize.tv_format", template); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
|
||||||
|
|
||||||
|
src := filepath.Join(root, "downloads", "Verify.Show.S01E02.2026.mkv")
|
||||||
|
writeOrgFile(t, src, "episode")
|
||||||
|
if _, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
|
||||||
|
SourcePath: src,
|
||||||
|
DestPath: dest,
|
||||||
|
MediaType: "tv",
|
||||||
|
TransferMode: TransferCopy,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("organize tv: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := filepath.Join(dest, "电视剧", "Verify Show (2026)", "Season 1", "Verify Show - S01E02 - 第 2 集.mkv")
|
||||||
|
if _, err := os.Stat(want); err != nil {
|
||||||
|
t.Fatalf("MoviePilot-style naming format not honored, want %q: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrganizePipelineUsesSameNamingFormat(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
src := filepath.Join(root, "downloads", "Pipeline.Show.S01E02.2026.mkv")
|
||||||
|
dest := filepath.Join(root, "media")
|
||||||
|
writeOrgFile(t, src, "episode")
|
||||||
|
|
||||||
|
repos := newOrganizerTestRepo(t)
|
||||||
|
if err := repos.Setting.Set(t.Context(), "organize.tv_format", "{title}/S{season:02}/{title} - EP{episode:02}"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
|
||||||
|
pipeline := NewOrganizePipelineService(zap.NewNop(), repos, organizer, nil, nil)
|
||||||
|
|
||||||
|
res, err := pipeline.Run(t.Context(), OrganizePipelineRequest{
|
||||||
|
Scope: OrganizeScopeDirectory,
|
||||||
|
Trigger: OrganizeTriggerManual,
|
||||||
|
SourcePath: src,
|
||||||
|
DestPath: dest,
|
||||||
|
TransferMode: string(TransferCopy),
|
||||||
|
MediaType: "tv",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pipeline organize: %v", err)
|
||||||
|
}
|
||||||
|
if res.Result == nil || res.Result.Organized != 1 {
|
||||||
|
t.Fatalf("pipeline result = %#v, want organized=1", res)
|
||||||
|
}
|
||||||
|
want := filepath.Join(dest, "电视剧", "Pipeline Show", "S01", "Pipeline Show - EP02.mkv")
|
||||||
|
if _, err := os.Stat(want); err != nil {
|
||||||
|
t.Fatalf("pipeline did not use configured naming format, want %q: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrganizeMediaHonorsConfiguredNamingFormat(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
sourceDir := filepath.Join(root, "incoming")
|
||||||
|
source := filepath.Join(sourceDir, "Some Show S01E02.mkv")
|
||||||
|
writeOrgFile(t, source, "episode")
|
||||||
|
|
||||||
|
repos := newOrganizerTestRepo(t)
|
||||||
|
if err := repos.Setting.Set(t.Context(), "organize.tv_format", "{title}/Season {season:02}/{title} - 第{episode:02}集"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lib := model.Library{Name: "TV", Path: root, Type: "tv", Enabled: true}
|
||||||
|
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
media := model.Media{
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
Title: "Some Show",
|
||||||
|
Path: source,
|
||||||
|
Container: "mkv",
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 2,
|
||||||
|
}
|
||||||
|
if err := repos.Media.Upsert(t.Context(), &media); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
organizer := NewOrganizerService(&config.Config{}, zap.NewNop(), repos)
|
||||||
|
dst, err := organizer.OrganizeMedia(t.Context(), media.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := filepath.Join(root, "Some Show", "Season 01", "Some Show - 第02集.mkv")
|
||||||
|
if dst != want {
|
||||||
|
t.Fatalf("dst = %q, want %q", dst, want)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(want); err != nil {
|
||||||
|
t.Fatalf("organized file missing: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OrganizeScope string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OrganizeScopeDirectory OrganizeScope = "directory"
|
||||||
|
OrganizeScopeMedia OrganizeScope = "media"
|
||||||
|
OrganizeScopeLibrary OrganizeScope = "library"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OrganizeTrigger string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OrganizeTriggerManual OrganizeTrigger = "manual"
|
||||||
|
OrganizeTriggerScheduled OrganizeTrigger = "scheduled"
|
||||||
|
OrganizeTriggerDownload OrganizeTrigger = "download"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OrganizePipelineRequest is the single service-facing entry point for every
|
||||||
|
// organize → rename → scan → scrape ingest workflow. Handlers, scheduler jobs
|
||||||
|
// and download-complete hooks should only decide the trigger/scope/options;
|
||||||
|
// the pipeline owns the execution order and task reporting.
|
||||||
|
type OrganizePipelineRequest struct {
|
||||||
|
Scope OrganizeScope
|
||||||
|
Trigger OrganizeTrigger
|
||||||
|
TaskName string
|
||||||
|
MediaID string
|
||||||
|
LibraryID string
|
||||||
|
PreferredLibraryID string
|
||||||
|
SourcePath string
|
||||||
|
DestPath string
|
||||||
|
TransferMode string
|
||||||
|
MediaType string
|
||||||
|
MediaCategory string
|
||||||
|
ScanAfter *bool
|
||||||
|
ScrapeAfter *bool
|
||||||
|
DryRun bool
|
||||||
|
AllowReplace bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrganizePipelineResponse struct {
|
||||||
|
Path string
|
||||||
|
Result *OrganizeResult
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrganizePipelineService struct {
|
||||||
|
log *zap.Logger
|
||||||
|
repo *repository.Container
|
||||||
|
organizer *OrganizerService
|
||||||
|
scanner *ScannerService
|
||||||
|
tasks *TaskTrackerService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOrganizePipelineService(log *zap.Logger, repo *repository.Container, organizer *OrganizerService, scanner *ScannerService, tasks *TaskTrackerService) *OrganizePipelineService {
|
||||||
|
return &OrganizePipelineService{
|
||||||
|
log: log,
|
||||||
|
repo: repo,
|
||||||
|
organizer: organizer,
|
||||||
|
scanner: scanner,
|
||||||
|
tasks: tasks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *OrganizePipelineService) Run(ctx context.Context, req OrganizePipelineRequest) (*OrganizePipelineResponse, error) {
|
||||||
|
if p == nil || p.organizer == nil {
|
||||||
|
return nil, errors.New("organize pipeline unavailable")
|
||||||
|
}
|
||||||
|
opts := OrganizeOptions{
|
||||||
|
SourcePath: strings.TrimSpace(req.SourcePath),
|
||||||
|
DestPath: strings.TrimSpace(req.DestPath),
|
||||||
|
MediaType: strings.TrimSpace(req.MediaType),
|
||||||
|
MediaCategory: strings.TrimSpace(req.MediaCategory),
|
||||||
|
DryRun: req.DryRun,
|
||||||
|
AllowReplaceExisting: req.AllowReplace,
|
||||||
|
}
|
||||||
|
if mode := strings.TrimSpace(req.TransferMode); mode != "" {
|
||||||
|
opts.TransferMode = TransferMode(mode)
|
||||||
|
}
|
||||||
|
task := p.startTask(ctx, req, opts)
|
||||||
|
|
||||||
|
response := &OrganizePipelineResponse{}
|
||||||
|
res, path, err := p.runOrganize(ctx, req, opts)
|
||||||
|
if err != nil {
|
||||||
|
p.finishTask(task, err, "organize", p.failureMessage(req), nil)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
response.Path = path
|
||||||
|
response.Result = res
|
||||||
|
|
||||||
|
if task != nil {
|
||||||
|
task.Update(TaskUpdate{
|
||||||
|
Stage: "organize",
|
||||||
|
SourcePath: res.SourcePath,
|
||||||
|
DestPath: firstNonEmpty(res.DestPath, filepath.Dir(path)),
|
||||||
|
Message: "整理/重命名完成,准备扫描入库",
|
||||||
|
Metrics: OrganizeTaskMetrics(res),
|
||||||
|
Details: OrganizeTaskDetails(res, 8),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.shouldScan(req, res) {
|
||||||
|
if task != nil {
|
||||||
|
task.Update(TaskUpdate{
|
||||||
|
Stage: "scan_scrape",
|
||||||
|
Message: "正在扫描入库并按设置刮削",
|
||||||
|
Metrics: OrganizeTaskMetrics(res),
|
||||||
|
Details: OrganizeTaskDetails(res, 8),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
scanRoot := organizeScanRoot(res, path)
|
||||||
|
if scanRoot == "" && strings.TrimSpace(path) != "" {
|
||||||
|
scanRoot = filepath.Dir(path)
|
||||||
|
}
|
||||||
|
preferredLibraryID := strings.TrimSpace(req.PreferredLibraryID)
|
||||||
|
if preferredLibraryID == "" && req.Scope == OrganizeScopeLibrary {
|
||||||
|
preferredLibraryID = strings.TrimSpace(req.LibraryID)
|
||||||
|
}
|
||||||
|
res.Scans, res.Scrapes = p.scanner.ScanAndScrapeLibrariesForPath(ctx, scanRoot, preferredLibraryID, p.scrapeAfter(ctx, req))
|
||||||
|
} else if p.log != nil && res != nil && !req.DryRun {
|
||||||
|
p.log.Info("organize pipeline skipped scan; no destination changes",
|
||||||
|
zap.String("trigger", string(req.Trigger)),
|
||||||
|
zap.String("scope", string(req.Scope)),
|
||||||
|
zap.String("source", res.SourcePath),
|
||||||
|
zap.String("dest", res.DestPath),
|
||||||
|
zap.Int("organized", res.Organized),
|
||||||
|
zap.Int("replaced", res.Replaced),
|
||||||
|
zap.Int("skipped", res.Skipped))
|
||||||
|
}
|
||||||
|
|
||||||
|
p.finishTask(task, nil, "completed", p.completedMessage(req), res)
|
||||||
|
if p.log != nil && res != nil {
|
||||||
|
p.log.Info("organize pipeline finished",
|
||||||
|
zap.String("trigger", string(req.Trigger)),
|
||||||
|
zap.String("scope", string(req.Scope)),
|
||||||
|
zap.String("source", res.SourcePath),
|
||||||
|
zap.String("dest", firstNonEmpty(res.DestPath, filepath.Dir(path))),
|
||||||
|
zap.Int("organized", res.Organized),
|
||||||
|
zap.Int("replaced", res.Replaced),
|
||||||
|
zap.Int("skipped", res.Skipped),
|
||||||
|
zap.Int("scrapes", len(res.Scrapes)),
|
||||||
|
zap.Int("errors", len(res.Errors)))
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *OrganizePipelineService) runOrganize(ctx context.Context, req OrganizePipelineRequest, opts OrganizeOptions) (*OrganizeResult, string, error) {
|
||||||
|
switch req.Scope {
|
||||||
|
case OrganizeScopeMedia:
|
||||||
|
path, err := p.organizer.OrganizeMediaWithOptions(ctx, strings.TrimSpace(req.MediaID), opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
res := &OrganizeResult{
|
||||||
|
Organized: 1,
|
||||||
|
SourcePath: opts.SourcePath,
|
||||||
|
DestPath: filepath.Dir(path),
|
||||||
|
DryRun: opts.DryRun,
|
||||||
|
Items: []OrganizePreviewItem{{
|
||||||
|
Target: path,
|
||||||
|
Action: "organize",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
return res, path, nil
|
||||||
|
case OrganizeScopeLibrary:
|
||||||
|
res, err := p.organizer.OrganizeLibraryWithOptions(ctx, strings.TrimSpace(req.LibraryID), opts)
|
||||||
|
return res, "", err
|
||||||
|
default:
|
||||||
|
res, err := p.organizer.OrganizeDirectory(ctx, opts)
|
||||||
|
return res, "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *OrganizePipelineService) shouldScan(req OrganizePipelineRequest, res *OrganizeResult) bool {
|
||||||
|
if req.DryRun || p == nil || p.scanner == nil || res == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if req.ScanAfter != nil && !*req.ScanAfter {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return OrganizeResultNeedsVisibilitySync(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func organizeScanRoot(res *OrganizeResult, path string) string {
|
||||||
|
if res == nil {
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Dir(path)
|
||||||
|
}
|
||||||
|
var root string
|
||||||
|
for _, item := range res.Items {
|
||||||
|
if !organizeItemNeedsVisibilitySync(item) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target := strings.TrimSpace(item.Target)
|
||||||
|
if target == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(target)
|
||||||
|
if root == "" {
|
||||||
|
root = dir
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
root = commonPathRoot(root, dir)
|
||||||
|
}
|
||||||
|
if root != "" {
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(path) != "" {
|
||||||
|
return filepath.Dir(path)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(res.DestPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func organizeItemNeedsVisibilitySync(item OrganizePreviewItem) bool {
|
||||||
|
switch item.Action {
|
||||||
|
case "organize", "replace":
|
||||||
|
return true
|
||||||
|
case "skip":
|
||||||
|
switch item.Reason {
|
||||||
|
case organizeSkipAlreadyOrganized, organizeSkipTargetExists, "duplicate exists", "target exists":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func commonPathRoot(a, b string) string {
|
||||||
|
a = filepath.Clean(strings.TrimSpace(a))
|
||||||
|
b = filepath.Clean(strings.TrimSpace(b))
|
||||||
|
if a == "" || a == "." {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
if b == "" || b == "." {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
if pathWithin(a, b) {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
if pathWithin(b, a) {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
parent := filepath.Dir(a)
|
||||||
|
if parent == a || parent == "." {
|
||||||
|
return parent
|
||||||
|
}
|
||||||
|
if pathWithin(b, parent) {
|
||||||
|
return parent
|
||||||
|
}
|
||||||
|
a = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *OrganizePipelineService) scrapeAfter(ctx context.Context, req OrganizePipelineRequest) bool {
|
||||||
|
if req.ScrapeAfter != nil {
|
||||||
|
return *req.ScrapeAfter
|
||||||
|
}
|
||||||
|
return OrganizeScrapeAfterEnabled(ctx, p.repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *OrganizePipelineService) startTask(ctx context.Context, req OrganizePipelineRequest, opts OrganizeOptions) *TaskHandle {
|
||||||
|
if p == nil || p.tasks == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(req.TaskName)
|
||||||
|
if name == "" {
|
||||||
|
name = p.defaultTaskName(req)
|
||||||
|
}
|
||||||
|
message := "正在整理/重命名/入库"
|
||||||
|
if req.DryRun {
|
||||||
|
message = "正在预览整理/重命名"
|
||||||
|
}
|
||||||
|
return p.tasks.Start(TaskKindOrganize, name, TaskUpdate{
|
||||||
|
Stage: "organize",
|
||||||
|
SourcePath: firstNonEmpty(opts.SourcePath, p.defaultSourcePath(ctx, req)),
|
||||||
|
DestPath: firstNonEmpty(opts.DestPath, p.defaultDestPath(ctx, req)),
|
||||||
|
Message: message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *OrganizePipelineService) finishTask(task *TaskHandle, err error, stage, message string, res *OrganizeResult) {
|
||||||
|
if task == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task.Finish(err, TaskUpdate{
|
||||||
|
Stage: stage,
|
||||||
|
Message: message,
|
||||||
|
Metrics: OrganizeTaskMetrics(res),
|
||||||
|
Details: OrganizeTaskDetails(res, 8),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *OrganizePipelineService) defaultTaskName(req OrganizePipelineRequest) string {
|
||||||
|
switch req.Trigger {
|
||||||
|
case OrganizeTriggerScheduled:
|
||||||
|
return "自动整理重命名刮削入库"
|
||||||
|
case OrganizeTriggerDownload:
|
||||||
|
return "下载完成自动整理重命名刮削入库"
|
||||||
|
default:
|
||||||
|
if req.DryRun {
|
||||||
|
return "预览整理重命名入库"
|
||||||
|
}
|
||||||
|
return "手动整理重命名刮削入库"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *OrganizePipelineService) failureMessage(req OrganizePipelineRequest) string {
|
||||||
|
switch req.Trigger {
|
||||||
|
case OrganizeTriggerScheduled:
|
||||||
|
return "自动整理重命名入库失败"
|
||||||
|
case OrganizeTriggerDownload:
|
||||||
|
return "下载完成自动整理失败"
|
||||||
|
default:
|
||||||
|
return "手动整理重命名入库失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *OrganizePipelineService) completedMessage(req OrganizePipelineRequest) string {
|
||||||
|
switch req.Trigger {
|
||||||
|
case OrganizeTriggerScheduled:
|
||||||
|
return "自动整理重命名刮削入库结束"
|
||||||
|
case OrganizeTriggerDownload:
|
||||||
|
return "下载完成自动整理入库结束"
|
||||||
|
default:
|
||||||
|
return "手动整理重命名刮削入库结束"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *OrganizePipelineService) defaultSourcePath(ctx context.Context, req OrganizePipelineRequest) string {
|
||||||
|
if p == nil || p.organizer == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if req.Scope == OrganizeScopeDirectory {
|
||||||
|
return p.organizer.defaultSourceRoot(ctx, "")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *OrganizePipelineService) defaultDestPath(ctx context.Context, req OrganizePipelineRequest) string {
|
||||||
|
if p == nil || p.organizer == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if req.Scope == OrganizeScopeDirectory {
|
||||||
|
return p.organizer.defaultDestRoot(ctx, "")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOrganizeScanRootUsesActualOrganizedTarget(t *testing.T) {
|
||||||
|
target := filepath.Join(string(filepath.Separator), "media", "电影", "动画电影", "Big Buck Bunny (2008)", "Big Buck Bunny (2008).mp4")
|
||||||
|
res := &OrganizeResult{
|
||||||
|
DestPath: filepath.Join(string(filepath.Separator), "media"),
|
||||||
|
Items: []OrganizePreviewItem{{
|
||||||
|
Target: target,
|
||||||
|
Action: "organize",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
want := filepath.Dir(target)
|
||||||
|
if got := organizeScanRoot(res, ""); got != want {
|
||||||
|
t.Fatalf("organizeScanRoot() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrganizeScanRootUsesCommonAffectedCategoryRoot(t *testing.T) {
|
||||||
|
root := filepath.Join(string(filepath.Separator), "media", "电影", "动画电影")
|
||||||
|
res := &OrganizeResult{
|
||||||
|
DestPath: filepath.Join(string(filepath.Separator), "media"),
|
||||||
|
Items: []OrganizePreviewItem{
|
||||||
|
{Target: filepath.Join(root, "Movie A (2026)", "Movie A (2026).mp4"), Action: "organize"},
|
||||||
|
{Target: filepath.Join(root, "Movie B (2026)", "Movie B (2026).mp4"), Action: "organize"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := organizeScanRoot(res, ""); got != root {
|
||||||
|
t.Fatalf("organizeScanRoot() = %q, want %q", got, root)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,21 +149,36 @@ func (o *OrganizerService) OrganizeMediaWithOptions(ctx context.Context, mediaID
|
|||||||
|
|
||||||
var dst string
|
var dst string
|
||||||
if isSeriesLibraryType(lib.Type) {
|
if isSeriesLibraryType(lib.Type) {
|
||||||
// TV: {baseRoot}/[分类]/{Title}/Season XX/{Title} - SxxExx.ext
|
|
||||||
season := fmt.Sprintf("Season %02d", m.SeasonNum)
|
|
||||||
epTag := fmt.Sprintf("S%02dE%02d", m.SeasonNum, m.EpisodeNum)
|
|
||||||
root := o.organizeRoot(baseRoot, lib.Type, category)
|
root := o.organizeRoot(baseRoot, lib.Type, category)
|
||||||
dir := filepath.Join(categoryRoot(root, category), title, season)
|
target, err := o.buildOrganizeTargetPath(ctx, organizeTargetInput{
|
||||||
dst = filepath.Join(dir, fmt.Sprintf("%s - %s%s", title, epTag, ext))
|
Root: categoryRoot(root, category),
|
||||||
} else {
|
MediaType: lib.Type,
|
||||||
// Movie: {baseRoot}/[分类]/{Title} ({Year})/{Title} ({Year}).ext
|
Category: category,
|
||||||
folder := title
|
Title: title,
|
||||||
if m.Year > 0 {
|
Ext: ext,
|
||||||
folder = fmt.Sprintf("%s (%d)", title, m.Year)
|
Year: m.Year,
|
||||||
|
Season: m.SeasonNum,
|
||||||
|
Episode: m.EpisodeNum,
|
||||||
|
Series: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
|
dst = target.Path
|
||||||
|
} else {
|
||||||
root := o.organizeRoot(baseRoot, lib.Type, category)
|
root := o.organizeRoot(baseRoot, lib.Type, category)
|
||||||
dir := filepath.Join(categoryRoot(root, category), folder)
|
target, err := o.buildOrganizeTargetPath(ctx, organizeTargetInput{
|
||||||
dst = filepath.Join(dir, folder+ext)
|
Root: categoryRoot(root, category),
|
||||||
|
MediaType: lib.Type,
|
||||||
|
Category: category,
|
||||||
|
Title: title,
|
||||||
|
Ext: ext,
|
||||||
|
Year: m.Year,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dst = target.Path
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if already in place.
|
// Skip if already in place.
|
||||||
|
|||||||
@@ -254,15 +254,23 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo
|
|||||||
title = "Unknown"
|
title = "Unknown"
|
||||||
}
|
}
|
||||||
season, episode := ParseEpisode(src)
|
season, episode := ParseEpisode(src)
|
||||||
layout := o.inferOrganizeDirectoryLayout(src, sourceRoot)
|
pathLayout := o.inferOrganizeDirectoryLayout(src, sourceRoot)
|
||||||
if forced := normalizeOrganizeMediaType(mediaTypeOverride); forced != "" {
|
layout := pathLayout
|
||||||
if layout.Category != "" && layout.MediaType != "" && layout.MediaType != forced {
|
forcedType := normalizeOrganizeMediaType(mediaTypeOverride)
|
||||||
|
inferredType := o.inferMediaTypeForSourceFile(src, title, season, episode)
|
||||||
|
if forcedType != "" {
|
||||||
|
if layout.Category != "" && layout.MediaType != "" && layout.MediaType != forcedType {
|
||||||
layout.Category = ""
|
layout.Category = ""
|
||||||
}
|
}
|
||||||
layout.MediaType = forced
|
layout.MediaType = forcedType
|
||||||
}
|
} else if inferredType != "" {
|
||||||
if layout.MediaType == "" {
|
if inferredType == "tv" && layout.MediaType == "movie" {
|
||||||
layout.MediaType = o.inferMediaTypeForSourceFile(src, title, season, episode)
|
// 文件名中明确有季/集信息时,目录名只能作为弱提示;否则
|
||||||
|
// 下载到错误的“电影/外语电影”等目录会把剧集按电影入库。
|
||||||
|
layout = organizeDirectoryLayout{MediaType: inferredType}
|
||||||
|
} else if layout.MediaType == "" {
|
||||||
|
layout.MediaType = inferredType
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var metadataMatch *Match
|
var metadataMatch *Match
|
||||||
if match := o.lookupOrganizeMetadata(ctx, src, sourceRoot, layout.MediaType, title, year, season, episode, metadataCache); match != nil {
|
if match := o.lookupOrganizeMetadata(ctx, src, sourceRoot, layout.MediaType, title, year, season, episode, metadataCache); match != nil {
|
||||||
@@ -277,8 +285,19 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo
|
|||||||
}
|
}
|
||||||
if category := strings.TrimSpace(mediaCategoryOverride); category != "" {
|
if category := strings.TrimSpace(mediaCategoryOverride); category != "" {
|
||||||
layout.Category = sanitizeFilename(category)
|
layout.Category = sanitizeFilename(category)
|
||||||
} else if layout.Category == "" {
|
} else if category := o.smartClassifySourceFile(ctx, src, sourceRoot, layout.MediaType, title, parsedTitle, metadataMatch); category != "" {
|
||||||
layout.Category = o.smartClassifySourceFile(ctx, src, sourceRoot, layout.MediaType, title, parsedTitle)
|
// MoviePilot 的分类策略以识别后的元数据为主,下载/源目录只作为
|
||||||
|
// 兜底提示。这里即使源目录已有二级分类,也允许 TMDb/Bangumi/NFO
|
||||||
|
// 识别结果修正到真正的分类,避免错误目录导致错误入库。
|
||||||
|
layout.Category = category
|
||||||
|
}
|
||||||
|
if forcedType == "" {
|
||||||
|
if impliedType, normalizedCategory := o.mediaTypeForDirectoryCategory(layout.Category); impliedType != "" {
|
||||||
|
layout.Category = normalizedCategory
|
||||||
|
if layout.MediaType == "" || layout.MediaType == "tv" || layout.MediaType == "anime" || pathLayout.Category != layout.Category {
|
||||||
|
layout.MediaType = impliedType
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
layoutRoot, matchedLibrary := o.organizeLibraryRootForLayout(ctx, destRoot, layout.MediaType, layout.Category)
|
layoutRoot, matchedLibrary := o.organizeLibraryRootForLayout(ctx, destRoot, layout.MediaType, layout.Category)
|
||||||
if !matchedLibrary && layout.MediaType != "" {
|
if !matchedLibrary && layout.MediaType != "" {
|
||||||
@@ -296,20 +315,23 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo
|
|||||||
if layout.MediaType != "" {
|
if layout.MediaType != "" {
|
||||||
isSeries = isSeriesLibraryType(layout.MediaType) && (season > 0 || episode > 0)
|
isSeries = isSeriesLibraryType(layout.MediaType) && (season > 0 || episode > 0)
|
||||||
}
|
}
|
||||||
if isSeries {
|
target, err := o.buildOrganizeTargetPath(ctx, organizeTargetInput{
|
||||||
// TV/动漫/综艺等剧集:{destRoot}/{Title}/Season XX/{Title} - SxxExx.ext
|
Root: layoutRoot,
|
||||||
episodeTag = fmt.Sprintf("S%02dE%02d", season, episode)
|
MediaType: layout.MediaType,
|
||||||
destDir = filepath.Join(layoutRoot, title, fmt.Sprintf("Season %02d", season))
|
Category: layout.Category,
|
||||||
dst = filepath.Join(destDir, fmt.Sprintf("%s - %s%s", title, episodeTag, ext))
|
Title: title,
|
||||||
} else {
|
Ext: ext,
|
||||||
// 电影:{destRoot}/{Title} ({Year})/{Title} ({Year}).ext
|
Year: year,
|
||||||
folder := title
|
Season: season,
|
||||||
if year > 0 {
|
Episode: episode,
|
||||||
folder = fmt.Sprintf("%s (%d)", title, year)
|
Series: isSeries,
|
||||||
}
|
})
|
||||||
destDir = filepath.Join(layoutRoot, folder)
|
if err != nil {
|
||||||
dst = filepath.Join(destDir, folder+ext)
|
return err
|
||||||
}
|
}
|
||||||
|
destDir = target.Dir
|
||||||
|
dst = target.Path
|
||||||
|
episodeTag = target.EpisodeTag
|
||||||
|
|
||||||
// 源文件已经位于目标位置:无需处理。
|
// 源文件已经位于目标位置:无需处理。
|
||||||
if filepath.Clean(src) == filepath.Clean(dst) {
|
if filepath.Clean(src) == filepath.Clean(dst) {
|
||||||
@@ -493,10 +515,28 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
match := o.scraper.lookup(ctx, lib, candidate, year)
|
match := o.scraper.lookup(ctx, lib, candidate, year)
|
||||||
if cache != nil {
|
|
||||||
cache[key] = match
|
|
||||||
}
|
|
||||||
if match != nil && strings.TrimSpace(match.Title) != "" {
|
if match != nil && strings.TrimSpace(match.Title) != "" {
|
||||||
|
if !organizeMetadataMatchTrusted(candidate, year, match) {
|
||||||
|
if cache != nil {
|
||||||
|
cache[key] = nil
|
||||||
|
}
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Warn("organize metadata match rejected before rename",
|
||||||
|
zap.String("source", src),
|
||||||
|
zap.String("query", candidate),
|
||||||
|
zap.String("title", match.Title),
|
||||||
|
zap.Int("source_year", year),
|
||||||
|
zap.Int("match_year", match.Year),
|
||||||
|
zap.Int("tmdb_id", match.TMDbID),
|
||||||
|
zap.Int("bangumi_id", match.BangumiID),
|
||||||
|
zap.String("douban_id", match.DoubanID),
|
||||||
|
zap.String("thetvdb_id", match.TheTVDBID))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cache != nil {
|
||||||
|
cache[key] = match
|
||||||
|
}
|
||||||
if o.log != nil {
|
if o.log != nil {
|
||||||
o.log.Info("organize metadata matched before rename",
|
o.log.Info("organize metadata matched before rename",
|
||||||
zap.String("source", src),
|
zap.String("source", src),
|
||||||
@@ -510,10 +550,29 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour
|
|||||||
}
|
}
|
||||||
return match
|
return match
|
||||||
}
|
}
|
||||||
|
if cache != nil {
|
||||||
|
cache[key] = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func organizeMetadataMatchTrusted(query string, sourceYear int, match *Match) bool {
|
||||||
|
if match == nil || strings.TrimSpace(match.Title) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if sourceYear > 0 && match.Year > 0 {
|
||||||
|
diff := sourceYear - match.Year
|
||||||
|
if diff < 0 {
|
||||||
|
diff = -diff
|
||||||
|
}
|
||||||
|
if diff > 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func organizeMatchFromLocalMetadata(local *LocalMetadata) *Match {
|
func organizeMatchFromLocalMetadata(local *LocalMetadata) *Match {
|
||||||
if local == nil || strings.TrimSpace(local.Title) == "" {
|
if local == nil || strings.TrimSpace(local.Title) == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -547,7 +606,7 @@ func organizeMetadataCacheKey(mediaType, query string, year int) string {
|
|||||||
return strings.ToLower(strings.TrimSpace(mediaType)) + "|" + fmt.Sprint(year) + "|" + strings.ToLower(strings.TrimSpace(query))
|
return strings.ToLower(strings.TrimSpace(mediaType)) + "|" + fmt.Sprint(year) + "|" + strings.ToLower(strings.TrimSpace(query))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *OrganizerService) smartClassifySourceFile(ctx context.Context, src, sourceRoot, mediaType, title, parsedTitle string) string {
|
func (o *OrganizerService) smartClassifySourceFile(ctx context.Context, src, sourceRoot, mediaType, title, parsedTitle string, metadataMatch *Match) string {
|
||||||
if o == nil || !o.isSmartClassifyEnabled(ctx) {
|
if o == nil || !o.isSmartClassifyEnabled(ctx) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -557,6 +616,20 @@ func (o *OrganizerService) smartClassifySourceFile(ctx context.Context, src, sou
|
|||||||
Title: strings.Join([]string{title, parsedTitle, filepath.Base(src)}, " "),
|
Title: strings.Join([]string{title, parsedTitle, filepath.Base(src)}, " "),
|
||||||
Category: strings.Join(organizeDirectoryCategoryCandidates(src, sourceRoot), " "),
|
Category: strings.Join(organizeDirectoryCategoryCandidates(src, sourceRoot), " "),
|
||||||
}
|
}
|
||||||
|
if metadataMatch != nil {
|
||||||
|
input.Title = strings.Join([]string{
|
||||||
|
metadataMatch.OriginalName,
|
||||||
|
title,
|
||||||
|
parsedTitle,
|
||||||
|
filepath.Base(src),
|
||||||
|
}, " ")
|
||||||
|
input.Languages = metadataMatch.Languages
|
||||||
|
input.Countries = metadataMatch.Countries
|
||||||
|
input.Genres = metadataMatch.Genres
|
||||||
|
if metadataMatch.NSFW {
|
||||||
|
input.MediaType = "adult"
|
||||||
|
}
|
||||||
|
}
|
||||||
if meta, err := ReadLocalMetadata(src, sourceRoot, seriesLike); err == nil && meta != nil && meta.HasNFO {
|
if meta, err := ReadLocalMetadata(src, sourceRoot, seriesLike); err == nil && meta != nil && meta.HasNFO {
|
||||||
input.Title = strings.Join([]string{meta.Title, meta.OriginalName, title, parsedTitle, filepath.Base(src)}, " ")
|
input.Title = strings.Join([]string{meta.Title, meta.OriginalName, title, parsedTitle, filepath.Base(src)}, " ")
|
||||||
input.Languages = parseCommaList(meta.Languages)
|
input.Languages = parseCommaList(meta.Languages)
|
||||||
@@ -595,7 +668,9 @@ func organizeDirectoryCategoryCandidates(src, sourceRoot string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cleanSourceRoot := filepath.Clean(sourceRoot)
|
cleanSourceRoot := filepath.Clean(sourceRoot)
|
||||||
add(filepath.Base(cleanSourceRoot))
|
for _, part := range organizePathNameParts(cleanSourceRoot) {
|
||||||
|
add(part)
|
||||||
|
}
|
||||||
rel, err := filepath.Rel(cleanSourceRoot, filepath.Clean(src))
|
rel, err := filepath.Rel(cleanSourceRoot, filepath.Clean(src))
|
||||||
if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
|
if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
|
||||||
return out
|
return out
|
||||||
@@ -610,6 +685,34 @@ func organizeDirectoryCategoryCandidates(src, sourceRoot string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func organizePathNameParts(path string) []string {
|
||||||
|
clean := filepath.Clean(strings.TrimSpace(path))
|
||||||
|
if clean == "" || clean == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
volume := filepath.VolumeName(clean)
|
||||||
|
if volume != "" {
|
||||||
|
clean = strings.TrimPrefix(clean, volume)
|
||||||
|
}
|
||||||
|
clean = strings.Trim(clean, string(os.PathSeparator))
|
||||||
|
if clean == "" {
|
||||||
|
base := filepath.Base(filepath.Clean(path))
|
||||||
|
if base == "." || base == string(os.PathSeparator) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{base}
|
||||||
|
}
|
||||||
|
parts := strings.Split(clean, string(os.PathSeparator))
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part != "" && part != "." {
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (o *OrganizerService) mediaTypeForDirectoryCategory(name string) (string, string) {
|
func (o *OrganizerService) mediaTypeForDirectoryCategory(name string) (string, string) {
|
||||||
key := strings.ToLower(strings.TrimSpace(name))
|
key := strings.ToLower(strings.TrimSpace(name))
|
||||||
if key == "" {
|
if key == "" {
|
||||||
|
|||||||
@@ -694,7 +694,7 @@ func TestOrganizeDirectorySmartClassifiesUncategorizedSources(t *testing.T) {
|
|||||||
filepath.Join(dest, "电影", "华语电影", "流浪地球2 (2023)", "流浪地球2 (2023).mkv"),
|
filepath.Join(dest, "电影", "华语电影", "流浪地球2 (2023)", "流浪地球2 (2023).mkv"),
|
||||||
filepath.Join(dest, "电影", "外语电影", "Dune (2021)", "Dune (2021).mkv"),
|
filepath.Join(dest, "电影", "外语电影", "Dune (2021)", "Dune (2021).mkv"),
|
||||||
filepath.Join(dest, "电视剧", "国产剧", "狂飙", "Season 01", "狂飙 - S01E01.mkv"),
|
filepath.Join(dest, "电视剧", "国产剧", "狂飙", "Season 01", "狂飙 - S01E01.mkv"),
|
||||||
filepath.Join(dest, "电视剧", "欧美剧", "The Last Of Us", "Season 01", "The Last Of Us - S01E01.mkv"),
|
filepath.Join(dest, "电视剧", "未分类", "The Last Of Us", "Season 01", "The Last Of Us - S01E01.mkv"),
|
||||||
} {
|
} {
|
||||||
if _, err := os.Stat(want); err != nil {
|
if _, err := os.Stat(want); err != nil {
|
||||||
t.Fatalf("expected smart classified file at %q: %v; items=%+v", want, err, res.Items)
|
t.Fatalf("expected smart classified file at %q: %v; items=%+v", want, err, res.Items)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -105,6 +106,325 @@ func TestOrganizeDirectoryUsesScraperMatchBeforeRename(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOrganizeDirectoryClassifiesScraperMatchBeforeRename(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/search/movie":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"results": []map[string]any{{
|
||||||
|
"id": 45745,
|
||||||
|
"title": "寻龙记",
|
||||||
|
"original_title": "Sintel",
|
||||||
|
"original_language": "en",
|
||||||
|
"genre_ids": []int{16, 14},
|
||||||
|
"release_date": "2010-09-27",
|
||||||
|
"vote_average": 7.4,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
repos := newOrganizerTestRepo(t)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Organizer.SmartClassify = true
|
||||||
|
cfg.Secrets.TMDbAPIKey = "test-key"
|
||||||
|
cfg.Secrets.TMDbAPIProxy = upstream.URL
|
||||||
|
scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop()))
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
src := filepath.Join(root, "downloads")
|
||||||
|
dest := filepath.Join(root, "media")
|
||||||
|
sourceFile := filepath.Join(src, "Sintel.2010.1080p.CodexVerify.mp4")
|
||||||
|
writeOrgFile(t, sourceFile, "movie")
|
||||||
|
|
||||||
|
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
|
||||||
|
organizer.SetScraper(scraper)
|
||||||
|
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
|
||||||
|
SourcePath: src,
|
||||||
|
DestPath: dest,
|
||||||
|
TransferMode: TransferCopy,
|
||||||
|
MediaType: "movie",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("organize directory: %v", err)
|
||||||
|
}
|
||||||
|
want := filepath.Join(dest, "电影", "动画电影", "寻龙记 (2010)", "寻龙记 (2010).mp4")
|
||||||
|
if res.Organized != 1 {
|
||||||
|
t.Fatalf("organized = %d, want 1; items=%#v errors=%#v", res.Organized, res.Items, res.Errors)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(want); err != nil {
|
||||||
|
t.Fatalf("organized movie should use metadata category path %q: %v; items=%#v", want, err, res.Items)
|
||||||
|
}
|
||||||
|
if len(res.Items) != 1 || res.Items[0].Category != "动画电影" {
|
||||||
|
t.Fatalf("organize category = %#v, want 动画电影", res.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrganizeDirectoryMetadataCategoryOverridesDownloadFolder(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/search/tv":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"results": []map[string]any{{
|
||||||
|
"id": 12345,
|
||||||
|
"name": "间谍过家家",
|
||||||
|
"original_name": "SPY×FAMILY",
|
||||||
|
"original_language": "ja",
|
||||||
|
"origin_country": []string{"JP"},
|
||||||
|
"genre_ids": []int{16, 35},
|
||||||
|
"first_air_date": "2022-04-09",
|
||||||
|
"vote_average": 8.6,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
repos := newOrganizerTestRepo(t)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Organizer.SmartClassify = true
|
||||||
|
cfg.Secrets.TMDbAPIKey = "test-key"
|
||||||
|
cfg.Secrets.TMDbAPIProxy = upstream.URL
|
||||||
|
scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop()))
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
srcRoot := filepath.Join(root, "downloads")
|
||||||
|
dest := filepath.Join(root, "media")
|
||||||
|
sourceFile := filepath.Join(srcRoot, "国产剧", "Spy.x.Family.S01E01.2022.1080p.mkv")
|
||||||
|
writeOrgFile(t, sourceFile, "episode")
|
||||||
|
|
||||||
|
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
|
||||||
|
organizer.SetScraper(scraper)
|
||||||
|
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
|
||||||
|
SourcePath: srcRoot,
|
||||||
|
DestPath: dest,
|
||||||
|
TransferMode: TransferCopy,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("organize directory: %v", err)
|
||||||
|
}
|
||||||
|
want := filepath.Join(dest, "电视剧", "日番", "间谍过家家", "Season 01", "间谍过家家 - S01E01.mkv")
|
||||||
|
if res.Organized != 1 {
|
||||||
|
t.Fatalf("organized = %d, want 1; items=%#v errors=%#v", res.Organized, res.Items, res.Errors)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(want); err != nil {
|
||||||
|
t.Fatalf("metadata category should override wrong source folder at %q: %v; items=%#v", want, err, res.Items)
|
||||||
|
}
|
||||||
|
if len(res.Items) != 1 || res.Items[0].Category != "日番" || res.Items[0].MediaType != "anime" {
|
||||||
|
t.Fatalf("organize metadata category/type = %#v, want 日番/anime", res.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrganizeDirectoryDoesNotScrapeByDownloadCategoryFolder(t *testing.T) {
|
||||||
|
var queries []string
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if r.URL.Path != "/search/tv" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query := r.URL.Query().Get("query")
|
||||||
|
queries = append(queries, query)
|
||||||
|
switch {
|
||||||
|
case query == "国产剧":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"results": []map[string]any{{
|
||||||
|
"id": 843248,
|
||||||
|
"name": "高达 G之复国运动 剧场版III 来自宇宙的遗产",
|
||||||
|
"first_air_date": "2021-07-22",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
case strings.EqualFold(query, "ashes to crown"):
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"results": []map[string]any{{
|
||||||
|
"id": 289271,
|
||||||
|
"name": "翘楚",
|
||||||
|
"original_name": "Ashes to Crown",
|
||||||
|
"original_language": "zh",
|
||||||
|
"origin_country": []string{"CN"},
|
||||||
|
"genre_ids": []int{18},
|
||||||
|
"first_air_date": "2026-06-01",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{}})
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
repos := newOrganizerTestRepo(t)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Organizer.SmartClassify = true
|
||||||
|
cfg.Secrets.TMDbAPIKey = "test-key"
|
||||||
|
cfg.Secrets.TMDbAPIProxy = upstream.URL
|
||||||
|
scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop()))
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
dest := filepath.Join(root, "media")
|
||||||
|
sourceFile := filepath.Join(root, "downloads", "国产剧", "Ashes.to.Crown.S01.1080p.YOUKU.WEB-DL.AAC2.0.H.264-MWeb", "Ashes.to.Crown.S01E06.1080p.YOUKU.WEB-DL.AAC2.0.H.264-MWeb.mkv")
|
||||||
|
writeOrgFile(t, sourceFile, "episode")
|
||||||
|
|
||||||
|
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
|
||||||
|
organizer.SetScraper(scraper)
|
||||||
|
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
|
||||||
|
SourcePath: sourceFile,
|
||||||
|
DestPath: dest,
|
||||||
|
TransferMode: TransferCopy,
|
||||||
|
MediaType: "tv",
|
||||||
|
MediaCategory: "国产剧",
|
||||||
|
AllowReplaceExisting: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("organize directory: %v", err)
|
||||||
|
}
|
||||||
|
want := filepath.Join(dest, "电视剧", "国产剧", "翘楚", "Season 01", "翘楚 - S01E06.mkv")
|
||||||
|
if res.Organized != 1 {
|
||||||
|
t.Fatalf("organized = %d, want 1; items=%#v errors=%#v queries=%#v", res.Organized, res.Items, res.Errors, queries)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(want); err != nil {
|
||||||
|
t.Fatalf("organized file should use release title metadata, not category query, at %q: %v; items=%#v queries=%#v", want, err, res.Items, queries)
|
||||||
|
}
|
||||||
|
if len(queries) == 0 || queries[0] == "国产剧" {
|
||||||
|
t.Fatalf("first scrape query = %#v, want release title before category folder", queries)
|
||||||
|
}
|
||||||
|
if len(res.Items) != 1 || res.Items[0].Title != "翘楚" || res.Items[0].Category != "国产剧" {
|
||||||
|
t.Fatalf("organize item = %#v, want title 翘楚 in category 国产剧", res.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrganizeDirectoryEpisodeMarkerOverridesMovieSourceFolder(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/search/tv":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"results": []map[string]any{{
|
||||||
|
"id": 100088,
|
||||||
|
"name": "The Last of Us",
|
||||||
|
"original_language": "en",
|
||||||
|
"origin_country": []string{"US"},
|
||||||
|
"genre_ids": []int{18},
|
||||||
|
"first_air_date": "2023-01-15",
|
||||||
|
"vote_average": 8.7,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
case r.URL.Path == "/search/movie":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"results": []map[string]any{{
|
||||||
|
"id": 999,
|
||||||
|
"title": "Wrong Movie",
|
||||||
|
"release_date": "2023-01-01",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
repos := newOrganizerTestRepo(t)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Organizer.SmartClassify = true
|
||||||
|
cfg.Secrets.TMDbAPIKey = "test-key"
|
||||||
|
cfg.Secrets.TMDbAPIProxy = upstream.URL
|
||||||
|
scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop()))
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
srcRoot := filepath.Join(root, "downloads")
|
||||||
|
dest := filepath.Join(root, "media")
|
||||||
|
sourceFile := filepath.Join(srcRoot, "外语电影", "The.Last.of.Us.S01E01.2023.1080p.mkv")
|
||||||
|
writeOrgFile(t, sourceFile, "episode")
|
||||||
|
|
||||||
|
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
|
||||||
|
organizer.SetScraper(scraper)
|
||||||
|
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
|
||||||
|
SourcePath: srcRoot,
|
||||||
|
DestPath: dest,
|
||||||
|
TransferMode: TransferCopy,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("organize directory: %v", err)
|
||||||
|
}
|
||||||
|
want := filepath.Join(dest, "电视剧", "欧美剧", "The Last of Us", "Season 01", "The Last of Us - S01E01.mkv")
|
||||||
|
if res.Organized != 1 {
|
||||||
|
t.Fatalf("organized = %d, want 1; items=%#v errors=%#v", res.Organized, res.Items, res.Errors)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(want); err != nil {
|
||||||
|
t.Fatalf("episode marker should force TV organize path %q: %v; items=%#v", want, err, res.Items)
|
||||||
|
}
|
||||||
|
if len(res.Items) != 1 || res.Items[0].Category != "欧美剧" || res.Items[0].MediaType != "tv" {
|
||||||
|
t.Fatalf("organize episode category/type = %#v, want 欧美剧/tv", res.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrganizeDirectoryRejectsWrongYearScraperRename(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/search/tv":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"results": []map[string]any{{
|
||||||
|
"id": 999,
|
||||||
|
"name": "Parade of Stars Auto Show",
|
||||||
|
"first_air_date": "1952-01-01",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
repos := newOrganizerTestRepo(t)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Secrets.TMDbAPIKey = "test-key"
|
||||||
|
cfg.Secrets.TMDbAPIProxy = upstream.URL
|
||||||
|
scraper := NewScraperService(cfg, zap.NewNop(), repos, NewTMDbProvider(cfg, zap.NewNop(), nil), nil, nil, nil, NewHub(zap.NewNop()))
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
src := filepath.Join(root, "downloads")
|
||||||
|
dest := filepath.Join(root, "media")
|
||||||
|
sourceFile := filepath.Join(src, "Auto.Show.S01E03.2026.1080p.mkv")
|
||||||
|
writeOrgFile(t, sourceFile, "episode")
|
||||||
|
secondSourceFile := filepath.Join(src, "Auto.Show.S01E04.2026.1080p.mkv")
|
||||||
|
writeOrgFile(t, secondSourceFile, "episode")
|
||||||
|
|
||||||
|
organizer := NewOrganizerService(cfg, zap.NewNop(), repos)
|
||||||
|
organizer.SetScraper(scraper)
|
||||||
|
res, err := organizer.OrganizeDirectory(t.Context(), OrganizeOptions{
|
||||||
|
SourcePath: src,
|
||||||
|
DestPath: dest,
|
||||||
|
TransferMode: TransferCopy,
|
||||||
|
MediaType: "tv",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("organize directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rejected := filepath.Join(dest, "电视剧", "Parade of Stars Auto Show", "Season 01", "Parade of Stars Auto Show - S01E03.mkv")
|
||||||
|
if _, err := os.Stat(rejected); err == nil {
|
||||||
|
t.Fatalf("wrong-year metadata match should not rename to %q", rejected)
|
||||||
|
}
|
||||||
|
want := filepath.Join(dest, "电视剧", "Auto Show", "Season 01", "Auto Show - S01E03.mkv")
|
||||||
|
if res.Organized != 2 {
|
||||||
|
t.Fatalf("organized = %d, want 2; items=%#v errors=%#v", res.Organized, res.Items, res.Errors)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(want); err != nil {
|
||||||
|
t.Fatalf("organize should keep parsed title at %q: %v; items=%#v", want, err, res.Items)
|
||||||
|
}
|
||||||
|
secondWant := filepath.Join(dest, "电视剧", "Auto Show", "Season 01", "Auto Show - S01E04.mkv")
|
||||||
|
if _, err := os.Stat(secondWant); err != nil {
|
||||||
|
t.Fatalf("organize should not reuse rejected cached match at %q: %v; items=%#v", secondWant, err, res.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOrganizeDirectoryDedupsByExternalIDBeforeRename(t *testing.T) {
|
func TestOrganizeDirectoryDedupsByExternalIDBeforeRename(t *testing.T) {
|
||||||
scraper, repos, closeServer := newTestScraper(t)
|
scraper, repos, closeServer := newTestScraper(t)
|
||||||
defer closeServer()
|
defer closeServer()
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type organizeTargetInput struct {
|
||||||
|
Root string
|
||||||
|
MediaType string
|
||||||
|
Category string
|
||||||
|
Title string
|
||||||
|
Ext string
|
||||||
|
Year int
|
||||||
|
Season int
|
||||||
|
Episode int
|
||||||
|
Series bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type organizeTargetPath struct {
|
||||||
|
Dir string
|
||||||
|
Path string
|
||||||
|
EpisodeTag string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OrganizerService) buildOrganizeTargetPath(ctx context.Context, in organizeTargetInput) (organizeTargetPath, error) {
|
||||||
|
root := filepath.Clean(strings.TrimSpace(in.Root))
|
||||||
|
if root == "" || root == "." {
|
||||||
|
return organizeTargetPath{}, fmt.Errorf("organize target root required")
|
||||||
|
}
|
||||||
|
title := sanitizeFilename(strings.TrimSpace(in.Title))
|
||||||
|
if title == "" {
|
||||||
|
title = "Unknown"
|
||||||
|
}
|
||||||
|
ext := strings.TrimSpace(in.Ext)
|
||||||
|
if ext != "" && !strings.HasPrefix(ext, ".") {
|
||||||
|
ext = "." + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
episodeTag := ""
|
||||||
|
if in.Series {
|
||||||
|
season := in.Season
|
||||||
|
if season <= 0 {
|
||||||
|
season = 1
|
||||||
|
}
|
||||||
|
episode := in.Episode
|
||||||
|
if episode <= 0 {
|
||||||
|
episode = 1
|
||||||
|
}
|
||||||
|
episodeTag = fmt.Sprintf("S%02dE%02d", season, episode)
|
||||||
|
}
|
||||||
|
|
||||||
|
template := strings.TrimSpace(o.organizeNamingFormat(ctx, in.MediaType, in.Series))
|
||||||
|
var rel string
|
||||||
|
if template == "" {
|
||||||
|
rel = defaultOrganizeRelativePath(title, ext, in.Year, in.Season, in.Episode, in.Series)
|
||||||
|
} else {
|
||||||
|
rel = renderOrganizeNamingTemplate(template, organizeNamingData{
|
||||||
|
Title: title,
|
||||||
|
Year: in.Year,
|
||||||
|
Season: in.Season,
|
||||||
|
Episode: in.Episode,
|
||||||
|
Ext: strings.TrimPrefix(ext, "."),
|
||||||
|
FileExt: ext,
|
||||||
|
Category: sanitizeFilename(in.Category),
|
||||||
|
MediaType: normalizeOrganizeMediaType(in.MediaType),
|
||||||
|
EpisodeTag: episodeTag,
|
||||||
|
})
|
||||||
|
rel = cleanOrganizeRelativePath(rel)
|
||||||
|
if rel == "" {
|
||||||
|
rel = defaultOrganizeRelativePath(title, ext, in.Year, in.Season, in.Episode, in.Series)
|
||||||
|
}
|
||||||
|
if ext != "" && !strings.EqualFold(filepath.Ext(rel), ext) {
|
||||||
|
rel += ext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dst := filepath.Join(root, rel)
|
||||||
|
return organizeTargetPath{
|
||||||
|
Dir: filepath.Dir(dst),
|
||||||
|
Path: dst,
|
||||||
|
EpisodeTag: episodeTag,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OrganizerService) organizeNamingFormat(ctx context.Context, mediaType string, series bool) string {
|
||||||
|
if o == nil || o.repo == nil || o.repo.Setting == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
key := "organize.movie_format"
|
||||||
|
if series {
|
||||||
|
if normalizeOrganizeMediaType(mediaType) == "anime" {
|
||||||
|
key = "organize.anime_format"
|
||||||
|
} else {
|
||||||
|
key = "organize.tv_format"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value, err := o.repo.Setting.Get(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultOrganizeRelativePath(title, ext string, year, season, episode int, series bool) string {
|
||||||
|
if series {
|
||||||
|
if season <= 0 {
|
||||||
|
season = 1
|
||||||
|
}
|
||||||
|
if episode <= 0 {
|
||||||
|
episode = 1
|
||||||
|
}
|
||||||
|
episodeTag := fmt.Sprintf("S%02dE%02d", season, episode)
|
||||||
|
return filepath.Join(title, fmt.Sprintf("Season %02d", season), fmt.Sprintf("%s - %s%s", title, episodeTag, ext))
|
||||||
|
}
|
||||||
|
folder := title
|
||||||
|
if year > 0 {
|
||||||
|
folder = fmt.Sprintf("%s (%d)", title, year)
|
||||||
|
}
|
||||||
|
return filepath.Join(folder, folder+ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
type organizeNamingData struct {
|
||||||
|
Title string
|
||||||
|
Year int
|
||||||
|
Season int
|
||||||
|
Episode int
|
||||||
|
Ext string
|
||||||
|
FileExt string
|
||||||
|
Category string
|
||||||
|
MediaType string
|
||||||
|
EpisodeTag string
|
||||||
|
}
|
||||||
|
|
||||||
|
var organizeTemplateTokenRE = regexp.MustCompile(`\{([A-Za-z_]+)(?::([^}]+))?\}`)
|
||||||
|
var organizeTemplateMustacheRE = regexp.MustCompile(`\{\{\s*([A-Za-z_]+)(?::([^}]+))?\s*\}\}`)
|
||||||
|
var organizeTemplateIfRE = regexp.MustCompile(`(?s)\{%\s*if\s+([A-Za-z_]+)\s*%\}(.*?)\{%\s*endif\s*%\}`)
|
||||||
|
|
||||||
|
func renderOrganizeNamingTemplate(template string, data organizeNamingData) string {
|
||||||
|
template = renderOrganizeConditionals(template, data)
|
||||||
|
template = organizeTemplateMustacheRE.ReplaceAllStringFunc(template, func(token string) string {
|
||||||
|
matches := organizeTemplateMustacheRE.FindStringSubmatch(token)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
format := ""
|
||||||
|
if len(matches) > 2 {
|
||||||
|
format = matches[2]
|
||||||
|
}
|
||||||
|
return organizeTemplateValue(matches[1], format, data, token)
|
||||||
|
})
|
||||||
|
return organizeTemplateTokenRE.ReplaceAllStringFunc(template, func(token string) string {
|
||||||
|
matches := organizeTemplateTokenRE.FindStringSubmatch(token)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
format := ""
|
||||||
|
if len(matches) > 2 {
|
||||||
|
format = matches[2]
|
||||||
|
}
|
||||||
|
return organizeTemplateValue(matches[1], format, data, token)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderOrganizeConditionals(template string, data organizeNamingData) string {
|
||||||
|
for {
|
||||||
|
next := organizeTemplateIfRE.ReplaceAllStringFunc(template, func(token string) string {
|
||||||
|
matches := organizeTemplateIfRE.FindStringSubmatch(token)
|
||||||
|
if len(matches) < 3 {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
if organizeTemplateTruthy(matches[1], data) {
|
||||||
|
return matches[2]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
if next == template {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
template = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func organizeTemplateTruthy(name string, data organizeNamingData) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||||
|
case "title", "name":
|
||||||
|
return data.Title != ""
|
||||||
|
case "year":
|
||||||
|
return data.Year > 0
|
||||||
|
case "season":
|
||||||
|
return data.Season > 0
|
||||||
|
case "episode", "ep":
|
||||||
|
return data.Episode > 0
|
||||||
|
case "ext", "extension":
|
||||||
|
return data.Ext != ""
|
||||||
|
case "fileext", "file_ext":
|
||||||
|
return data.FileExt != ""
|
||||||
|
case "category":
|
||||||
|
return data.Category != ""
|
||||||
|
case "type", "media_type":
|
||||||
|
return data.MediaType != ""
|
||||||
|
case "episode_tag", "episodetag", "season_episode":
|
||||||
|
return data.EpisodeTag != ""
|
||||||
|
case "part", "videoformat", "video_format":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func organizeTemplateValue(name, format string, data organizeNamingData, fallback string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||||
|
case "title", "name":
|
||||||
|
return data.Title
|
||||||
|
case "year":
|
||||||
|
if data.Year <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.Itoa(data.Year)
|
||||||
|
case "season":
|
||||||
|
return formatOrganizeNumber(data.Season, format, 1)
|
||||||
|
case "episode", "ep":
|
||||||
|
return formatOrganizeNumber(data.Episode, format, 1)
|
||||||
|
case "ext", "extension":
|
||||||
|
return data.Ext
|
||||||
|
case "fileext", "file_ext":
|
||||||
|
return data.FileExt
|
||||||
|
case "category":
|
||||||
|
return data.Category
|
||||||
|
case "type", "media_type":
|
||||||
|
return data.MediaType
|
||||||
|
case "episode_tag", "episodetag", "season_episode":
|
||||||
|
return data.EpisodeTag
|
||||||
|
case "part", "videoformat", "video_format":
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatOrganizeNumber(value int, format string, fallback int) string {
|
||||||
|
if value <= 0 {
|
||||||
|
value = fallback
|
||||||
|
}
|
||||||
|
format = strings.TrimSpace(strings.TrimSuffix(format, "d"))
|
||||||
|
if strings.HasPrefix(format, "0") {
|
||||||
|
width, err := strconv.Atoi(strings.TrimPrefix(format, "0"))
|
||||||
|
if err == nil && width > 0 {
|
||||||
|
return fmt.Sprintf("%0*d", width, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strconv.Itoa(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanOrganizeRelativePath(path string) string {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
path = strings.ReplaceAll(path, "\\", "/")
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
part = sanitizeFilename(strings.TrimSpace(part))
|
||||||
|
part = strings.Trim(part, ". ")
|
||||||
|
if part == "" || part == "." || part == ".." {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(out...)
|
||||||
|
}
|
||||||
@@ -17,10 +17,17 @@ func translateClientPath(clientPath string, mappings map[string]string) string {
|
|||||||
if _, err := os.Stat(clean); err == nil {
|
if _, err := os.Stat(clean); err == nil {
|
||||||
return clean
|
return clean
|
||||||
}
|
}
|
||||||
|
normalizedClean := cleanPathForVolumeMapping(clientPath)
|
||||||
|
if normalizedClean != "" && normalizedClean != "." {
|
||||||
|
direct := filepath.Clean(filepath.FromSlash(normalizedClean))
|
||||||
|
if _, err := os.Stat(direct); err == nil {
|
||||||
|
return direct
|
||||||
|
}
|
||||||
|
}
|
||||||
// 尝试路径映射
|
// 尝试路径映射
|
||||||
cleanForMatch := filepath.ToSlash(clean)
|
cleanForMatch := strings.TrimRight(normalizedClean, "/")
|
||||||
for clientPrefix, localPrefix := range mappings {
|
for clientPrefix, localPrefix := range mappings {
|
||||||
prefix := strings.TrimRight(filepath.ToSlash(filepath.Clean(clientPrefix)), "/")
|
prefix := strings.TrimRight(cleanPathForVolumeMapping(clientPrefix), "/")
|
||||||
if prefix == "" || prefix == "." {
|
if prefix == "" || prefix == "." {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ func (q *QBitClient) addTorrentLocked(ctx context.Context, magnetOrURL string, t
|
|||||||
if hash := torrentInfoHash(torrentData); hash != "" {
|
if hash := torrentInfoHash(torrentData); hash != "" {
|
||||||
if _, ok := beforeHashes[hash]; ok {
|
if _, ok := beforeHashes[hash]; ok {
|
||||||
q.log.Info("qbittorrent: torrent already exists", zap.String("hash", hash), zap.String("name", torrentName))
|
q.log.Info("qbittorrent: torrent already exists", zap.String("hash", hash), zap.String("name", torrentName))
|
||||||
return nil
|
return ErrDownloadAlreadyExists
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -187,7 +188,7 @@ func TestQBitAddTorrentUploadsFetchedTorrentFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQBitAddTorrentFileTreatsExistingInfoHashAsSuccess(t *testing.T) {
|
func TestQBitAddTorrentFileReturnsDedupForExistingInfoHash(t *testing.T) {
|
||||||
torrentData := []byte("d4:infod4:name7:fixtureee")
|
torrentData := []byte("d4:infod4:name7:fixtureee")
|
||||||
hash := torrentInfoHash(torrentData)
|
hash := torrentInfoHash(torrentData)
|
||||||
if hash == "" {
|
if hash == "" {
|
||||||
@@ -215,8 +216,8 @@ func TestQBitAddTorrentFileTreatsExistingInfoHashAsSuccess(t *testing.T) {
|
|||||||
Password: "adminadmin",
|
Password: "adminadmin",
|
||||||
})
|
})
|
||||||
|
|
||||||
if err := client.AddTorrentFile(context.Background(), torrentData, "fixture.torrent", ""); err != nil {
|
if err := client.AddTorrentFile(context.Background(), torrentData, "fixture.torrent", ""); !errors.Is(err, ErrDownloadAlreadyExists) {
|
||||||
t.Fatalf("expected existing torrent to be accepted: %v", err)
|
t.Fatalf("err = %v, want ErrDownloadAlreadyExists", err)
|
||||||
}
|
}
|
||||||
if addCalled.Load() {
|
if addCalled.Load() {
|
||||||
t.Fatal("expected qbit add to be skipped for existing infohash")
|
t.Fatal("expected qbit add to be skipped for existing infohash")
|
||||||
|
|||||||
@@ -40,16 +40,17 @@ import (
|
|||||||
|
|
||||||
// SchedulerService runs the periodic jobs.
|
// SchedulerService runs the periodic jobs.
|
||||||
type SchedulerService struct {
|
type SchedulerService struct {
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
repo *repository.Container
|
repo *repository.Container
|
||||||
scanner *ScannerService
|
scanner *ScannerService
|
||||||
transcoder *TranscoderService
|
transcoder *TranscoderService
|
||||||
organizer *OrganizerService
|
organizer *OrganizerService
|
||||||
storageCfg *StorageConfigService
|
organizePipeline *OrganizePipelineService
|
||||||
hub *Hub
|
storageCfg *StorageConfigService
|
||||||
tasks *TaskTrackerService
|
hub *Hub
|
||||||
cacheDir string
|
tasks *TaskTrackerService
|
||||||
now func() time.Time
|
cacheDir string
|
||||||
|
now func() time.Time
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
@@ -65,6 +66,10 @@ func (s *SchedulerService) SetTaskTracker(tasks *TaskTrackerService) {
|
|||||||
s.tasks = tasks
|
s.tasks = tasks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService) {
|
||||||
|
s.organizePipeline = pipeline
|
||||||
|
}
|
||||||
|
|
||||||
// scheduledJob is one recurring task.
|
// scheduledJob is one recurring task.
|
||||||
type scheduledJob struct {
|
type scheduledJob struct {
|
||||||
name string
|
name string
|
||||||
@@ -552,53 +557,21 @@ func (s *SchedulerService) jobOrganizeSource(ctx context.Context) error {
|
|||||||
if s.organizer == nil || (!manual && !s.autoOrganizeSourceEnabled(ctx)) {
|
if s.organizer == nil || (!manual && !s.autoOrganizeSourceEnabled(ctx)) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
task := s.startScheduledOrganizeTask(ctx, manual)
|
taskName := "自动整理重命名刮削入库"
|
||||||
res, err := s.organizer.OrganizeDirectory(ctx, OrganizeOptions{})
|
if manual {
|
||||||
|
taskName = "手动触发自动整理重命名刮削入库"
|
||||||
|
}
|
||||||
|
resWrap, err := s.ensureOrganizePipeline().Run(ctx, OrganizePipelineRequest{
|
||||||
|
Scope: OrganizeScopeDirectory,
|
||||||
|
Trigger: OrganizeTriggerScheduled,
|
||||||
|
TaskName: taskName,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if task != nil {
|
|
||||||
task.Finish(err, TaskUpdate{
|
|
||||||
Stage: "organize",
|
|
||||||
Message: "自动整理入库失败",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if task != nil && res != nil {
|
res := resWrap.Result
|
||||||
task.Update(TaskUpdate{
|
if res == nil {
|
||||||
Stage: "organize",
|
res = &OrganizeResult{}
|
||||||
SourcePath: res.SourcePath,
|
|
||||||
DestPath: res.DestPath,
|
|
||||||
Message: "自动整理完成,准备扫描入库",
|
|
||||||
Metrics: OrganizeTaskMetrics(res),
|
|
||||||
Details: OrganizeTaskDetails(res, 8),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if s.scanner != nil && res != nil && strings.TrimSpace(res.DestPath) != "" && OrganizeResultNeedsVisibilitySync(res) {
|
|
||||||
if task != nil {
|
|
||||||
task.Update(TaskUpdate{
|
|
||||||
Stage: "scan_scrape",
|
|
||||||
Message: "正在扫描入库并按设置刮削",
|
|
||||||
Metrics: OrganizeTaskMetrics(res),
|
|
||||||
Details: OrganizeTaskDetails(res, 8),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
res.Scans, res.Scrapes = s.scanner.ScanAndScrapeLibrariesForPath(ctx, res.DestPath, "", OrganizeScrapeAfterEnabled(ctx, s.repo))
|
|
||||||
} else if s.log != nil && res != nil && !OrganizeResultNeedsVisibilitySync(res) {
|
|
||||||
s.log.Info("scheduled source organize skipped scan; no destination changes",
|
|
||||||
zap.String("source", res.SourcePath),
|
|
||||||
zap.String("dest", res.DestPath),
|
|
||||||
zap.Int("organized", res.Organized),
|
|
||||||
zap.Int("replaced", res.Replaced),
|
|
||||||
zap.Int("skipped", res.Skipped),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if task != nil {
|
|
||||||
task.Finish(nil, TaskUpdate{
|
|
||||||
Stage: "completed",
|
|
||||||
Message: "自动整理入库结束",
|
|
||||||
Metrics: OrganizeTaskMetrics(res),
|
|
||||||
Details: OrganizeTaskDetails(res, 8),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if s.log != nil && res != nil {
|
if s.log != nil && res != nil {
|
||||||
s.log.Info("scheduled source organize finished",
|
s.log.Info("scheduled source organize finished",
|
||||||
@@ -614,6 +587,13 @@ func (s *SchedulerService) jobOrganizeSource(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SchedulerService) ensureOrganizePipeline() *OrganizePipelineService {
|
||||||
|
if s.organizePipeline != nil {
|
||||||
|
return s.organizePipeline
|
||||||
|
}
|
||||||
|
return NewOrganizePipelineService(s.log, s.repo, s.organizer, s.scanner, s.tasks)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SchedulerService) startScheduledOrganizeTask(ctx context.Context, manual bool) *TaskHandle {
|
func (s *SchedulerService) startScheduledOrganizeTask(ctx context.Context, manual bool) *TaskHandle {
|
||||||
if s == nil || s.tasks == nil {
|
if s == nil || s.tasks == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+122
-6
@@ -105,6 +105,17 @@ var noiseTokenSet = func() map[string]struct{} {
|
|||||||
return set
|
return set
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var releaseBoundaryTokenSet = map[string]struct{}{
|
||||||
|
"1080p": {}, "2160p": {}, "4k": {}, "720p": {}, "480p": {}, "uhd": {}, "fhd": {},
|
||||||
|
"bd": {}, "bdrip": {}, "brrip": {}, "dvd": {}, "dvdrip": {}, "hdtv": {}, "pdtv": {},
|
||||||
|
"webdl": {}, "hdrip": {}, "bluray": {}, "webrip": {}, "web": {}, "remux": {},
|
||||||
|
"x264": {}, "x265": {}, "h264": {}, "h265": {}, "hevc": {}, "avc": {},
|
||||||
|
}
|
||||||
|
var strictSeasonFolderPatterns = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`(?i)^(?:s|season)\.?\s*(\d{1,2})$`),
|
||||||
|
regexp.MustCompile(`^第\s*([0-9一二三四五六七八九十百零两]+)\s*季$`),
|
||||||
|
}
|
||||||
|
|
||||||
// bracketedTag matches "[anything]", "(anything)" or "{anything}" segments.
|
// bracketedTag matches "[anything]", "(anything)" or "{anything}" segments.
|
||||||
var bracketedTag = regexp.MustCompile(`[\[\(\{][^\]\)\}]*[\]\)\}]`)
|
var bracketedTag = regexp.MustCompile(`[\[\(\{][^\]\)\}]*[\]\)\}]`)
|
||||||
var multiWordNoise = []*regexp.Regexp{
|
var multiWordNoise = []*regexp.Regexp{
|
||||||
@@ -142,6 +153,7 @@ func CleanQuery(raw string) (title string, year int) {
|
|||||||
lower = patCN.ReplaceAllString(lower, " ")
|
lower = patCN.ReplaceAllString(lower, " ")
|
||||||
// 去掉中文季/部标记(如「第二季」「第2部」),避免残留在标题里既污染
|
// 去掉中文季/部标记(如「第二季」「第2部」),避免残留在标题里既污染
|
||||||
// 搜索查询又导致整理后的目录名重复季信息。
|
// 搜索查询又导致整理后的目录名重复季信息。
|
||||||
|
lower = patSeasonOnly.ReplaceAllString(lower, " ")
|
||||||
lower = patCNSeason.ReplaceAllString(lower, " ")
|
lower = patCNSeason.ReplaceAllString(lower, " ")
|
||||||
|
|
||||||
for _, pat := range multiWordNoise {
|
for _, pat := range multiWordNoise {
|
||||||
@@ -153,8 +165,15 @@ func CleanQuery(raw string) (title string, year int) {
|
|||||||
// 拆分后丢掉过短(≤1)且全为 ASCII 数字 / 字母的"碎片",避免
|
// 拆分后丢掉过短(≤1)且全为 ASCII 数字 / 字母的"碎片",避免
|
||||||
// 「2」「0」「v」之类残留干扰 TMDb 搜索。中文字符不算碎片。
|
// 「2」「0」「v」之类残留干扰 TMDb 搜索。中文字符不算碎片。
|
||||||
out := make([]string, 0, 8)
|
out := make([]string, 0, 8)
|
||||||
|
seenReleaseBoundary := false
|
||||||
for _, w := range strings.Fields(lower) {
|
for _, w := range strings.Fields(lower) {
|
||||||
if _, ok := noiseTokenSet[w]; ok {
|
if _, ok := noiseTokenSet[w]; ok {
|
||||||
|
if _, boundary := releaseBoundaryTokenSet[w]; boundary {
|
||||||
|
seenReleaseBoundary = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seenReleaseBoundary && isASCIIWord(w) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(w) <= 1 {
|
if len(w) <= 1 {
|
||||||
@@ -169,6 +188,22 @@ func CleanQuery(raw string) (title string, year int) {
|
|||||||
return title, year
|
return title, year
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isASCIIWord(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if r >= 128 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// EnrichOne runs the provider chain for a single media row.
|
// EnrichOne runs the provider chain for a single media row.
|
||||||
func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
|
func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
|
||||||
lib, err := s.repo.Library.FindByID(ctx, m.LibraryID)
|
lib, err := s.repo.Library.FindByID(ctx, m.LibraryID)
|
||||||
@@ -188,10 +223,7 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
year := m.Year
|
year := mediaYearHint(m)
|
||||||
if year == 0 {
|
|
||||||
_, year = CleanQuery(filepath.Base(m.Path))
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.adult != nil && s.adult.Enabled() {
|
if s.adult != nil && s.adult.Enabled() {
|
||||||
if code := firstText(localAdultCode(local), AdultCodeFromMediaPath(m.Path), normalizeAdultCode(m.OriginalName), normalizeAdultCode(m.Title)); code != "" {
|
if code := firstText(localAdultCode(local), AdultCodeFromMediaPath(m.Path), normalizeAdultCode(m.OriginalName), normalizeAdultCode(m.Title)); code != "" {
|
||||||
@@ -208,8 +240,25 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
|
|||||||
var query string
|
var query string
|
||||||
match := (*Match)(nil)
|
match := (*Match)(nil)
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
match = s.lookup(ctx, lib, candidate, year)
|
|
||||||
query = candidate
|
query = candidate
|
||||||
|
candidateMatch := s.lookup(ctx, lib, candidate, year)
|
||||||
|
if candidateMatch == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !organizeMetadataMatchTrusted(candidate, year, candidateMatch) {
|
||||||
|
s.log.Warn("metadata scrape match rejected",
|
||||||
|
zap.String("media_id", m.ID),
|
||||||
|
zap.String("query", candidate),
|
||||||
|
zap.String("title", candidateMatch.Title),
|
||||||
|
zap.Int("source_year", year),
|
||||||
|
zap.Int("match_year", candidateMatch.Year),
|
||||||
|
zap.Int("tmdb_id", candidateMatch.TMDbID),
|
||||||
|
zap.Int("bangumi_id", candidateMatch.BangumiID),
|
||||||
|
zap.String("douban_id", candidateMatch.DoubanID),
|
||||||
|
zap.String("thetvdb_id", candidateMatch.TheTVDBID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
match = candidateMatch
|
||||||
if match != nil {
|
if match != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -242,6 +291,31 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
|
|||||||
return s.applyProviderMatch(ctx, m, lib, match)
|
return s.applyProviderMatch(ctx, m, lib, match)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mediaYearHint(m *model.Media) int {
|
||||||
|
if m == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if m.Year > 0 {
|
||||||
|
return m.Year
|
||||||
|
}
|
||||||
|
if _, year := CleanQuery(filepath.Base(m.Path)); year > 0 {
|
||||||
|
return year
|
||||||
|
}
|
||||||
|
return yearFromText(m.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func yearFromText(raw string) int {
|
||||||
|
if raw == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
matches := yearPattern.FindStringSubmatch(strings.ToLower(raw))
|
||||||
|
if len(matches) < 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
year, _ := strconv.Atoi(matches[1])
|
||||||
|
return year
|
||||||
|
}
|
||||||
|
|
||||||
func localAdultCode(local *LocalMetadata) string {
|
func localAdultCode(local *LocalMetadata) string {
|
||||||
if local == nil {
|
if local == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -489,7 +563,7 @@ func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string {
|
|||||||
|
|
||||||
func seriesFolderTitle(mediaPath, libraryRoot string) string {
|
func seriesFolderTitle(mediaPath, libraryRoot string) string {
|
||||||
dir := filepath.Dir(mediaPath)
|
dir := filepath.Dir(mediaPath)
|
||||||
if seasonFromDir(filepath.Base(dir)) > 0 {
|
if strictSeasonFolder(filepath.Base(dir)) > 0 {
|
||||||
dir = filepath.Dir(dir)
|
dir = filepath.Dir(dir)
|
||||||
}
|
}
|
||||||
if libraryRoot != "" && samePath(dir, filepath.Clean(libraryRoot)) {
|
if libraryRoot != "" && samePath(dir, filepath.Clean(libraryRoot)) {
|
||||||
@@ -499,9 +573,51 @@ func seriesFolderTitle(mediaPath, libraryRoot string) string {
|
|||||||
if base == "." || base == string(filepath.Separator) {
|
if base == "." || base == string(filepath.Separator) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
if isGenericMediaCategoryFolder(base) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isGenericMediaCategoryFolder(name string) bool {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
key = strings.Trim(key, `\/`)
|
||||||
|
switch key {
|
||||||
|
case "",
|
||||||
|
"电影", "movies", "movie",
|
||||||
|
"电视剧", "剧集", "tv", "shows", "series",
|
||||||
|
"动漫", "动画", "anime", "bangumi",
|
||||||
|
"国产剧", "国剧", "大陆剧", "国产电视剧",
|
||||||
|
"欧美剧", "欧美电视剧",
|
||||||
|
"日韩剧", "日剧", "韩剧",
|
||||||
|
"华语电影", "国产电影", "大陆电影",
|
||||||
|
"外语电影", "欧美电影", "日韩电影",
|
||||||
|
"动画电影", "动漫电影",
|
||||||
|
"国漫", "国产动漫", "日番", "日漫", "日本动漫", "日本动画",
|
||||||
|
"综艺", "真人秀",
|
||||||
|
"纪录片", "纪录",
|
||||||
|
"儿童", "少儿",
|
||||||
|
"成人", "番号", "9kg",
|
||||||
|
"未分类", "uncategorized":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func strictSeasonFolder(name string) int {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for _, pattern := range strictSeasonFolderPatterns {
|
||||||
|
if m := pattern.FindStringSubmatch(name); len(m) == 2 {
|
||||||
|
return mustAtoi(m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func seasonFromDir(name string) int {
|
func seasonFromDir(name string) int {
|
||||||
if m := patSeasonFolder.FindStringSubmatch(name); len(m) >= 3 {
|
if m := patSeasonFolder.FindStringSubmatch(name); len(m) >= 3 {
|
||||||
for _, group := range m[1:] {
|
for _, group := range m[1:] {
|
||||||
|
|||||||
@@ -71,6 +71,30 @@ func TestScrapeQueryCandidatesPreferSeriesFolderAndCJKTitle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScrapeQueryCandidatesSkipCategoryFolderAsSeriesTitle(t *testing.T) {
|
||||||
|
lib := &model.Library{
|
||||||
|
Path: `/downloads`,
|
||||||
|
Type: "tv",
|
||||||
|
}
|
||||||
|
media := &model.Media{
|
||||||
|
Title: "Ashes To Crown",
|
||||||
|
Path: `/downloads/国产剧/Ashes.to.Crown.S01E06.1080p.WEB-DL.mkv`,
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := scrapeQueryCandidates(media, lib)
|
||||||
|
if len(got) == 0 {
|
||||||
|
t.Fatal("scrapeQueryCandidates returned no candidates")
|
||||||
|
}
|
||||||
|
if got[0] == "国产剧" {
|
||||||
|
t.Fatalf("first query candidate = %q, category folders must not be used as title candidates: %#v", got[0], got)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(got[0], "Ashes To Crown") {
|
||||||
|
t.Fatalf("first query candidate = %q, want release title; all candidates=%#v", got[0], got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnrichOneWritesTMDbIDColumn(t *testing.T) {
|
func TestEnrichOneWritesTMDbIDColumn(t *testing.T) {
|
||||||
scraper, repos, closeServer := newTestScraper(t)
|
scraper, repos, closeServer := newTestScraper(t)
|
||||||
defer closeServer()
|
defer closeServer()
|
||||||
@@ -108,6 +132,69 @@ func TestEnrichOneWritesTMDbIDColumn(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnrichOneRejectsWrongYearMatchFromSeriesFolder(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/search/tv":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"results": []map[string]any{{
|
||||||
|
"id": 999,
|
||||||
|
"name": "Parade of Stars Auto Show",
|
||||||
|
"first_air_date": "1952-01-01",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.Library{}, &model.Series{}, &model.Media{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Secrets.TMDbAPIKey = "test-key"
|
||||||
|
cfg.Secrets.TMDbAPIProxy = upstream.URL
|
||||||
|
log := zap.NewNop()
|
||||||
|
scraper := NewScraperService(cfg, log, repos, NewTMDbProvider(cfg, log, nil), nil, nil, nil, NewHub(log))
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
mediaPath := filepath.Join(root, "Auto Show (2026)", "Season 1", "Auto Show - S01E03 - 第 3 集.mkv")
|
||||||
|
lib := model.Library{Name: "剧集", Path: root, Type: "tv", Enabled: true}
|
||||||
|
if err := repos.DB.Create(&lib).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
media := model.Media{
|
||||||
|
LibraryID: lib.ID,
|
||||||
|
Title: "auto show",
|
||||||
|
Path: mediaPath,
|
||||||
|
SeasonNum: 1,
|
||||||
|
EpisodeNum: 3,
|
||||||
|
ScrapeStatus: "pending",
|
||||||
|
}
|
||||||
|
if err := repos.DB.Create(&media).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scraper.EnrichOne(t.Context(), &media); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got model.Media
|
||||||
|
if err := repos.DB.First(&got, "id = ?", media.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.ScrapeStatus != "no_match" || got.Title != "auto show" || got.Year != 0 || got.TMDbID != 0 {
|
||||||
|
t.Fatalf("wrong-year scrape should be rejected, got status=%q title=%q year=%d tmdb=%d", got.ScrapeStatus, got.Title, got.Year, got.TMDbID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnrichOnePrefersLocalMetadataWithoutProvider(t *testing.T) {
|
func TestEnrichOnePrefersLocalMetadataWithoutProvider(t *testing.T) {
|
||||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+118
-112
@@ -18,61 +18,62 @@ import (
|
|||||||
|
|
||||||
// Container 持有在启动时初始化的每个服务。Handler 接收指向它的指针并选择相关字段。
|
// Container 持有在启动时初始化的每个服务。Handler 接收指向它的指针并选择相关字段。
|
||||||
type Container struct {
|
type Container struct {
|
||||||
Cfg *config.Config
|
Cfg *config.Config
|
||||||
Log *zap.Logger
|
Log *zap.Logger
|
||||||
Repo *repository.Container
|
Repo *repository.Container
|
||||||
WSHub *Hub
|
WSHub *Hub
|
||||||
SSEHub *SSEHub
|
SSEHub *SSEHub
|
||||||
Tasks *TaskTrackerService
|
Tasks *TaskTrackerService
|
||||||
Auth *AuthService
|
Auth *AuthService
|
||||||
Media *MediaService
|
Media *MediaService
|
||||||
Scan *ScannerService
|
Scan *ScannerService
|
||||||
Stream *StreamService
|
Stream *StreamService
|
||||||
Transcoder *TranscoderService
|
Transcoder *TranscoderService
|
||||||
FFprobe *FFprobeService
|
FFprobe *FFprobeService
|
||||||
TMDb *TMDbProvider
|
TMDb *TMDbProvider
|
||||||
Bangumi *BangumiProvider
|
Bangumi *BangumiProvider
|
||||||
TheTVDB *TheTVDBProvider
|
TheTVDB *TheTVDBProvider
|
||||||
Fanart *FanartProvider
|
Fanart *FanartProvider
|
||||||
Scraper *ScraperService
|
Scraper *ScraperService
|
||||||
Discover *DiscoverService
|
Discover *DiscoverService
|
||||||
Playback *PlaybackService
|
Playback *PlaybackService
|
||||||
ImageProxy *ImageProxy
|
ImageProxy *ImageProxy
|
||||||
Watcher *WatcherService
|
Watcher *WatcherService
|
||||||
Downloads *DownloadService
|
Downloads *DownloadService
|
||||||
Subscription *SubscriptionService
|
Subscription *SubscriptionService
|
||||||
Subtitle *SubtitleService
|
Subtitle *SubtitleService
|
||||||
Stats *StatsService
|
Stats *StatsService
|
||||||
Profile *ProfileService
|
Profile *ProfileService
|
||||||
Audit *AuditService
|
Audit *AuditService
|
||||||
NFO *NFOService
|
NFO *NFOService
|
||||||
AI *AIService
|
AI *AIService
|
||||||
APIConfig *APIConfigService
|
APIConfig *APIConfigService
|
||||||
Crypto *CryptoService
|
Crypto *CryptoService
|
||||||
Duplicate *DuplicateService
|
Duplicate *DuplicateService
|
||||||
FileManager *FileManagerService
|
FileManager *FileManagerService
|
||||||
DLNA *DLNAService
|
DLNA *DLNAService
|
||||||
Scheduler *SchedulerService
|
Scheduler *SchedulerService
|
||||||
Storage *StorageService
|
Storage *StorageService
|
||||||
Emby *EmbyService
|
Emby *EmbyService
|
||||||
Backup *BackupService
|
Backup *BackupService
|
||||||
Notifier *NotifierService
|
Notifier *NotifierService
|
||||||
NotifyChannels *NotifyChannelService
|
NotifyChannels *NotifyChannelService
|
||||||
TelegramBot *TelegramBotService
|
TelegramBot *TelegramBotService
|
||||||
PlayProfiles *PlayProfileService
|
PlayProfiles *PlayProfileService
|
||||||
Permissions *PermissionService
|
Permissions *PermissionService
|
||||||
StorageCfg *StorageConfigService
|
StorageCfg *StorageConfigService
|
||||||
STRM *STRMService
|
STRM *STRMService
|
||||||
DownloadClients *DownloadClientService
|
DownloadClients *DownloadClientService
|
||||||
Assistant *AssistantService
|
Assistant *AssistantService
|
||||||
Organizer *OrganizerService
|
Organizer *OrganizerService
|
||||||
Douban *DoubanProvider
|
OrganizePipeline *OrganizePipelineService
|
||||||
Token *TokenService
|
Douban *DoubanProvider
|
||||||
ApiConfig *ApiConfigService
|
Token *TokenService
|
||||||
DownloadMgr *DownloadManager
|
ApiConfig *ApiConfigService
|
||||||
Notify *NotifyService
|
DownloadMgr *DownloadManager
|
||||||
Site *SiteService
|
Notify *NotifyService
|
||||||
Device *DeviceService
|
Site *SiteService
|
||||||
|
Device *DeviceService
|
||||||
|
|
||||||
stopCtx context.Context
|
stopCtx context.Context
|
||||||
stopCancel context.CancelFunc
|
stopCancel context.CancelFunc
|
||||||
@@ -107,6 +108,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
|||||||
discover := NewDiscoverService(log, tmdb)
|
discover := NewDiscoverService(log, tmdb)
|
||||||
transcoder := NewTranscoderService(cfg, log, repos, hub)
|
transcoder := NewTranscoderService(cfg, log, repos, hub)
|
||||||
scanner := NewScannerService(cfg, log, repos, hub, probe, scraper)
|
scanner := NewScannerService(cfg, log, repos, hub, probe, scraper)
|
||||||
|
organizePipeline := NewOrganizePipelineService(log, repos, organizer, scanner, tasks)
|
||||||
watcher := NewWatcherService(log, repos, scanner)
|
watcher := NewWatcherService(log, repos, scanner)
|
||||||
nfo := NewNFOService(log, repos)
|
nfo := NewNFOService(log, repos)
|
||||||
ai := NewAIService(cfg, log, apiConfig)
|
ai := NewAIService(cfg, log, apiConfig)
|
||||||
@@ -128,6 +130,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
|||||||
assistant := NewAssistantService(log, repos, ai)
|
assistant := NewAssistantService(log, repos, ai)
|
||||||
scheduler := NewSchedulerService(log, repos, scanner, transcoder, organizer, storageCfg, hub, cfg.Cache.CacheDir)
|
scheduler := NewSchedulerService(log, repos, scanner, transcoder, organizer, storageCfg, hub, cfg.Cache.CacheDir)
|
||||||
scheduler.SetTaskTracker(tasks)
|
scheduler.SetTaskTracker(tasks)
|
||||||
|
scheduler.SetOrganizePipeline(organizePipeline)
|
||||||
|
|
||||||
// 初始化认证相关服务
|
// 初始化认证相关服务
|
||||||
tokenSvc := NewTokenService(cfg, log, repos)
|
tokenSvc := NewTokenService(cfg, log, repos)
|
||||||
@@ -151,7 +154,9 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
|||||||
downloads := NewDownloadService(log, repos, hub, organizer, siteSvc)
|
downloads := NewDownloadService(log, repos, hub, organizer, siteSvc)
|
||||||
downloads.SetScanner(scanner)
|
downloads.SetScanner(scanner)
|
||||||
downloads.SetTaskTracker(tasks)
|
downloads.SetTaskTracker(tasks)
|
||||||
|
downloads.SetOrganizePipeline(organizePipeline)
|
||||||
subscription := NewSubscriptionService(cfg, log, repos, downloads, siteSvc, hub)
|
subscription := NewSubscriptionService(cfg, log, repos, downloads, siteSvc, hub)
|
||||||
|
subscription.SetScraper(scraper)
|
||||||
|
|
||||||
// 让图片代理把媒体库根目录视为可读的本地图片位置:海报/封面等
|
// 让图片代理把媒体库根目录视为可读的本地图片位置:海报/封面等
|
||||||
// sidecar 资源就存放在这些(用户自定义、任意)目录下,否则会被
|
// sidecar 资源就存放在这些(用户自定义、任意)目录下,否则会被
|
||||||
@@ -175,63 +180,64 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
return &Container{
|
return &Container{
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
Log: log,
|
Log: log,
|
||||||
Repo: repos,
|
Repo: repos,
|
||||||
WSHub: hub,
|
WSHub: hub,
|
||||||
SSEHub: sseHub,
|
SSEHub: sseHub,
|
||||||
Tasks: tasks,
|
Tasks: tasks,
|
||||||
Auth: authSvc,
|
Auth: authSvc,
|
||||||
Media: NewMediaService(cfg, log, repos),
|
Media: NewMediaService(cfg, log, repos),
|
||||||
Scan: scanner,
|
Scan: scanner,
|
||||||
Stream: NewStreamService(cfg, log, repos, transcoder),
|
Stream: NewStreamService(cfg, log, repos, transcoder),
|
||||||
Transcoder: transcoder,
|
Transcoder: transcoder,
|
||||||
FFprobe: probe,
|
FFprobe: probe,
|
||||||
TMDb: tmdb,
|
TMDb: tmdb,
|
||||||
Bangumi: bangumi,
|
Bangumi: bangumi,
|
||||||
TheTVDB: thetvdb,
|
TheTVDB: thetvdb,
|
||||||
Fanart: fanart,
|
Fanart: fanart,
|
||||||
Scraper: scraper,
|
Scraper: scraper,
|
||||||
Discover: discover,
|
Discover: discover,
|
||||||
Playback: NewPlaybackService(log, repos),
|
Playback: NewPlaybackService(log, repos),
|
||||||
ImageProxy: imageProxy,
|
ImageProxy: imageProxy,
|
||||||
Watcher: watcher,
|
Watcher: watcher,
|
||||||
Downloads: downloads,
|
Downloads: downloads,
|
||||||
Subscription: subscription,
|
Subscription: subscription,
|
||||||
Subtitle: NewSubtitleService(log, repos),
|
Subtitle: NewSubtitleService(log, repos),
|
||||||
Stats: NewStatsService(log, repos),
|
Stats: NewStatsService(log, repos),
|
||||||
Profile: NewProfileService(log, repos),
|
Profile: NewProfileService(log, repos),
|
||||||
Audit: NewAuditService(log, repos),
|
Audit: NewAuditService(log, repos),
|
||||||
NFO: nfo,
|
NFO: nfo,
|
||||||
AI: ai,
|
AI: ai,
|
||||||
APIConfig: apiConfig,
|
APIConfig: apiConfig,
|
||||||
Crypto: crypto,
|
Crypto: crypto,
|
||||||
Duplicate: duplicate,
|
Duplicate: duplicate,
|
||||||
FileManager: filemanager,
|
FileManager: filemanager,
|
||||||
DLNA: dlna,
|
DLNA: dlna,
|
||||||
Scheduler: scheduler,
|
Scheduler: scheduler,
|
||||||
Storage: storage,
|
Storage: storage,
|
||||||
Emby: emby,
|
Emby: emby,
|
||||||
Backup: backup,
|
Backup: backup,
|
||||||
Notifier: notifier,
|
Notifier: notifier,
|
||||||
NotifyChannels: notifyChannels,
|
NotifyChannels: notifyChannels,
|
||||||
TelegramBot: telegramBot,
|
TelegramBot: telegramBot,
|
||||||
PlayProfiles: playProfiles,
|
PlayProfiles: playProfiles,
|
||||||
Permissions: permissions,
|
Permissions: permissions,
|
||||||
StorageCfg: storageCfg,
|
StorageCfg: storageCfg,
|
||||||
STRM: strmSvc,
|
STRM: strmSvc,
|
||||||
DownloadClients: downloadClients,
|
DownloadClients: downloadClients,
|
||||||
Assistant: assistant,
|
Assistant: assistant,
|
||||||
Organizer: organizer,
|
Organizer: organizer,
|
||||||
Douban: douban,
|
OrganizePipeline: organizePipeline,
|
||||||
Token: tokenSvc,
|
Douban: douban,
|
||||||
ApiConfig: apiConfigSvc,
|
Token: tokenSvc,
|
||||||
DownloadMgr: downloadMgr,
|
ApiConfig: apiConfigSvc,
|
||||||
Notify: notifySvc,
|
DownloadMgr: downloadMgr,
|
||||||
Site: siteSvc,
|
Notify: notifySvc,
|
||||||
Device: deviceSvc,
|
Site: siteSvc,
|
||||||
stopCtx: ctx,
|
Device: deviceSvc,
|
||||||
stopCancel: cancel,
|
stopCtx: ctx,
|
||||||
|
stopCancel: cancel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type SubscriptionService struct {
|
|||||||
repo *repository.Container
|
repo *repository.Container
|
||||||
downloads *DownloadService
|
downloads *DownloadService
|
||||||
site *SiteService
|
site *SiteService
|
||||||
|
scraper *ScraperService
|
||||||
hub *Hub
|
hub *Hub
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
}
|
}
|
||||||
@@ -49,6 +50,10 @@ func NewSubscriptionService(cfg *config.Config, log *zap.Logger, repo *repositor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SubscriptionService) SetScraper(scraper *ScraperService) {
|
||||||
|
s.scraper = scraper
|
||||||
|
}
|
||||||
|
|
||||||
// Start runs the polling loop in the background.
|
// Start runs the polling loop in the background.
|
||||||
func (s *SubscriptionService) Start(ctx context.Context) {
|
func (s *SubscriptionService) Start(ctx context.Context) {
|
||||||
go s.loop(ctx)
|
go s.loop(ctx)
|
||||||
@@ -114,6 +119,11 @@ func (s *SubscriptionService) List(ctx context.Context) ([]model.Subscription, e
|
|||||||
return s.repo.Subscription.List(ctx)
|
return s.repo.Subscription.List(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// History returns completed/archived subscription rules.
|
||||||
|
func (s *SubscriptionService) History(ctx context.Context) ([]model.Subscription, error) {
|
||||||
|
return s.repo.Subscription.History(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// Delete removes a subscription.
|
// Delete removes a subscription.
|
||||||
func (s *SubscriptionService) Delete(ctx context.Context, id string) error {
|
func (s *SubscriptionService) Delete(ctx context.Context, id string) error {
|
||||||
return s.repo.DB.Where("id = ?", id).Delete(&model.Subscription{}).Error
|
return s.repo.DB.Where("id = ?", id).Delete(&model.Subscription{}).Error
|
||||||
@@ -126,6 +136,9 @@ func (s *SubscriptionService) RunNow(ctx context.Context, id string) (int, error
|
|||||||
if err := s.repo.DB.Where("id = ?", id).First(&sub).Error; err != nil {
|
if err := s.repo.DB.Where("id = ?", id).First(&sub).Error; err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
if sub.ArchivedAt != nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
return s.runOne(ctx, &sub)
|
return s.runOne(ctx, &sub)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +256,7 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio
|
|||||||
seen = append(seen, guid)
|
seen = append(seen, guid)
|
||||||
seenSet[guid] = struct{}{}
|
seenSet[guid] = struct{}{}
|
||||||
}
|
}
|
||||||
|
avail = s.finalizePendingAvailability(sub, avail)
|
||||||
// Remember the last 200 GUIDs so the seen set doesn't grow forever.
|
// Remember the last 200 GUIDs so the seen set doesn't grow forever.
|
||||||
if len(seen) > 200 {
|
if len(seen) > 200 {
|
||||||
seen = seen[len(seen)-200:]
|
seen = seen[len(seen)-200:]
|
||||||
@@ -251,6 +265,7 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error
|
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error
|
||||||
|
_ = s.archiveCompletedSubscription(ctx, sub, avail)
|
||||||
if queued > 0 {
|
if queued > 0 {
|
||||||
s.hub.Publish("subscription", map[string]any{
|
s.hub.Publish("subscription", map[string]any{
|
||||||
"id": sub.ID,
|
"id": sub.ID,
|
||||||
@@ -300,6 +315,7 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
|
|||||||
item := candidate.Item
|
item := candidate.Item
|
||||||
mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, item.Title, item.Category)
|
mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, item.Title, item.Category)
|
||||||
if s.shouldSkipExistingTorrent(ctx, mediaType, candidate) {
|
if s.shouldSkipExistingTorrent(ctx, mediaType, candidate) {
|
||||||
|
addAvailabilityTitle(item.Title, availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), &availability)
|
||||||
seen = append(seen, candidate.GUID)
|
seen = append(seen, candidate.GUID)
|
||||||
seenSet[candidate.GUID] = struct{}{}
|
seenSet[candidate.GUID] = struct{}{}
|
||||||
continue
|
continue
|
||||||
@@ -307,6 +323,7 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
|
|||||||
realURL := s.site.ResolveDownloadURL(ctx, candidate.Download)
|
realURL := s.site.ResolveDownloadURL(ctx, candidate.Download)
|
||||||
savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory)
|
savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory)
|
||||||
if s.downloadPathHasCandidate(ctx, sub, candidate.Item.Title, savePath) {
|
if s.downloadPathHasCandidate(ctx, sub, candidate.Item.Title, savePath) {
|
||||||
|
addAvailabilityTitle(item.Title, availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), &availability)
|
||||||
seen = append(seen, candidate.GUID)
|
seen = append(seen, candidate.GUID)
|
||||||
seenSet[candidate.GUID] = struct{}{}
|
seenSet[candidate.GUID] = struct{}{}
|
||||||
continue
|
continue
|
||||||
@@ -322,6 +339,7 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
|
|||||||
AllowExistingLibrary: sub.WashEnabled,
|
AllowExistingLibrary: sub.WashEnabled,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
if IsDownloadDedupError(err) {
|
if IsDownloadDedupError(err) {
|
||||||
|
addAvailabilityTitle(item.Title, availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), &availability)
|
||||||
seen = append(seen, candidate.GUID)
|
seen = append(seen, candidate.GUID)
|
||||||
seenSet[candidate.GUID] = struct{}{}
|
seenSet[candidate.GUID] = struct{}{}
|
||||||
continue
|
continue
|
||||||
@@ -338,16 +356,19 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
queued++
|
queued++
|
||||||
|
addAvailabilityTitle(item.Title, availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), &availability)
|
||||||
resources = append(resources, item.Title)
|
resources = append(resources, item.Title)
|
||||||
seen = append(seen, candidate.GUID)
|
seen = append(seen, candidate.GUID)
|
||||||
seenSet[candidate.GUID] = struct{}{}
|
seenSet[candidate.GUID] = struct{}{}
|
||||||
}
|
}
|
||||||
|
availability = s.finalizePendingAvailability(sub, availability)
|
||||||
if len(seen) > 200 {
|
if len(seen) > 200 {
|
||||||
seen = seen[len(seen)-200:]
|
seen = seen[len(seen)-200:]
|
||||||
}
|
}
|
||||||
_ = s.repo.Setting.Set(ctx, guidKey, strings.Join(seen, "\n"))
|
_ = s.repo.Setting.Set(ctx, guidKey, strings.Join(seen, "\n"))
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error
|
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error
|
||||||
|
_ = s.archiveCompletedSubscription(ctx, sub, availability)
|
||||||
if queued > 0 {
|
if queued > 0 {
|
||||||
s.hub.Publish("subscription", map[string]any{
|
s.hub.Publish("subscription", map[string]any{
|
||||||
"id": sub.ID,
|
"id": sub.ID,
|
||||||
@@ -364,6 +385,88 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
|
|||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SubscriptionService) archiveCompletedSubscription(ctx context.Context, sub *model.Subscription, availability LocalAvailability) error {
|
||||||
|
if s == nil || s.repo == nil || s.repo.Subscription == nil || sub == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !subscriptionShouldArchive(sub, availability) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
reason := subscriptionArchiveReason(sub, availability)
|
||||||
|
if err := s.repo.Subscription.Archive(ctx, sub.ID, reason, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sub.Enabled = false
|
||||||
|
sub.ArchivedAt = &now
|
||||||
|
sub.ArchiveReason = reason
|
||||||
|
if s.log != nil {
|
||||||
|
s.log.Info("subscription completed, moved to history",
|
||||||
|
zap.String("id", sub.ID),
|
||||||
|
zap.String("name", sub.Name),
|
||||||
|
zap.String("reason", reason))
|
||||||
|
}
|
||||||
|
if s.hub != nil {
|
||||||
|
s.hub.Publish("subscription", map[string]any{
|
||||||
|
"id": sub.ID,
|
||||||
|
"name": sub.Name,
|
||||||
|
"archived": true,
|
||||||
|
"reason": reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func subscriptionShouldArchive(sub *model.Subscription, availability LocalAvailability) bool {
|
||||||
|
if sub == nil || sub.WashEnabled || sub.ArchivedAt != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mediaType := strings.ToLower(strings.TrimSpace(sub.MediaType))
|
||||||
|
if !isSubscriptionSeriesType(mediaType) {
|
||||||
|
return availability.InLibrary || availability.LocalMediaCount > 0 || availability.DownloadedEpisodes > 0
|
||||||
|
}
|
||||||
|
if availability.HasSeriesPack {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
total := sub.TotalEpisodes
|
||||||
|
if total <= 0 {
|
||||||
|
total = availability.TotalEpisodes
|
||||||
|
}
|
||||||
|
if total > 0 {
|
||||||
|
return availability.DownloadedEpisodes >= total && len(availability.MissingEpisodes) == 0
|
||||||
|
}
|
||||||
|
return subscriptionLooksSingleEpisode(sub) && availability.DownloadedEpisodes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func subscriptionArchiveReason(sub *model.Subscription, availability LocalAvailability) string {
|
||||||
|
if sub != nil && sub.WashEnabled {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if availability.HasSeriesPack {
|
||||||
|
return "整季资源已加入下载/入库"
|
||||||
|
}
|
||||||
|
if availability.TotalEpisodes > 0 {
|
||||||
|
return fmt.Sprintf("订阅完成:%d/%d", availability.DownloadedEpisodes, availability.TotalEpisodes)
|
||||||
|
}
|
||||||
|
if availability.DownloadedEpisodes > 0 {
|
||||||
|
return "单集订阅已加入下载/入库"
|
||||||
|
}
|
||||||
|
return "订阅媒体已加入下载/入库"
|
||||||
|
}
|
||||||
|
|
||||||
|
func subscriptionLooksSingleEpisode(sub *model.Subscription) bool {
|
||||||
|
if sub == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, value := range []string{sub.Name, sub.Filter} {
|
||||||
|
_, episode := ParseEpisode(value)
|
||||||
|
if episode > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SubscriptionService) shouldSkipExistingTorrent(ctx context.Context, mediaType string, candidate siteSearchCandidate) bool {
|
func (s *SubscriptionService) shouldSkipExistingTorrent(ctx context.Context, mediaType string, candidate siteSearchCandidate) bool {
|
||||||
if s == nil || s.downloads == nil {
|
if s == nil || s.downloads == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -433,6 +433,184 @@ func TestSubscriptionPendingDownloadAvailabilityIncludesLiveQBTorrents(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSubscriptionRunOneArchivesCompletedMovieRSS(t *testing.T) {
|
||||||
|
rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/rss+xml")
|
||||||
|
_, _ = w.Write([]byte(`<?xml version="1.0"?>
|
||||||
|
<rss><channel>
|
||||||
|
<item>
|
||||||
|
<title>Dune 2021 1080p WEB-DL</title>
|
||||||
|
<guid>dune-1080-web</guid>
|
||||||
|
<link>magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd&dn=Dune+2021+1080p+WEB-DL</link>
|
||||||
|
</item>
|
||||||
|
</channel></rss>`))
|
||||||
|
}))
|
||||||
|
defer rss.Close()
|
||||||
|
|
||||||
|
var addCalls int32
|
||||||
|
var added bool
|
||||||
|
qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v2/auth/login":
|
||||||
|
_, _ = w.Write([]byte("Ok."))
|
||||||
|
case "/api/v2/torrents/info":
|
||||||
|
if added {
|
||||||
|
_, _ = w.Write([]byte(`[{"hash":"dunehash","name":"Dune 2021 1080p WEB-DL","state":"downloading","progress":0.1}]`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
case "/api/v2/torrents/add":
|
||||||
|
added = true
|
||||||
|
atomic.AddInt32(&addCalls, 1)
|
||||||
|
_, _ = w.Write([]byte("Ok."))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer qb.Close()
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.Subscription{}, &model.Setting{}, &model.DownloadTask{}, &model.Media{}, &model.DownloadClient{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
configureTestDefaultQB(t, repos, qb.URL)
|
||||||
|
downloads := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
|
||||||
|
svc := NewSubscriptionService(nil, zap.NewNop(), repos, downloads, nil, NewHub(zap.NewNop()))
|
||||||
|
|
||||||
|
sub := &model.Subscription{
|
||||||
|
Name: "Dune 自动订阅",
|
||||||
|
FeedURL: rss.URL,
|
||||||
|
Filter: "Dune 2021",
|
||||||
|
MediaType: "movie",
|
||||||
|
SavePath: "/downloads/movies",
|
||||||
|
}
|
||||||
|
if err := repos.Subscription.Create(t.Context(), sub); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
queued, err := svc.runOne(t.Context(), sub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if queued != 1 {
|
||||||
|
t.Fatalf("queued = %d, want 1", queued)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&addCalls); got != 1 {
|
||||||
|
t.Fatalf("qb add calls = %d, want 1", got)
|
||||||
|
}
|
||||||
|
active, err := repos.Subscription.List(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(active) != 0 {
|
||||||
|
t.Fatalf("active subscriptions = %d, want 0 after completion", len(active))
|
||||||
|
}
|
||||||
|
history, err := repos.Subscription.History(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(history) != 1 || history[0].ArchivedAt == nil {
|
||||||
|
t.Fatalf("history = %#v, want one archived subscription", history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscriptionArchiveCompletedSingleEpisodeTV(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.Subscription{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop()))
|
||||||
|
sub := &model.Subscription{
|
||||||
|
Name: "Some Show S01E01 自动订阅",
|
||||||
|
FeedURL: "site-search://search?keyword=Some%20Show%20S01E01",
|
||||||
|
Filter: "Some Show S01E01",
|
||||||
|
MediaType: "tv",
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if err := repos.Subscription.Create(t.Context(), sub); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = svc.archiveCompletedSubscription(t.Context(), sub, LocalAvailability{
|
||||||
|
DownloadedEpisodes: 1,
|
||||||
|
LocalMediaCount: 1,
|
||||||
|
InLibrary: true,
|
||||||
|
ExistingEpisodeKeys: map[string]struct{}{
|
||||||
|
episodeKey(1, 1): {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
active, err := repos.Subscription.List(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(active) != 0 {
|
||||||
|
t.Fatalf("active subscriptions = %d, want 0", len(active))
|
||||||
|
}
|
||||||
|
history, err := repos.Subscription.History(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(history) != 1 || history[0].ArchiveReason == "" {
|
||||||
|
t.Fatalf("history = %#v, want archived single episode", history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscriptionArchiveKeepsWashSubscriptionActive(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.Subscription{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop()))
|
||||||
|
sub := &model.Subscription{
|
||||||
|
Name: "Dune 自动订阅",
|
||||||
|
FeedURL: "site-search://search?keyword=Dune",
|
||||||
|
Filter: "Dune 2021",
|
||||||
|
MediaType: "movie",
|
||||||
|
WashEnabled: true,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if err := repos.Subscription.Create(t.Context(), sub); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = svc.archiveCompletedSubscription(t.Context(), sub, LocalAvailability{
|
||||||
|
DownloadedEpisodes: 1,
|
||||||
|
LocalMediaCount: 1,
|
||||||
|
InLibrary: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
active, err := repos.Subscription.List(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(active) != 1 {
|
||||||
|
t.Fatalf("active subscriptions = %d, want wash subscription to stay active", len(active))
|
||||||
|
}
|
||||||
|
history, err := repos.Subscription.History(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(history) != 0 {
|
||||||
|
t.Fatalf("history subscriptions = %d, want 0", len(history))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSubscriptionRunOneDeduplicatesDuplicateRSSGUIDInSameFeed(t *testing.T) {
|
func TestSubscriptionRunOneDeduplicatesDuplicateRSSGUIDInSameFeed(t *testing.T) {
|
||||||
rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/rss+xml")
|
w.Header().Set("Content-Type", "application/rss+xml")
|
||||||
|
|||||||
+61
-23
@@ -22,6 +22,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -166,13 +167,16 @@ func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int)
|
|||||||
u := base + "/search/movie?" + q.Encode()
|
u := base + "/search/movie?" + q.Encode()
|
||||||
|
|
||||||
type result struct {
|
type result struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Overview string `json:"overview"`
|
OriginalTitle string `json:"original_title"`
|
||||||
PosterPath string `json:"poster_path"`
|
OriginalLanguage string `json:"original_language"`
|
||||||
BackdropPath string `json:"backdrop_path"`
|
Overview string `json:"overview"`
|
||||||
ReleaseDate string `json:"release_date"`
|
PosterPath string `json:"poster_path"`
|
||||||
VoteAverage float32 `json:"vote_average"`
|
BackdropPath string `json:"backdrop_path"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
VoteAverage float32 `json:"vote_average"`
|
||||||
|
GenreIDs []int `json:"genre_ids"`
|
||||||
}
|
}
|
||||||
type page struct {
|
type page struct {
|
||||||
Results []result `json:"results"`
|
Results []result `json:"results"`
|
||||||
@@ -187,10 +191,13 @@ func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int)
|
|||||||
}
|
}
|
||||||
r := p.Results[0]
|
r := p.Results[0]
|
||||||
m := &Match{
|
m := &Match{
|
||||||
TMDbID: r.ID,
|
TMDbID: r.ID,
|
||||||
Title: r.Title,
|
Title: r.Title,
|
||||||
Overview: r.Overview,
|
OriginalName: r.OriginalTitle,
|
||||||
Rating: r.VoteAverage,
|
Overview: r.Overview,
|
||||||
|
Rating: r.VoteAverage,
|
||||||
|
Languages: nonEmptyStrings(r.OriginalLanguage),
|
||||||
|
Genres: genreIDStrings(r.GenreIDs),
|
||||||
}
|
}
|
||||||
if r.PosterPath != "" {
|
if r.PosterPath != "" {
|
||||||
m.PosterURL = t.imgCDN + "/w500" + r.PosterPath
|
m.PosterURL = t.imgCDN + "/w500" + r.PosterPath
|
||||||
@@ -228,14 +235,17 @@ func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*M
|
|||||||
u := base + "/search/tv?" + q.Encode()
|
u := base + "/search/tv?" + q.Encode()
|
||||||
|
|
||||||
type result struct {
|
type result struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
OriginalName string `json:"original_name"`
|
OriginalName string `json:"original_name"`
|
||||||
Overview string `json:"overview"`
|
OriginalLanguage string `json:"original_language"`
|
||||||
PosterPath string `json:"poster_path"`
|
OriginCountry []string `json:"origin_country"`
|
||||||
BackdropPath string `json:"backdrop_path"`
|
Overview string `json:"overview"`
|
||||||
FirstAirDate string `json:"first_air_date"`
|
PosterPath string `json:"poster_path"`
|
||||||
VoteAverage float32 `json:"vote_average"`
|
BackdropPath string `json:"backdrop_path"`
|
||||||
|
FirstAirDate string `json:"first_air_date"`
|
||||||
|
VoteAverage float32 `json:"vote_average"`
|
||||||
|
GenreIDs []int `json:"genre_ids"`
|
||||||
}
|
}
|
||||||
type page struct {
|
type page struct {
|
||||||
Results []result `json:"results"`
|
Results []result `json:"results"`
|
||||||
@@ -250,10 +260,14 @@ func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*M
|
|||||||
}
|
}
|
||||||
r := p.Results[0]
|
r := p.Results[0]
|
||||||
m := &Match{
|
m := &Match{
|
||||||
TMDbID: r.ID,
|
TMDbID: r.ID,
|
||||||
Title: r.Name,
|
Title: r.Name,
|
||||||
Overview: r.Overview,
|
OriginalName: r.OriginalName,
|
||||||
Rating: r.VoteAverage,
|
Overview: r.Overview,
|
||||||
|
Rating: r.VoteAverage,
|
||||||
|
Languages: nonEmptyStrings(r.OriginalLanguage),
|
||||||
|
Countries: deduplicate(r.OriginCountry),
|
||||||
|
Genres: genreIDStrings(r.GenreIDs),
|
||||||
}
|
}
|
||||||
if m.Title == "" {
|
if m.Title == "" {
|
||||||
m.Title = r.OriginalName
|
m.Title = r.OriginalName
|
||||||
@@ -438,3 +452,27 @@ func deduplicate(s []string) []string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nonEmptyStrings(values ...string) []string {
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value != "" {
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func genreIDStrings(ids []int) []string {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if id > 0 {
|
||||||
|
out = append(out, fmt.Sprint(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ export const subscriptionsAPI = {
|
|||||||
list: () =>
|
list: () =>
|
||||||
api.get<{ items: Subscription[] }>('/subscriptions').then((r) => r.data.items),
|
api.get<{ items: Subscription[] }>('/subscriptions').then((r) => r.data.items),
|
||||||
|
|
||||||
|
history: () =>
|
||||||
|
api.get<{ items: Subscription[] }>('/subscriptions/history').then((r) => r.data.items),
|
||||||
|
|
||||||
create: (input: {
|
create: (input: {
|
||||||
name: string
|
name: string
|
||||||
feed_url: string
|
feed_url: string
|
||||||
|
|||||||
@@ -58,32 +58,67 @@ type AutoOrganizeConfig = {
|
|||||||
enabled: string
|
enabled: string
|
||||||
afterDownload: string
|
afterDownload: string
|
||||||
scrapeAfter: string
|
scrapeAfter: string
|
||||||
|
downloadSmartClassify: string
|
||||||
|
smartClassify: string
|
||||||
sourceDir: string
|
sourceDir: string
|
||||||
targetDir: string
|
targetDir: string
|
||||||
transferMode: string
|
transferMode: string
|
||||||
intervalSeconds: string
|
intervalSeconds: string
|
||||||
|
keepSeeding: string
|
||||||
|
movieFormat: string
|
||||||
|
tvFormat: string
|
||||||
|
animeFormat: string
|
||||||
|
scrapeAutoOnScan: string
|
||||||
|
scrapeProviders: string
|
||||||
|
scrapeLanguage: string
|
||||||
|
scrapeDelayMinMs: string
|
||||||
|
scrapeDelayMaxMs: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUTO_ORGANIZE_DEFAULTS: AutoOrganizeConfig = {
|
const AUTO_ORGANIZE_DEFAULTS: AutoOrganizeConfig = {
|
||||||
enabled: 'false',
|
enabled: 'false',
|
||||||
afterDownload: 'false',
|
afterDownload: 'false',
|
||||||
scrapeAfter: 'true',
|
scrapeAfter: 'true',
|
||||||
|
downloadSmartClassify: 'true',
|
||||||
|
smartClassify: 'true',
|
||||||
sourceDir: '',
|
sourceDir: '',
|
||||||
targetDir: '',
|
targetDir: '',
|
||||||
transferMode: 'hardlink',
|
transferMode: 'hardlink',
|
||||||
intervalSeconds: '300',
|
intervalSeconds: '300',
|
||||||
|
keepSeeding: 'true',
|
||||||
|
movieFormat: '{title} ({year})/{title} ({year})',
|
||||||
|
tvFormat: '{title} ({year})/Season {season:02}/{title} S{season:02}E{episode:02}',
|
||||||
|
animeFormat: '{title}/Season {season:02}/{title} S{season:02}E{episode:02}',
|
||||||
|
scrapeAutoOnScan: 'false',
|
||||||
|
scrapeProviders: 'tmdb,douban,bangumi,thetvdb,fanart',
|
||||||
|
scrapeLanguage: 'zh-CN',
|
||||||
|
scrapeDelayMinMs: '250',
|
||||||
|
scrapeDelayMaxMs: '500',
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUTO_ORGANIZE_KEYS: Record<keyof AutoOrganizeConfig, string> = {
|
const AUTO_ORGANIZE_KEYS: Record<keyof AutoOrganizeConfig, string> = {
|
||||||
enabled: 'organize.auto',
|
enabled: 'organize.auto',
|
||||||
afterDownload: 'organizer.auto_after_download',
|
afterDownload: 'organizer.auto_after_download',
|
||||||
scrapeAfter: 'organize.scrape_after',
|
scrapeAfter: 'organize.scrape_after',
|
||||||
|
downloadSmartClassify: 'downloads.smart_classify',
|
||||||
|
smartClassify: 'organizer.smart_classify',
|
||||||
sourceDir: 'organize.source_dir',
|
sourceDir: 'organize.source_dir',
|
||||||
targetDir: 'organize.target_dir',
|
targetDir: 'organize.target_dir',
|
||||||
transferMode: 'organize.transfer_mode',
|
transferMode: 'organize.transfer_mode',
|
||||||
intervalSeconds: 'organize.interval_seconds',
|
intervalSeconds: 'organize.interval_seconds',
|
||||||
|
keepSeeding: 'organize.keep_seeding',
|
||||||
|
movieFormat: 'organize.movie_format',
|
||||||
|
tvFormat: 'organize.tv_format',
|
||||||
|
animeFormat: 'organize.anime_format',
|
||||||
|
scrapeAutoOnScan: 'scrape.auto_on_scan',
|
||||||
|
scrapeProviders: 'scrape.providers',
|
||||||
|
scrapeLanguage: 'scrape.language',
|
||||||
|
scrapeDelayMinMs: 'scrape.delay_min_ms',
|
||||||
|
scrapeDelayMaxMs: 'scrape.delay_max_ms',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AutoOrganizeTab = 'basic' | 'naming' | 'scrape'
|
||||||
|
|
||||||
function settingIndex(rows: Setting[]): Record<string, string> {
|
function settingIndex(rows: Setting[]): Record<string, string> {
|
||||||
const out: Record<string, string> = {}
|
const out: Record<string, string> = {}
|
||||||
for (const row of rows) out[row.key] = row.value
|
for (const row of rows) out[row.key] = row.value
|
||||||
@@ -96,10 +131,21 @@ function mergeAutoOrganizeSettings(rows: Setting[]): AutoOrganizeConfig {
|
|||||||
enabled: idx[AUTO_ORGANIZE_KEYS.enabled] ?? AUTO_ORGANIZE_DEFAULTS.enabled,
|
enabled: idx[AUTO_ORGANIZE_KEYS.enabled] ?? AUTO_ORGANIZE_DEFAULTS.enabled,
|
||||||
afterDownload: idx[AUTO_ORGANIZE_KEYS.afterDownload] ?? AUTO_ORGANIZE_DEFAULTS.afterDownload,
|
afterDownload: idx[AUTO_ORGANIZE_KEYS.afterDownload] ?? AUTO_ORGANIZE_DEFAULTS.afterDownload,
|
||||||
scrapeAfter: idx[AUTO_ORGANIZE_KEYS.scrapeAfter] ?? AUTO_ORGANIZE_DEFAULTS.scrapeAfter,
|
scrapeAfter: idx[AUTO_ORGANIZE_KEYS.scrapeAfter] ?? AUTO_ORGANIZE_DEFAULTS.scrapeAfter,
|
||||||
|
downloadSmartClassify: idx[AUTO_ORGANIZE_KEYS.downloadSmartClassify] ?? AUTO_ORGANIZE_DEFAULTS.downloadSmartClassify,
|
||||||
|
smartClassify: idx[AUTO_ORGANIZE_KEYS.smartClassify] ?? AUTO_ORGANIZE_DEFAULTS.smartClassify,
|
||||||
sourceDir: idx[AUTO_ORGANIZE_KEYS.sourceDir] ?? AUTO_ORGANIZE_DEFAULTS.sourceDir,
|
sourceDir: idx[AUTO_ORGANIZE_KEYS.sourceDir] ?? AUTO_ORGANIZE_DEFAULTS.sourceDir,
|
||||||
targetDir: idx[AUTO_ORGANIZE_KEYS.targetDir] ?? AUTO_ORGANIZE_DEFAULTS.targetDir,
|
targetDir: idx[AUTO_ORGANIZE_KEYS.targetDir] ?? AUTO_ORGANIZE_DEFAULTS.targetDir,
|
||||||
transferMode: idx[AUTO_ORGANIZE_KEYS.transferMode] ?? AUTO_ORGANIZE_DEFAULTS.transferMode,
|
transferMode: idx[AUTO_ORGANIZE_KEYS.transferMode] ?? AUTO_ORGANIZE_DEFAULTS.transferMode,
|
||||||
intervalSeconds: idx[AUTO_ORGANIZE_KEYS.intervalSeconds] ?? AUTO_ORGANIZE_DEFAULTS.intervalSeconds,
|
intervalSeconds: idx[AUTO_ORGANIZE_KEYS.intervalSeconds] ?? AUTO_ORGANIZE_DEFAULTS.intervalSeconds,
|
||||||
|
keepSeeding: idx[AUTO_ORGANIZE_KEYS.keepSeeding] ?? AUTO_ORGANIZE_DEFAULTS.keepSeeding,
|
||||||
|
movieFormat: idx[AUTO_ORGANIZE_KEYS.movieFormat] ?? AUTO_ORGANIZE_DEFAULTS.movieFormat,
|
||||||
|
tvFormat: idx[AUTO_ORGANIZE_KEYS.tvFormat] ?? AUTO_ORGANIZE_DEFAULTS.tvFormat,
|
||||||
|
animeFormat: idx[AUTO_ORGANIZE_KEYS.animeFormat] ?? AUTO_ORGANIZE_DEFAULTS.animeFormat,
|
||||||
|
scrapeAutoOnScan: idx[AUTO_ORGANIZE_KEYS.scrapeAutoOnScan] ?? AUTO_ORGANIZE_DEFAULTS.scrapeAutoOnScan,
|
||||||
|
scrapeProviders: idx[AUTO_ORGANIZE_KEYS.scrapeProviders] ?? AUTO_ORGANIZE_DEFAULTS.scrapeProviders,
|
||||||
|
scrapeLanguage: idx[AUTO_ORGANIZE_KEYS.scrapeLanguage] ?? AUTO_ORGANIZE_DEFAULTS.scrapeLanguage,
|
||||||
|
scrapeDelayMinMs: idx[AUTO_ORGANIZE_KEYS.scrapeDelayMinMs] ?? AUTO_ORGANIZE_DEFAULTS.scrapeDelayMinMs,
|
||||||
|
scrapeDelayMaxMs: idx[AUTO_ORGANIZE_KEYS.scrapeDelayMaxMs] ?? AUTO_ORGANIZE_DEFAULTS.scrapeDelayMaxMs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +190,7 @@ export function FileManagerPage() {
|
|||||||
const [autoSaving, setAutoSaving] = useState(false)
|
const [autoSaving, setAutoSaving] = useState(false)
|
||||||
const [autoRunning, setAutoRunning] = useState(false)
|
const [autoRunning, setAutoRunning] = useState(false)
|
||||||
const [autoLoading, setAutoLoading] = useState(true)
|
const [autoLoading, setAutoLoading] = useState(true)
|
||||||
|
const [autoTab, setAutoTab] = useState<AutoOrganizeTab>('basic')
|
||||||
|
|
||||||
const currentDir = useMemo(() => {
|
const currentDir = useMemo(() => {
|
||||||
if (data?.path) return data.path
|
if (data?.path) return data.path
|
||||||
@@ -213,6 +260,7 @@ export function FileManagerPage() {
|
|||||||
|
|
||||||
const changeAutoConfig = (key: keyof AutoOrganizeConfig, value: string) => {
|
const changeAutoConfig = (key: keyof AutoOrganizeConfig, value: string) => {
|
||||||
setAutoConfig((current) => ({ ...current, [key]: value }))
|
setAutoConfig((current) => ({ ...current, [key]: value }))
|
||||||
|
if (key === 'scrapeAfter') setScrapeAfter(settingOn(value))
|
||||||
setAutoDirty(true)
|
setAutoDirty(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,10 +271,10 @@ export function FileManagerPage() {
|
|||||||
await adminAPI.updateSetting(AUTO_ORGANIZE_KEYS[key], autoConfig[key] ?? '')
|
await adminAPI.updateSetting(AUTO_ORGANIZE_KEYS[key], autoConfig[key] ?? '')
|
||||||
}
|
}
|
||||||
setAutoDirty(false)
|
setAutoDirty(false)
|
||||||
toast.success('自动整理设置已保存')
|
toast.success('整理入库设置已保存')
|
||||||
return true
|
return true
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存自动整理设置失败')
|
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存整理入库设置失败')
|
||||||
return false
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
setAutoSaving(false)
|
setAutoSaving(false)
|
||||||
@@ -447,99 +495,234 @@ export function FileManagerPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 lg:grid-cols-[1fr_1fr_150px_140px]">
|
<div className="flex flex-wrap gap-2 rounded-2xl border border-gray-200 bg-gray-50 p-1">
|
||||||
<label className="space-y-1">
|
{[
|
||||||
<span className="text-xs text-ink-50">整理源目录(待整理 / 下载目录)</span>
|
['basic', '基础设置'],
|
||||||
<div className="flex gap-2">
|
['naming', '命名规则'],
|
||||||
<input
|
['scrape', '刮削联动'],
|
||||||
className="input-base w-full"
|
].map(([key, label]) => (
|
||||||
placeholder="例如 F:\\downloads 或 /downloads"
|
<button
|
||||||
value={autoConfig.sourceDir}
|
key={key}
|
||||||
onChange={(event) => changeAutoConfig('sourceDir', event.target.value)}
|
type="button"
|
||||||
/>
|
className={
|
||||||
<button
|
autoTab === key
|
||||||
type="button"
|
? 'rounded-xl bg-white px-3 py-1.5 text-xs font-semibold text-brand-500 shadow-sm'
|
||||||
className="rounded-xl border border-gray-200 px-3 text-xs text-ink-100 hover:border-primary-400/40"
|
: 'rounded-xl px-3 py-1.5 text-xs text-ink-100 hover:bg-white/70'
|
||||||
disabled={!currentDir}
|
}
|
||||||
onClick={() => changeAutoConfig('sourceDir', currentDir)}
|
onClick={() => setAutoTab(key as AutoOrganizeTab)}
|
||||||
>
|
|
||||||
当前
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-xs text-ink-50">整理目的地目录(媒体库根目录)</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
className="input-base w-full"
|
|
||||||
placeholder="例如 F:\\media 或 /media"
|
|
||||||
value={autoConfig.targetDir}
|
|
||||||
onChange={(event) => changeAutoConfig('targetDir', event.target.value)}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-xl border border-gray-200 px-3 text-xs text-ink-100 hover:border-primary-400/40"
|
|
||||||
disabled={!currentDir}
|
|
||||||
onClick={() => changeAutoConfig('targetDir', currentDir)}
|
|
||||||
>
|
|
||||||
当前
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-xs text-ink-50">默认整理方式</span>
|
|
||||||
<select
|
|
||||||
className="input-base w-full"
|
|
||||||
value={autoConfig.transferMode}
|
|
||||||
onChange={(event) => changeAutoConfig('transferMode', event.target.value)}
|
|
||||||
>
|
>
|
||||||
<option value="hardlink">硬链接</option>
|
{label}
|
||||||
<option value="move">移动</option>
|
</button>
|
||||||
<option value="copy">复制</option>
|
))}
|
||||||
<option value="symlink">软链接</option>
|
<span className="ml-auto self-center px-2 text-xs text-sand-500">
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="space-y-1">
|
|
||||||
<span className="text-xs text-ink-50">检查间隔(秒)</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={60}
|
|
||||||
className="input-base w-full"
|
|
||||||
value={autoConfig.intervalSeconds}
|
|
||||||
onChange={(event) => changeAutoConfig('intervalSeconds', event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={settingOn(autoConfig.enabled)}
|
|
||||||
onChange={(event) => changeAutoConfig('enabled', event.target.checked ? 'true' : 'false')}
|
|
||||||
/>
|
|
||||||
整理源目录定时自动整理
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={settingOn(autoConfig.afterDownload)}
|
|
||||||
onChange={(event) => changeAutoConfig('afterDownload', event.target.checked ? 'true' : 'false')}
|
|
||||||
/>
|
|
||||||
qB 下载完成后自动整理
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={settingOn(autoConfig.scrapeAfter)}
|
|
||||||
onChange={(event) => changeAutoConfig('scrapeAfter', event.target.checked ? 'true' : 'false')}
|
|
||||||
/>
|
|
||||||
整理后自动刮削
|
|
||||||
</label>
|
|
||||||
<span className="text-xs text-sand-500">
|
|
||||||
{autoDirty ? '有未保存设置' : '设置已同步'} · 定时任务名:organize_source
|
{autoDirty ? '有未保存设置' : '设置已同步'} · 定时任务名:organize_source
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{autoTab === 'basic' && (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-3 lg:grid-cols-[1fr_1fr_150px_140px]">
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-ink-50">整理源目录(待整理 / 下载目录)</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="input-base w-full"
|
||||||
|
placeholder="例如 F:\\downloads 或 /downloads"
|
||||||
|
value={autoConfig.sourceDir}
|
||||||
|
onChange={(event) => changeAutoConfig('sourceDir', event.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border border-gray-200 px-3 text-xs text-ink-100 hover:border-primary-400/40"
|
||||||
|
disabled={!currentDir}
|
||||||
|
onClick={() => changeAutoConfig('sourceDir', currentDir)}
|
||||||
|
>
|
||||||
|
当前
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-ink-50">整理目的地目录(媒体库根目录)</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="input-base w-full"
|
||||||
|
placeholder="例如 F:\\media 或 /media"
|
||||||
|
value={autoConfig.targetDir}
|
||||||
|
onChange={(event) => changeAutoConfig('targetDir', event.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border border-gray-200 px-3 text-xs text-ink-100 hover:border-primary-400/40"
|
||||||
|
disabled={!currentDir}
|
||||||
|
onClick={() => changeAutoConfig('targetDir', currentDir)}
|
||||||
|
>
|
||||||
|
当前
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-ink-50">默认整理方式</span>
|
||||||
|
<select
|
||||||
|
className="input-base w-full"
|
||||||
|
value={autoConfig.transferMode}
|
||||||
|
onChange={(event) => changeAutoConfig('transferMode', event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="hardlink">硬链接</option>
|
||||||
|
<option value="move">移动</option>
|
||||||
|
<option value="copy">复制</option>
|
||||||
|
<option value="symlink">软链接</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-ink-50">检查间隔(秒)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={60}
|
||||||
|
className="input-base w-full"
|
||||||
|
value={autoConfig.intervalSeconds}
|
||||||
|
onChange={(event) => changeAutoConfig('intervalSeconds', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settingOn(autoConfig.enabled)}
|
||||||
|
onChange={(event) => changeAutoConfig('enabled', event.target.checked ? 'true' : 'false')}
|
||||||
|
/>
|
||||||
|
整理源目录定时自动整理
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settingOn(autoConfig.afterDownload)}
|
||||||
|
onChange={(event) => changeAutoConfig('afterDownload', event.target.checked ? 'true' : 'false')}
|
||||||
|
/>
|
||||||
|
qB 下载完成后自动整理
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settingOn(autoConfig.downloadSmartClassify)}
|
||||||
|
onChange={(event) => changeAutoConfig('downloadSmartClassify', event.target.checked ? 'true' : 'false')}
|
||||||
|
/>
|
||||||
|
下载器智能分类
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settingOn(autoConfig.smartClassify)}
|
||||||
|
onChange={(event) => changeAutoConfig('smartClassify', event.target.checked ? 'true' : 'false')}
|
||||||
|
/>
|
||||||
|
智能分类到子库
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settingOn(autoConfig.keepSeeding)}
|
||||||
|
onChange={(event) => changeAutoConfig('keepSeeding', event.target.checked ? 'true' : 'false')}
|
||||||
|
/>
|
||||||
|
保种
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{autoTab === 'naming' && (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-ink-50">电影命名格式</span>
|
||||||
|
<input
|
||||||
|
className="input-base w-full font-mono text-xs"
|
||||||
|
value={autoConfig.movieFormat}
|
||||||
|
onChange={(event) => changeAutoConfig('movieFormat', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-ink-50">剧集命名格式</span>
|
||||||
|
<input
|
||||||
|
className="input-base w-full font-mono text-xs"
|
||||||
|
value={autoConfig.tvFormat}
|
||||||
|
onChange={(event) => changeAutoConfig('tvFormat', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-ink-50">动漫命名格式</span>
|
||||||
|
<input
|
||||||
|
className="input-base w-full font-mono text-xs"
|
||||||
|
value={autoConfig.animeFormat}
|
||||||
|
onChange={(event) => changeAutoConfig('animeFormat', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-sand-500">
|
||||||
|
可用占位符:{'{title}'} {'{year}'} {'{season}'} {'{season:02}'} {'{episode}'} {'{episode:02}'} {'{category}'}。扩展名会自动补齐。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{autoTab === 'scrape' && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settingOn(autoConfig.scrapeAfter)}
|
||||||
|
onChange={(event) => changeAutoConfig('scrapeAfter', event.target.checked ? 'true' : 'false')}
|
||||||
|
/>
|
||||||
|
整理后自动刮削
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-ink-100">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settingOn(autoConfig.scrapeAutoOnScan)}
|
||||||
|
onChange={(event) => changeAutoConfig('scrapeAutoOnScan', event.target.checked ? 'true' : 'false')}
|
||||||
|
/>
|
||||||
|
扫描后自动刮削
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 lg:grid-cols-[1fr_160px_160px_160px]">
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-ink-50">刮削源优先级</span>
|
||||||
|
<input
|
||||||
|
className="input-base w-full"
|
||||||
|
placeholder="tmdb,douban,bangumi,thetvdb,fanart"
|
||||||
|
value={autoConfig.scrapeProviders}
|
||||||
|
onChange={(event) => changeAutoConfig('scrapeProviders', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-ink-50">首选语言</span>
|
||||||
|
<input
|
||||||
|
className="input-base w-full"
|
||||||
|
value={autoConfig.scrapeLanguage}
|
||||||
|
onChange={(event) => changeAutoConfig('scrapeLanguage', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-ink-50">最小间隔 ms</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="input-base w-full"
|
||||||
|
value={autoConfig.scrapeDelayMinMs}
|
||||||
|
onChange={(event) => changeAutoConfig('scrapeDelayMinMs', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-ink-50">最大间隔 ms</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="input-base w-full"
|
||||||
|
value={autoConfig.scrapeDelayMaxMs}
|
||||||
|
onChange={(event) => changeAutoConfig('scrapeDelayMaxMs', event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{data?.path && (
|
{data?.path && (
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { FormEvent, useEffect, useState } from 'react'
|
import { FormEvent, useEffect, useState } from 'react'
|
||||||
import { FolderOpen, Loader2, Save, SettingsIcon } from 'lucide-react'
|
import { Loader2, Save, SettingsIcon } from 'lucide-react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
import { adminAPI } from '../api/admin'
|
import { adminAPI } from '../api/admin'
|
||||||
import { libraryAPI } from '../api/library'
|
import { libraryAPI } from '../api/library'
|
||||||
import type { Library, Setting } from '../types'
|
import type { Library, Setting } from '../types'
|
||||||
|
|
||||||
// SettingsPage replaces the Vue SettingsView's General / Organize /
|
// SettingsPage replaces the Vue SettingsView's curated runtime settings.
|
||||||
// Scrape / Adult tabs. The Go backend stores settings as a single
|
// The Go backend stores settings as a single
|
||||||
// key/value table; we group the most useful keys client-side and let
|
// key/value table; we group the most useful keys client-side and let
|
||||||
// the operator edit them with typed widgets (select / toggle / input).
|
// the operator edit them with typed widgets (select / toggle / input).
|
||||||
//
|
//
|
||||||
@@ -156,145 +156,6 @@ const GROUPS: SettingGroup[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'organize',
|
|
||||||
label: '整理 & 刮削',
|
|
||||||
description: '媒体文件命名 + 自动刮削 + 整理目标',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
key: 'organize.auto',
|
|
||||||
label: '整理源目录定时自动整理',
|
|
||||||
type: 'toggle',
|
|
||||||
hint: '开启后后台会按下方间隔递归扫描「整理源目录」,自动整理到「整理目的地目录」并扫描入库。默认关闭,避免无意中频繁读盘。',
|
|
||||||
defaultValue: 'false',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'organizer.auto_after_download',
|
|
||||||
label: '下载完成后自动整理入库',
|
|
||||||
type: 'toggle',
|
|
||||||
hint: '开启后 qB 下载完成时,系统会优先使用种子的 content_path 整理该文件/目录,并在整理完成后扫描目标媒体库。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'organize.scrape_after',
|
|
||||||
label: '整理后自动刮削',
|
|
||||||
type: 'toggle',
|
|
||||||
hint: '开启后,手动/自动整理完成并扫描入库后,会立即触发 TMDb/豆瓣/Bangumi/JavBus/JavDB 等元数据刮削。需要先配置可用刮削源。',
|
|
||||||
defaultValue: 'false',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'downloads.smart_classify',
|
|
||||||
label: '下载器智能分类',
|
|
||||||
type: 'toggle',
|
|
||||||
hint: '订阅下载和站点搜索下载未指定保存路径时,自动按媒体类型/分类写入 qB 保存目录与 qB 分类(如:/downloads/国产剧、/downloads/综艺)。',
|
|
||||||
defaultValue: 'true',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'organizer.smart_classify',
|
|
||||||
label: '启用智能分类',
|
|
||||||
type: 'toggle',
|
|
||||||
hint: '整理/入库时根据元数据(语言/国家/类型)自动分类到媒体库子目录(如:华语电影、欧美剧、日番)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'organize.source_dir',
|
|
||||||
label: '整理源目录(待整理)',
|
|
||||||
type: 'text',
|
|
||||||
hint: '从该目录读取待整理文件;留空则默认整理整个媒体库(媒体库路径)。',
|
|
||||||
placeholder: '/mnt/downloads',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'organize.target_dir',
|
|
||||||
label: '整理目的地目录',
|
|
||||||
type: 'text',
|
|
||||||
hint: '整理后输出到该目录;留空则默认整理到各媒体库对应路径(见下方参考)。与「源目录」相互独立。',
|
|
||||||
placeholder: '/mnt/media/organized',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'organize.transfer_mode',
|
|
||||||
label: '默认转移方式',
|
|
||||||
type: 'select',
|
|
||||||
hint: '移动会删除源文件;复制/硬链接/软链接保留源文件,PT 做种不中断。硬链接同盘零额外占用。',
|
|
||||||
options: [
|
|
||||||
{ value: 'move', label: '移动(删除源文件)' },
|
|
||||||
{ value: 'copy', label: '复制(保留源文件)' },
|
|
||||||
{ value: 'hardlink', label: '硬链接(保留源,做种不中断,不占双倍空间)' },
|
|
||||||
{ value: 'symlink', label: '软链接(符号链接,保留源)' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'organize.interval_seconds',
|
|
||||||
label: '自动整理间隔秒数',
|
|
||||||
type: 'number',
|
|
||||||
hint: '仅在「整理源目录定时自动整理」开启后生效;最小 60 秒,建议 300 秒或更高。',
|
|
||||||
defaultValue: '300',
|
|
||||||
placeholder: '300',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'organize.keep_seeding',
|
|
||||||
label: '保种(整理后继续做种上传)',
|
|
||||||
type: 'toggle',
|
|
||||||
hint: '开启后即使选择「移动」也会自动改用硬链接保留源文件,确保 qBittorrent 继续做种。硬链接要求源和目标在同一文件系统;失败时会提示,不再静默复制占用双倍空间。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'organize.movie_format',
|
|
||||||
label: '电影命名格式',
|
|
||||||
type: 'text',
|
|
||||||
hint: '例: {title} ({year})/{title} ({year})',
|
|
||||||
placeholder: '{title} ({year})/{title} ({year})',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'organize.tv_format',
|
|
||||||
label: '剧集命名格式',
|
|
||||||
type: 'text',
|
|
||||||
placeholder: '{title} ({year})/Season {season}/{title} S{season:02}E{episode:02}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'organize.anime_format',
|
|
||||||
label: '动漫命名格式',
|
|
||||||
type: 'text',
|
|
||||||
placeholder: '{title}/Season {season}/{title} S{season:02}E{episode:02}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'scrape.auto_on_scan',
|
|
||||||
label: '扫描后自动刮削',
|
|
||||||
type: 'toggle',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'scrape.providers',
|
|
||||||
label: '刮削源优先级',
|
|
||||||
type: 'text',
|
|
||||||
hint: '逗号分隔: tmdb,bangumi,thetvdb,fanart',
|
|
||||||
placeholder: 'tmdb,bangumi,thetvdb,fanart',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'scrape.language',
|
|
||||||
label: '刮削首选语言',
|
|
||||||
type: 'text',
|
|
||||||
placeholder: 'zh-CN',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'scrape.delay_min_ms',
|
|
||||||
label: '刮削最小间隔毫秒',
|
|
||||||
type: 'number',
|
|
||||||
hint: '参考 nowen-video 的随机节流策略。批量刮削时两条媒体之间会随机等待,避免 TMDb / Bangumi / JavBus / JavDB 等源请求过快。',
|
|
||||||
defaultValue: '250',
|
|
||||||
placeholder: '250',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'scrape.delay_max_ms',
|
|
||||||
label: '刮削最大间隔毫秒',
|
|
||||||
type: 'number',
|
|
||||||
hint: '如遇到站点限速、超时或 403,可提高到 2000-5000;填 0 可关闭批量刮削间隔。',
|
|
||||||
defaultValue: '500',
|
|
||||||
placeholder: '500',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'scan.periodic_enabled',
|
|
||||||
label: '周期性整库重扫',
|
|
||||||
type: 'toggle',
|
|
||||||
hint: '默认关闭。文件新增/变更由实时监听增量入库,无需定时全量重扫。开启会每 60 分钟重扫整库,频繁读盘会损伤硬盘,一般无需开启。',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'cloud-upload',
|
key: 'cloud-upload',
|
||||||
label: '网盘转存',
|
label: '网盘转存',
|
||||||
@@ -499,7 +360,7 @@ export function SettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="font-display text-3xl font-bold text-ink-600">系统设置</h1>
|
<h1 className="font-display text-3xl font-bold text-ink-600">系统设置</h1>
|
||||||
<p className="text-sm text-ink-50">
|
<p className="text-sm text-ink-50">
|
||||||
按分组编辑转码 / 整理 / 刮削 / 下载器等关键配置
|
按分组编辑转码 / 网盘转存 / Adult 等关键配置
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -554,44 +415,6 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 整理 tab 时显示各媒体库默认路径 */}
|
|
||||||
{!loading && activeGroup === 'organize' && libraries.length > 0 && (
|
|
||||||
<div className="glass-panel">
|
|
||||||
<div className="mb-3 flex items-center gap-2 text-sm text-ink-100">
|
|
||||||
<FolderOpen size={16} className="text-brand-500" />
|
|
||||||
<span>默认整理路径参考(未设目的地目录时按媒体库归类)</span>
|
|
||||||
</div>
|
|
||||||
<table className="w-full text-left text-sm">
|
|
||||||
<thead className="text-xs uppercase tracking-wider text-sand-500">
|
|
||||||
<tr>
|
|
||||||
<th className="py-2">媒体库</th>
|
|
||||||
<th>类型</th>
|
|
||||||
<th>路径</th>
|
|
||||||
<th>整理后示例</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{libraries.map((lib) => (
|
|
||||||
<tr key={lib.id} className="border-t border-gray-200">
|
|
||||||
<td className="py-2 font-medium text-ink-600">{lib.name}</td>
|
|
||||||
<td className="text-ink-50">
|
|
||||||
{lib.type === 'movie' ? '电影' : lib.type === 'tv' ? '电视剧' : lib.type === 'anime' ? '动漫' : '音乐'}
|
|
||||||
</td>
|
|
||||||
<td className="font-mono text-xs text-ink-50">{lib.path}</td>
|
|
||||||
<td className="font-mono text-[11px] text-sand-500">
|
|
||||||
{lib.type === 'movie'
|
|
||||||
? `${lib.path}/片名 (2024)/片名 (2024).mkv`
|
|
||||||
: lib.type === 'tv' || lib.type === 'anime'
|
|
||||||
? `${lib.path}/剧名/Season 01/剧名 - S01E01.mkv`
|
|
||||||
: lib.path}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FormEvent, useEffect, useState } from 'react'
|
import { FormEvent, useEffect, useState } from 'react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { CalendarClock, CheckCircle2, Film, Pencil, Play, Plus, Save, ShieldCheck, Trash2 } from 'lucide-react'
|
import { Archive, CalendarClock, CheckCircle2, Film, Pencil, Play, Plus, Save, ShieldCheck, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
import { subscriptionsAPI } from '../api/subscriptions'
|
import { subscriptionsAPI } from '../api/subscriptions'
|
||||||
import { imageURL } from '../api/client'
|
import { imageURL } from '../api/client'
|
||||||
@@ -9,6 +9,7 @@ import type { Subscription } from '../types'
|
|||||||
|
|
||||||
export function SubscriptionsPage() {
|
export function SubscriptionsPage() {
|
||||||
const [items, setItems] = useState<Subscription[]>([])
|
const [items, setItems] = useState<Subscription[]>([])
|
||||||
|
const [historyItems, setHistoryItems] = useState<Subscription[]>([])
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [feed, setFeed] = useState('')
|
const [feed, setFeed] = useState('')
|
||||||
const [filter, setFilter] = useState('')
|
const [filter, setFilter] = useState('')
|
||||||
@@ -28,9 +29,11 @@ export function SubscriptionsPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
const refresh = () =>
|
const refresh = () =>
|
||||||
subscriptionsAPI
|
Promise.all([subscriptionsAPI.list(), subscriptionsAPI.history()])
|
||||||
.list()
|
.then(([active, history]) => {
|
||||||
.then(setItems)
|
setItems(active)
|
||||||
|
setHistoryItems(history)
|
||||||
|
})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -311,6 +314,43 @@ export function SubscriptionsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{historyItems.length > 0 && (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Archive size={18} className="text-brand-500" />
|
||||||
|
<h2 className="font-display text-xl font-semibold text-ink-600">订阅历史</h2>
|
||||||
|
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-ink-50">{historyItems.length} 条</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{historyItems.map((subscription) => (
|
||||||
|
<article key={subscription.id} className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="h-20 w-14 flex-shrink-0 overflow-hidden rounded-xl bg-primary-400/10">
|
||||||
|
{subscription.poster_url ? (
|
||||||
|
<img src={imageURL(subscription.poster_url)} alt={subscription.name} className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-brand-500">
|
||||||
|
<Film size={18} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="truncate font-semibold text-ink-600" title={subscription.name}>{subscription.name}</h3>
|
||||||
|
<p className="mt-1 text-xs text-ink-50">
|
||||||
|
{subscription.archive_reason || '订阅已完成'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-ink-100">
|
||||||
|
{subscription.archived_at ? new Date(subscription.archived_at).toLocaleString() : '完成时间未知'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-ink-50">{subscriptionProgressLabel(subscription)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,6 +206,8 @@ export interface Subscription {
|
|||||||
priority?: number
|
priority?: number
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
last_run_at?: string
|
last_run_at?: string
|
||||||
|
archived_at?: string
|
||||||
|
archive_reason?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user