mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-08 14:26:37 +08:00
refactor: split modules and harden scraping workflows
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
)
|
||||
|
||||
// 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
|
||||
// been scanned into the library yet.
|
||||
func (d *DownloadService) onTorrentComplete(ctx context.Context, torrent QBitTorrent) {
|
||||
taskRow, hasTask := d.completedTorrentTask(ctx, torrent)
|
||||
d.notifyDownloadComplete(ctx, torrent, taskRow)
|
||||
if d.organizer == nil {
|
||||
return
|
||||
}
|
||||
// 仅当显式开启 organizer.auto_after_download / organize.auto 时才在下载完成后整理。
|
||||
// 之前的代码错误地把 organizer.smart_classify 也当成"自动整理"开关,
|
||||
// 让操作员只想启用"分类子目录"就被动触发了文件 move。
|
||||
autoOrganize := d.downloadAutoOrganizeEnabled(ctx)
|
||||
if !autoOrganize {
|
||||
d.log.Info("download completed, auto-organize disabled", zap.String("hash", torrent.Hash))
|
||||
return
|
||||
}
|
||||
source := d.completedTorrentSource(ctx, torrent)
|
||||
if source == "" {
|
||||
d.log.Warn("download completed but payload path is not accessible",
|
||||
zap.String("hash", torrent.Hash),
|
||||
zap.String("name", torrent.Name),
|
||||
zap.String("save_path", torrent.SavePath),
|
||||
zap.String("content_path", torrent.ContentPath))
|
||||
return
|
||||
}
|
||||
allowReplace := hasTask && taskRow.AllowExistingLibrary
|
||||
d.runCompletedTorrentOrganize(ctx, torrent, taskRow, source, allowReplace)
|
||||
}
|
||||
|
||||
func (d *DownloadService) runCompletedTorrentOrganize(ctx context.Context, torrent QBitTorrent, task *model.DownloadTask, source string, allowReplace bool) {
|
||||
d.log.Info("download completed, triggering directory organize",
|
||||
zap.String("hash", torrent.Hash),
|
||||
zap.String("name", torrent.Name),
|
||||
zap.String("source", source),
|
||||
zap.Bool("allow_replace_existing", allowReplace))
|
||||
resWrap, err := d.ensureOrganizePipeline().Run(ctx, OrganizePipelineRequest{
|
||||
Scope: OrganizeScopeDirectory,
|
||||
Trigger: OrganizeTriggerDownload,
|
||||
TaskName: d.downloadOrganizeTaskName(torrent, allowReplace),
|
||||
SourcePath: source,
|
||||
MediaType: downloadTaskMediaType(task),
|
||||
MediaCategory: firstNonEmpty(downloadTaskMediaCategory(task), torrent.Category),
|
||||
AllowReplace: allowReplace,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUnsupportedOrganizeSource) {
|
||||
d.markCompletedTorrentCatchupRecorded(context.Background(), torrent)
|
||||
d.log.Warn("auto organize skipped unsupported completed torrent",
|
||||
zap.String("hash", torrent.Hash),
|
||||
zap.String("source", source),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
d.log.Error("auto organize completed torrent failed",
|
||||
zap.String("hash", torrent.Hash),
|
||||
zap.String("source", source),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
res := resWrap.Result
|
||||
if res == nil {
|
||||
res = &OrganizeResult{}
|
||||
}
|
||||
d.markCompletedTorrentCatchupRecorded(context.Background(), torrent)
|
||||
d.log.Info("auto organize completed torrent finished",
|
||||
zap.String("hash", torrent.Hash),
|
||||
zap.String("source", source),
|
||||
zap.String("dest", firstNonEmpty(res.DestPath, "")),
|
||||
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)))
|
||||
}
|
||||
|
||||
func (d *DownloadService) downloadOrganizeTaskName(torrent QBitTorrent, allowReplace bool) string {
|
||||
name := strings.TrimSpace(torrent.Name)
|
||||
if name == "" {
|
||||
name = "下载完成自动整理"
|
||||
}
|
||||
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) {
|
||||
if d == nil || d.repo == nil || d.repo.Download == nil {
|
||||
return nil, false
|
||||
}
|
||||
rows, err := d.repo.Download.List(ctx)
|
||||
if err != nil || len(rows) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
taskByKey := tasksByTorrentIdentity(rows)
|
||||
if task, ok := findMatchingTaskByTorrentIdentity(torrent.Name, taskByKey); ok {
|
||||
return &task, true
|
||||
}
|
||||
if strings.TrimSpace(torrent.ContentPath) != "" {
|
||||
if task, ok := findMatchingTaskByTorrentIdentity(filepath.Base(torrent.ContentPath), taskByKey); ok {
|
||||
return &task, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func downloadTaskMediaType(task *model.DownloadTask) string {
|
||||
if task == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(task.MediaType)
|
||||
}
|
||||
|
||||
func downloadTaskMediaCategory(task *model.DownloadTask) string {
|
||||
if task == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(task.MediaCategory)
|
||||
}
|
||||
|
||||
// DownloadPathMappingsSettingKey 允许用户自定义「下载器路径 → 本程序路径」
|
||||
// 映射,每行一条,格式 `客户端路径=本地路径`(也接受 `=>` 或单个 `:` 分隔)。
|
||||
// qBittorrent 与本程序常在不同容器/主机里,对同一份数据看到的路径不同;
|
||||
// 此前映射表是写死的三条猜测,对不上时整理静默失败。
|
||||
const DownloadPathMappingsSettingKey = "download.path_mappings"
|
||||
|
||||
func (d *DownloadService) completedTorrentSource(ctx context.Context, torrent QBitTorrent) string {
|
||||
// 常见路径映射:qBittorrent容器路径 -> MediaStationGo容器路径
|
||||
mappings := map[string]string{
|
||||
"/var/apps/qBittorrent/shares/qBittorrent/Download": "/downloads",
|
||||
"/data/qBittorrent/downloads": "/downloads",
|
||||
"/downloads/qBittorrent": "/downloads",
|
||||
}
|
||||
// 用户自定义映射优先(可覆盖内置猜测)。
|
||||
for clientPrefix, localPrefix := range d.userPathMappings(ctx) {
|
||||
mappings[clientPrefix] = localPrefix
|
||||
}
|
||||
for _, candidate := range []string{
|
||||
torrent.ContentPath,
|
||||
filepath.Join(torrent.SavePath, torrent.Name),
|
||||
} {
|
||||
clean := strings.TrimSpace(candidate)
|
||||
if clean == "" || clean == "." {
|
||||
continue
|
||||
}
|
||||
// 尝试直接访问或路径映射
|
||||
if translated := translateClientPath(clean, mappings); translated != "" {
|
||||
return translated
|
||||
}
|
||||
// 复用 compose 注入的 MEDIASTATION_DOWNLOAD_DIR/MEDIA_DIR 宿主机↔容器
|
||||
// 映射(与媒体库路径换算同一套规则),覆盖「qB 跑在宿主机、
|
||||
// 本程序在容器里」的最常见部署形态。
|
||||
for _, mapped := range mappedPathCandidates(clean) {
|
||||
if mapped == clean {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(mapped); err == nil {
|
||||
return mapped
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// userPathMappings 解析用户配置的下载器路径映射。
|
||||
func (d *DownloadService) userPathMappings(ctx context.Context) map[string]string {
|
||||
out := map[string]string{}
|
||||
if d == nil || d.repo == nil || d.repo.Setting == nil {
|
||||
return out
|
||||
}
|
||||
raw, err := d.repo.Setting.Get(ctx, DownloadPathMappingsSettingKey)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
var from, to string
|
||||
switch {
|
||||
case strings.Contains(line, "=>"):
|
||||
parts := strings.SplitN(line, "=>", 2)
|
||||
from, to = parts[0], parts[1]
|
||||
case strings.Contains(line, "="):
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
from, to = parts[0], parts[1]
|
||||
case strings.Count(line, ":") == 1:
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
from, to = parts[0], parts[1]
|
||||
default:
|
||||
continue
|
||||
}
|
||||
from = strings.TrimSpace(from)
|
||||
to = strings.TrimSpace(to)
|
||||
if from != "" && to != "" {
|
||||
out[from] = to
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user