mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
Unify organize ingest pipeline
This commit is contained in:
@@ -9,6 +9,11 @@ docs/
|
||||
data/
|
||||
cache/
|
||||
logs/
|
||||
verify-data/
|
||||
verify-cache/
|
||||
verify-media/
|
||||
verify-downloads/
|
||||
.codex-*
|
||||
.tmp_*
|
||||
.tmp-deploy-*
|
||||
.tmp-deploy-data/
|
||||
|
||||
@@ -64,6 +64,7 @@ config.yaml
|
||||
# Runtime / local-only artifacts (清理补充)
|
||||
.tmp-live-backups/
|
||||
.tmp-*
|
||||
.codex-*
|
||||
downloads/
|
||||
media/
|
||||
*.pid
|
||||
|
||||
@@ -71,8 +71,8 @@ func downloadOrganizeOneHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// existing OrganizerService for each library that contains those files.
|
||||
func downloadOrganizeAllHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Walk each library and re-organize. The OrganizerService is
|
||||
// idempotent so this is safe to run repeatedly.
|
||||
// Walk each library through the unified organize pipeline so rename,
|
||||
// scan, scrape and task reporting stay identical to manual organize.
|
||||
libs, err := svc.Repo.Library.List(c.Request.Context())
|
||||
if err != nil {
|
||||
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))
|
||||
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 {
|
||||
results = append(results, gin.H{"library": l.Name, "error": err.Error()})
|
||||
continue
|
||||
}
|
||||
if svc.Scan != nil && res != nil && !res.DryRun {
|
||||
res.Scans, res.Scrapes = scanAndScrapeAfterOrganize(c, svc, res.DestPath, l.ID, nil)
|
||||
}
|
||||
results = append(results, gin.H{"library": l.Name, "result": res})
|
||||
results = append(results, gin.H{"library": l.Name, "result": resp.Result})
|
||||
}
|
||||
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))
|
||||
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 {
|
||||
out = append(out, gin.H{"library": l.Name, "error": err.Error()})
|
||||
continue
|
||||
}
|
||||
if svc.Scan != nil && res != nil && !res.DryRun {
|
||||
res.Scans, res.Scrapes = scanAndScrapeAfterOrganize(c, svc, res.DestPath, l.ID, nil)
|
||||
}
|
||||
out = append(out, gin.H{"library": l.Name, "result": res})
|
||||
out = append(out, gin.H{"library": l.Name, "result": resp.Result})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"results": out})
|
||||
}
|
||||
|
||||
@@ -57,22 +57,18 @@ func organizeMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req organizeReq
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
opts := organizeOptionsFromReq(req)
|
||||
task := startOrganizeHTTPTask(svc, "手动整理媒体", opts)
|
||||
dst, err := svc.Organizer.OrganizeMediaWithOptions(c.Request.Context(), c.Param("id"), opts)
|
||||
runReq := organizePipelineRequestFromReq(req, service.OrganizeScopeMedia, "手动整理媒体")
|
||||
runReq.MediaID = c.Param("id")
|
||||
resp, err := organizePipeline(svc).Run(c.Request.Context(), runReq)
|
||||
if err != nil {
|
||||
finishHTTPTask(task, err, "organize", "手动整理媒体失败", nil, nil)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
payload := gin.H{"path": dst}
|
||||
if organizeScanAfter(req.ScanAfter) && !req.DryRun && svc.Scan != nil {
|
||||
updateHTTPTask(task, "scan_scrape", "正在扫描入库并按设置刮削", nil, nil)
|
||||
scans, scrapes := scanAndScrapeAfterOrganize(c, svc, dst, strings.TrimSpace(req.LibraryID), req.ScrapeAfter)
|
||||
payload["scans"] = scans
|
||||
payload["scrapes"] = scrapes
|
||||
payload := gin.H{"path": resp.Path}
|
||||
if resp.Result != nil {
|
||||
payload["scans"] = resp.Result.Scans
|
||||
payload["scrapes"] = resp.Result.Scrapes
|
||||
}
|
||||
finishHTTPTask(task, nil, "completed", "手动整理媒体结束", nil, nil)
|
||||
c.JSON(http.StatusOK, payload)
|
||||
}
|
||||
}
|
||||
@@ -81,21 +77,14 @@ func organizeLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req organizeReq
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
opts := organizeOptionsFromReq(req)
|
||||
task := startOrganizeHTTPTask(svc, "手动整理媒体库", opts)
|
||||
res, err := svc.Organizer.OrganizeLibraryWithOptions(c.Request.Context(), c.Param("id"), opts)
|
||||
runReq := organizePipelineRequestFromReq(req, service.OrganizeScopeLibrary, "手动整理媒体库")
|
||||
runReq.LibraryID = c.Param("id")
|
||||
resp, err := organizePipeline(svc).Run(c.Request.Context(), runReq)
|
||||
if err != nil {
|
||||
finishHTTPTask(task, err, "organize", "手动整理媒体库失败", nil, nil)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
details := service.OrganizeTaskDetails(res, 8)
|
||||
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)
|
||||
c.JSON(http.StatusOK, resp.Result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,63 +102,40 @@ func organizeDirectoryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req organizeReq
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
opts := organizeOptionsFromReq(req)
|
||||
task := startOrganizeHTTPTask(svc, "手动整理入库", opts)
|
||||
res, err := svc.Organizer.OrganizeDirectory(c.Request.Context(), opts)
|
||||
runReq := organizePipelineRequestFromReq(req, service.OrganizeScopeDirectory, "手动整理入库")
|
||||
runReq.PreferredLibraryID = strings.TrimSpace(req.LibraryID)
|
||||
resp, err := organizePipeline(svc).Run(c.Request.Context(), runReq)
|
||||
if err != nil {
|
||||
finishHTTPTask(task, err, "organize", "手动整理入库失败", nil, nil)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
details := service.OrganizeTaskDetails(res, 8)
|
||||
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)
|
||||
c.JSON(http.StatusOK, resp.Result)
|
||||
}
|
||||
}
|
||||
|
||||
func startOrganizeHTTPTask(svc *service.Container, name string, opts service.OrganizeOptions) *service.TaskHandle {
|
||||
if svc == nil || svc.Tasks == nil {
|
||||
return nil
|
||||
func organizePipelineRequestFromReq(req organizeReq, scope service.OrganizeScope, taskName string) service.OrganizePipelineRequest {
|
||||
dest := strings.TrimSpace(req.DestPath)
|
||||
if dest == "" {
|
||||
dest = strings.TrimSpace(req.TargetPath)
|
||||
}
|
||||
message := "正在整理/重命名/入库"
|
||||
if opts.DryRun {
|
||||
message = "正在预览整理/重命名"
|
||||
return service.OrganizePipelineRequest{
|
||||
Scope: scope,
|
||||
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) {
|
||||
if task == nil {
|
||||
return
|
||||
func organizePipeline(svc *service.Container) *service.OrganizePipelineService {
|
||||
if svc.OrganizePipeline != nil {
|
||||
return svc.OrganizePipeline
|
||||
}
|
||||
task.Update(service.TaskUpdate{Stage: stage, Message: message, Metrics: metrics, Details: details})
|
||||
}
|
||||
|
||||
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
|
||||
return service.NewOrganizePipelineService(svc.Log, svc.Repo, svc.Organizer, svc.Scan, svc.Tasks)
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc *
|
||||
|
||||
// Subscriptions.
|
||||
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.DELETE("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), deleteSubscriptionHandler(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 {
|
||||
return func(c *gin.Context) {
|
||||
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": "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",
|
||||
"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
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
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"`
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// SettingRepository persists key/value preferences.
|
||||
|
||||
@@ -66,6 +66,13 @@ func (s *DownloadClientService) Create(ctx context.Context, in DownloadClientInp
|
||||
IsDefault: normalized.IsDefault,
|
||||
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 {
|
||||
_ = 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) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
|
||||
+139
-80
@@ -39,14 +39,15 @@ import (
|
||||
|
||||
// DownloadService is the single download orchestrator.
|
||||
type DownloadService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
hub *Hub
|
||||
qb *QBitClient
|
||||
organizer *OrganizerService
|
||||
scanner *ScannerService
|
||||
site *SiteService
|
||||
tasks *TaskTrackerService
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
hub *Hub
|
||||
qb *QBitClient
|
||||
organizer *OrganizerService
|
||||
organizePipeline *OrganizePipelineService
|
||||
scanner *ScannerService
|
||||
site *SiteService
|
||||
tasks *TaskTrackerService
|
||||
|
||||
mu sync.Mutex
|
||||
stopCh chan struct{}
|
||||
@@ -62,6 +63,10 @@ func (d *DownloadService) SetScanner(scanner *ScannerService) {
|
||||
d.scanner = scanner
|
||||
}
|
||||
|
||||
func (d *DownloadService) SetOrganizePipeline(pipeline *OrganizePipelineService) {
|
||||
d.organizePipeline = pipeline
|
||||
}
|
||||
|
||||
func (d *DownloadService) SetTaskTracker(tasks *TaskTrackerService) {
|
||||
d.tasks = tasks
|
||||
}
|
||||
@@ -203,6 +208,10 @@ func (d *DownloadService) ReloadConfig(ctx context.Context) error {
|
||||
cfg.BaseURL = strings.TrimRight(c.Host, "/")
|
||||
cfg.Username = c.Username
|
||||
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 {
|
||||
@@ -228,6 +237,28 @@ func (d *DownloadService) ReloadConfig(ctx context.Context) error {
|
||||
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.
|
||||
func (d *DownloadService) AddDownload(ctx context.Context, userID, urlStr, savePath string) (*model.DownloadTask, error) {
|
||||
return d.AddDownloadWithMeta(ctx, userID, urlStr, savePath, DownloadTaskMeta{})
|
||||
@@ -313,7 +344,7 @@ func (d *DownloadService) resolveDownloadSavePath(ctx context.Context, explicitS
|
||||
if !autoClassify || category == "" {
|
||||
return base, ""
|
||||
}
|
||||
return categoryRoot(base, sanitizeFilename(category)), category
|
||||
return downloadSavePathCategoryRoot(base, sanitizeFilename(category)), category
|
||||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
d.markDownloadTaskDeleted(ctx, torrentName)
|
||||
d.markDownloadTaskDeleted(ctx, hash, torrentName)
|
||||
stateKey := strings.ToLower(hash)
|
||||
d.mu.Lock()
|
||||
delete(d.prevStates, stateKey)
|
||||
@@ -747,16 +778,28 @@ func (d *DownloadService) Delete(ctx context.Context, hash string, withFiles boo
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DownloadService) markDownloadTaskDeleted(ctx context.Context, torrentName string) {
|
||||
if d == nil || d.repo == nil || d.repo.DB == nil || strings.TrimSpace(torrentName) == "" {
|
||||
func (d *DownloadService) markDownloadTaskDeleted(ctx context.Context, hash, torrentName string) {
|
||||
if d == nil || d.repo == nil || d.repo.DB == nil {
|
||||
return
|
||||
}
|
||||
rows, err := d.repo.Download.List(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
taskByKey := tasksByIdentity(rows)
|
||||
matched, ok := findMatchingTaskByIdentity(torrentName, taskByKey)
|
||||
if matched, ok := findDownloadTaskByHash(rows, hash); ok {
|
||||
_ = 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 {
|
||||
return
|
||||
}
|
||||
@@ -768,6 +811,19 @@ func (d *DownloadService) markDownloadTaskDeleted(ctx context.Context, torrentNa
|
||||
}).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
|
||||
// it seeding (qBittorrent performs the physical move and resumes seeding).
|
||||
// 用于「移动 PT 种子文件且转移后继续做种上传」的整盘迁移场景。
|
||||
@@ -803,7 +859,7 @@ func (d *DownloadService) poll(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
rows, _ := d.repo.Download.List(ctx)
|
||||
taskByKey := tasksByIdentity(rows)
|
||||
taskByKey := tasksByTorrentIdentity(rows)
|
||||
d.processDownloadSnapshot(ctx, live, taskByKey)
|
||||
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 {
|
||||
stateKey := completedTorrentQueueKey(torrent)
|
||||
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)
|
||||
if stateKey == "" {
|
||||
continue
|
||||
@@ -839,11 +899,13 @@ func (d *DownloadService) processDownloadSnapshot(ctx context.Context, live []QB
|
||||
// (onTorrentComplete 内部仍受 organize.auto 开关约束,且
|
||||
// 整理对已存在的目标文件幂等跳过)。
|
||||
d.prevStates[stateKey] = true
|
||||
if recentlyCompletedTorrent(torrent, time.Now()) && !d.completedTorrentCatchupRecorded(ctx, torrent) {
|
||||
if taskNeedsOrganize {
|
||||
shouldQueue = true
|
||||
}
|
||||
case complete && !wasComplete:
|
||||
shouldQueue = true
|
||||
case complete && taskNeedsOrganize:
|
||||
shouldQueue = true
|
||||
case complete:
|
||||
d.prevStates[stateKey] = true
|
||||
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) == "" {
|
||||
return
|
||||
}
|
||||
matched, ok := findMatchingTaskByIdentity(torrent.Name, taskByKey)
|
||||
matched, ok := findMatchingTaskByTorrentIdentity(torrent.Name, taskByKey)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -1053,6 +1115,17 @@ func tasksByIdentity(rows []model.DownloadTask) map[string]model.DownloadTask {
|
||||
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) {
|
||||
key := downloadTaskIdentityKey(title)
|
||||
if key == "" {
|
||||
@@ -1069,6 +1142,29 @@ func findMatchingTaskByIdentity(title string, taskByKey map[string]model.Downloa
|
||||
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.
|
||||
// It organizes the completed torrent payload directly. Relying on existing
|
||||
// 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("source", source),
|
||||
zap.Bool("allow_replace_existing", allowReplace))
|
||||
taskHandle := d.startDownloadOrganizeTask(torrent, source, allowReplace)
|
||||
res, err := d.organizer.OrganizeDirectory(ctx, OrganizeOptions{
|
||||
SourcePath: source,
|
||||
MediaType: downloadTaskMediaType(taskRow),
|
||||
MediaCategory: firstNonEmpty(downloadTaskMediaCategory(taskRow), torrent.Category),
|
||||
AllowReplaceExisting: allowReplace,
|
||||
resWrap, err := d.ensureOrganizePipeline().Run(ctx, OrganizePipelineRequest{
|
||||
Scope: OrganizeScopeDirectory,
|
||||
Trigger: OrganizeTriggerDownload,
|
||||
TaskName: d.downloadOrganizeTaskName(torrent, allowReplace),
|
||||
SourcePath: source,
|
||||
MediaType: downloadTaskMediaType(taskRow),
|
||||
MediaCategory: firstNonEmpty(downloadTaskMediaCategory(taskRow), torrent.Category),
|
||||
AllowReplace: allowReplace,
|
||||
})
|
||||
if err != nil {
|
||||
if taskHandle != nil {
|
||||
taskHandle.Finish(err, TaskUpdate{
|
||||
Stage: "organize",
|
||||
Message: "下载完成自动整理失败",
|
||||
})
|
||||
}
|
||||
d.log.Error("auto organize completed torrent failed",
|
||||
zap.String("hash", torrent.Hash),
|
||||
zap.String("source", source),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
if taskHandle != nil && res != nil {
|
||||
taskHandle.Update(TaskUpdate{
|
||||
Stage: "organize",
|
||||
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),
|
||||
})
|
||||
res := resWrap.Result
|
||||
if res == nil {
|
||||
res = &OrganizeResult{}
|
||||
}
|
||||
d.markCompletedTorrentCatchupRecorded(context.Background(), torrent)
|
||||
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)))
|
||||
}
|
||||
|
||||
func (d *DownloadService) startDownloadOrganizeTask(torrent QBitTorrent, source string, allowReplace bool) *TaskHandle {
|
||||
if d == nil || d.tasks == nil {
|
||||
return nil
|
||||
}
|
||||
message := "下载完成后自动整理/重命名/入库"
|
||||
if allowReplace {
|
||||
message = "下载完成后自动整理/重命名/入库(允许洗版替换)"
|
||||
}
|
||||
func (d *DownloadService) downloadOrganizeTaskName(torrent QBitTorrent, allowReplace bool) string {
|
||||
name := strings.TrimSpace(torrent.Name)
|
||||
if name == "" {
|
||||
name = "下载完成自动整理"
|
||||
}
|
||||
return d.tasks.Start(TaskKindOrganize, name, TaskUpdate{
|
||||
Stage: "organize",
|
||||
SourcePath: source,
|
||||
Message: message,
|
||||
})
|
||||
if allowReplace {
|
||||
name += "(允许洗版)"
|
||||
}
|
||||
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) {
|
||||
@@ -1204,12 +1263,12 @@ func (d *DownloadService) completedTorrentTask(ctx context.Context, torrent QBit
|
||||
if err != nil || len(rows) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
taskByKey := tasksByIdentity(rows)
|
||||
if task, ok := findMatchingTaskByIdentity(torrent.Name, taskByKey); ok {
|
||||
taskByKey := tasksByTorrentIdentity(rows)
|
||||
if task, ok := findMatchingTaskByTorrentIdentity(torrent.Name, taskByKey); ok {
|
||||
return &task, true
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "downloads", "国产剧", "狂飙.S01E01.2023.1080p.mkv")
|
||||
@@ -335,17 +430,31 @@ func TestDownloadPollBaselinesAlreadyCompletedTorrents(t *testing.T) {
|
||||
|
||||
func TestDownloadPollCatchesUpRecentlyCompletedTorrents(t *testing.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.processDownloadSnapshot(t.Context(), []QBitTorrent{
|
||||
{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: "no-timestamp", Name: "No Timestamp S01E01", Progress: 1},
|
||||
}, nil)
|
||||
}, tasksByTorrentIdentity([]model.DownloadTask{*task}))
|
||||
|
||||
// 只有补整理时间窗内完成的种子会被补整理;无 completion_on 的保守跳过。
|
||||
// 只有补整理时间窗内、且存在本地追踪任务的种子会被补整理;无 completion_on 的保守跳过。
|
||||
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) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
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) {
|
||||
var addCalls int32
|
||||
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) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
|
||||
@@ -28,6 +28,7 @@ var (
|
||||
patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\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*季`)
|
||||
patSeasonOnly = regexp.MustCompile(`(?i)(?:^|[\s._-])(?:s|season)\.?\s*\d{1,2}(?:[\s._-]|$)`)
|
||||
patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`)
|
||||
// patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
|
||||
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -34,12 +35,11 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
|
||||
languages := normalizeTokens(input.Languages...)
|
||||
rawText := input.Title + " " + input.Category + " " + strings.Join(input.Genres, " ")
|
||||
text := strings.ToLower(rawText)
|
||||
hasMetadata := len(genres) > 0 || len(countries) > 0 || len(languages) > 0
|
||||
|
||||
isChinesePlatform := containsAnyText(text,
|
||||
"iqiyi", "qiyi", "youku", "tencent", "wetv", "mgtv", "mango", "hunantv", "cctv",
|
||||
"bilibili", "bili", "芒果tv", "腾讯视频", "优酷", "爱奇艺",
|
||||
)
|
||||
isChinese := hasAny(languages, "ZH", "ZH-CN", "ZH-TW", "CN") || hasAny(countries, "CN", "TW", "HK", "MO") || containsHan(rawText) || containsAnyText(text, "华语", "国产", "国剧", "国漫") || isChinesePlatform
|
||||
isChineseByMetadata := hasAny(languages, "ZH", "ZH-CN", "ZH-TW", "CN", "BO", "ZA") || hasAny(countries, "CN", "TW", "HK", "MO")
|
||||
isChineseByText := containsHan(rawText) || containsAnyText(text, "华语", "国产", "国剧", "国漫")
|
||||
isChinese := isChineseByMetadata || (!hasMetadata && isChineseByText)
|
||||
isJapanese := hasAny(languages, "JA", "JP") || hasAny(countries, "JP") || containsJapaneseKana(rawText) || strings.Contains(text, "日番")
|
||||
isKorean := hasAny(languages, "KO", "KR") || hasAny(countries, "KR", "KP") || containsKoreanHangul(rawText)
|
||||
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",
|
||||
"FI", "ES", "IT", "PT", "AT", "CH", "BE", "RU",
|
||||
)
|
||||
isLatinFallback := containsLatin(rawText) && !containsHan(rawText) && !containsJapaneseKana(rawText) && !containsKoreanHangul(rawText)
|
||||
isWestern := isWesternByMetadata || (mediaType == "tv" && isLatinFallback)
|
||||
isWestern := isWesternByMetadata
|
||||
hasAnimeText := containsAnyText(text, "动画", "动漫", "番剧", "年番", "国漫", "日番", "bangumi", "anime", "b-global", "ani-one", "crunchyroll")
|
||||
hasVarietyText := containsAnyText(text, "综艺", "真人秀", "脱口秀", "晚会", "春晚", "gala", "festival gala", "reality", "talk show")
|
||||
hasDocumentaryText := containsAnyText(text, "纪录", "纪录片", "documentary", "docu", "national geographic", "natgeo")
|
||||
@@ -74,18 +73,12 @@ func classifyMediaCategory(input mediaClassifyInput, categories map[string]strin
|
||||
if isAdultText {
|
||||
return categoryName(categories, "adult", "成人")
|
||||
}
|
||||
if hasGenre("16", "ANIMATION", "动画", "动漫") {
|
||||
if hasGenre("16", "ANIMATION", "动画", "动漫") || hasAnimeText {
|
||||
return categoryName(categories, "animation_movie", "动画电影")
|
||||
}
|
||||
if isChinese {
|
||||
return categoryName(categories, "chinese_movie", "华语电影")
|
||||
}
|
||||
if isEastAsian {
|
||||
return categoryName(categories, "jk_movie", "日韩电影")
|
||||
}
|
||||
if isWesternByMetadata {
|
||||
return categoryName(categories, "euus_movie", "欧美电影")
|
||||
}
|
||||
return categoryName(categories, "foreign_movie", "外语电影")
|
||||
case "anime":
|
||||
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) {
|
||||
mediaType := normalizeMediaType(sub.MediaType, title+" "+sub.Name+" "+sub.Filter, sourceCategory)
|
||||
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 == "" {
|
||||
category = classifyMediaCategory(mediaClassifyInput{
|
||||
MediaType: mediaType,
|
||||
@@ -290,6 +306,78 @@ func (s *SubscriptionService) classifySubscriptionItem(ctx context.Context, sub
|
||||
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 {
|
||||
if s == nil || s.cfg == nil || s.cfg.Organizer.Categories == nil {
|
||||
return nil
|
||||
@@ -311,7 +399,7 @@ func (s *SubscriptionService) resolveSubscriptionSavePath(ctx context.Context, s
|
||||
if !s.isSmartClassifyEnabled(ctx) || category == "" {
|
||||
return base
|
||||
}
|
||||
return categoryRoot(base, sanitizeFilename(category))
|
||||
return downloadSavePathCategoryRoot(base, sanitizeFilename(category))
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) isSmartClassifyEnabled(ctx context.Context) bool {
|
||||
@@ -368,3 +456,39 @@ func downloadCategoryMap(organizer *OrganizerService) map[string]string {
|
||||
}
|
||||
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: "动画电影",
|
||||
},
|
||||
{
|
||||
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",
|
||||
input: mediaClassifyInput{
|
||||
@@ -82,26 +113,27 @@ func TestClassifyMediaCategoryMatchesMoviePilotStyleRules(t *testing.T) {
|
||||
want: "国产剧",
|
||||
},
|
||||
{
|
||||
name: "latin tv title without metadata",
|
||||
name: "latin tv title without metadata stays uncategorized",
|
||||
input: mediaClassifyInput{
|
||||
MediaType: "tv",
|
||||
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{
|
||||
MediaType: "tv",
|
||||
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{
|
||||
MediaType: "tv",
|
||||
Title: "Ashes.to.Crown.S01E15.2160p.YOUKU.WEB-DL",
|
||||
Countries: []string{"CN"},
|
||||
},
|
||||
want: "国产剧",
|
||||
},
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
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:
|
||||
// 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},
|
||||
{"Oppenheimer.2023.2160p.UHD.BluRay.mkv", "Oppenheimer", 2023, 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 {
|
||||
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
|
||||
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)
|
||||
dir := filepath.Join(categoryRoot(root, category), title, season)
|
||||
dst = filepath.Join(dir, fmt.Sprintf("%s - %s%s", title, epTag, ext))
|
||||
} else {
|
||||
// Movie: {baseRoot}/[分类]/{Title} ({Year})/{Title} ({Year}).ext
|
||||
folder := title
|
||||
if m.Year > 0 {
|
||||
folder = fmt.Sprintf("%s (%d)", title, m.Year)
|
||||
target, err := o.buildOrganizeTargetPath(ctx, organizeTargetInput{
|
||||
Root: categoryRoot(root, category),
|
||||
MediaType: lib.Type,
|
||||
Category: category,
|
||||
Title: title,
|
||||
Ext: ext,
|
||||
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)
|
||||
dir := filepath.Join(categoryRoot(root, category), folder)
|
||||
dst = filepath.Join(dir, folder+ext)
|
||||
target, err := o.buildOrganizeTargetPath(ctx, organizeTargetInput{
|
||||
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.
|
||||
|
||||
@@ -254,15 +254,23 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo
|
||||
title = "Unknown"
|
||||
}
|
||||
season, episode := ParseEpisode(src)
|
||||
layout := o.inferOrganizeDirectoryLayout(src, sourceRoot)
|
||||
if forced := normalizeOrganizeMediaType(mediaTypeOverride); forced != "" {
|
||||
if layout.Category != "" && layout.MediaType != "" && layout.MediaType != forced {
|
||||
pathLayout := o.inferOrganizeDirectoryLayout(src, sourceRoot)
|
||||
layout := pathLayout
|
||||
forcedType := normalizeOrganizeMediaType(mediaTypeOverride)
|
||||
inferredType := o.inferMediaTypeForSourceFile(src, title, season, episode)
|
||||
if forcedType != "" {
|
||||
if layout.Category != "" && layout.MediaType != "" && layout.MediaType != forcedType {
|
||||
layout.Category = ""
|
||||
}
|
||||
layout.MediaType = forced
|
||||
}
|
||||
if layout.MediaType == "" {
|
||||
layout.MediaType = o.inferMediaTypeForSourceFile(src, title, season, episode)
|
||||
layout.MediaType = forcedType
|
||||
} else if inferredType != "" {
|
||||
if inferredType == "tv" && layout.MediaType == "movie" {
|
||||
// 文件名中明确有季/集信息时,目录名只能作为弱提示;否则
|
||||
// 下载到错误的“电影/外语电影”等目录会把剧集按电影入库。
|
||||
layout = organizeDirectoryLayout{MediaType: inferredType}
|
||||
} else if layout.MediaType == "" {
|
||||
layout.MediaType = inferredType
|
||||
}
|
||||
}
|
||||
var metadataMatch *Match
|
||||
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 != "" {
|
||||
layout.Category = sanitizeFilename(category)
|
||||
} else if layout.Category == "" {
|
||||
layout.Category = o.smartClassifySourceFile(ctx, src, sourceRoot, layout.MediaType, title, parsedTitle)
|
||||
} else if category := o.smartClassifySourceFile(ctx, src, sourceRoot, layout.MediaType, title, parsedTitle, metadataMatch); category != "" {
|
||||
// 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)
|
||||
if !matchedLibrary && layout.MediaType != "" {
|
||||
@@ -296,20 +315,23 @@ func (o *OrganizerService) organizeSourceFile(ctx context.Context, src, sourceRo
|
||||
if layout.MediaType != "" {
|
||||
isSeries = isSeriesLibraryType(layout.MediaType) && (season > 0 || episode > 0)
|
||||
}
|
||||
if isSeries {
|
||||
// TV/动漫/综艺等剧集:{destRoot}/{Title}/Season XX/{Title} - SxxExx.ext
|
||||
episodeTag = fmt.Sprintf("S%02dE%02d", season, episode)
|
||||
destDir = filepath.Join(layoutRoot, title, fmt.Sprintf("Season %02d", season))
|
||||
dst = filepath.Join(destDir, fmt.Sprintf("%s - %s%s", title, episodeTag, ext))
|
||||
} else {
|
||||
// 电影:{destRoot}/{Title} ({Year})/{Title} ({Year}).ext
|
||||
folder := title
|
||||
if year > 0 {
|
||||
folder = fmt.Sprintf("%s (%d)", title, year)
|
||||
}
|
||||
destDir = filepath.Join(layoutRoot, folder)
|
||||
dst = filepath.Join(destDir, folder+ext)
|
||||
target, err := o.buildOrganizeTargetPath(ctx, organizeTargetInput{
|
||||
Root: layoutRoot,
|
||||
MediaType: layout.MediaType,
|
||||
Category: layout.Category,
|
||||
Title: title,
|
||||
Ext: ext,
|
||||
Year: year,
|
||||
Season: season,
|
||||
Episode: episode,
|
||||
Series: isSeries,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
destDir = target.Dir
|
||||
dst = target.Path
|
||||
episodeTag = target.EpisodeTag
|
||||
|
||||
// 源文件已经位于目标位置:无需处理。
|
||||
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)
|
||||
if cache != nil {
|
||||
cache[key] = match
|
||||
}
|
||||
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 {
|
||||
o.log.Info("organize metadata matched before rename",
|
||||
zap.String("source", src),
|
||||
@@ -510,10 +550,29 @@ func (o *OrganizerService) lookupOrganizeMetadata(ctx context.Context, src, sour
|
||||
}
|
||||
return match
|
||||
}
|
||||
if cache != nil {
|
||||
cache[key] = 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 {
|
||||
if local == nil || strings.TrimSpace(local.Title) == "" {
|
||||
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))
|
||||
}
|
||||
|
||||
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) {
|
||||
return ""
|
||||
}
|
||||
@@ -557,6 +616,20 @@ func (o *OrganizerService) smartClassifySourceFile(ctx context.Context, src, sou
|
||||
Title: strings.Join([]string{title, parsedTitle, filepath.Base(src)}, " "),
|
||||
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 {
|
||||
input.Title = strings.Join([]string{meta.Title, meta.OriginalName, title, parsedTitle, filepath.Base(src)}, " ")
|
||||
input.Languages = parseCommaList(meta.Languages)
|
||||
@@ -595,7 +668,9 @@ func organizeDirectoryCategoryCandidates(src, sourceRoot string) []string {
|
||||
}
|
||||
|
||||
cleanSourceRoot := filepath.Clean(sourceRoot)
|
||||
add(filepath.Base(cleanSourceRoot))
|
||||
for _, part := range organizePathNameParts(cleanSourceRoot) {
|
||||
add(part)
|
||||
}
|
||||
rel, err := filepath.Rel(cleanSourceRoot, filepath.Clean(src))
|
||||
if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
|
||||
return out
|
||||
@@ -610,6 +685,34 @@ func organizeDirectoryCategoryCandidates(src, sourceRoot string) []string {
|
||||
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) {
|
||||
key := strings.ToLower(strings.TrimSpace(name))
|
||||
if key == "" {
|
||||
|
||||
@@ -694,7 +694,7 @@ func TestOrganizeDirectorySmartClassifiesUncategorizedSources(t *testing.T) {
|
||||
filepath.Join(dest, "电影", "华语电影", "流浪地球2 (2023)", "流浪地球2 (2023).mkv"),
|
||||
filepath.Join(dest, "电影", "外语电影", "Dune (2021)", "Dune (2021).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 {
|
||||
t.Fatalf("expected smart classified file at %q: %v; items=%+v", want, err, res.Items)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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) {
|
||||
scraper, repos, closeServer := newTestScraper(t)
|
||||
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 {
|
||||
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 {
|
||||
prefix := strings.TrimRight(filepath.ToSlash(filepath.Clean(clientPrefix)), "/")
|
||||
prefix := strings.TrimRight(cleanPathForVolumeMapping(clientPrefix), "/")
|
||||
if prefix == "" || prefix == "." {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ func (q *QBitClient) addTorrentLocked(ctx context.Context, magnetOrURL string, t
|
||||
if hash := torrentInfoHash(torrentData); hash != "" {
|
||||
if _, ok := beforeHashes[hash]; ok {
|
||||
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 (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"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")
|
||||
hash := torrentInfoHash(torrentData)
|
||||
if hash == "" {
|
||||
@@ -215,8 +216,8 @@ func TestQBitAddTorrentFileTreatsExistingInfoHashAsSuccess(t *testing.T) {
|
||||
Password: "adminadmin",
|
||||
})
|
||||
|
||||
if err := client.AddTorrentFile(context.Background(), torrentData, "fixture.torrent", ""); err != nil {
|
||||
t.Fatalf("expected existing torrent to be accepted: %v", err)
|
||||
if err := client.AddTorrentFile(context.Background(), torrentData, "fixture.torrent", ""); !errors.Is(err, ErrDownloadAlreadyExists) {
|
||||
t.Fatalf("err = %v, want ErrDownloadAlreadyExists", err)
|
||||
}
|
||||
if addCalled.Load() {
|
||||
t.Fatal("expected qbit add to be skipped for existing infohash")
|
||||
|
||||
@@ -40,16 +40,17 @@ import (
|
||||
|
||||
// SchedulerService runs the periodic jobs.
|
||||
type SchedulerService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
scanner *ScannerService
|
||||
transcoder *TranscoderService
|
||||
organizer *OrganizerService
|
||||
storageCfg *StorageConfigService
|
||||
hub *Hub
|
||||
tasks *TaskTrackerService
|
||||
cacheDir string
|
||||
now func() time.Time
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
scanner *ScannerService
|
||||
transcoder *TranscoderService
|
||||
organizer *OrganizerService
|
||||
organizePipeline *OrganizePipelineService
|
||||
storageCfg *StorageConfigService
|
||||
hub *Hub
|
||||
tasks *TaskTrackerService
|
||||
cacheDir string
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
stopCh chan struct{}
|
||||
@@ -65,6 +66,10 @@ func (s *SchedulerService) SetTaskTracker(tasks *TaskTrackerService) {
|
||||
s.tasks = tasks
|
||||
}
|
||||
|
||||
func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService) {
|
||||
s.organizePipeline = pipeline
|
||||
}
|
||||
|
||||
// scheduledJob is one recurring task.
|
||||
type scheduledJob struct {
|
||||
name string
|
||||
@@ -552,53 +557,21 @@ func (s *SchedulerService) jobOrganizeSource(ctx context.Context) error {
|
||||
if s.organizer == nil || (!manual && !s.autoOrganizeSourceEnabled(ctx)) {
|
||||
return nil
|
||||
}
|
||||
task := s.startScheduledOrganizeTask(ctx, manual)
|
||||
res, err := s.organizer.OrganizeDirectory(ctx, OrganizeOptions{})
|
||||
taskName := "自动整理重命名刮削入库"
|
||||
if manual {
|
||||
taskName = "手动触发自动整理重命名刮削入库"
|
||||
}
|
||||
resWrap, err := s.ensureOrganizePipeline().Run(ctx, OrganizePipelineRequest{
|
||||
Scope: OrganizeScopeDirectory,
|
||||
Trigger: OrganizeTriggerScheduled,
|
||||
TaskName: taskName,
|
||||
})
|
||||
if err != nil {
|
||||
if task != nil {
|
||||
task.Finish(err, TaskUpdate{
|
||||
Stage: "organize",
|
||||
Message: "自动整理入库失败",
|
||||
})
|
||||
}
|
||||
return err
|
||||
}
|
||||
if task != nil && res != nil {
|
||||
task.Update(TaskUpdate{
|
||||
Stage: "organize",
|
||||
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),
|
||||
})
|
||||
res := resWrap.Result
|
||||
if res == nil {
|
||||
res = &OrganizeResult{}
|
||||
}
|
||||
if s.log != nil && res != nil {
|
||||
s.log.Info("scheduled source organize finished",
|
||||
@@ -614,6 +587,13 @@ func (s *SchedulerService) jobOrganizeSource(ctx context.Context) error {
|
||||
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 {
|
||||
if s == nil || s.tasks == nil {
|
||||
return nil
|
||||
|
||||
+122
-6
@@ -105,6 +105,17 @@ var noiseTokenSet = func() map[string]struct{} {
|
||||
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.
|
||||
var bracketedTag = regexp.MustCompile(`[\[\(\{][^\]\)\}]*[\]\)\}]`)
|
||||
var multiWordNoise = []*regexp.Regexp{
|
||||
@@ -142,6 +153,7 @@ func CleanQuery(raw string) (title string, year int) {
|
||||
lower = patCN.ReplaceAllString(lower, " ")
|
||||
// 去掉中文季/部标记(如「第二季」「第2部」),避免残留在标题里既污染
|
||||
// 搜索查询又导致整理后的目录名重复季信息。
|
||||
lower = patSeasonOnly.ReplaceAllString(lower, " ")
|
||||
lower = patCNSeason.ReplaceAllString(lower, " ")
|
||||
|
||||
for _, pat := range multiWordNoise {
|
||||
@@ -153,8 +165,15 @@ func CleanQuery(raw string) (title string, year int) {
|
||||
// 拆分后丢掉过短(≤1)且全为 ASCII 数字 / 字母的"碎片",避免
|
||||
// 「2」「0」「v」之类残留干扰 TMDb 搜索。中文字符不算碎片。
|
||||
out := make([]string, 0, 8)
|
||||
seenReleaseBoundary := false
|
||||
for _, w := range strings.Fields(lower) {
|
||||
if _, ok := noiseTokenSet[w]; ok {
|
||||
if _, boundary := releaseBoundaryTokenSet[w]; boundary {
|
||||
seenReleaseBoundary = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if seenReleaseBoundary && isASCIIWord(w) {
|
||||
continue
|
||||
}
|
||||
if len(w) <= 1 {
|
||||
@@ -169,6 +188,22 @@ func CleanQuery(raw string) (title string, year int) {
|
||||
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.
|
||||
func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
|
||||
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
|
||||
if year == 0 {
|
||||
_, year = CleanQuery(filepath.Base(m.Path))
|
||||
}
|
||||
year := mediaYearHint(m)
|
||||
|
||||
if s.adult != nil && s.adult.Enabled() {
|
||||
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
|
||||
match := (*Match)(nil)
|
||||
for _, candidate := range candidates {
|
||||
match = s.lookup(ctx, lib, candidate, year)
|
||||
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 {
|
||||
break
|
||||
}
|
||||
@@ -242,6 +291,31 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
|
||||
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 {
|
||||
if local == nil {
|
||||
return ""
|
||||
@@ -489,7 +563,7 @@ func scrapeQueryCandidates(m *model.Media, lib *model.Library) []string {
|
||||
|
||||
func seriesFolderTitle(mediaPath, libraryRoot string) string {
|
||||
dir := filepath.Dir(mediaPath)
|
||||
if seasonFromDir(filepath.Base(dir)) > 0 {
|
||||
if strictSeasonFolder(filepath.Base(dir)) > 0 {
|
||||
dir = filepath.Dir(dir)
|
||||
}
|
||||
if libraryRoot != "" && samePath(dir, filepath.Clean(libraryRoot)) {
|
||||
@@ -499,9 +573,51 @@ func seriesFolderTitle(mediaPath, libraryRoot string) string {
|
||||
if base == "." || base == string(filepath.Separator) {
|
||||
return ""
|
||||
}
|
||||
if isGenericMediaCategoryFolder(base) {
|
||||
return ""
|
||||
}
|
||||
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 {
|
||||
if m := patSeasonFolder.FindStringSubmatch(name); len(m) >= 3 {
|
||||
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) {
|
||||
scraper, repos, closeServer := newTestScraper(t)
|
||||
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) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
|
||||
+118
-112
@@ -18,61 +18,62 @@ import (
|
||||
|
||||
// Container 持有在启动时初始化的每个服务。Handler 接收指向它的指针并选择相关字段。
|
||||
type Container struct {
|
||||
Cfg *config.Config
|
||||
Log *zap.Logger
|
||||
Repo *repository.Container
|
||||
WSHub *Hub
|
||||
SSEHub *SSEHub
|
||||
Tasks *TaskTrackerService
|
||||
Auth *AuthService
|
||||
Media *MediaService
|
||||
Scan *ScannerService
|
||||
Stream *StreamService
|
||||
Transcoder *TranscoderService
|
||||
FFprobe *FFprobeService
|
||||
TMDb *TMDbProvider
|
||||
Bangumi *BangumiProvider
|
||||
TheTVDB *TheTVDBProvider
|
||||
Fanart *FanartProvider
|
||||
Scraper *ScraperService
|
||||
Discover *DiscoverService
|
||||
Playback *PlaybackService
|
||||
ImageProxy *ImageProxy
|
||||
Watcher *WatcherService
|
||||
Downloads *DownloadService
|
||||
Subscription *SubscriptionService
|
||||
Subtitle *SubtitleService
|
||||
Stats *StatsService
|
||||
Profile *ProfileService
|
||||
Audit *AuditService
|
||||
NFO *NFOService
|
||||
AI *AIService
|
||||
APIConfig *APIConfigService
|
||||
Crypto *CryptoService
|
||||
Duplicate *DuplicateService
|
||||
FileManager *FileManagerService
|
||||
DLNA *DLNAService
|
||||
Scheduler *SchedulerService
|
||||
Storage *StorageService
|
||||
Emby *EmbyService
|
||||
Backup *BackupService
|
||||
Notifier *NotifierService
|
||||
NotifyChannels *NotifyChannelService
|
||||
TelegramBot *TelegramBotService
|
||||
PlayProfiles *PlayProfileService
|
||||
Permissions *PermissionService
|
||||
StorageCfg *StorageConfigService
|
||||
STRM *STRMService
|
||||
DownloadClients *DownloadClientService
|
||||
Assistant *AssistantService
|
||||
Organizer *OrganizerService
|
||||
Douban *DoubanProvider
|
||||
Token *TokenService
|
||||
ApiConfig *ApiConfigService
|
||||
DownloadMgr *DownloadManager
|
||||
Notify *NotifyService
|
||||
Site *SiteService
|
||||
Device *DeviceService
|
||||
Cfg *config.Config
|
||||
Log *zap.Logger
|
||||
Repo *repository.Container
|
||||
WSHub *Hub
|
||||
SSEHub *SSEHub
|
||||
Tasks *TaskTrackerService
|
||||
Auth *AuthService
|
||||
Media *MediaService
|
||||
Scan *ScannerService
|
||||
Stream *StreamService
|
||||
Transcoder *TranscoderService
|
||||
FFprobe *FFprobeService
|
||||
TMDb *TMDbProvider
|
||||
Bangumi *BangumiProvider
|
||||
TheTVDB *TheTVDBProvider
|
||||
Fanart *FanartProvider
|
||||
Scraper *ScraperService
|
||||
Discover *DiscoverService
|
||||
Playback *PlaybackService
|
||||
ImageProxy *ImageProxy
|
||||
Watcher *WatcherService
|
||||
Downloads *DownloadService
|
||||
Subscription *SubscriptionService
|
||||
Subtitle *SubtitleService
|
||||
Stats *StatsService
|
||||
Profile *ProfileService
|
||||
Audit *AuditService
|
||||
NFO *NFOService
|
||||
AI *AIService
|
||||
APIConfig *APIConfigService
|
||||
Crypto *CryptoService
|
||||
Duplicate *DuplicateService
|
||||
FileManager *FileManagerService
|
||||
DLNA *DLNAService
|
||||
Scheduler *SchedulerService
|
||||
Storage *StorageService
|
||||
Emby *EmbyService
|
||||
Backup *BackupService
|
||||
Notifier *NotifierService
|
||||
NotifyChannels *NotifyChannelService
|
||||
TelegramBot *TelegramBotService
|
||||
PlayProfiles *PlayProfileService
|
||||
Permissions *PermissionService
|
||||
StorageCfg *StorageConfigService
|
||||
STRM *STRMService
|
||||
DownloadClients *DownloadClientService
|
||||
Assistant *AssistantService
|
||||
Organizer *OrganizerService
|
||||
OrganizePipeline *OrganizePipelineService
|
||||
Douban *DoubanProvider
|
||||
Token *TokenService
|
||||
ApiConfig *ApiConfigService
|
||||
DownloadMgr *DownloadManager
|
||||
Notify *NotifyService
|
||||
Site *SiteService
|
||||
Device *DeviceService
|
||||
|
||||
stopCtx context.Context
|
||||
stopCancel context.CancelFunc
|
||||
@@ -107,6 +108,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
discover := NewDiscoverService(log, tmdb)
|
||||
transcoder := NewTranscoderService(cfg, log, repos, hub)
|
||||
scanner := NewScannerService(cfg, log, repos, hub, probe, scraper)
|
||||
organizePipeline := NewOrganizePipelineService(log, repos, organizer, scanner, tasks)
|
||||
watcher := NewWatcherService(log, repos, scanner)
|
||||
nfo := NewNFOService(log, repos)
|
||||
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)
|
||||
scheduler := NewSchedulerService(log, repos, scanner, transcoder, organizer, storageCfg, hub, cfg.Cache.CacheDir)
|
||||
scheduler.SetTaskTracker(tasks)
|
||||
scheduler.SetOrganizePipeline(organizePipeline)
|
||||
|
||||
// 初始化认证相关服务
|
||||
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.SetScanner(scanner)
|
||||
downloads.SetTaskTracker(tasks)
|
||||
downloads.SetOrganizePipeline(organizePipeline)
|
||||
subscription := NewSubscriptionService(cfg, log, repos, downloads, siteSvc, hub)
|
||||
subscription.SetScraper(scraper)
|
||||
|
||||
// 让图片代理把媒体库根目录视为可读的本地图片位置:海报/封面等
|
||||
// sidecar 资源就存放在这些(用户自定义、任意)目录下,否则会被
|
||||
@@ -175,63 +180,64 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &Container{
|
||||
Cfg: cfg,
|
||||
Log: log,
|
||||
Repo: repos,
|
||||
WSHub: hub,
|
||||
SSEHub: sseHub,
|
||||
Tasks: tasks,
|
||||
Auth: authSvc,
|
||||
Media: NewMediaService(cfg, log, repos),
|
||||
Scan: scanner,
|
||||
Stream: NewStreamService(cfg, log, repos, transcoder),
|
||||
Transcoder: transcoder,
|
||||
FFprobe: probe,
|
||||
TMDb: tmdb,
|
||||
Bangumi: bangumi,
|
||||
TheTVDB: thetvdb,
|
||||
Fanart: fanart,
|
||||
Scraper: scraper,
|
||||
Discover: discover,
|
||||
Playback: NewPlaybackService(log, repos),
|
||||
ImageProxy: imageProxy,
|
||||
Watcher: watcher,
|
||||
Downloads: downloads,
|
||||
Subscription: subscription,
|
||||
Subtitle: NewSubtitleService(log, repos),
|
||||
Stats: NewStatsService(log, repos),
|
||||
Profile: NewProfileService(log, repos),
|
||||
Audit: NewAuditService(log, repos),
|
||||
NFO: nfo,
|
||||
AI: ai,
|
||||
APIConfig: apiConfig,
|
||||
Crypto: crypto,
|
||||
Duplicate: duplicate,
|
||||
FileManager: filemanager,
|
||||
DLNA: dlna,
|
||||
Scheduler: scheduler,
|
||||
Storage: storage,
|
||||
Emby: emby,
|
||||
Backup: backup,
|
||||
Notifier: notifier,
|
||||
NotifyChannels: notifyChannels,
|
||||
TelegramBot: telegramBot,
|
||||
PlayProfiles: playProfiles,
|
||||
Permissions: permissions,
|
||||
StorageCfg: storageCfg,
|
||||
STRM: strmSvc,
|
||||
DownloadClients: downloadClients,
|
||||
Assistant: assistant,
|
||||
Organizer: organizer,
|
||||
Douban: douban,
|
||||
Token: tokenSvc,
|
||||
ApiConfig: apiConfigSvc,
|
||||
DownloadMgr: downloadMgr,
|
||||
Notify: notifySvc,
|
||||
Site: siteSvc,
|
||||
Device: deviceSvc,
|
||||
stopCtx: ctx,
|
||||
stopCancel: cancel,
|
||||
Cfg: cfg,
|
||||
Log: log,
|
||||
Repo: repos,
|
||||
WSHub: hub,
|
||||
SSEHub: sseHub,
|
||||
Tasks: tasks,
|
||||
Auth: authSvc,
|
||||
Media: NewMediaService(cfg, log, repos),
|
||||
Scan: scanner,
|
||||
Stream: NewStreamService(cfg, log, repos, transcoder),
|
||||
Transcoder: transcoder,
|
||||
FFprobe: probe,
|
||||
TMDb: tmdb,
|
||||
Bangumi: bangumi,
|
||||
TheTVDB: thetvdb,
|
||||
Fanart: fanart,
|
||||
Scraper: scraper,
|
||||
Discover: discover,
|
||||
Playback: NewPlaybackService(log, repos),
|
||||
ImageProxy: imageProxy,
|
||||
Watcher: watcher,
|
||||
Downloads: downloads,
|
||||
Subscription: subscription,
|
||||
Subtitle: NewSubtitleService(log, repos),
|
||||
Stats: NewStatsService(log, repos),
|
||||
Profile: NewProfileService(log, repos),
|
||||
Audit: NewAuditService(log, repos),
|
||||
NFO: nfo,
|
||||
AI: ai,
|
||||
APIConfig: apiConfig,
|
||||
Crypto: crypto,
|
||||
Duplicate: duplicate,
|
||||
FileManager: filemanager,
|
||||
DLNA: dlna,
|
||||
Scheduler: scheduler,
|
||||
Storage: storage,
|
||||
Emby: emby,
|
||||
Backup: backup,
|
||||
Notifier: notifier,
|
||||
NotifyChannels: notifyChannels,
|
||||
TelegramBot: telegramBot,
|
||||
PlayProfiles: playProfiles,
|
||||
Permissions: permissions,
|
||||
StorageCfg: storageCfg,
|
||||
STRM: strmSvc,
|
||||
DownloadClients: downloadClients,
|
||||
Assistant: assistant,
|
||||
Organizer: organizer,
|
||||
OrganizePipeline: organizePipeline,
|
||||
Douban: douban,
|
||||
Token: tokenSvc,
|
||||
ApiConfig: apiConfigSvc,
|
||||
DownloadMgr: downloadMgr,
|
||||
Notify: notifySvc,
|
||||
Site: siteSvc,
|
||||
Device: deviceSvc,
|
||||
stopCtx: ctx,
|
||||
stopCancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ type SubscriptionService struct {
|
||||
repo *repository.Container
|
||||
downloads *DownloadService
|
||||
site *SiteService
|
||||
scraper *ScraperService
|
||||
hub *Hub
|
||||
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.
|
||||
func (s *SubscriptionService) Start(ctx context.Context) {
|
||||
go s.loop(ctx)
|
||||
@@ -114,6 +119,11 @@ func (s *SubscriptionService) List(ctx context.Context) ([]model.Subscription, e
|
||||
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.
|
||||
func (s *SubscriptionService) Delete(ctx context.Context, id string) 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 {
|
||||
return 0, err
|
||||
}
|
||||
if sub.ArchivedAt != nil {
|
||||
return 0, nil
|
||||
}
|
||||
return s.runOne(ctx, &sub)
|
||||
}
|
||||
|
||||
@@ -243,6 +256,7 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio
|
||||
seen = append(seen, guid)
|
||||
seenSet[guid] = struct{}{}
|
||||
}
|
||||
avail = s.finalizePendingAvailability(sub, avail)
|
||||
// Remember the last 200 GUIDs so the seen set doesn't grow forever.
|
||||
if 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()
|
||||
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error
|
||||
_ = s.archiveCompletedSubscription(ctx, sub, avail)
|
||||
if queued > 0 {
|
||||
s.hub.Publish("subscription", map[string]any{
|
||||
"id": sub.ID,
|
||||
@@ -300,6 +315,7 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
|
||||
item := candidate.Item
|
||||
mediaType, mediaCategory := s.classifySubscriptionItem(ctx, sub, item.Title, item.Category)
|
||||
if s.shouldSkipExistingTorrent(ctx, mediaType, candidate) {
|
||||
addAvailabilityTitle(item.Title, availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), &availability)
|
||||
seen = append(seen, candidate.GUID)
|
||||
seenSet[candidate.GUID] = struct{}{}
|
||||
continue
|
||||
@@ -307,6 +323,7 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
|
||||
realURL := s.site.ResolveDownloadURL(ctx, candidate.Download)
|
||||
savePath := s.resolveSubscriptionSavePath(ctx, sub, mediaType, mediaCategory)
|
||||
if s.downloadPathHasCandidate(ctx, sub, candidate.Item.Title, savePath) {
|
||||
addAvailabilityTitle(item.Title, availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), &availability)
|
||||
seen = append(seen, candidate.GUID)
|
||||
seenSet[candidate.GUID] = struct{}{}
|
||||
continue
|
||||
@@ -322,6 +339,7 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
|
||||
AllowExistingLibrary: sub.WashEnabled,
|
||||
}); err != nil {
|
||||
if IsDownloadDedupError(err) {
|
||||
addAvailabilityTitle(item.Title, availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), &availability)
|
||||
seen = append(seen, candidate.GUID)
|
||||
seenSet[candidate.GUID] = struct{}{}
|
||||
continue
|
||||
@@ -338,16 +356,19 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
|
||||
continue
|
||||
}
|
||||
queued++
|
||||
addAvailabilityTitle(item.Title, availabilityQuery(subscriptionName(sub), subscriptionFilter(sub)), &availability)
|
||||
resources = append(resources, item.Title)
|
||||
seen = append(seen, candidate.GUID)
|
||||
seenSet[candidate.GUID] = struct{}{}
|
||||
}
|
||||
availability = s.finalizePendingAvailability(sub, availability)
|
||||
if len(seen) > 200 {
|
||||
seen = seen[len(seen)-200:]
|
||||
}
|
||||
_ = s.repo.Setting.Set(ctx, guidKey, strings.Join(seen, "\n"))
|
||||
now := time.Now()
|
||||
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error
|
||||
_ = s.archiveCompletedSubscription(ctx, sub, availability)
|
||||
if queued > 0 {
|
||||
s.hub.Publish("subscription", map[string]any{
|
||||
"id": sub.ID,
|
||||
@@ -364,6 +385,88 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
|
||||
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 {
|
||||
if s == nil || s.downloads == nil {
|
||||
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) {
|
||||
rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/rss+xml")
|
||||
|
||||
+61
-23
@@ -22,6 +22,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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()
|
||||
|
||||
type result struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
GenreIDs []int `json:"genre_ids"`
|
||||
}
|
||||
type page struct {
|
||||
Results []result `json:"results"`
|
||||
@@ -187,10 +191,13 @@ func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int)
|
||||
}
|
||||
r := p.Results[0]
|
||||
m := &Match{
|
||||
TMDbID: r.ID,
|
||||
Title: r.Title,
|
||||
Overview: r.Overview,
|
||||
Rating: r.VoteAverage,
|
||||
TMDbID: r.ID,
|
||||
Title: r.Title,
|
||||
OriginalName: r.OriginalTitle,
|
||||
Overview: r.Overview,
|
||||
Rating: r.VoteAverage,
|
||||
Languages: nonEmptyStrings(r.OriginalLanguage),
|
||||
Genres: genreIDStrings(r.GenreIDs),
|
||||
}
|
||||
if 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()
|
||||
|
||||
type result struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
GenreIDs []int `json:"genre_ids"`
|
||||
}
|
||||
type page struct {
|
||||
Results []result `json:"results"`
|
||||
@@ -250,10 +260,14 @@ func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*M
|
||||
}
|
||||
r := p.Results[0]
|
||||
m := &Match{
|
||||
TMDbID: r.ID,
|
||||
Title: r.Name,
|
||||
Overview: r.Overview,
|
||||
Rating: r.VoteAverage,
|
||||
TMDbID: r.ID,
|
||||
Title: r.Name,
|
||||
OriginalName: r.OriginalName,
|
||||
Overview: r.Overview,
|
||||
Rating: r.VoteAverage,
|
||||
Languages: nonEmptyStrings(r.OriginalLanguage),
|
||||
Countries: deduplicate(r.OriginCountry),
|
||||
Genres: genreIDStrings(r.GenreIDs),
|
||||
}
|
||||
if m.Title == "" {
|
||||
m.Title = r.OriginalName
|
||||
@@ -438,3 +452,27 @@ func deduplicate(s []string) []string {
|
||||
}
|
||||
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: () =>
|
||||
api.get<{ items: Subscription[] }>('/subscriptions').then((r) => r.data.items),
|
||||
|
||||
history: () =>
|
||||
api.get<{ items: Subscription[] }>('/subscriptions/history').then((r) => r.data.items),
|
||||
|
||||
create: (input: {
|
||||
name: string
|
||||
feed_url: string
|
||||
|
||||
@@ -58,32 +58,67 @@ type AutoOrganizeConfig = {
|
||||
enabled: string
|
||||
afterDownload: string
|
||||
scrapeAfter: string
|
||||
downloadSmartClassify: string
|
||||
smartClassify: string
|
||||
sourceDir: string
|
||||
targetDir: string
|
||||
transferMode: 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 = {
|
||||
enabled: 'false',
|
||||
afterDownload: 'false',
|
||||
scrapeAfter: 'true',
|
||||
downloadSmartClassify: 'true',
|
||||
smartClassify: 'true',
|
||||
sourceDir: '',
|
||||
targetDir: '',
|
||||
transferMode: 'hardlink',
|
||||
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> = {
|
||||
enabled: 'organize.auto',
|
||||
afterDownload: 'organizer.auto_after_download',
|
||||
scrapeAfter: 'organize.scrape_after',
|
||||
downloadSmartClassify: 'downloads.smart_classify',
|
||||
smartClassify: 'organizer.smart_classify',
|
||||
sourceDir: 'organize.source_dir',
|
||||
targetDir: 'organize.target_dir',
|
||||
transferMode: 'organize.transfer_mode',
|
||||
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> {
|
||||
const out: Record<string, string> = {}
|
||||
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,
|
||||
afterDownload: idx[AUTO_ORGANIZE_KEYS.afterDownload] ?? AUTO_ORGANIZE_DEFAULTS.afterDownload,
|
||||
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,
|
||||
targetDir: idx[AUTO_ORGANIZE_KEYS.targetDir] ?? AUTO_ORGANIZE_DEFAULTS.targetDir,
|
||||
transferMode: idx[AUTO_ORGANIZE_KEYS.transferMode] ?? AUTO_ORGANIZE_DEFAULTS.transferMode,
|
||||
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 [autoRunning, setAutoRunning] = useState(false)
|
||||
const [autoLoading, setAutoLoading] = useState(true)
|
||||
const [autoTab, setAutoTab] = useState<AutoOrganizeTab>('basic')
|
||||
|
||||
const currentDir = useMemo(() => {
|
||||
if (data?.path) return data.path
|
||||
@@ -213,6 +260,7 @@ export function FileManagerPage() {
|
||||
|
||||
const changeAutoConfig = (key: keyof AutoOrganizeConfig, value: string) => {
|
||||
setAutoConfig((current) => ({ ...current, [key]: value }))
|
||||
if (key === 'scrapeAfter') setScrapeAfter(settingOn(value))
|
||||
setAutoDirty(true)
|
||||
}
|
||||
|
||||
@@ -223,10 +271,10 @@ export function FileManagerPage() {
|
||||
await adminAPI.updateSetting(AUTO_ORGANIZE_KEYS[key], autoConfig[key] ?? '')
|
||||
}
|
||||
setAutoDirty(false)
|
||||
toast.success('自动整理设置已保存')
|
||||
toast.success('整理入库设置已保存')
|
||||
return true
|
||||
} 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
|
||||
} finally {
|
||||
setAutoSaving(false)
|
||||
@@ -447,99 +495,234 @@ export function FileManagerPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
<div className="flex flex-wrap gap-2 rounded-2xl border border-gray-200 bg-gray-50 p-1">
|
||||
{[
|
||||
['basic', '基础设置'],
|
||||
['naming', '命名规则'],
|
||||
['scrape', '刮削联动'],
|
||||
].map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={
|
||||
autoTab === key
|
||||
? 'rounded-xl bg-white px-3 py-1.5 text-xs font-semibold text-brand-500 shadow-sm'
|
||||
: 'rounded-xl px-3 py-1.5 text-xs text-ink-100 hover:bg-white/70'
|
||||
}
|
||||
onClick={() => setAutoTab(key as AutoOrganizeTab)}
|
||||
>
|
||||
<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.scrapeAfter)}
|
||||
onChange={(event) => changeAutoConfig('scrapeAfter', event.target.checked ? 'true' : 'false')}
|
||||
/>
|
||||
整理后自动刮削
|
||||
</label>
|
||||
<span className="text-xs text-sand-500">
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-auto self-center px-2 text-xs text-sand-500">
|
||||
{autoDirty ? '有未保存设置' : '设置已同步'} · 定时任务名:organize_source
|
||||
</span>
|
||||
</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>
|
||||
|
||||
{data?.path && (
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
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 { adminAPI } from '../api/admin'
|
||||
import { libraryAPI } from '../api/library'
|
||||
import type { Library, Setting } from '../types'
|
||||
|
||||
// SettingsPage replaces the Vue SettingsView's General / Organize /
|
||||
// Scrape / Adult tabs. The Go backend stores settings as a single
|
||||
// SettingsPage replaces the Vue SettingsView's curated runtime settings.
|
||||
// The Go backend stores settings as a single
|
||||
// key/value table; we group the most useful keys client-side and let
|
||||
// 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',
|
||||
label: '网盘转存',
|
||||
@@ -499,7 +360,7 @@ export function SettingsPage() {
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">系统设置</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
按分组编辑转码 / 整理 / 刮削 / 下载器等关键配置
|
||||
按分组编辑转码 / 网盘转存 / Adult 等关键配置
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -554,44 +415,6 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
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 { imageURL } from '../api/client'
|
||||
@@ -9,6 +9,7 @@ import type { Subscription } from '../types'
|
||||
|
||||
export function SubscriptionsPage() {
|
||||
const [items, setItems] = useState<Subscription[]>([])
|
||||
const [historyItems, setHistoryItems] = useState<Subscription[]>([])
|
||||
const [name, setName] = useState('')
|
||||
const [feed, setFeed] = useState('')
|
||||
const [filter, setFilter] = useState('')
|
||||
@@ -28,9 +29,11 @@ export function SubscriptionsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const refresh = () =>
|
||||
subscriptionsAPI
|
||||
.list()
|
||||
.then(setItems)
|
||||
Promise.all([subscriptionsAPI.list(), subscriptionsAPI.history()])
|
||||
.then(([active, history]) => {
|
||||
setItems(active)
|
||||
setHistoryItems(history)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
|
||||
useEffect(() => {
|
||||
@@ -311,6 +314,43 @@ export function SubscriptionsPage() {
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -206,6 +206,8 @@ export interface Subscription {
|
||||
priority?: number
|
||||
enabled: boolean
|
||||
last_run_at?: string
|
||||
archived_at?: string
|
||||
archive_reason?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user