Unify organize ingest pipeline

This commit is contained in:
ShukeBta
2026-06-14 23:17:52 +08:00
parent a623702276
commit 479a06f008
41 changed files with 3075 additions and 728 deletions
+5
View File
@@ -9,6 +9,11 @@ docs/
data/
cache/
logs/
verify-data/
verify-cache/
verify-media/
verify-downloads/
.codex-*
.tmp_*
.tmp-deploy-*
.tmp-deploy-data/
+1
View File
@@ -64,6 +64,7 @@ config.yaml
# Runtime / local-only artifacts (清理补充)
.tmp-live-backups/
.tmp-*
.codex-*
downloads/
media/
*.pid
+9 -7
View File
@@ -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})
}
+7 -5
View File
@@ -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})
}
+35 -69
View File
@@ -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)
}
+1
View File
@@ -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))
+12
View File
@@ -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 {
-24
View File
@@ -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": "网盘转存",
+10
View File
@@ -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})
}
+2
View File
@@ -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"`
+22 -2
View File
@@ -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.
+7
View File
@@ -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)
}
+25
View File
@@ -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
View File
@@ -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
}
}
+254 -3
View File
@@ -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 {
+1
View File
@@ -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*[季部]`)
+139 -15
View File
@@ -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)
}
+37 -5
View File
@@ -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: "国产剧",
},
+173 -1
View File
@@ -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)
}
}
+359
View File
@@ -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)
}
}
+27 -12
View File
@@ -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.
+130 -27
View File
@@ -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 == "" {
+1 -1
View File
@@ -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)
+320
View File
@@ -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()
+278
View File
@@ -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...)
}
+9 -2
View File
@@ -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
}
+1 -1
View File
@@ -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
}
}
}
+4 -3
View File
@@ -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")
+34 -54
View File
@@ -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
View File
@@ -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:] {
+87
View File
@@ -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
View File
@@ -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,
}
}
+103
View File
@@ -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
+178
View File
@@ -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&amp;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
View File
@@ -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
}
+3
View File
@@ -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
+274 -91
View File
@@ -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 && (
+4 -181
View File
@@ -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>
)
}
+44 -4
View File
@@ -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>
)
}
+2
View File
@@ -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
}