package torrent import ( "io" "os" "path/filepath" "strings" "sync" "time" "github.com/debridmediamanager/zurg/internal/config" "github.com/debridmediamanager/zurg/internal/fs" "github.com/debridmediamanager/zurg/pkg/logutil" "github.com/debridmediamanager/zurg/pkg/realdebrid" mapset "github.com/deckarep/golang-set/v2" cmap "github.com/orcaman/concurrent-map/v2" "github.com/panjf2000/ants/v2" ) const ( INT_ALL = "int__all__" ) type TorrentManager struct { requiredVersion string Config config.ConfigInterface api *realdebrid.RealDebrid workerPool *ants.Pool log *logutil.Logger DirectoryMap cmap.ConcurrentMap[string, cmap.ConcurrentMap[string, *Torrent]] // directory -> accessKey -> Torrent DownloadMap cmap.ConcurrentMap[string, *realdebrid.Download] RootNode *fs.FileNode RefreshKillSwitch chan struct{} RepairKillSwitch chan struct{} RemountTrigger chan struct{} latestState *LibraryState repairTrigger chan *Torrent repairQueue mapset.Set[*Torrent] repairRunning bool repairRunningMu sync.Mutex trashBin mapset.Set[string] repairBin mapset.Set[string] // same as trash bin, but only if the torrent has been downloaded } // NewTorrentManager creates a new torrent manager // it will fetch all torrents and their info in the background // and store them in-memory and cached in files func NewTorrentManager(cfg config.ConfigInterface, api *realdebrid.RealDebrid, workerPool *ants.Pool, log *logutil.Logger) *TorrentManager { t := &TorrentManager{ requiredVersion: "0.10.0", Config: cfg, api: api, workerPool: workerPool, log: log, DirectoryMap: cmap.New[cmap.ConcurrentMap[string, *Torrent]](), DownloadMap: cmap.New[*realdebrid.Download](), RootNode: fs.NewFileNode("root", true), RefreshKillSwitch: make(chan struct{}, 1), RepairKillSwitch: make(chan struct{}, 1), RemountTrigger: make(chan struct{}, 1), latestState: &LibraryState{log: log}, } t.trashBin = mapset.NewSet[string]() t.initializeDirectories() t.workerPool.Submit(func() { t.refreshTorrents() t.setNewLatestState(t.getCurrentState()) t.StartRefreshJob() t.StartRepairJob() t.TriggerRepair(nil) }) t.workerPool.Submit(func() { t.mountDownloads() t.StartDownloadsJob() }) return t } // proxy function func (t *TorrentManager) UnrestrictLinkUntilOk(link string) *realdebrid.Download { ret, err := t.api.UnrestrictLink(link, t.Config.ShouldServeFromRclone()) if err != nil { t.log.Warnf("Cannot unrestrict link %s: %v", link, err) return nil } if ret != nil && ret.Link != "" && ret.Filename != "" { if t.Config.EnableDownloadMount() { t.DownloadMap.Set(ret.Filename, ret) } } return ret } func (t *TorrentManager) UnrestrictFileUntilOk(file *File) *realdebrid.Download { if !file.State.Is("ok_file") { return nil } return t.UnrestrictLinkUntilOk(file.Link) } func (t *TorrentManager) GetKey(torrent *Torrent) string { if !t.Config.ShouldIgnoreRenames() && torrent.Rename != "" { return torrent.Rename } if t.Config.EnableRetainRDTorrentName() { return torrent.Name } // drop the extension from the name if t.Config.EnableRetainFolderNameExtension() && strings.Contains(torrent.Name, torrent.OriginalName) { return torrent.Name } else { ret := strings.TrimSuffix(torrent.OriginalName, ".mp4") ret = strings.TrimSuffix(ret, ".mkv") return ret } } func (t *TorrentManager) GetPath(file *File) string { if !t.Config.ShouldIgnoreRenames() && file.Rename != "" { return file.Rename } filename := filepath.Base(file.Path) return filename } /// torrent functions func (t *TorrentManager) getTorrentFiles() mapset.Set[string] { files, err := filepath.Glob("data/*.torrent_zurg") if err != nil { t.log.Warnf("Cannot get files in data directory: %v", err) return nil } return mapset.NewSet[string](files...) } func (t *TorrentManager) writeTorrentToFile(torrent *Torrent) { filePath := "data/" + torrent.Hash + ".torrent_zurg" file, err := os.Create(filePath) if err != nil { t.log.Warnf("Cannot create file %s: %v", filePath, err) return } defer file.Close() torrent.Version = t.requiredVersion jsonData, err := json.Marshal(torrent) if err != nil { t.log.Warnf("Cannot marshal torrent: %v", err) return } if _, err := file.Write(jsonData); err != nil { t.log.Warnf("Cannot write to file %s: %v", filePath, err) return } t.log.Debugf("Saved torrent %s (hash=%s) to file", t.GetKey(torrent), torrent.Hash) } func (t *TorrentManager) readTorrentFromFile(hash string) *Torrent { filePath := "data/" + hash + ".torrent_zurg" file, err := os.Open(filePath) if err != nil { if os.IsNotExist(err) { return nil } return nil } defer file.Close() jsonData, err := io.ReadAll(file) if err != nil { return nil } var torrent *Torrent if err := json.Unmarshal(jsonData, &torrent); err != nil { return nil } if torrent.Version != t.requiredVersion { return nil } return torrent } func (t *TorrentManager) deleteTorrentFile(hash string) { filePath := "data/" + hash + ".torrent_zurg" _ = os.Remove(filePath) } /// end torrent functions /// info functions func (t *TorrentManager) getInfoFiles() mapset.Set[string] { files, err := filepath.Glob("data/*.info_zurg") if err != nil { t.log.Warnf("Cannot get files in data directory: %v", err) return nil } return mapset.NewSet[string](files...) } func (t *TorrentManager) writeInfoToFile(info *realdebrid.TorrentInfo) { filePath := "data/" + info.ID + ".info_zurg" file, err := os.Create(filePath) if err != nil { t.log.Warnf("Cannot create info file %s: %v", filePath, err) return } defer file.Close() jsonData, err := json.Marshal(info) if err != nil { t.log.Warnf("Cannot marshal torrent info: %v", err) return } if _, err := file.Write(jsonData); err != nil { t.log.Warnf("Cannot write to info file %s: %v", filePath, err) return } t.log.Debugf("Saved torrent %s to info file", info.ID) } func (t *TorrentManager) readInfoFromFile(torrentID string) *realdebrid.TorrentInfo { filePath := "data/" + torrentID + ".info_zurg" file, err := os.Open(filePath) if err != nil { if os.IsNotExist(err) { return nil } return nil } defer file.Close() jsonData, err := io.ReadAll(file) if err != nil { return nil } var info *realdebrid.TorrentInfo if err := json.Unmarshal(jsonData, &info); err != nil { return nil } return info } func (t *TorrentManager) deleteInfoFile(torrentID string) { filePath := "data/" + torrentID + ".info_zurg" _ = os.Remove(filePath) } /// end info functions func (t *TorrentManager) mountDownloads() { if !t.Config.EnableDownloadMount() { return } t.DownloadMap.Clear() _ = t.workerPool.Submit(func() { page := 1 offset := 0 for { downloads, totalDownloads, err := t.api.GetDownloads(page, offset) if err != nil { // if we get an error, we just stop t.log.Warnf("Cannot get downloads on page %d: %v", page, err) continue } for i := range downloads { t.DownloadMap.Set(downloads[i].Filename, &downloads[i]) } offset += len(downloads) page++ if offset >= totalDownloads { break } } t.log.Infof("Compiled into %d downloads", t.DownloadMap.Count()) }) } func (t *TorrentManager) StartDownloadsJob() { _ = t.workerPool.Submit(func() { remountTicker := time.NewTicker(time.Duration(t.Config.GetDownloadsEveryMins()) * time.Minute) defer remountTicker.Stop() for { select { case <-remountTicker.C: t.mountDownloads() case <-t.RemountTrigger: t.mountDownloads() } } }) } func (t *TorrentManager) initializeDirectories() { // create internal directories t.DirectoryMap.Set(INT_ALL, cmap.New[*Torrent]()) // key is GetAccessKey() // create directory maps for _, directory := range t.Config.GetDirectories() { t.DirectoryMap.Set(directory, cmap.New[*Torrent]()) // t.RootNode.AddChild(fs.NewFileNode(directory, true)) } }