- Revamp decypharr arch \n

- Add callback_ur, download_folder to addContent API \n
- Fix few bugs \n
- More declarative UI keywords
- Speed up repairs
- Few other improvements/bug fixes
This commit is contained in:
Mukhtar Akere
2025-06-02 12:57:36 +01:00
parent 1cd09239f9
commit 9c6c44d785
67 changed files with 1726 additions and 1464 deletions

858
pkg/debrid/store/cache.go Normal file
View File

@@ -0,0 +1,858 @@
package store
import (
"bufio"
"cmp"
"context"
"errors"
"fmt"
"github.com/sirrobot01/decypharr/pkg/debrid/types"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"encoding/json"
"github.com/go-co-op/gocron/v2"
"github.com/rs/zerolog"
"github.com/sirrobot01/decypharr/internal/config"
"github.com/sirrobot01/decypharr/internal/logger"
"github.com/sirrobot01/decypharr/internal/utils"
_ "time/tzdata"
)
type WebDavFolderNaming string
const (
WebDavUseFileName WebDavFolderNaming = "filename"
WebDavUseOriginalName WebDavFolderNaming = "original"
WebDavUseFileNameNoExt WebDavFolderNaming = "filename_no_ext"
WebDavUseOriginalNameNoExt WebDavFolderNaming = "original_no_ext"
WebDavUseID WebDavFolderNaming = "id"
WebdavUseHash WebDavFolderNaming = "infohash"
)
type CachedTorrent struct {
*types.Torrent
AddedOn time.Time `json:"added_on"`
IsComplete bool `json:"is_complete"`
Bad bool `json:"bad"`
}
func (c CachedTorrent) copy() CachedTorrent {
return CachedTorrent{
Torrent: c.Torrent,
AddedOn: c.AddedOn,
IsComplete: c.IsComplete,
Bad: c.Bad,
}
}
type RepairType string
const (
RepairTypeReinsert RepairType = "reinsert"
RepairTypeDelete RepairType = "delete"
)
type RepairRequest struct {
Type RepairType
TorrentID string
Priority int
FileName string
}
type Cache struct {
dir string
client types.Client
logger zerolog.Logger
torrents *torrentCache
downloadLinks *downloadLinkCache
invalidDownloadLinks sync.Map
folderNaming WebDavFolderNaming
listingDebouncer *utils.Debouncer[bool]
// monitors
repairRequest sync.Map
failedToReinsert sync.Map
downloadLinkRequests sync.Map
// repair
repairChan chan RepairRequest
// readiness
ready chan struct{}
// config
workers int
torrentRefreshInterval string
downloadLinksRefreshInterval string
autoExpiresLinksAfterDuration time.Duration
// refresh mutex
downloadLinksRefreshMu sync.RWMutex // for refreshing download links
torrentsRefreshMu sync.RWMutex // for refreshing torrents
scheduler gocron.Scheduler
cetScheduler gocron.Scheduler
saveSemaphore chan struct{}
config config.Debrid
customFolders []string
}
func NewDebridCache(dc config.Debrid, client types.Client) *Cache {
cfg := config.Get()
cetSc, err := gocron.NewScheduler(gocron.WithLocation(time.UTC))
if err != nil {
// If we can't create a CET scheduler, fallback to local time
cetSc, _ = gocron.NewScheduler(gocron.WithLocation(time.Local))
}
scheduler, err := gocron.NewScheduler(gocron.WithLocation(time.Local))
if err != nil {
// If we can't create a local scheduler, fallback to CET
scheduler = cetSc
}
autoExpiresLinksAfter, err := time.ParseDuration(dc.AutoExpireLinksAfter)
if autoExpiresLinksAfter == 0 || err != nil {
autoExpiresLinksAfter = 48 * time.Hour
}
var customFolders []string
dirFilters := map[string][]directoryFilter{}
for name, value := range dc.Directories {
for filterType, v := range value.Filters {
df := directoryFilter{filterType: filterType, value: v}
switch filterType {
case filterByRegex, filterByNotRegex:
df.regex = regexp.MustCompile(v)
case filterBySizeGT, filterBySizeLT:
df.sizeThreshold, _ = config.ParseSize(v)
case filterBLastAdded:
df.ageThreshold, _ = time.ParseDuration(v)
}
dirFilters[name] = append(dirFilters[name], df)
}
customFolders = append(customFolders, name)
}
_log := logger.New(fmt.Sprintf("%s-webdav", client.GetName()))
c := &Cache{
dir: filepath.Join(cfg.Path, "cache", dc.Name), // path to save cache files
torrents: newTorrentCache(dirFilters),
client: client,
logger: _log,
workers: dc.Workers,
downloadLinks: newDownloadLinkCache(),
torrentRefreshInterval: dc.TorrentsRefreshInterval,
downloadLinksRefreshInterval: dc.DownloadLinksRefreshInterval,
folderNaming: WebDavFolderNaming(dc.FolderNaming),
autoExpiresLinksAfterDuration: autoExpiresLinksAfter,
saveSemaphore: make(chan struct{}, 50),
cetScheduler: cetSc,
scheduler: scheduler,
config: dc,
customFolders: customFolders,
ready: make(chan struct{}),
}
c.listingDebouncer = utils.NewDebouncer[bool](100*time.Millisecond, func(refreshRclone bool) {
c.RefreshListings(refreshRclone)
})
return c
}
func (c *Cache) IsReady() chan struct{} {
return c.ready
}
func (c *Cache) StreamWithRclone() bool {
return c.config.ServeFromRclone
}
// Reset clears all internal state so the Cache can be reused without leaks.
// Call this after stopping the old Cache (so no goroutines are holding references),
// and before you discard the instance on a restart.
func (c *Cache) Reset() {
if err := c.scheduler.StopJobs(); err != nil {
c.logger.Error().Err(err).Msg("Failed to stop scheduler jobs")
}
if err := c.scheduler.Shutdown(); err != nil {
c.logger.Error().Err(err).Msg("Failed to stop scheduler")
}
// Stop the listing debouncer
c.listingDebouncer.Stop()
// Close the repair channel
close(c.repairChan)
// 1. Reset torrent storage
c.torrents.reset()
// 2. Reset download-link cache
c.downloadLinks.reset()
// 3. Clear any sync.Maps
c.invalidDownloadLinks = sync.Map{}
c.repairRequest = sync.Map{}
c.failedToReinsert = sync.Map{}
c.downloadLinkRequests = sync.Map{}
// 5. Rebuild the listing debouncer
c.listingDebouncer = utils.NewDebouncer[bool](
100*time.Millisecond,
func(refreshRclone bool) {
c.RefreshListings(refreshRclone)
},
)
// 6. Reset repair channel so the next Start() can spin it up
c.repairChan = make(chan RepairRequest, 100)
}
func (c *Cache) Start(ctx context.Context) error {
if err := os.MkdirAll(c.dir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
if err := c.Sync(ctx); err != nil {
return fmt.Errorf("failed to sync cache: %w", err)
}
// initial download links
go c.refreshDownloadLinks(ctx)
if err := c.StartSchedule(ctx); err != nil {
c.logger.Error().Err(err).Msg("Failed to start cache worker")
}
c.repairChan = make(chan RepairRequest, 100)
go c.repairWorker(ctx)
// Fire the ready channel
close(c.ready)
cfg := config.Get()
name := c.client.GetName()
addr := cfg.BindAddress + ":" + cfg.Port + cfg.URLBase + "webdav/" + name + "/"
c.logger.Info().Msgf("%s WebDav server running at %s", name, addr)
<-ctx.Done()
c.logger.Info().Msgf("Stopping %s WebDav server", name)
c.Reset()
return nil
}
func (c *Cache) load(ctx context.Context) (map[string]CachedTorrent, error) {
mu := sync.Mutex{}
if err := os.MkdirAll(c.dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
files, err := os.ReadDir(c.dir)
if err != nil {
return nil, fmt.Errorf("failed to read cache directory: %w", err)
}
// Get only json files
var jsonFiles []os.DirEntry
for _, file := range files {
if !file.IsDir() && filepath.Ext(file.Name()) == ".json" {
jsonFiles = append(jsonFiles, file)
}
}
if len(jsonFiles) == 0 {
return nil, nil
}
// Create channels with appropriate buffering
workChan := make(chan os.DirEntry, min(c.workers, len(jsonFiles)))
// Create a wait group for workers
var wg sync.WaitGroup
torrents := make(map[string]CachedTorrent, len(jsonFiles))
// Start workers
for i := 0; i < c.workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
file, ok := <-workChan
if !ok {
return // Channel closed, exit goroutine
}
fileName := file.Name()
filePath := filepath.Join(c.dir, fileName)
data, err := os.ReadFile(filePath)
if err != nil {
c.logger.Error().Err(err).Msgf("Failed to read file: %s", filePath)
continue
}
var ct CachedTorrent
if err := json.Unmarshal(data, &ct); err != nil {
c.logger.Error().Err(err).Msgf("Failed to unmarshal file: %s", filePath)
continue
}
isComplete := true
if len(ct.GetFiles()) != 0 {
// Check if all files are valid, if not, delete the file.json and remove from cache.
fs := make(map[string]types.File, len(ct.GetFiles()))
for _, f := range ct.GetFiles() {
if f.Link == "" {
isComplete = false
break
}
f.TorrentId = ct.Id
fs[f.Name] = f
}
if isComplete {
if addedOn, err := time.Parse(time.RFC3339, ct.Added); err == nil {
ct.AddedOn = addedOn
}
ct.IsComplete = true
ct.Files = fs
ct.Name = path.Clean(ct.Name)
mu.Lock()
torrents[ct.Id] = ct
mu.Unlock()
}
}
}
}()
}
// Feed work to workers
for _, file := range jsonFiles {
select {
case <-ctx.Done():
break // Context cancelled
default:
workChan <- file
}
}
// Signal workers that no more work is coming
close(workChan)
// Wait for all workers to complete
wg.Wait()
return torrents, nil
}
func (c *Cache) Sync(ctx context.Context) error {
cachedTorrents, err := c.load(ctx)
if err != nil {
c.logger.Error().Err(err).Msg("Failed to load cache")
}
torrents, err := c.client.GetTorrents()
if err != nil {
return fmt.Errorf("failed to sync torrents: %v", err)
}
totalTorrents := len(torrents)
c.logger.Info().Msgf("%d torrents found from %s", totalTorrents, c.client.GetName())
newTorrents := make([]*types.Torrent, 0)
idStore := make(map[string]struct{}, totalTorrents)
for _, t := range torrents {
idStore[t.Id] = struct{}{}
if _, ok := cachedTorrents[t.Id]; !ok {
newTorrents = append(newTorrents, t)
}
}
// Check for deleted torrents
deletedTorrents := make([]string, 0)
for _, t := range cachedTorrents {
if _, ok := idStore[t.Id]; !ok {
deletedTorrents = append(deletedTorrents, t.Id)
}
}
if len(deletedTorrents) > 0 {
c.logger.Info().Msgf("Found %d deleted torrents", len(deletedTorrents))
for _, id := range deletedTorrents {
if _, ok := cachedTorrents[id]; ok {
c.deleteTorrent(id, false) // delete from cache
}
}
}
// Write these torrents to the cache
c.setTorrents(cachedTorrents, func() {
c.listingDebouncer.Call(false)
}) // Initial calls
c.logger.Info().Msgf("Loaded %d torrents from cache", len(cachedTorrents))
if len(newTorrents) > 0 {
c.logger.Info().Msgf("Found %d new torrents", len(newTorrents))
if err := c.sync(ctx, newTorrents); err != nil {
return fmt.Errorf("failed to sync torrents: %v", err)
}
}
return nil
}
func (c *Cache) sync(ctx context.Context, torrents []*types.Torrent) error {
// Create channels with appropriate buffering
workChan := make(chan *types.Torrent, min(c.workers, len(torrents)))
// Use an atomic counter for progress tracking
var processed int64
var errorCount int64
// Create a wait group for workers
var wg sync.WaitGroup
// Start workers
for i := 0; i < c.workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case t, ok := <-workChan:
if !ok {
return // Channel closed, exit goroutine
}
if err := c.ProcessTorrent(t); err != nil {
c.logger.Error().Err(err).Str("torrent", t.Name).Msg("sync error")
atomic.AddInt64(&errorCount, 1)
}
count := atomic.AddInt64(&processed, 1)
if count%1000 == 0 {
c.logger.Info().Msgf("Progress: %d/%d torrents processed", count, len(torrents))
}
case <-ctx.Done():
return // Context cancelled, exit goroutine
}
}
}()
}
// Feed work to workers
for _, t := range torrents {
select {
case workChan <- t:
// Work sent successfully
case <-ctx.Done():
break // Context cancelled
}
}
// Signal workers that no more work is coming
close(workChan)
// Wait for all workers to complete
wg.Wait()
c.listingDebouncer.Call(false) // final refresh
c.logger.Info().Msgf("Sync complete: %d torrents processed, %d errors", len(torrents), errorCount)
return nil
}
func (c *Cache) GetTorrentFolder(torrent *types.Torrent) string {
switch c.folderNaming {
case WebDavUseFileName:
return path.Clean(torrent.Filename)
case WebDavUseOriginalName:
return path.Clean(torrent.OriginalFilename)
case WebDavUseFileNameNoExt:
return path.Clean(utils.RemoveExtension(torrent.Filename))
case WebDavUseOriginalNameNoExt:
return path.Clean(utils.RemoveExtension(torrent.OriginalFilename))
case WebDavUseID:
return torrent.Id
case WebdavUseHash:
return strings.ToLower(torrent.InfoHash)
default:
return path.Clean(torrent.Filename)
}
}
func (c *Cache) setTorrent(t CachedTorrent, callback func(torrent CachedTorrent)) {
torrentName := c.GetTorrentFolder(t.Torrent)
updatedTorrent := t.copy()
if o, ok := c.torrents.getByName(torrentName); ok && o.Id != t.Id {
// If another torrent with the same name exists, merge the files, if the same file exists,
// keep the one with the most recent added date
// Save the most recent torrent
mergedFiles := mergeFiles(o, updatedTorrent) // Useful for merging files across multiple torrents, while keeping the most recent
updatedTorrent.Files = mergedFiles
}
c.torrents.set(torrentName, t, updatedTorrent)
c.SaveTorrent(t)
if callback != nil {
callback(updatedTorrent)
}
}
func (c *Cache) setTorrents(torrents map[string]CachedTorrent, callback func()) {
for _, t := range torrents {
torrentName := c.GetTorrentFolder(t.Torrent)
updatedTorrent := t.copy()
if o, ok := c.torrents.getByName(torrentName); ok && o.Id != t.Id {
// Save the most recent torrent
mergedFiles := mergeFiles(o, updatedTorrent)
updatedTorrent.Files = mergedFiles
}
c.torrents.set(torrentName, t, updatedTorrent)
}
c.SaveTorrents()
if callback != nil {
callback()
}
}
// GetListing returns a sorted list of torrents(READ-ONLY)
func (c *Cache) GetListing(folder string) []os.FileInfo {
switch folder {
case "__all__", "torrents":
return c.torrents.getListing()
default:
return c.torrents.getFolderListing(folder)
}
}
func (c *Cache) GetCustomFolders() []string {
return c.customFolders
}
func (c *Cache) Close() error {
return nil
}
func (c *Cache) GetTorrents() map[string]CachedTorrent {
return c.torrents.getAll()
}
func (c *Cache) GetTorrentByName(name string) *CachedTorrent {
if torrent, ok := c.torrents.getByName(name); ok {
return &torrent
}
return nil
}
func (c *Cache) GetTorrent(torrentId string) *CachedTorrent {
if torrent, ok := c.torrents.getByID(torrentId); ok {
return &torrent
}
return nil
}
func (c *Cache) SaveTorrents() {
torrents := c.torrents.getAll()
for _, torrent := range torrents {
c.SaveTorrent(torrent)
}
}
func (c *Cache) SaveTorrent(ct CachedTorrent) {
marshaled, err := json.MarshalIndent(ct, "", " ")
if err != nil {
c.logger.Error().Err(err).Msgf("Failed to marshal torrent: %s", ct.Id)
return
}
// Store just the essential info needed for the file operation
saveInfo := struct {
id string
jsonData []byte
}{
id: ct.Torrent.Id,
jsonData: marshaled,
}
// Try to acquire semaphore without blocking
select {
case c.saveSemaphore <- struct{}{}:
go func() {
defer func() { <-c.saveSemaphore }()
c.saveTorrent(saveInfo.id, saveInfo.jsonData)
}()
default:
c.saveTorrent(saveInfo.id, saveInfo.jsonData)
}
}
func (c *Cache) saveTorrent(id string, data []byte) {
fileName := id + ".json"
filePath := filepath.Join(c.dir, fileName)
// Use a unique temporary filename for concurrent safety
tmpFile := filePath + ".tmp." + strconv.FormatInt(time.Now().UnixNano(), 10)
f, err := os.Create(tmpFile)
if err != nil {
c.logger.Error().Err(err).Msgf("Failed to create file: %s", tmpFile)
return
}
// Track if we've closed the file
fileClosed := false
defer func() {
// Only close if not already closed
if !fileClosed {
_ = f.Close()
}
// Clean up the temp file if it still exists and rename failed
_ = os.Remove(tmpFile)
}()
w := bufio.NewWriter(f)
if _, err := w.Write(data); err != nil {
c.logger.Error().Err(err).Msgf("Failed to write data: %s", tmpFile)
return
}
if err := w.Flush(); err != nil {
c.logger.Error().Err(err).Msgf("Failed to flush data: %s", tmpFile)
return
}
// Close the file before renaming
_ = f.Close()
fileClosed = true
if err := os.Rename(tmpFile, filePath); err != nil {
c.logger.Error().Err(err).Msgf("Failed to rename file: %s", tmpFile)
return
}
}
func (c *Cache) ProcessTorrent(t *types.Torrent) error {
isComplete := func(files map[string]types.File) bool {
_complete := len(files) > 0
for _, file := range files {
if file.Link == "" {
_complete = false
break
}
}
return _complete
}
if !isComplete(t.Files) {
if err := c.client.UpdateTorrent(t); err != nil {
return fmt.Errorf("failed to update torrent: %w", err)
}
}
if !isComplete(t.Files) {
c.logger.Debug().Msgf("Torrent %s is still not complete. Triggering a reinsert(disabled)", t.Id)
} else {
addedOn, err := time.Parse(time.RFC3339, t.Added)
if err != nil {
addedOn = time.Now()
}
ct := CachedTorrent{
Torrent: t,
IsComplete: len(t.Files) > 0,
AddedOn: addedOn,
}
c.setTorrent(ct, func(tor CachedTorrent) {
c.listingDebouncer.Call(false)
})
}
return nil
}
func (c *Cache) Add(t *types.Torrent) error {
if len(t.Files) == 0 {
if err := c.client.UpdateTorrent(t); err != nil {
return fmt.Errorf("failed to update torrent: %w", err)
}
}
addedOn, err := time.Parse(time.RFC3339, t.Added)
if err != nil {
addedOn = time.Now()
}
ct := CachedTorrent{
Torrent: t,
IsComplete: len(t.Files) > 0,
AddedOn: addedOn,
}
c.setTorrent(ct, func(tor CachedTorrent) {
c.RefreshListings(true)
})
go c.GenerateDownloadLinks(ct)
return nil
}
func (c *Cache) GetClient() types.Client {
return c.client
}
func (c *Cache) DeleteTorrent(id string) error {
c.torrentsRefreshMu.Lock()
defer c.torrentsRefreshMu.Unlock()
if c.deleteTorrent(id, true) {
go c.RefreshListings(true)
return nil
}
return nil
}
func (c *Cache) validateAndDeleteTorrents(torrents []string) {
wg := sync.WaitGroup{}
for _, torrent := range torrents {
wg.Add(1)
go func(t string) {
defer wg.Done()
// Check if torrent is truly deleted
if _, err := c.client.GetTorrent(t); err != nil {
c.deleteTorrent(t, false) // Since it's removed from debrid already
}
}(torrent)
}
wg.Wait()
c.listingDebouncer.Call(true)
}
// deleteTorrent deletes the torrent from the cache and debrid service
// It also handles torrents with the same name but different IDs
func (c *Cache) deleteTorrent(id string, removeFromDebrid bool) bool {
if torrent, ok := c.torrents.getByID(id); ok {
c.torrents.removeId(id) // Delete id from cache
defer func() {
c.removeFromDB(id)
if removeFromDebrid {
_ = c.client.DeleteTorrent(id) // Skip error handling, we don't care if it fails
}
}() // defer delete from debrid
torrentName := torrent.Name
if t, ok := c.torrents.getByName(torrentName); ok {
newFiles := map[string]types.File{}
newId := ""
for _, file := range t.GetFiles() {
if file.TorrentId != "" && file.TorrentId != id {
if newId == "" && file.TorrentId != "" {
newId = file.TorrentId
}
newFiles[file.Name] = file
}
}
if len(newFiles) == 0 {
// Delete the torrent since no files are left
c.torrents.remove(torrentName)
} else {
t.Files = newFiles
newId = cmp.Or(newId, t.Id)
t.Id = newId
c.setTorrent(t, nil) // This gets called after calling deleteTorrent
}
}
return true
}
return false
}
func (c *Cache) DeleteTorrents(ids []string) {
c.logger.Info().Msgf("Deleting %d torrents", len(ids))
for _, id := range ids {
_ = c.deleteTorrent(id, true)
}
c.listingDebouncer.Call(true)
}
func (c *Cache) removeFromDB(torrentId string) {
// Moves the torrent file to the trash
filePath := filepath.Join(c.dir, torrentId+".json")
// Check if the file exists
if _, err := os.Stat(filePath); errors.Is(err, os.ErrNotExist) {
return
}
// Move the file to the trash
trashPath := filepath.Join(c.dir, "trash", torrentId+".json")
if err := os.MkdirAll(filepath.Dir(trashPath), 0755); err != nil {
return
}
if err := os.Rename(filePath, trashPath); err != nil {
return
}
}
func (c *Cache) OnRemove(torrentId string) {
c.logger.Debug().Msgf("OnRemove triggered for %s", torrentId)
err := c.DeleteTorrent(torrentId)
if err != nil {
c.logger.Error().Err(err).Msgf("Failed to delete torrent: %s", torrentId)
return
}
}
// RemoveFile removes a file from the torrent cache
// TODO sends a re-insert that removes the file from debrid
func (c *Cache) RemoveFile(torrentId string, filename string) error {
c.logger.Debug().Str("torrent_id", torrentId).Msgf("Removing file %s", filename)
torrent, ok := c.torrents.getByID(torrentId)
if !ok {
return fmt.Errorf("torrent %s not found", torrentId)
}
file, ok := torrent.GetFile(filename)
if !ok {
return fmt.Errorf("file %s not found in torrent %s", filename, torrentId)
}
file.Deleted = true
torrent.Files[filename] = file
// If the torrent has no files left, delete it
if len(torrent.GetFiles()) == 0 {
c.logger.Debug().Msgf("Torrent %s has no files left, deleting it", torrentId)
if err := c.DeleteTorrent(torrentId); err != nil {
return fmt.Errorf("failed to delete torrent %s: %w", torrentId, err)
}
return nil
}
c.setTorrent(torrent, func(torrent CachedTorrent) {
c.listingDebouncer.Call(true)
}) // Update the torrent in the cache
return nil
}
func (c *Cache) GetLogger() zerolog.Logger {
return c.logger
}

View File

@@ -0,0 +1,257 @@
package store
import (
"errors"
"fmt"
"github.com/sirrobot01/decypharr/pkg/debrid/types"
"sync"
"time"
"github.com/sirrobot01/decypharr/internal/request"
)
type linkCache struct {
Id string
link string
accountId string
expiresAt time.Time
}
type downloadLinkCache struct {
data map[string]linkCache
mu sync.Mutex
}
func newDownloadLinkCache() *downloadLinkCache {
return &downloadLinkCache{
data: make(map[string]linkCache),
}
}
func (c *downloadLinkCache) reset() {
c.mu.Lock()
c.data = make(map[string]linkCache)
c.mu.Unlock()
}
func (c *downloadLinkCache) Load(key string) (linkCache, bool) {
c.mu.Lock()
defer c.mu.Unlock()
dl, ok := c.data[key]
return dl, ok
}
func (c *downloadLinkCache) Store(key string, value linkCache) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
func (c *downloadLinkCache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.data, key)
}
func (c *downloadLinkCache) Len() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.data)
}
type downloadLinkRequest struct {
result string
err error
done chan struct{}
}
func newDownloadLinkRequest() *downloadLinkRequest {
return &downloadLinkRequest{
done: make(chan struct{}),
}
}
func (r *downloadLinkRequest) Complete(result string, err error) {
r.result = result
r.err = err
close(r.done)
}
func (r *downloadLinkRequest) Wait() (string, error) {
<-r.done
return r.result, r.err
}
func (c *Cache) GetDownloadLink(torrentName, filename, fileLink string) (string, error) {
// Check link cache
if dl := c.checkDownloadLink(fileLink); dl != "" {
return dl, nil
}
if req, inFlight := c.downloadLinkRequests.Load(fileLink); inFlight {
// Wait for the other request to complete and use its result
result := req.(*downloadLinkRequest)
return result.Wait()
}
// Create a new request object
req := newDownloadLinkRequest()
c.downloadLinkRequests.Store(fileLink, req)
downloadLink, err := c.fetchDownloadLink(torrentName, filename, fileLink)
// Complete the request and remove it from the map
req.Complete(downloadLink, err)
c.downloadLinkRequests.Delete(fileLink)
return downloadLink, err
}
func (c *Cache) fetchDownloadLink(torrentName, filename, fileLink string) (string, error) {
ct := c.GetTorrentByName(torrentName)
if ct == nil {
return "", fmt.Errorf("torrent not found")
}
file, ok := ct.GetFile(filename)
if !ok {
return "", fmt.Errorf("file %s not found in torrent %s", filename, torrentName)
}
if file.Link == "" {
// file link is empty, refresh the torrent to get restricted links
ct = c.refreshTorrent(file.TorrentId) // Refresh the torrent from the debrid
if ct == nil {
return "", fmt.Errorf("failed to refresh torrent")
} else {
file, ok = ct.GetFile(filename)
if !ok {
return "", fmt.Errorf("file %s not found in refreshed torrent %s", filename, torrentName)
}
}
}
// If file.Link is still empty, return
if file.Link == "" {
// Try to reinsert the torrent?
newCt, err := c.reInsertTorrent(ct)
if err != nil {
return "", fmt.Errorf("failed to reinsert torrent. %w", err)
}
ct = newCt
file, ok = ct.GetFile(filename)
if !ok {
return "", fmt.Errorf("file %s not found in reinserted torrent %s", filename, torrentName)
}
}
c.logger.Trace().Msgf("Getting download link for %s(%s)", filename, file.Link)
downloadLink, err := c.client.GetDownloadLink(ct.Torrent, &file)
if err != nil {
if errors.Is(err, request.HosterUnavailableError) {
newCt, err := c.reInsertTorrent(ct)
if err != nil {
return "", fmt.Errorf("failed to reinsert torrent: %w", err)
}
ct = newCt
file, ok = ct.GetFile(filename)
if !ok {
return "", fmt.Errorf("file %s not found in reinserted torrent %s", filename, torrentName)
}
// Retry getting the download link
downloadLink, err = c.client.GetDownloadLink(ct.Torrent, &file)
if err != nil {
return "", err
}
if downloadLink == nil {
return "", fmt.Errorf("download link is empty for")
}
c.updateDownloadLink(downloadLink)
return "", nil
} else if errors.Is(err, request.TrafficExceededError) {
// This is likely a fair usage limit error
return "", err
} else {
return "", fmt.Errorf("failed to get download link: %w", err)
}
}
if downloadLink == nil {
return "", fmt.Errorf("download link is empty")
}
c.updateDownloadLink(downloadLink)
return downloadLink.DownloadLink, nil
}
func (c *Cache) GenerateDownloadLinks(t CachedTorrent) {
if err := c.client.GenerateDownloadLinks(t.Torrent); err != nil {
c.logger.Error().Err(err).Str("torrent", t.Name).Msg("Failed to generate download links")
return
}
for _, file := range t.GetFiles() {
if file.DownloadLink != nil {
c.updateDownloadLink(file.DownloadLink)
}
}
c.setTorrent(t, nil)
}
func (c *Cache) updateDownloadLink(dl *types.DownloadLink) {
c.downloadLinks.Store(dl.Link, linkCache{
Id: dl.Id,
link: dl.DownloadLink,
expiresAt: time.Now().Add(c.autoExpiresLinksAfterDuration),
accountId: dl.AccountId,
})
}
func (c *Cache) checkDownloadLink(link string) string {
if dl, ok := c.downloadLinks.Load(link); ok {
if dl.expiresAt.After(time.Now()) && !c.IsDownloadLinkInvalid(dl.link) {
return dl.link
}
}
return ""
}
func (c *Cache) MarkDownloadLinkAsInvalid(link, downloadLink, reason string) {
c.invalidDownloadLinks.Store(downloadLink, reason)
// Remove the download api key from active
if reason == "bandwidth_exceeded" {
if dl, ok := c.downloadLinks.Load(link); ok {
if dl.accountId != "" && dl.link == downloadLink {
c.client.DisableAccount(dl.accountId)
}
}
}
c.removeDownloadLink(link)
}
func (c *Cache) removeDownloadLink(link string) {
if dl, ok := c.downloadLinks.Load(link); ok {
// Delete dl from cache
c.downloadLinks.Delete(link)
// Delete dl from debrid
if dl.Id != "" {
_ = c.client.DeleteDownloadLink(dl.Id)
}
}
}
func (c *Cache) IsDownloadLinkInvalid(downloadLink string) bool {
if reason, ok := c.invalidDownloadLinks.Load(downloadLink); ok {
c.logger.Debug().Msgf("Download link %s is invalid: %s", downloadLink, reason)
return true
}
return false
}
func (c *Cache) GetDownloadByteRange(torrentName, filename string) (*[2]int64, error) {
ct := c.GetTorrentByName(torrentName)
if ct == nil {
return nil, fmt.Errorf("torrent not found")
}
file := ct.Files[filename]
return file.ByteRange, nil
}
func (c *Cache) GetTotalActiveDownloadLinks() int {
return c.downloadLinks.Len()
}

42
pkg/debrid/store/misc.go Normal file
View File

@@ -0,0 +1,42 @@
package store
import (
"github.com/sirrobot01/decypharr/pkg/debrid/types"
"sort"
)
// MergeFiles merges the files from multiple torrents into a single map.
// It uses the file name as the key and the file object as the value.
// This is useful for deduplicating files across multiple torrents.
// The order of the torrents is determined by the AddedOn time, with the earliest added torrent first.
// If a file with the same name exists in multiple torrents, the last one will be used.
func mergeFiles(torrents ...CachedTorrent) map[string]types.File {
merged := make(map[string]types.File)
// order torrents by added time
sort.Slice(torrents, func(i, j int) bool {
return torrents[i].AddedOn.Before(torrents[j].AddedOn)
})
for _, torrent := range torrents {
for _, file := range torrent.GetFiles() {
merged[file.Name] = file
}
}
return merged
}
func (c *Cache) GetIngests() ([]types.IngestData, error) {
torrents := c.GetTorrents()
debridName := c.client.GetName()
var ingests []types.IngestData
for _, torrent := range torrents {
ingests = append(ingests, types.IngestData{
Debrid: debridName,
Name: torrent.Filename,
Hash: torrent.InfoHash,
Size: torrent.Bytes,
})
}
return ingests, nil
}

267
pkg/debrid/store/refresh.go Normal file
View File

@@ -0,0 +1,267 @@
package store
import (
"context"
"fmt"
"github.com/sirrobot01/decypharr/pkg/debrid/types"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
)
type fileInfo struct {
id string
name string
size int64
mode os.FileMode
modTime time.Time
isDir bool
}
func (fi *fileInfo) Name() string { return fi.name }
func (fi *fileInfo) Size() int64 { return fi.size }
func (fi *fileInfo) Mode() os.FileMode { return fi.mode }
func (fi *fileInfo) ModTime() time.Time { return fi.modTime }
func (fi *fileInfo) IsDir() bool { return fi.isDir }
func (fi *fileInfo) ID() string { return fi.id }
func (fi *fileInfo) Sys() interface{} { return nil }
func (c *Cache) RefreshListings(refreshRclone bool) {
// Copy the torrents to a string|time map
c.torrents.refreshListing() // refresh torrent listings
if refreshRclone {
if err := c.refreshRclone(); err != nil {
c.logger.Error().Err(err).Msg("Failed to refresh rclone") // silent error
}
}
}
func (c *Cache) refreshTorrents(ctx context.Context) {
select {
case <-ctx.Done():
return
default:
}
if !c.torrentsRefreshMu.TryLock() {
return
}
defer c.torrentsRefreshMu.Unlock()
// Get all torrents from the debrid service
debTorrents, err := c.client.GetTorrents()
if err != nil {
c.logger.Error().Err(err).Msg("Failed to get torrents")
return
}
if len(debTorrents) == 0 {
// Maybe an error occurred
return
}
currentTorrentIds := make(map[string]struct{}, len(debTorrents))
for _, t := range debTorrents {
currentTorrentIds[t.Id] = struct{}{}
}
// Let's implement deleting torrents removed from debrid
deletedTorrents := make([]string, 0)
cachedTorrents := c.torrents.getIdMaps()
for id := range cachedTorrents {
if _, exists := currentTorrentIds[id]; !exists {
deletedTorrents = append(deletedTorrents, id)
}
}
if len(deletedTorrents) > 0 {
go c.validateAndDeleteTorrents(deletedTorrents)
}
newTorrents := make([]*types.Torrent, 0)
for _, t := range debTorrents {
if _, exists := cachedTorrents[t.Id]; !exists {
newTorrents = append(newTorrents, t)
}
}
if len(newTorrents) == 0 {
return
}
c.logger.Trace().Msgf("Found %d new torrents", len(newTorrents))
workChan := make(chan *types.Torrent, min(100, len(newTorrents)))
errChan := make(chan error, len(newTorrents))
var wg sync.WaitGroup
counter := 0
for i := 0; i < c.workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for t := range workChan {
if err := c.ProcessTorrent(t); err != nil {
c.logger.Error().Err(err).Msgf("Failed to process new torrent %s", t.Id)
errChan <- err
}
counter++
}
}()
}
for _, t := range newTorrents {
workChan <- t
}
close(workChan)
wg.Wait()
c.listingDebouncer.Call(false)
c.logger.Debug().Msgf("Processed %d new torrents", counter)
}
func (c *Cache) refreshRclone() error {
cfg := c.config
if cfg.RcUrl == "" {
return nil
}
if cfg.RcUrl == "" {
return nil
}
client := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
DisableCompression: false,
MaxIdleConnsPerHost: 5,
},
}
// Create form data
data := ""
dirs := strings.FieldsFunc(cfg.RcRefreshDirs, func(r rune) bool {
return r == ',' || r == '&'
})
if len(dirs) == 0 {
data = "dir=__all__"
} else {
for index, dir := range dirs {
if dir != "" {
if index == 0 {
data += "dir=" + dir
} else {
data += "&dir" + fmt.Sprint(index+1) + "=" + dir
}
}
}
}
sendRequest := func(endpoint string) error {
req, err := http.NewRequest("POST", fmt.Sprintf("%s/%s", cfg.RcUrl, endpoint), strings.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if cfg.RcUser != "" && cfg.RcPass != "" {
req.SetBasicAuth(cfg.RcUser, cfg.RcPass)
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("failed to perform %s: %s - %s", endpoint, resp.Status, string(body))
}
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
if err := sendRequest("vfs/forget"); err != nil {
return err
}
if err := sendRequest("vfs/refresh"); err != nil {
return err
}
return nil
}
func (c *Cache) refreshTorrent(torrentId string) *CachedTorrent {
if torrentId == "" {
c.logger.Error().Msg("Torrent ID is empty")
return nil
}
torrent, err := c.client.GetTorrent(torrentId)
if err != nil {
c.logger.Error().Err(err).Msgf("Failed to get torrent %s", torrentId)
return nil
}
addedOn, err := time.Parse(time.RFC3339, torrent.Added)
if err != nil {
addedOn = time.Now()
}
ct := CachedTorrent{
Torrent: torrent,
AddedOn: addedOn,
IsComplete: len(torrent.Files) > 0,
}
c.setTorrent(ct, func(torrent CachedTorrent) {
go c.listingDebouncer.Call(true)
})
return &ct
}
func (c *Cache) refreshDownloadLinks(ctx context.Context) {
select {
case <-ctx.Done():
return
default:
}
if !c.downloadLinksRefreshMu.TryLock() {
return
}
defer c.downloadLinksRefreshMu.Unlock()
downloadLinks, err := c.client.GetDownloads()
if err != nil {
c.logger.Error().Err(err).Msg("Failed to get download links")
return
}
for k, v := range downloadLinks {
// if link is generated in the last 24 hours, add it to cache
timeSince := time.Since(v.Generated)
if timeSince < c.autoExpiresLinksAfterDuration {
c.downloadLinks.Store(k, linkCache{
Id: v.Id,
accountId: v.AccountId,
link: v.DownloadLink,
expiresAt: v.Generated.Add(c.autoExpiresLinksAfterDuration - timeSince),
})
} else {
c.downloadLinks.Delete(k)
}
}
c.logger.Trace().Msgf("Refreshed %d download links", len(downloadLinks))
}

257
pkg/debrid/store/repair.go Normal file
View File

@@ -0,0 +1,257 @@
package store
import (
"context"
"errors"
"fmt"
"github.com/sirrobot01/decypharr/internal/request"
"github.com/sirrobot01/decypharr/internal/utils"
"github.com/sirrobot01/decypharr/pkg/debrid/types"
"sync"
"time"
)
type reInsertRequest struct {
result *CachedTorrent
err error
done chan struct{}
}
func newReInsertRequest() *reInsertRequest {
return &reInsertRequest{
done: make(chan struct{}),
}
}
func (r *reInsertRequest) Complete(result *CachedTorrent, err error) {
r.result = result
r.err = err
close(r.done)
}
func (r *reInsertRequest) Wait() (*CachedTorrent, error) {
<-r.done
return r.result, r.err
}
func (c *Cache) markAsFailedToReinsert(torrentId string) {
c.failedToReinsert.Store(torrentId, struct{}{})
// Remove the torrent from the directory if it has failed to reinsert, max retries are hardcoded to 5
if torrent, ok := c.torrents.getByID(torrentId); ok {
torrent.Bad = true
c.setTorrent(torrent, func(t CachedTorrent) {
c.RefreshListings(false)
})
}
}
func (c *Cache) markAsSuccessfullyReinserted(torrentId string) {
if _, ok := c.failedToReinsert.Load(torrentId); !ok {
return
}
c.failedToReinsert.Delete(torrentId)
if torrent, ok := c.torrents.getByID(torrentId); ok {
torrent.Bad = false
c.setTorrent(torrent, func(torrent CachedTorrent) {
c.RefreshListings(false)
})
}
}
func (c *Cache) GetBrokenFiles(t *CachedTorrent, filenames []string) []string {
files := make(map[string]types.File)
brokenFiles := make([]string, 0)
if len(filenames) > 0 {
for name, f := range t.Files {
if utils.Contains(filenames, name) {
files[name] = f
}
}
} else {
files = t.Files
}
for _, f := range files {
// Check if file is missing
if f.Link == "" {
// refresh torrent and then break
if newT := c.refreshTorrent(f.TorrentId); newT != nil {
t = newT
} else {
c.logger.Error().Str("torrentId", t.Torrent.Id).Msg("Failed to refresh torrent")
return filenames // Return original filenames if refresh fails(torrent is somehow botched)
}
}
}
if t.Torrent == nil {
c.logger.Error().Str("torrentId", t.Torrent.Id).Msg("Failed to refresh torrent")
return filenames // Return original filenames if refresh fails(torrent is somehow botched)
}
files = t.Files
for _, f := range files {
// Check if file link is still missing
if f.Link == "" {
brokenFiles = append(brokenFiles, f.Name)
} else {
// Check if file.Link not in the downloadLink Cache
if err := c.client.CheckLink(f.Link); err != nil {
if errors.Is(err, request.HosterUnavailableError) {
brokenFiles = append(brokenFiles, f.Name)
}
}
}
}
// Try to reinsert the torrent if it's broken
if len(brokenFiles) > 0 && t.Torrent != nil {
// Check if the torrent is already in progress
if _, err := c.reInsertTorrent(t); err != nil {
c.logger.Error().Err(err).Str("torrentId", t.Torrent.Id).Msg("Failed to reinsert torrent")
return brokenFiles // Return broken files if reinsert fails
}
return nil // Return nil if the torrent was successfully reinserted
}
return brokenFiles
}
func (c *Cache) repairWorker(ctx context.Context) {
// This watches a channel for torrents to repair and can be cancelled via context
for {
select {
case <-ctx.Done():
return
case req, ok := <-c.repairChan:
// Channel was closed
if !ok {
c.logger.Debug().Msg("Repair channel closed, shutting down worker")
return
}
torrentId := req.TorrentID
c.logger.Debug().Str("torrentId", req.TorrentID).Msg("Received repair request")
// Get the torrent from the cache
cachedTorrent := c.GetTorrent(torrentId)
if cachedTorrent == nil {
c.logger.Warn().Str("torrentId", torrentId).Msg("Torrent not found in cache")
continue
}
switch req.Type {
case RepairTypeReinsert:
c.logger.Debug().Str("torrentId", torrentId).Msg("Reinserting torrent")
if _, err := c.reInsertTorrent(cachedTorrent); err != nil {
c.logger.Error().Err(err).Str("torrentId", cachedTorrent.Id).Msg("Failed to reinsert torrent")
continue
}
case RepairTypeDelete:
c.logger.Debug().Str("torrentId", torrentId).Msg("Deleting torrent")
if err := c.DeleteTorrent(torrentId); err != nil {
c.logger.Error().Err(err).Str("torrentId", torrentId).Msg("Failed to delete torrent")
continue
}
}
}
}
}
func (c *Cache) reInsertTorrent(ct *CachedTorrent) (*CachedTorrent, error) {
// Check if Magnet is not empty, if empty, reconstruct the magnet
torrent := ct.Torrent
oldID := torrent.Id // Store the old ID
if _, ok := c.failedToReinsert.Load(oldID); ok {
return ct, fmt.Errorf("can't retry re-insert for %s", torrent.Id)
}
if reqI, inFlight := c.repairRequest.Load(oldID); inFlight {
req := reqI.(*reInsertRequest)
c.logger.Debug().Msgf("Waiting for existing reinsert request to complete for torrent %s", oldID)
return req.Wait()
}
req := newReInsertRequest()
c.repairRequest.Store(oldID, req)
// Make sure we clean up even if there's a panic
defer func() {
c.repairRequest.Delete(oldID)
}()
// Submit the magnet to the debrid service
newTorrent := &types.Torrent{
Name: torrent.Name,
Magnet: utils.ConstructMagnet(torrent.InfoHash, torrent.Name),
InfoHash: torrent.InfoHash,
Size: torrent.Size,
Files: make(map[string]types.File),
Arr: torrent.Arr,
}
var err error
newTorrent, err = c.client.SubmitMagnet(newTorrent)
if err != nil {
c.markAsFailedToReinsert(oldID)
// Remove the old torrent from the cache and debrid service
return ct, fmt.Errorf("failed to submit magnet: %w", err)
}
// Check if the torrent was submitted
if newTorrent == nil || newTorrent.Id == "" {
c.markAsFailedToReinsert(oldID)
return ct, fmt.Errorf("failed to submit magnet: empty torrent")
}
newTorrent.DownloadUncached = false // Set to false, avoid re-downloading
newTorrent, err = c.client.CheckStatus(newTorrent, true)
if err != nil {
if newTorrent != nil && newTorrent.Id != "" {
// Delete the torrent if it was not downloaded
_ = c.client.DeleteTorrent(newTorrent.Id)
}
c.markAsFailedToReinsert(oldID)
return ct, err
}
// Update the torrent in the cache
addedOn, err := time.Parse(time.RFC3339, newTorrent.Added)
if err != nil {
addedOn = time.Now()
}
for _, f := range newTorrent.GetFiles() {
if f.Link == "" {
c.markAsFailedToReinsert(oldID)
return ct, fmt.Errorf("failed to reinsert torrent: empty link")
}
}
// Set torrent to newTorrent
newCt := CachedTorrent{
Torrent: newTorrent,
AddedOn: addedOn,
IsComplete: len(newTorrent.Files) > 0,
}
c.setTorrent(newCt, func(torrent CachedTorrent) {
c.RefreshListings(true)
})
ct = &newCt // Update ct to point to the new torrent
// We can safely delete the old torrent here
if oldID != "" {
if err := c.DeleteTorrent(oldID); err != nil {
return ct, fmt.Errorf("failed to delete old torrent: %w", err)
}
}
req.Complete(ct, err)
c.markAsSuccessfullyReinserted(oldID)
c.logger.Debug().Str("torrentId", torrent.Id).Msg("Torrent successfully reinserted")
return ct, nil
}
func (c *Cache) resetInvalidLinks() {
c.invalidDownloadLinks = sync.Map{}
c.client.ResetActiveDownloadKeys() // Reset the active download keys
}

298
pkg/debrid/store/torrent.go Normal file
View File

@@ -0,0 +1,298 @@
package store
import (
"fmt"
"os"
"regexp"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
const (
filterByInclude string = "include"
filterByExclude string = "exclude"
filterByStartsWith string = "starts_with"
filterByEndsWith string = "ends_with"
filterByNotStartsWith string = "not_starts_with"
filterByNotEndsWith string = "not_ends_with"
filterByRegex string = "regex"
filterByNotRegex string = "not_regex"
filterByExactMatch string = "exact_match"
filterByNotExactMatch string = "not_exact_match"
filterBySizeGT string = "size_gt"
filterBySizeLT string = "size_lt"
filterBLastAdded string = "last_added"
)
type directoryFilter struct {
filterType string
value string
regex *regexp.Regexp // only for regex/not_regex
sizeThreshold int64 // only for size_gt/size_lt
ageThreshold time.Duration // only for last_added
}
type torrentCache struct {
mu sync.Mutex
byID map[string]CachedTorrent
byName map[string]CachedTorrent
listing atomic.Value
folderListing map[string][]os.FileInfo
folderListingMu sync.RWMutex
directoriesFilters map[string][]directoryFilter
sortNeeded atomic.Bool
}
type sortableFile struct {
id string
name string
modTime time.Time
size int64
bad bool
}
func newTorrentCache(dirFilters map[string][]directoryFilter) *torrentCache {
tc := &torrentCache{
byID: make(map[string]CachedTorrent),
byName: make(map[string]CachedTorrent),
folderListing: make(map[string][]os.FileInfo),
directoriesFilters: dirFilters,
}
tc.sortNeeded.Store(false)
tc.listing.Store(make([]os.FileInfo, 0))
return tc
}
func (tc *torrentCache) reset() {
tc.mu.Lock()
tc.byID = make(map[string]CachedTorrent)
tc.byName = make(map[string]CachedTorrent)
tc.mu.Unlock()
// reset the sorted listing
tc.sortNeeded.Store(false)
tc.listing.Store(make([]os.FileInfo, 0))
// reset any per-folder views
tc.folderListingMu.Lock()
tc.folderListing = make(map[string][]os.FileInfo)
tc.folderListingMu.Unlock()
}
func (tc *torrentCache) getByID(id string) (CachedTorrent, bool) {
tc.mu.Lock()
defer tc.mu.Unlock()
torrent, exists := tc.byID[id]
return torrent, exists
}
func (tc *torrentCache) getByName(name string) (CachedTorrent, bool) {
tc.mu.Lock()
defer tc.mu.Unlock()
torrent, exists := tc.byName[name]
return torrent, exists
}
func (tc *torrentCache) set(name string, torrent, newTorrent CachedTorrent) {
tc.mu.Lock()
// Set the id first
tc.byID[newTorrent.Id] = torrent // This is the unadulterated torrent
tc.byName[name] = newTorrent // This is likely the modified torrent
tc.mu.Unlock()
tc.sortNeeded.Store(true)
}
func (tc *torrentCache) getListing() []os.FileInfo {
// Fast path: if we have a sorted list and no changes since last sort
if !tc.sortNeeded.Load() {
return tc.listing.Load().([]os.FileInfo)
}
// Slow path: need to sort
tc.refreshListing()
return tc.listing.Load().([]os.FileInfo)
}
func (tc *torrentCache) getFolderListing(folderName string) []os.FileInfo {
tc.folderListingMu.RLock()
defer tc.folderListingMu.RUnlock()
if folderName == "" {
return tc.getListing()
}
if folder, ok := tc.folderListing[folderName]; ok {
return folder
}
// If folder not found, return empty slice
return []os.FileInfo{}
}
func (tc *torrentCache) refreshListing() {
tc.mu.Lock()
all := make([]sortableFile, 0, len(tc.byName))
for name, t := range tc.byName {
all = append(all, sortableFile{t.Id, name, t.AddedOn, t.Bytes, t.Bad})
}
tc.sortNeeded.Store(false)
tc.mu.Unlock()
sort.Slice(all, func(i, j int) bool {
if all[i].name != all[j].name {
return all[i].name < all[j].name
}
return all[i].modTime.Before(all[j].modTime)
})
wg := sync.WaitGroup{}
wg.Add(1) // for all listing
go func() {
listing := make([]os.FileInfo, len(all))
for i, sf := range all {
listing[i] = &fileInfo{sf.id, sf.name, sf.size, 0755 | os.ModeDir, sf.modTime, true}
}
tc.listing.Store(listing)
}()
wg.Done()
wg.Add(1)
// For __bad__
go func() {
listing := make([]os.FileInfo, 0)
for _, sf := range all {
if sf.bad {
listing = append(listing, &fileInfo{
id: sf.id,
name: fmt.Sprintf("%s || %s", sf.name, sf.id),
size: sf.size,
mode: 0755 | os.ModeDir,
modTime: sf.modTime,
isDir: true,
})
}
}
tc.folderListingMu.Lock()
if len(listing) > 0 {
tc.folderListing["__bad__"] = listing
} else {
delete(tc.folderListing, "__bad__")
}
tc.folderListingMu.Unlock()
}()
wg.Done()
now := time.Now()
wg.Add(len(tc.directoriesFilters)) // for each directory filter
for dir, filters := range tc.directoriesFilters {
go func(dir string, filters []directoryFilter) {
defer wg.Done()
var matched []os.FileInfo
for _, sf := range all {
if tc.torrentMatchDirectory(filters, sf, now) {
matched = append(matched, &fileInfo{
id: sf.id,
name: sf.name, size: sf.size,
mode: 0755 | os.ModeDir, modTime: sf.modTime, isDir: true,
})
}
}
tc.folderListingMu.Lock()
if len(matched) > 0 {
tc.folderListing[dir] = matched
} else {
delete(tc.folderListing, dir)
}
tc.folderListingMu.Unlock()
}(dir, filters)
}
wg.Wait()
}
func (tc *torrentCache) torrentMatchDirectory(filters []directoryFilter, file sortableFile, now time.Time) bool {
torrentName := strings.ToLower(file.name)
for _, filter := range filters {
matched := false
switch filter.filterType {
case filterByInclude:
matched = strings.Contains(torrentName, filter.value)
case filterByStartsWith:
matched = strings.HasPrefix(torrentName, filter.value)
case filterByEndsWith:
matched = strings.HasSuffix(torrentName, filter.value)
case filterByExactMatch:
matched = torrentName == filter.value
case filterByExclude:
matched = !strings.Contains(torrentName, filter.value)
case filterByNotStartsWith:
matched = !strings.HasPrefix(torrentName, filter.value)
case filterByNotEndsWith:
matched = !strings.HasSuffix(torrentName, filter.value)
case filterByRegex:
matched = filter.regex.MatchString(torrentName)
case filterByNotRegex:
matched = !filter.regex.MatchString(torrentName)
case filterByNotExactMatch:
matched = torrentName != filter.value
case filterBySizeGT:
matched = file.size > filter.sizeThreshold
case filterBySizeLT:
matched = file.size < filter.sizeThreshold
case filterBLastAdded:
matched = file.modTime.After(now.Add(-filter.ageThreshold))
}
if !matched {
return false // All filters must match
}
}
// If we get here, all filters matched
return true
}
func (tc *torrentCache) getAll() map[string]CachedTorrent {
tc.mu.Lock()
defer tc.mu.Unlock()
result := make(map[string]CachedTorrent)
for name, torrent := range tc.byID {
result[name] = torrent
}
return result
}
func (tc *torrentCache) getIdMaps() map[string]struct{} {
tc.mu.Lock()
defer tc.mu.Unlock()
res := make(map[string]struct{}, len(tc.byID))
for id := range tc.byID {
res[id] = struct{}{}
}
return res
}
func (tc *torrentCache) removeId(id string) {
tc.mu.Lock()
defer tc.mu.Unlock()
delete(tc.byID, id)
tc.sortNeeded.Store(true)
}
func (tc *torrentCache) remove(name string) {
tc.mu.Lock()
defer tc.mu.Unlock()
delete(tc.byName, name)
tc.sortNeeded.Store(true)
}

View File

@@ -0,0 +1,60 @@
package store
import (
"context"
"github.com/go-co-op/gocron/v2"
"github.com/sirrobot01/decypharr/internal/utils"
)
func (c *Cache) StartSchedule(ctx context.Context) error {
// For now, we just want to refresh the listing and download links
// Schedule download link refresh job
if jd, err := utils.ConvertToJobDef(c.downloadLinksRefreshInterval); err != nil {
c.logger.Error().Err(err).Msg("Failed to convert download link refresh interval to job definition")
} else {
// Schedule the job
if _, err := c.scheduler.NewJob(jd, gocron.NewTask(func() {
c.refreshDownloadLinks(ctx)
}), gocron.WithContext(ctx)); err != nil {
c.logger.Error().Err(err).Msg("Failed to create download link refresh job")
} else {
c.logger.Debug().Msgf("Download link refresh job scheduled for every %s", c.downloadLinksRefreshInterval)
}
}
// Schedule torrent refresh job
if jd, err := utils.ConvertToJobDef(c.torrentRefreshInterval); err != nil {
c.logger.Error().Err(err).Msg("Failed to convert torrent refresh interval to job definition")
} else {
// Schedule the job
if _, err := c.scheduler.NewJob(jd, gocron.NewTask(func() {
c.refreshTorrents(ctx)
}), gocron.WithContext(ctx)); err != nil {
c.logger.Error().Err(err).Msg("Failed to create torrent refresh job")
} else {
c.logger.Debug().Msgf("Torrent refresh job scheduled for every %s", c.torrentRefreshInterval)
}
}
// Schedule the reset invalid links job
// This job will run every at 00:00 CET
// and reset the invalid links in the cache
if jd, err := utils.ConvertToJobDef("00:00"); err != nil {
c.logger.Error().Err(err).Msg("Failed to convert link reset interval to job definition")
} else {
// Schedule the job
if _, err := c.cetScheduler.NewJob(jd, gocron.NewTask(func() {
c.resetInvalidLinks()
}), gocron.WithContext(ctx)); err != nil {
c.logger.Error().Err(err).Msg("Failed to create link reset job")
} else {
c.logger.Debug().Msgf("Link reset job scheduled for every midnight, CET")
}
}
// Start the scheduler
c.scheduler.Start()
c.cetScheduler.Start()
return nil
}

1
pkg/debrid/store/xml.go Normal file
View File

@@ -0,0 +1 @@
package store