package torrent import ( "fmt" "path/filepath" "strings" "sync" "time" "github.com/debridmediamanager/zurg/internal/config" "github.com/debridmediamanager/zurg/pkg/realdebrid" "github.com/debridmediamanager/zurg/pkg/utils" mapset "github.com/deckarep/golang-set/v2" cmap "github.com/orcaman/concurrent-map/v2" ) func (t *TorrentManager) refreshTorrents(isInitialRun bool) []string { instances, _, err := t.api.GetTorrents(false) if err != nil { t.log.Warnf("Cannot get torrents: %v", err) return nil } t.log.Infof("Fetched %d torrents", len(instances)) torChan := make(chan *Torrent, len(instances)) var wg sync.WaitGroup for i := range instances { idx := i wg.Add(1) _ = t.workerPool.Submit(func() { defer wg.Done() torChan <- t.getMoreInfo(instances[idx]) }) } wg.Wait() close(torChan) t.log.Infof("Fetched info for %d torrents", len(instances)) var updatedPaths []string noInfoCount := 0 allTorrents, _ := t.DirectoryMap.Get(INT_ALL) freshAccessKeys := mapset.NewSet[string]() allHashes := mapset.NewSet[string]() t.getTorrentFiles().Each(func(path string) bool { path = filepath.Base(path) hash := strings.TrimSuffix(path, ".torrent_zurg") allHashes.Add(hash) return false }) freshHashes := mapset.NewSet[string]() allIDs := mapset.NewSet[string]() t.getInfoFiles().Each(func(path string) bool { path = filepath.Base(path) torrentID := strings.TrimSuffix(path, ".info_zurg") allIDs.Add(torrentID) return false }) freshIDs := mapset.NewSet[string]() for torrent := range torChan { if torrent == nil { noInfoCount++ continue } // there's only 1 component torrent at this point, let's get it var tInfo *realdebrid.TorrentInfo for _, tInfo = range torrent.Components { break } accessKey := t.GetKey(torrent) freshAccessKeys.Add(accessKey) freshHashes.Add(torrent.Hash) freshIDs.Add(tInfo.ID) // update allTorrents isNewID := false mainTorrent, exists := allTorrents.Get(accessKey) if !exists { allTorrents.Set(accessKey, torrent) mainTorrent = torrent isNewID = true } else if _, ok := mainTorrent.Components[tInfo.ID]; !ok { merged := t.mergeToMain(mainTorrent, torrent) allTorrents.Set(accessKey, merged) mainTorrent = merged isNewID = true } if isNewID && tInfo.Progress == 100 { // assign to directory t.assignedDirectoryCb(mainTorrent, func(directory string) { listing, _ := t.DirectoryMap.Get(directory) listing.Set(accessKey, mainTorrent) updatedPaths = append(updatedPaths, fmt.Sprintf("%s/%s", directory, accessKey)) }) // write torrent to file if !allHashes.Contains(mainTorrent.Hash) { t.writeTorrentToFile(mainTorrent) } } } // removed torrents allAccessKeys := mapset.NewSet[string](allTorrents.Keys()...) allAccessKeys.Difference(freshAccessKeys).Each(func(accessKey string) bool { t.Delete(accessKey, false) return false }) t.log.Infof("Compiled into %d torrents, %d were missing info", allTorrents.Count(), noInfoCount) // data directory cleanup allHashes.Difference(freshHashes).Each(func(hash string) bool { t.log.Infof("Deleting stale torrent file %s", hash) t.deleteTorrentFile(hash) return false }) allIDs.Difference(freshIDs).Each(func(id string) bool { t.log.Infof("Deleting stale info file %s", id) t.deleteInfoFile(id) return false }) if t.Config.EnableRepair() { if isInitialRun { t.removeExpiredFixers(instances) t.processFixers(instances) } else { t.workerPool.Submit(func() { t.processFixers(instances) }) } } return updatedPaths } // StartRefreshJob periodically refreshes the torrents func (t *TorrentManager) StartRefreshJob() { go func() { t.log.Info("Starting periodic refresh job") refreshTicker := time.NewTicker(time.Duration(t.Config.GetRefreshEverySecs()) * time.Second) defer refreshTicker.Stop() for { select { case <-refreshTicker.C: checksum := t.getCurrentState() if t.latestState.Eq(checksum) { continue } t.log.Infof("Detected changes! Refreshing %d torrents", checksum.TotalCount) t.setNewLatestState(checksum) updatedPaths := t.refreshTorrents(false) t.log.Info("Finished refreshing torrents") t.TriggerHookOnLibraryUpdate(updatedPaths) case <-t.RefreshKillSwitch: t.log.Info("Stopping periodic refresh job") return } } }() } // getMoreInfo gets original name, size and files for a torrent func (t *TorrentManager) getMoreInfo(rdTorrent realdebrid.Torrent) *Torrent { diskTor := t.readTorrentFromFile(rdTorrent.Hash) if diskTor != nil { if diskInfo, ok := diskTor.Components[rdTorrent.ID]; ok && diskInfo.Progress == 100 { diskTor.Components = map[string]*realdebrid.TorrentInfo{rdTorrent.ID: diskInfo} return diskTor } } info := t.readInfoFromFile(rdTorrent.ID) if info == nil { var err error info, err = t.api.GetTorrentInfo(rdTorrent.ID) if err != nil { t.log.Warnf("Cannot get info for id=%s: %v", rdTorrent.ID, err) return nil } if info.Progress == 100 { t.writeInfoToFile(info) } } torrent := Torrent{ Name: info.Name, OriginalName: info.OriginalName, Added: info.Added, Hash: info.Hash, State: NewTorrentState("ok"), } // SelectedFiles is a subset of Files with only the selected ones // it also has a Link field, which can be empty // if it is empty, it means the file is no longer available // Files+Links together are the same as SelectedFiles 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, consider it broken State: NewFileState("broken"), }) } if len(selectedFiles) == len(info.Links) { // all links are still intact! good! for i, file := range selectedFiles { file.Link = info.Links[i] file.State.SetState("ok") } torrent.UnassignedLinks = mapset.NewSet[string]() } else { torrent.UnassignedLinks = mapset.NewSet[string](info.Links...) } torrent.SelectedFiles = cmap.New[*File]() for _, file := range selectedFiles { filename := t.GetPath(file) // todo better handling of duplicate filenames if torrent.SelectedFiles.Has(filename) { oldName := filename ext := filepath.Ext(oldName) noExtension := strings.TrimSuffix(oldName, ext) newName := fmt.Sprintf("%s (%d)%s", noExtension, file.ID, ext) torrent.SelectedFiles.Set(newName, file) } else { torrent.SelectedFiles.Set(filename, file) } } torrent.Components = map[string]*realdebrid.TorrentInfo{rdTorrent.ID: info} return &torrent } // ResetSelectedFiles resets the selected files for a torrent func (t *TorrentManager) ResetSelectedFiles(torrent *Torrent) { // reset selected files newSelectedFiles := cmap.New[*File]() torrent.SelectedFiles.IterCb(func(_ string, file *File) { filename := t.GetPath(file) if newSelectedFiles.Has(filename) { oldName := filename ext := filepath.Ext(oldName) noExtension := strings.TrimSuffix(oldName, ext) newName := fmt.Sprintf("%s (%d)%s", noExtension, file.ID, ext) newSelectedFiles.Set(newName, file) } else { newSelectedFiles.Set(filename, file) } }) torrent.SelectedFiles = newSelectedFiles } func (t *TorrentManager) mergeToMain(existing, toMerge *Torrent) *Torrent { var newer, older *Torrent if existing.Added < toMerge.Added { newer = toMerge older = existing } else { newer = existing older = toMerge } mergedComponents := map[string]*realdebrid.TorrentInfo{} for k, v := range older.Components { mergedComponents[k] = v } for k, v := range newer.Components { mergedComponents[k] = v } // build the main torrent mainTorrent := Torrent{ Name: newer.Name, OriginalName: newer.OriginalName, Rename: newer.Rename, Hash: newer.Hash, Added: newer.Added, Components: mergedComponents, UnassignedLinks: newer.UnassignedLinks.Union(older.UnassignedLinks), UnrepairableReason: newer.UnrepairableReason, State: older.State, } // unrepairable reason if mainTorrent.UnrepairableReason != "" && older.UnrepairableReason != "" && mainTorrent.UnrepairableReason != older.UnrepairableReason { mainTorrent.UnrepairableReason = fmt.Sprintf("%s, %s", mainTorrent.UnrepairableReason, older.UnrepairableReason) } else if older.UnrepairableReason != "" { mainTorrent.UnrepairableReason = older.UnrepairableReason } // the link can have the following values // 1. https://*** - the file is available // 3. empty - the file is not available mainTorrent.SelectedFiles = cmap.New[*File]() newer.SelectedFiles.IterCb(func(key string, newerFile *File) { mainTorrent.SelectedFiles.Set(key, newerFile) }) older.SelectedFiles.IterCb(func(key string, olderFile *File) { if !mainTorrent.SelectedFiles.Has(key) { mainTorrent.SelectedFiles.Set(key, olderFile) } else if olderFile.State.Is("deleted") { newerFile, _ := mainTorrent.SelectedFiles.Get(key) newerFile.State.SetState("deleted") } }) t.CheckDeletedStatus(&mainTorrent) return &mainTorrent } func (t *TorrentManager) assignedDirectoryCb(tor *Torrent, cb func(string)) { torrentIDs := []string{} for id := range tor.Components { torrentIDs = append(torrentIDs, id) } // get filenames needed for directory conditions var filenames []string var fileSizes []int64 unplayable := true tor.SelectedFiles.IterCb(func(key string, file *File) { filenames = append(filenames, filepath.Base(file.Path)) fileSizes = append(fileSizes, file.Bytes) if utils.IsPlayable(file.Path) || t.IsPlayable(file.Path) { unplayable = false } }) if unplayable { t.markAsUnplayable(tor, "no playable files") return } // Map torrents to directories switch t.Config.GetVersion() { case "v1": configV1 := t.Config.(*config.ZurgConfigV1) for _, directories := range configV1.GetGroupMap() { for _, directory := range directories { if t.Config.MeetsConditions(directory, t.GetKey(tor), tor.ComputeTotalSize(), torrentIDs, filenames, fileSizes) { cb(directory) break } } } } } func (t *TorrentManager) IsPlayable(filePath string) bool { filePath = strings.ToLower(filePath) playableExts := t.Config.GetPlayableExtensions() for _, ext := range playableExts { if strings.HasSuffix(filePath, fmt.Sprintf(".%s", ext)) { return true } } return false }