Files
MeBox/internal/service/download_completion_state.go
T
2026-08-10 21:04:04 +08:00

328 lines
9.6 KiB
Go

package service
import (
"context"
"crypto/sha1"
"fmt"
"math"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
// completedTorrentCatchupWindow 限定重启补整理只覆盖最近完成的种子,
// 防止每次启动都把全部历史种子重新过一遍整理流程。
const completedTorrentCatchupWindow = 24 * time.Hour
const completedTorrentCatchupSettingPrefix = "download.auto_organized."
const completedTorrentNotifySettingPrefix = "download.completed_notified."
func (d *DownloadService) downloadAutoOrganizeEnabled(ctx context.Context) bool {
if d == nil || d.repo == nil || d.repo.Setting == nil {
return false
}
if v, err := d.repo.Setting.Get(ctx, "organizer.auto_after_download"); err == nil && parseBoolSetting(v, false) {
return true
}
if v, err := d.repo.Setting.Get(ctx, "organize.auto"); err == nil && parseBoolSetting(v, false) {
return true
}
return false
}
// recentlyCompletedTorrent 报告该种子是否在补整理时间窗内完成。
// qBittorrent 未提供 completion_on 时保守地返回 false。
func recentlyCompletedTorrent(torrent QBitTorrent, now time.Time) bool {
if torrent.CompletionOn <= 0 {
return false
}
completed := time.Unix(torrent.CompletionOn, 0)
return now.Sub(completed) <= completedTorrentCatchupWindow
}
func qbitTorrentCompleted(torrent QBitTorrent) bool {
if torrent.Progress < 1 {
return false
}
state := strings.ToLower(strings.TrimSpace(torrent.State))
switch state {
case "completed", "complete", "seeding", "uploading", "stalledup", "pausedup", "queuedup", "forcedup":
return true
default:
return false
}
}
func (d *DownloadService) completedTorrentCatchupRecorded(ctx context.Context, torrent QBitTorrent) bool {
if d == nil || d.repo == nil || d.repo.Setting == nil {
return false
}
key := completedTorrentCatchupSettingKey(torrent)
if key == "" {
return false
}
value, err := d.repo.Setting.Get(ctx, key)
if err != nil {
return false
}
return parseBoolSetting(value, false)
}
func (d *DownloadService) markCompletedTorrentCatchupRecorded(ctx context.Context, torrent QBitTorrent) {
if d == nil || d.repo == nil || d.repo.Setting == nil {
return
}
key := completedTorrentCatchupSettingKey(torrent)
if key == "" {
return
}
if err := d.repo.Setting.Set(ctx, key, "true"); err != nil && d.log != nil {
d.log.Debug("mark completed torrent catchup failed",
zap.String("hash", torrent.Hash),
zap.String("name", torrent.Name),
zap.Error(err))
}
}
func completedTorrentCatchupSettingKey(torrent QBitTorrent) string {
key := completedTorrentQueueKey(torrent)
if key == "" {
return ""
}
sum := sha1.Sum([]byte(key))
return completedTorrentCatchupSettingPrefix + fmt.Sprintf("%x", sum[:])
}
func (d *DownloadService) completedTorrentNotified(ctx context.Context, torrent QBitTorrent) bool {
if d == nil || d.repo == nil || d.repo.Setting == nil {
return false
}
key := completedTorrentNotifySettingKey(torrent)
if key == "" {
return false
}
value, err := d.repo.Setting.Get(ctx, key)
if err != nil {
return false
}
return parseBoolSetting(value, false)
}
func (d *DownloadService) markCompletedTorrentNotified(ctx context.Context, torrent QBitTorrent) {
if d == nil || d.repo == nil || d.repo.Setting == nil {
return
}
key := completedTorrentNotifySettingKey(torrent)
if key == "" {
return
}
if err := d.repo.Setting.Set(ctx, key, "true"); err != nil && d.log != nil {
d.log.Debug("mark completed torrent notification failed",
zap.String("hash", torrent.Hash),
zap.String("name", torrent.Name),
zap.Error(err))
}
}
func completedTorrentNotifySettingKey(torrent QBitTorrent) string {
key := completedTorrentQueueKey(torrent)
if key == "" {
return ""
}
sum := sha1.Sum([]byte(key))
return completedTorrentNotifySettingPrefix + fmt.Sprintf("%x", sum[:])
}
func completedTorrentQueueKey(torrent QBitTorrent) string {
hash := strings.ToLower(strings.TrimSpace(torrent.Hash))
if hash != "" {
owner := strings.ToLower(firstNonEmpty(torrent.ClientID, torrent.Source))
if owner != "" {
return owner + "|" + hash
}
return hash
}
parts := []string{torrent.Name, torrent.ContentPath, torrent.SavePath}
if owner := firstNonEmpty(torrent.ClientID, torrent.Source); owner != "" {
parts = append([]string{owner}, parts...)
}
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
key := strings.Join(parts, "|")
if strings.Trim(key, "|") == "" {
return ""
}
return strings.ToLower(key)
}
func (d *DownloadService) syncDownloadTaskProgress(ctx context.Context, torrent QBitTorrent, taskByKey map[string]model.DownloadTask) {
if d == nil || d.repo == nil || d.repo.DB == nil {
return
}
matched, ok := findMatchingTaskForTorrent(torrent, taskByKey)
if !ok {
return
}
status := torrent.State
if qbitTorrentCompleted(torrent) {
status = "completed"
}
if strings.TrimSpace(status) == "" {
status = matched.Status
}
updates := map[string]any{}
if math.Abs(float64(matched.Progress-torrent.Progress)) > 0.0001 {
updates["progress"] = torrent.Progress
}
if status != "" && status != matched.Status {
updates["status"] = status
}
if strings.TrimSpace(matched.DownloadClientID) == "" && strings.TrimSpace(torrent.ClientID) != "" {
updates["download_client_id"] = strings.TrimSpace(torrent.ClientID)
}
if strings.TrimSpace(matched.ExternalID) == "" && strings.TrimSpace(torrent.Hash) != "" {
updates["external_id"] = strings.TrimSpace(torrent.Hash)
}
if strings.TrimSpace(torrent.Source) != "" && matched.Source != strings.TrimSpace(torrent.Source) {
updates["source"] = strings.TrimSpace(torrent.Source)
}
if len(updates) == 0 {
return
}
_ = d.repo.DB.WithContext(ctx).Model(&model.DownloadTask{}).Where("id = ?", matched.ID).Updates(updates).Error
}
func tasksByIdentity(rows []model.DownloadTask) map[string]model.DownloadTask {
out := make(map[string]model.DownloadTask, len(rows))
for _, row := range rows {
key := downloadTaskIdentityKey(row.Title)
if key != "" {
out[key] = row
}
}
return out
}
func tasksByTorrentIdentity(rows []model.DownloadTask) map[string]model.DownloadTask {
out := make(map[string]model.DownloadTask, len(rows)*4)
for _, row := range rows {
key := normalizeTorrentName(row.Title)
if key != "" {
setDownloadTaskIndex(out, key, row)
setDownloadTaskIndex(out, downloadTaskClientTitleKey(row.DownloadClientID, key), row)
}
if externalID := strings.TrimSpace(row.ExternalID); externalID != "" {
setDownloadTaskIndex(out, downloadTaskExternalKey(row.DownloadClientID, externalID), row)
setDownloadTaskIndex(out, downloadTaskAnyExternalKey(externalID), row)
}
}
return out
}
func setDownloadTaskIndex(index map[string]model.DownloadTask, key string, row model.DownloadTask) {
if key == "" {
return
}
if _, exists := index[key]; !exists {
index[key] = row
}
}
func downloadTaskExternalKey(clientID, externalID string) string {
clientID = strings.ToLower(strings.TrimSpace(clientID))
externalID = strings.ToLower(strings.TrimSpace(externalID))
if clientID == "" || externalID == "" {
return ""
}
return "\x00external:" + clientID + ":" + externalID
}
func downloadTaskAnyExternalKey(externalID string) string {
externalID = strings.ToLower(strings.TrimSpace(externalID))
if externalID == "" {
return ""
}
return "\x00external-any:" + externalID
}
func downloadTaskClientTitleKey(clientID, titleKey string) string {
clientID = strings.ToLower(strings.TrimSpace(clientID))
titleKey = strings.TrimSpace(titleKey)
if clientID == "" || titleKey == "" {
return ""
}
return "\x00client-title:" + clientID + ":" + titleKey
}
func findMatchingTaskForTorrent(torrent QBitTorrent, taskByKey map[string]model.DownloadTask) (model.DownloadTask, bool) {
if row, ok := taskByKey[downloadTaskExternalKey(torrent.ClientID, torrent.Hash)]; ok {
return row, true
}
if row, ok := taskByKey[downloadTaskAnyExternalKey(torrent.Hash)]; ok {
if strings.TrimSpace(row.DownloadClientID) == "" || strings.TrimSpace(torrent.ClientID) == "" || row.DownloadClientID == torrent.ClientID {
return row, true
}
}
titleKey := normalizeTorrentName(torrent.Name)
if row, ok := taskByKey[downloadTaskClientTitleKey(torrent.ClientID, titleKey)]; ok {
return row, true
}
row, ok := findMatchingTaskByTorrentIdentity(torrent.Name, taskByKey)
if !ok {
return model.DownloadTask{}, false
}
if strings.TrimSpace(torrent.ClientID) != "" && strings.TrimSpace(row.DownloadClientID) != "" && row.DownloadClientID != torrent.ClientID {
return model.DownloadTask{}, false
}
return row, true
}
func findMatchingTaskByIdentity(title string, taskByKey map[string]model.DownloadTask) (model.DownloadTask, bool) {
key := downloadTaskIdentityKey(title)
if key == "" {
return model.DownloadTask{}, false
}
if row, ok := taskByKey[key]; ok {
return row, true
}
for currentKey, row := range taskByKey {
if strings.HasPrefix(currentKey, "\x00") {
continue
}
if strings.Contains(key, currentKey) || strings.Contains(currentKey, key) {
return row, true
}
}
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.HasPrefix(currentKey, "\x00") {
continue
}
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"
}