528 lines
16 KiB
Go
528 lines
16 KiB
Go
package torrent
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/debridmediamanager/zurg/internal/config"
|
|
"github.com/debridmediamanager/zurg/pkg/realdebrid"
|
|
mapset "github.com/deckarep/golang-set/v2"
|
|
cmap "github.com/orcaman/concurrent-map/v2"
|
|
)
|
|
|
|
const (
|
|
EXPIRED_LINK_TOLERANCE_HOURS = 24
|
|
)
|
|
|
|
func (t *TorrentManager) StartRepairJob() {
|
|
if !t.Config.EnableRepair() {
|
|
t.log.Debug("Repair is disabled, skipping repair job")
|
|
return
|
|
}
|
|
t.repairTrigger = make(chan *Torrent)
|
|
t.repairSet = mapset.NewSet[*Torrent]()
|
|
// there is 1 repair worker, with max 1 blocking task
|
|
_ = t.workerPool.Submit(func() {
|
|
t.log.Info("Starting periodic repair job")
|
|
repairTicker := time.NewTicker(time.Duration(t.Config.GetRepairEveryMins()) * time.Minute)
|
|
defer repairTicker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-repairTicker.C:
|
|
t.invokeRepair(nil)
|
|
case torrent := <-t.repairTrigger:
|
|
// On-demand trigger with a specific torrent
|
|
t.invokeRepair(torrent)
|
|
case <-t.RepairKillSwitch:
|
|
t.log.Info("Stopping periodic repair job")
|
|
return
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func (t *TorrentManager) invokeRepair(torrent *Torrent) {
|
|
t.repairRunningMu.Lock()
|
|
if t.repairRunning {
|
|
t.repairRunningMu.Unlock()
|
|
t.repairSet.Add(torrent)
|
|
// don't do anything if repair is already running
|
|
return
|
|
}
|
|
|
|
t.repairRunning = true
|
|
t.repairRunningMu.Unlock()
|
|
|
|
// Execute the repair job
|
|
t.repairAll(torrent)
|
|
|
|
// After repair is done
|
|
t.repairRunningMu.Lock()
|
|
t.repairRunning = false
|
|
t.repairRunningMu.Unlock()
|
|
|
|
// before we let go, let's check repairSet
|
|
t.workerPool.Submit(func() {
|
|
queuedTorrent, exists := t.repairSet.Pop()
|
|
if exists {
|
|
t.TriggerRepair(queuedTorrent)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TriggerRepair allows an on-demand repair to be initiated.
|
|
func (t *TorrentManager) TriggerRepair(torrent *Torrent) {
|
|
t.repairTrigger <- torrent
|
|
}
|
|
|
|
func (t *TorrentManager) repairAll(torrent *Torrent) {
|
|
// todo: a more elegant way to do this
|
|
var haystack cmap.ConcurrentMap[string, *Torrent]
|
|
if torrent == nil {
|
|
haystack, _ = t.DirectoryMap.Get(INT_ALL)
|
|
t.log.Info("Periodic repair started; searching for broken torrents")
|
|
} else {
|
|
haystack = cmap.New[*Torrent]()
|
|
haystack.Set("", torrent)
|
|
t.log.Infof("Repair invoked for torrent %s", t.GetKey(torrent))
|
|
}
|
|
|
|
// collect all torrents that need to be repaired
|
|
toRepair := mapset.NewSet[*Torrent]()
|
|
|
|
haystack.IterCb(func(_ string, torrent *Torrent) {
|
|
if torrent.AnyInProgress() || torrent.AllInProgress() || torrent.UnrepairableReason != "" {
|
|
return
|
|
}
|
|
// check 1: for broken files
|
|
brokenFileIDs := mapset.NewSet[int]()
|
|
torrent.SelectedFiles.IterCb(func(_ string, file *File) {
|
|
if file.IsBroken && !file.IsDeleted {
|
|
brokenFileIDs.Add(file.ID)
|
|
}
|
|
})
|
|
if brokenFileIDs.Cardinality() > 0 {
|
|
t.log.Debugf("Torrent %s has broken files (ids=%v), adding to repair list", t.GetKey(torrent), brokenFileIDs.ToSlice())
|
|
toRepair.Add(torrent)
|
|
return
|
|
}
|
|
// check 2: for expired links
|
|
if torrent.UnassignedLinks.Cardinality() > 0 {
|
|
t.log.Debugf("Torrent %s has unassigned links, adding to repair list", t.GetKey(torrent))
|
|
toRepair.Add(torrent)
|
|
return
|
|
}
|
|
})
|
|
|
|
t.log.Infof("Found %d broken torrents to repair in total", toRepair.Cardinality())
|
|
|
|
var wg sync.WaitGroup
|
|
toRepair.Each(func(torrent *Torrent) bool {
|
|
wg.Add(1)
|
|
t.Repair(torrent, &wg)
|
|
return false
|
|
})
|
|
wg.Wait()
|
|
|
|
t.log.Infof("Finished repairing %d broken torrents", toRepair.Cardinality())
|
|
}
|
|
|
|
func (t *TorrentManager) Repair(torrent *Torrent, wg *sync.WaitGroup) {
|
|
if torrent.UnrepairableReason != "" {
|
|
t.log.Warnf("Torrent %s is unfixable (%s), skipping repair", t.GetKey(torrent), torrent.UnrepairableReason)
|
|
wg.Done()
|
|
return
|
|
}
|
|
if torrent.AnyInProgress() || torrent.AllInProgress() {
|
|
t.log.Infof("Torrent %s is in progress, skipping repair until download is done", t.GetKey(torrent))
|
|
wg.Done()
|
|
return
|
|
}
|
|
|
|
t.log.Infof("Attempting repair for torrent %s", t.GetKey(torrent))
|
|
|
|
// blocks for approx 45 minutes if active torrents are full
|
|
if !t.canCapacityHandle() {
|
|
t.log.Error("Blocked for too long due to limit of active torrents, cannot continue with the repair")
|
|
wg.Done()
|
|
return
|
|
}
|
|
|
|
// assign to a worker
|
|
_ = t.workerPool.Submit(func() {
|
|
defer wg.Done()
|
|
t.repair(torrent)
|
|
})
|
|
}
|
|
|
|
func (t *TorrentManager) repair(torrent *Torrent) {
|
|
t.log.Infof("Started repair process for torrent %s", t.GetKey(torrent))
|
|
|
|
// handle torrents with incomplete links for selected files
|
|
if !t.assignUnassignedLinks(torrent) {
|
|
t.log.Debugf("Ending repair process early for torrent %s", t.GetKey(torrent))
|
|
return
|
|
}
|
|
|
|
// get all broken files
|
|
brokenFiles := getBrokenFiles(torrent)
|
|
t.log.Debugf("Torrent %s has %d broken out of %d files", t.GetKey(torrent), len(brokenFiles), torrent.SelectedFiles.Count())
|
|
brokenFileIDs := getFileIDs(brokenFiles)
|
|
|
|
// first step: redownload the whole torrent
|
|
t.log.Debugf("Repairing torrent %s by redownloading", t.GetKey(torrent))
|
|
info, err := t.redownloadTorrent(torrent, "") // reinsert the torrent, passing ""
|
|
if err != nil {
|
|
t.log.Warnf("Cannot repair torrent %s by redownloading (error=%s)", t.GetKey(torrent), err.Error())
|
|
} else if info != nil && info.Progress != 100 {
|
|
t.log.Infof("Torrent %s is still in progress after redownloading but it should be repaired once done", t.GetKey(torrent))
|
|
return
|
|
} else if info != nil && info.IsDone() && !t.isStillBroken(info, brokenFiles) {
|
|
selectedFiles := getSelectedFiles(info)
|
|
torrent.SelectedFiles.IterCb(func(_ string, oldFile *File) {
|
|
for _, newFile := range selectedFiles {
|
|
if oldFile.Bytes == newFile.Bytes {
|
|
oldFile.Link = newFile.Link
|
|
break
|
|
}
|
|
}
|
|
})
|
|
t.saveTorrentChangesToDisk(torrent, nil)
|
|
t.log.Infof("Successfully repaired torrent %s by redownloading", t.GetKey(torrent))
|
|
return
|
|
}
|
|
|
|
if torrent.UnrepairableReason != "" {
|
|
t.log.Debugf("Torrent %s has been marked as unfixable during redownload (%s), ending repair process early", t.GetKey(torrent), torrent.UnrepairableReason)
|
|
return
|
|
}
|
|
|
|
if len(brokenFiles) == torrent.SelectedFiles.Count() {
|
|
// all files are broken, nothing we can do
|
|
t.log.Warnf("Torrent %s has broken cached files (cached but cannot be downloaded), you can repair it manually, marking as unfixable", t.GetKey(torrent))
|
|
t.markAsUnfixable(torrent, "broken cache")
|
|
return
|
|
}
|
|
|
|
// second step: download the broken files
|
|
if len(brokenFiles) > 0 {
|
|
t.log.Infof("Repairing by downloading only the %d broken out of %d files of torrent %s", len(brokenFiles), torrent.SelectedFiles.Count(), t.GetKey(torrent))
|
|
redownloadedTorrent, err := t.redownloadTorrent(torrent, brokenFileIDs)
|
|
if err != nil {
|
|
t.log.Warnf("Cannot repair torrent %s by downloading broken files (error=%s) giving up", t.GetKey(torrent), err.Error())
|
|
return
|
|
}
|
|
if redownloadedTorrent != nil {
|
|
t.fixerAddCommand(redownloadedTorrent.ID, "repair")
|
|
return
|
|
}
|
|
}
|
|
|
|
t.log.Infof("Torrent %s has no broken files to repair", t.GetKey(torrent))
|
|
}
|
|
|
|
func (t *TorrentManager) assignUnassignedLinks(torrent *Torrent) bool {
|
|
// handle torrents with incomplete links for selected files
|
|
assignedCount := 0
|
|
rarCount := 0
|
|
newUnassignedLinks := cmap.New[*realdebrid.Download]()
|
|
torrent.UnassignedLinks.Each(func(link string) bool {
|
|
// unrestrict each unassigned link that was filled out during torrent init
|
|
unrestrict := t.UnrestrictUntilOk(link)
|
|
if unrestrict == nil {
|
|
newUnassignedLinks.Set(link, nil)
|
|
// return early, no point continuing
|
|
return false
|
|
}
|
|
|
|
// try to assign to a selected file
|
|
assigned := false
|
|
torrent.SelectedFiles.IterCb(func(_ string, file *File) {
|
|
// base it on size because why not?
|
|
if file.Bytes == unrestrict.Filesize {
|
|
file.Link = link
|
|
assigned = true
|
|
assignedCount++
|
|
}
|
|
})
|
|
|
|
if !assigned {
|
|
// if not assigned and is a rar, likely it was rar'ed by RD
|
|
if strings.HasSuffix(strings.ToLower(unrestrict.Filename), ".rar") {
|
|
rarCount++
|
|
}
|
|
newUnassignedLinks.Set(link, unrestrict)
|
|
}
|
|
return false
|
|
})
|
|
if assignedCount == 0 && rarCount > 0 && newUnassignedLinks.Count() > 0 {
|
|
// this is a rar'ed torrent, nothing we can do
|
|
if t.Config.ShouldDeleteRarFiles() {
|
|
t.log.Warnf("Torrent %s is rar'ed and we cannot repair it, deleting it as configured", t.GetKey(torrent))
|
|
t.Delete(t.GetKey(torrent), true)
|
|
} else {
|
|
t.log.Warnf("Torrent %s is rar'ed and we cannot repair it", t.GetKey(torrent))
|
|
newUnassignedLinks.IterCb(func(_ string, unassigned *realdebrid.Download) {
|
|
if unassigned == nil {
|
|
return
|
|
}
|
|
newFile := &File{
|
|
File: realdebrid.File{
|
|
ID: 0,
|
|
Path: unassigned.Filename,
|
|
Bytes: unassigned.Filesize,
|
|
Selected: 1,
|
|
},
|
|
Ended: torrent.Added,
|
|
Link: unassigned.Link,
|
|
}
|
|
torrent.SelectedFiles.Set(unassigned.Filename, newFile)
|
|
})
|
|
torrent.UnassignedLinks = mapset.NewSet[string]()
|
|
t.markAsUnfixable(torrent, "rar'ed by RD")
|
|
t.markAsUnplayable(torrent, "rar'ed by RD")
|
|
}
|
|
return false
|
|
}
|
|
// empty the unassigned links as we have assigned them
|
|
if torrent.UnassignedLinks.Cardinality() > 0 {
|
|
t.saveTorrentChangesToDisk(torrent, func(info *Torrent) {
|
|
info.UnassignedLinks = mapset.NewSet[string]()
|
|
})
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func (t *TorrentManager) redownloadTorrent(torrent *Torrent, selection string) (*realdebrid.TorrentInfo, error) {
|
|
// broken files means broken links
|
|
// if brokenFiles is not provided, we will redownload all files
|
|
oldTorrentIDs := make([]string, 0)
|
|
if selection == "" {
|
|
// only delete the old torrent if we are redownloading all files
|
|
oldTorrentIDs = torrent.DownloadedIDs.Union(torrent.InProgressIDs).ToSlice()
|
|
tmpSelection := ""
|
|
torrent.SelectedFiles.IterCb(func(_ string, file *File) {
|
|
tmpSelection += fmt.Sprintf("%d,", file.ID) // select all files
|
|
})
|
|
if tmpSelection == "" {
|
|
return nil, nil // nothing to repair
|
|
} else {
|
|
selection = tmpSelection[:len(tmpSelection)-1]
|
|
}
|
|
}
|
|
|
|
// redownload torrent
|
|
resp, err := t.Api.AddMagnetHash(torrent.Hash)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "infringing") {
|
|
t.markAsUnfixable(torrent, "infringing torrent")
|
|
} else if strings.Contains(err.Error(), "unsupported") {
|
|
t.markAsUnfixable(torrent, "unsupported torrent")
|
|
} else if strings.Contains(err.Error(), "unavailable") {
|
|
t.markAsUnfixable(torrent, "unavailable torrent")
|
|
} else if strings.Contains(err.Error(), "invalid") {
|
|
t.markAsUnfixable(torrent, "invalid torrent")
|
|
} else if strings.Contains(err.Error(), "big") {
|
|
t.markAsUnfixable(torrent, "torrent too big")
|
|
} else if strings.Contains(err.Error(), "allowed") {
|
|
t.markAsUnfixable(torrent, "torrent not allowed")
|
|
}
|
|
return nil, fmt.Errorf("cannot redownload torrent: %v", err)
|
|
}
|
|
|
|
newTorrentID := resp.ID
|
|
|
|
// sleep for 1 second to let RD process the magnet
|
|
time.Sleep(1 * time.Second)
|
|
|
|
// select files
|
|
err = t.Api.SelectTorrentFiles(newTorrentID, selection)
|
|
if err != nil {
|
|
t.fixerAddCommand(newTorrentID, "delete_failed")
|
|
return nil, fmt.Errorf("cannot start redownloading: %v", err)
|
|
}
|
|
|
|
// sleep for 1 second to let RD process the magnet
|
|
time.Sleep(1 * time.Second)
|
|
|
|
// see if the torrent is ready
|
|
info, err := t.Api.GetTorrentInfo(newTorrentID)
|
|
if err != nil {
|
|
t.fixerAddCommand(newTorrentID, "delete_failed")
|
|
return nil, fmt.Errorf("cannot get info on redownloaded torrent %s (id=%s) : %v", t.GetKey(torrent), newTorrentID, err)
|
|
}
|
|
|
|
// documented status: magnet_error, magnet_conversion, waiting_files_selection, queued, downloading, downloaded, error, virus, compressing, uploading, dead
|
|
okStatuses := []string{"magnet_conversion", "waiting_files_selection", "queued", "downloading", "downloaded", "uploading"}
|
|
// not compressing because we need playable files
|
|
isOkStatus := false
|
|
for _, status := range okStatuses {
|
|
if info.Status == status {
|
|
isOkStatus = true
|
|
break
|
|
}
|
|
}
|
|
if !isOkStatus {
|
|
t.fixerAddCommand(newTorrentID, "delete_failed")
|
|
return nil, fmt.Errorf("the redownloaded torrent %s (id=%s) is in error state: %s", t.GetKey(torrent), newTorrentID, info.Status)
|
|
}
|
|
|
|
// check if incorrect number of links
|
|
selectionCount := len(strings.Split(selection, ","))
|
|
if info.Progress == 100 && len(info.Links) != selectionCount {
|
|
t.fixerAddCommand(newTorrentID, "delete_failed")
|
|
return nil, fmt.Errorf("it did not fix the issue for %s (id=%s), only got %d files but we need %d, undoing", t.GetKey(torrent), info.ID, len(info.Links), selectionCount)
|
|
}
|
|
|
|
// looks like it's fixed
|
|
|
|
if len(oldTorrentIDs) > 0 {
|
|
// replace the old torrent (empty selection)
|
|
for _, id := range oldTorrentIDs {
|
|
t.fixerAddCommand(id, "delete_replaced")
|
|
}
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
func (t *TorrentManager) canCapacityHandle() bool {
|
|
// max waiting time is 45 minutes
|
|
const maxRetries = 50
|
|
const baseDelay = 1 * time.Second
|
|
const maxDelay = 60 * time.Second
|
|
retryCount := 0
|
|
for {
|
|
count, err := t.Api.GetActiveTorrentCount()
|
|
if err != nil {
|
|
t.log.Warnf("Cannot get active downloads count: %v", err)
|
|
if retryCount >= maxRetries {
|
|
t.log.Error("Max retries reached. Exiting.")
|
|
return false
|
|
}
|
|
delay := time.Duration(math.Pow(2, float64(retryCount))) * baseDelay
|
|
if delay > maxDelay {
|
|
delay = maxDelay
|
|
}
|
|
time.Sleep(delay)
|
|
retryCount++
|
|
continue
|
|
}
|
|
|
|
if count.DownloadingCount < count.MaxNumberOfTorrents-1 {
|
|
return true
|
|
}
|
|
|
|
delay := time.Duration(math.Pow(2, float64(retryCount))) * baseDelay
|
|
if delay > maxDelay {
|
|
delay = maxDelay
|
|
}
|
|
t.log.Infof("We have reached the max number of active torrents, waiting for %s seconds before retrying", delay)
|
|
|
|
if retryCount >= maxRetries {
|
|
t.log.Error("Max retries reached. Exiting.")
|
|
return false
|
|
}
|
|
|
|
time.Sleep(delay)
|
|
retryCount++
|
|
}
|
|
}
|
|
|
|
func (t *TorrentManager) markAsUnplayable(torrent *Torrent, reason string) {
|
|
t.log.Warnf("Marking torrent %s as unplayable - %s", t.GetKey(torrent), reason)
|
|
t.DirectoryMap.IterCb(func(directory string, torrents cmap.ConcurrentMap[string, *Torrent]) {
|
|
torrents.Remove(t.GetKey(torrent))
|
|
})
|
|
torrents, _ := t.DirectoryMap.Get(config.UNPLAYABLE_TORRENTS)
|
|
torrents.Set(t.GetKey(torrent), torrent)
|
|
}
|
|
|
|
func (t *TorrentManager) markAsUnfixable(torrent *Torrent, reason string) {
|
|
t.log.Warnf("Marking torrent %s as unfixable - %s", t.GetKey(torrent), reason)
|
|
torrent.UnrepairableReason = reason
|
|
t.saveTorrentChangesToDisk(torrent, func(t *Torrent) {
|
|
t.UnrepairableReason = reason
|
|
})
|
|
}
|
|
|
|
// getBrokenFiles returns the files that are not http links and not deleted
|
|
func getBrokenFiles(torrent *Torrent) []*File {
|
|
var brokenFiles []*File
|
|
torrent.SelectedFiles.IterCb(func(_ string, file *File) {
|
|
if file.IsBroken && !file.IsDeleted {
|
|
brokenFiles = append(brokenFiles, file)
|
|
}
|
|
})
|
|
return brokenFiles
|
|
}
|
|
|
|
// isStillBroken checks if the torrent is still broken
|
|
// if it's not broken anymore, it will assign the links to the files
|
|
func (t *TorrentManager) isStillBroken(info *realdebrid.TorrentInfo, brokenFiles []*File) bool {
|
|
var selectedFiles []*File
|
|
for _, file := range info.Files {
|
|
if file.Selected == 0 {
|
|
continue
|
|
}
|
|
selectedFiles = append(selectedFiles, &File{
|
|
File: file,
|
|
Ended: info.Ended,
|
|
Link: "", // no link yet
|
|
})
|
|
}
|
|
if len(selectedFiles) == len(info.Links) {
|
|
// all links are still intact! good!
|
|
for i, file := range selectedFiles {
|
|
file.Link = info.Links[i]
|
|
}
|
|
} else {
|
|
// if we can't assign links, it's still broken
|
|
return true
|
|
}
|
|
|
|
if len(brokenFiles) == 0 {
|
|
// just check for the last file
|
|
brokenFiles = append(brokenFiles, selectedFiles[len(selectedFiles)-1])
|
|
}
|
|
|
|
// check if the broken files can now be unrestricted
|
|
for _, oldFile := range brokenFiles {
|
|
for idx, newFile := range selectedFiles {
|
|
if oldFile.Bytes == newFile.Bytes {
|
|
unrestrict := t.UnrestrictUntilOk(selectedFiles[idx].Link)
|
|
if unrestrict == nil || oldFile.Bytes != unrestrict.Filesize {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func getSelectedFiles(info *realdebrid.TorrentInfo) []*File {
|
|
var selectedFiles []*File
|
|
// if some Links are empty, we need to repair it
|
|
for _, file := range info.Files {
|
|
if file.Selected == 0 {
|
|
continue
|
|
}
|
|
selectedFiles = append(selectedFiles, &File{
|
|
File: file,
|
|
Ended: info.Ended,
|
|
Link: "", // no link yet
|
|
})
|
|
}
|
|
if len(selectedFiles) == len(info.Links) {
|
|
// all links are still intact! good!
|
|
for i, file := range selectedFiles {
|
|
file.Link = info.Links[i]
|
|
}
|
|
}
|
|
return selectedFiles
|
|
}
|