diff --git a/internal/config/config.go b/internal/config/config.go index 84bca76..13750a4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,13 @@ import ( "sync" ) +type RepairStrategy string + +const ( + RepairStrategyPerFile RepairStrategy = "per_file" + RepairStrategyPerTorrent RepairStrategy = "per_torrent" +) + var ( instance *Config once sync.Once @@ -60,13 +67,14 @@ type Arr struct { } type Repair struct { - Enabled bool `json:"enabled,omitempty"` - Interval string `json:"interval,omitempty"` - ZurgURL string `json:"zurg_url,omitempty"` - AutoProcess bool `json:"auto_process,omitempty"` - UseWebDav bool `json:"use_webdav,omitempty"` - Workers int `json:"workers,omitempty"` - ReInsert bool `json:"reinsert,omitempty"` + Enabled bool `json:"enabled,omitempty"` + Interval string `json:"interval,omitempty"` + ZurgURL string `json:"zurg_url,omitempty"` + AutoProcess bool `json:"auto_process,omitempty"` + UseWebDav bool `json:"use_webdav,omitempty"` + Workers int `json:"workers,omitempty"` + ReInsert bool `json:"reinsert,omitempty"` + Strategy RepairStrategy `json:"strategy,omitempty"` } type Auth struct { @@ -352,6 +360,11 @@ func (c *Config) setDefaults() { c.URLBase += "/" } + // Set repair defaults + if c.Repair.Strategy == "" { + c.Repair.Strategy = RepairStrategyPerTorrent + } + // Load the auth file c.Auth = c.GetAuth() } diff --git a/pkg/arr/arr.go b/pkg/arr/arr.go index 9b77cec..5934d5f 100644 --- a/pkg/arr/arr.go +++ b/pkg/arr/arr.go @@ -115,8 +115,10 @@ func (a *Arr) Validate() error { if err != nil { return err } - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("arr test failed: %s", resp.Status) + defer resp.Body.Close() + // If response is not 200 or 404(this is the case for Lidarr, etc), return an error + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNotFound { + return fmt.Errorf("failed to validate arr %s: %s", a.Name, resp.Status) } return nil } diff --git a/pkg/debrid/providers/alldebrid/alldebrid.go b/pkg/debrid/providers/alldebrid/alldebrid.go index c41748f..0430b87 100644 --- a/pkg/debrid/providers/alldebrid/alldebrid.go +++ b/pkg/debrid/providers/alldebrid/alldebrid.go @@ -309,7 +309,7 @@ func (ad *AllDebrid) GetFileDownloadLinks(t *types.Torrent) error { errCh <- err return } - if link != nil { + if link == nil { errCh <- fmt.Errorf("download link is empty") return } diff --git a/pkg/debrid/providers/alldebrid/types.go b/pkg/debrid/providers/alldebrid/types.go index bcc1130..2f0d418 100644 --- a/pkg/debrid/providers/alldebrid/types.go +++ b/pkg/debrid/providers/alldebrid/types.go @@ -1,5 +1,10 @@ package alldebrid +import ( + "encoding/json" + "fmt" +) + type errorResponse struct { Code string `json:"code"` Message string `json:"message"` @@ -32,6 +37,8 @@ type magnetInfo struct { Files []MagnetFile `json:"files"` } +type Magnets []magnetInfo + type TorrentInfoResponse struct { Status string `json:"status"` Data struct { @@ -43,7 +50,7 @@ type TorrentInfoResponse struct { type TorrentsListResponse struct { Status string `json:"status"` Data struct { - Magnets []magnetInfo `json:"magnets"` + Magnets Magnets `json:"magnets"` } `json:"data"` Error *errorResponse `json:"error"` } @@ -81,3 +88,27 @@ type DownloadLink struct { } `json:"data"` Error *errorResponse `json:"error"` } + +// UnmarshalJSON implements custom unmarshaling for Magnets type +// It can handle both an array of magnetInfo objects or a map with string keys. +// If the input is an array, it will be unmarshaled directly into the Magnets slice. +// If the input is a map, it will extract the values and append them to the Magnets slice. +// If the input is neither, it will return an error. +func (m *Magnets) UnmarshalJSON(data []byte) error { + // Try to unmarshal as array + var arr []magnetInfo + if err := json.Unmarshal(data, &arr); err == nil { + *m = arr + return nil + } + + // Try to unmarshal as map + var obj map[string]magnetInfo + if err := json.Unmarshal(data, &obj); err == nil { + for _, v := range obj { + *m = append(*m, v) + } + return nil + } + return fmt.Errorf("magnets: unsupported JSON format") +} diff --git a/pkg/debrid/store/refresh.go b/pkg/debrid/store/refresh.go index 2682441..8d42d87 100644 --- a/pkg/debrid/store/refresh.go +++ b/pkg/debrid/store/refresh.go @@ -136,15 +136,7 @@ func (c *Cache) refreshRclone() error { return nil } - client := &http.Client{ - Timeout: 60 * time.Second, - Transport: &http.Transport{ - MaxIdleConns: 10, - IdleConnTimeout: 60 * time.Second, - DisableCompression: false, - MaxIdleConnsPerHost: 5, - }, - } + client := http.DefaultClient // Create form data data := c.buildRcloneRequestData() diff --git a/pkg/debrid/store/repair.go b/pkg/debrid/store/repair.go index b807f27..dccf0d8 100644 --- a/pkg/debrid/store/repair.go +++ b/pkg/debrid/store/repair.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "github.com/sirrobot01/decypharr/internal/config" "github.com/sirrobot01/decypharr/internal/utils" "github.com/sirrobot01/decypharr/pkg/debrid/types" "sync" @@ -60,6 +61,7 @@ func (c *Cache) markAsSuccessfullyReinserted(torrentId string) { func (c *Cache) GetBrokenFiles(t *CachedTorrent, filenames []string) []string { files := make(map[string]types.File) + repairStrategy := config.Get().Repair.Strategy brokenFiles := make([]string, 0) if len(filenames) > 0 { for name, f := range t.Files { @@ -93,6 +95,10 @@ func (c *Cache) GetBrokenFiles(t *CachedTorrent, filenames []string) []string { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Use a mutex to protect brokenFiles slice and torrent-wide failure flag + var mu sync.Mutex + torrentWideFailed := false + wg.Add(len(files)) for _, f := range files { @@ -106,14 +112,33 @@ func (c *Cache) GetBrokenFiles(t *CachedTorrent, filenames []string) []string { } if f.Link == "" { - cancel() + mu.Lock() + if repairStrategy == config.RepairStrategyPerTorrent { + torrentWideFailed = true + mu.Unlock() + cancel() // Signal all other goroutines to stop + return + } else { + // per_file strategy - only mark this file as broken + brokenFiles = append(brokenFiles, f.Name) + } + mu.Unlock() return } if err := c.client.CheckLink(f.Link); err != nil { if errors.Is(err, utils.HosterUnavailableError) { - cancel() // Signal all other goroutines to stop - return + mu.Lock() + if repairStrategy == config.RepairStrategyPerTorrent { + torrentWideFailed = true + mu.Unlock() + cancel() // Signal all other goroutines to stop + return + } else { + // per_file strategy - only mark this file as broken + brokenFiles = append(brokenFiles, f.Name) + } + mu.Unlock() } } }(f) @@ -121,12 +146,14 @@ func (c *Cache) GetBrokenFiles(t *CachedTorrent, filenames []string) []string { wg.Wait() - // If context was cancelled, mark all files as broken - if ctx.Err() != nil { + // Handle the result based on strategy + if repairStrategy == config.RepairStrategyPerTorrent && torrentWideFailed { + // Mark all files as broken for per_torrent strategy for _, f := range files { brokenFiles = append(brokenFiles, f.Name) } } + // For per_file strategy, brokenFiles already contains only the broken ones // Try to reinsert the torrent if it's broken if len(brokenFiles) > 0 && t.Torrent != nil { diff --git a/pkg/repair/misc.go b/pkg/repair/misc.go index bc36088..f7f0569 100644 --- a/pkg/repair/misc.go +++ b/pkg/repair/misc.go @@ -88,6 +88,8 @@ func collectFiles(media arr.Content) map[string][]arr.ContentFile { func (r *Repair) checkTorrentFiles(torrentPath string, files []arr.ContentFile, clients map[string]types.Client, caches map[string]*store.Cache) []arr.ContentFile { brokenFiles := make([]arr.ContentFile, 0) + emptyFiles := make([]arr.ContentFile, 0) + r.logger.Debug().Msgf("Checking %s", torrentPath) // Get the debrid client @@ -95,17 +97,18 @@ func (r *Repair) checkTorrentFiles(torrentPath string, files []arr.ContentFile, debridName := r.findDebridForPath(dir, clients) if debridName == "" { r.logger.Debug().Msgf("No debrid found for %s. Skipping", torrentPath) - return files // Return all files as broken if no debrid found + return emptyFiles } cache, ok := caches[debridName] if !ok { r.logger.Debug().Msgf("No cache found for %s. Skipping", debridName) - return files // Return all files as broken if no cache found + return emptyFiles } tor, ok := r.torrentsMap.Load(debridName) if !ok { r.logger.Debug().Msgf("Could not find torrents for %s. Skipping", debridName) + return emptyFiles } torrentsMap := tor.(map[string]store.CachedTorrent) @@ -114,8 +117,9 @@ func (r *Repair) checkTorrentFiles(torrentPath string, files []arr.ContentFile, torrentName := filepath.Clean(filepath.Base(torrentPath)) torrent, ok := torrentsMap[torrentName] if !ok { - r.logger.Debug().Msgf("No torrent found for %s. Skipping", torrentName) - return files // Return all files as broken if torrent not found + r.logger.Debug().Msgf("Can't find torrent %s in %s. Marking as broken", torrentName, debridName) + // Return all files as broken + return files } // Batch check files diff --git a/pkg/store/torrent.go b/pkg/store/torrent.go index 61e39d9..57bac5d 100644 --- a/pkg/store/torrent.go +++ b/pkg/store/torrent.go @@ -9,6 +9,7 @@ import ( "github.com/sirrobot01/decypharr/internal/utils" debridTypes "github.com/sirrobot01/decypharr/pkg/debrid" "github.com/sirrobot01/decypharr/pkg/debrid/types" + "math" "os" "path/filepath" "time" @@ -207,6 +208,9 @@ func (s *Store) partialTorrentUpdate(t *Torrent, debridTorrent *types.Torrent) * } totalSize := debridTorrent.Bytes progress := (cmp.Or(debridTorrent.Progress, 0.0)) / 100.0 + if math.IsNaN(progress) || math.IsInf(progress, 0) { + progress = 0 + } sizeCompleted := int64(float64(totalSize) * progress) var speed int64 diff --git a/pkg/web/templates/config.html b/pkg/web/templates/config.html index eb1999e..45dad9e 100644 --- a/pkg/web/templates/config.html +++ b/pkg/web/templates/config.html @@ -337,6 +337,14 @@
+
+ + + How to handle repairs, per torrent or per file +
0 { + if _, werr := w.Write(smallBuf[:n]); werr != nil { + return werr + } + flusher.Flush() + } else if err != nil && err != io.EOF { + return err + } + + buf := make([]byte, 256*1024) // 256 KB + for { + n, readErr := src.Read(buf) + if n > 0 { + if _, writeErr := w.Write(buf[:n]); writeErr != nil { + if isClientDisconnection(writeErr) { + return &streamError{Err: writeErr, StatusCode: 0, IsClientDisconnection: true} + } + return writeErr + } + flusher.Flush() + } + if readErr != nil { + if readErr == io.EOF { + return nil + } + if isClientDisconnection(readErr) { + return &streamError{Err: readErr, StatusCode: 0, IsClientDisconnection: true} + } + return readErr + } + } } func (f *File) handleUpstream(resp *http.Response, retryCount, maxRetries int) (shouldRetry bool, err error) { @@ -319,51 +363,6 @@ func (f *File) handleRangeRequest(upstreamReq *http.Request, r *http.Request, w return 1 // Valid range request } -func (f *File) streamVideoOptimized(w http.ResponseWriter, src io.Reader) error { - // Use larger buffer for video streaming (better throughput) - buf := make([]byte, 64*1024) // 64KB buffer - - // First chunk optimization - send immediately for faster start - n, err := src.Read(buf) - if err != nil && err != io.EOF { - if isClientDisconnection(err) { - return &streamError{Err: err, StatusCode: 0, IsClientDisconnection: true} - } - return &streamError{Err: err, StatusCode: 0} - } - - if n > 0 { - // Write first chunk immediately - _, writeErr := w.Write(buf[:n]) - if writeErr != nil { - if isClientDisconnection(writeErr) { - return &streamError{Err: writeErr, StatusCode: 0, IsClientDisconnection: true} - } - return &streamError{Err: writeErr, StatusCode: 0} - } - - // Flush immediately for faster video start - if flusher, ok := w.(http.Flusher); ok { - flusher.Flush() - } - } - - if err == io.EOF { - return nil - } - - // Continue with optimized copy for remaining data - _, err = io.CopyBuffer(w, src, buf) - if err != nil { - if isClientDisconnection(err) { - return &streamError{Err: err, StatusCode: 0, IsClientDisconnection: true} - } - return &streamError{Err: err, StatusCode: 0} - } - - return nil -} - /* These are the methods that implement the os.File interface for the File type. Only Stat and ReadDir are used diff --git a/pkg/webdav/handler.go b/pkg/webdav/handler.go index 1bd0a8e..310c46f 100644 --- a/pkg/webdav/handler.go +++ b/pkg/webdav/handler.go @@ -22,6 +22,8 @@ import ( "github.com/sirrobot01/decypharr/pkg/version" ) +const DeleteAllBadTorrentKey = "DELETE_ALL_BAD_TORRENTS" + type Handler struct { Name string logger zerolog.Logger @@ -180,7 +182,7 @@ func (h *Handler) getChildren(name string) []os.FileInfo { if len(parts) == 2 && utils.Contains(h.getParentItems(), parts[0]) { torrentName := parts[1] if t := h.cache.GetTorrentByName(torrentName); t != nil { - return h.getFileInfos(t.Torrent) + return h.getFileInfos(t) } } return nil @@ -267,10 +269,9 @@ func (h *Handler) Stat(ctx context.Context, name string) (os.FileInfo, error) { return f.Stat() } -func (h *Handler) getFileInfos(torrent *types.Torrent) []os.FileInfo { +func (h *Handler) getFileInfos(torrent *store.CachedTorrent) []os.FileInfo { torrentFiles := torrent.GetFiles() files := make([]os.FileInfo, 0, len(torrentFiles)) - now := time.Now() // Sort by file name since the order is lost when using the map sortedFiles := make([]*types.File, 0, len(torrentFiles)) @@ -286,7 +287,7 @@ func (h *Handler) getFileInfos(torrent *types.Torrent) []os.FileInfo { name: file.Name, size: file.Size, mode: 0644, - modTime: now, + modTime: torrent.AddedOn, isDir: false, }) } @@ -309,7 +310,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.handlePropfind(w, r) return case "DELETE": - if err := h.handleIDDelete(w, r); err == nil { + if err := h.handleDelete(w, r); err == nil { return } // fallthrough to default @@ -388,21 +389,23 @@ func (h *Handler) serveDirectory(w http.ResponseWriter, r *http.Request, file we // Prepare template data data := struct { - Path string - ParentPath string - ShowParent bool - Children []os.FileInfo - URLBase string - IsBadPath bool - CanDelete bool + Path string + ParentPath string + ShowParent bool + Children []os.FileInfo + URLBase string + IsBadPath bool + CanDelete bool + DeleteAllBadTorrentKey string }{ - Path: cleanPath, - ParentPath: parentPath, - ShowParent: showParent, - Children: children, - URLBase: h.URLBase, - IsBadPath: isBadPath, - CanDelete: canDelete, + Path: cleanPath, + ParentPath: parentPath, + ShowParent: showParent, + Children: children, + URLBase: h.URLBase, + IsBadPath: isBadPath, + CanDelete: canDelete, + DeleteAllBadTorrentKey: DeleteAllBadTorrentKey, } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -535,8 +538,8 @@ func (h *Handler) handleOptions(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } -// handleDelete deletes a torrent from using id -func (h *Handler) handleIDDelete(w http.ResponseWriter, r *http.Request) error { +// handleDelete deletes a torrent by id, or all bad torrents if the id is DeleteAllBadTorrentKey +func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) error { cleanPath := path.Clean(r.URL.Path) // Remove any leading slashes _, torrentId := path.Split(cleanPath) @@ -544,7 +547,15 @@ func (h *Handler) handleIDDelete(w http.ResponseWriter, r *http.Request) error { return os.ErrNotExist } - cachedTorrent := h.cache.GetTorrent(torrentId) + if torrentId == DeleteAllBadTorrentKey { + return h.handleDeleteAll(w) + } + + return h.handleDeleteById(w, torrentId) +} + +func (h *Handler) handleDeleteById(w http.ResponseWriter, tId string) error { + cachedTorrent := h.cache.GetTorrent(tId) if cachedTorrent == nil { return os.ErrNotExist } @@ -553,3 +564,22 @@ func (h *Handler) handleIDDelete(w http.ResponseWriter, r *http.Request) error { w.WriteHeader(http.StatusNoContent) return nil } + +func (h *Handler) handleDeleteAll(w http.ResponseWriter) error { + badTorrents := h.cache.GetListing("__bad__") + if len(badTorrents) == 0 { + http.Error(w, "No bad torrents to delete", http.StatusNotFound) + return nil + } + + for _, fi := range badTorrents { + tName := strings.TrimSpace(strings.SplitN(fi.Name(), "||", 2)[0]) + t := h.cache.GetTorrentByName(tName) + if t != nil { + h.cache.OnRemove(t.Id) + } + } + + w.WriteHeader(http.StatusNoContent) + return nil +} diff --git a/pkg/webdav/misc.go b/pkg/webdav/misc.go index 04868b1..024b54c 100644 --- a/pkg/webdav/misc.go +++ b/pkg/webdav/misc.go @@ -56,7 +56,7 @@ type entry struct { func filesToXML(urlPath string, fi os.FileInfo, children []os.FileInfo) stringbuf.StringBuf { - now := time.Now().UTC().Format("2006-01-02T15:04:05.000-07:00") + now := time.Now().UTC().Format(time.RFC3339) entries := make([]entry, 0, len(children)+1) // Add the current file itself @@ -65,7 +65,7 @@ func filesToXML(urlPath string, fi os.FileInfo, children []os.FileInfo) stringbu escName: xmlEscape(fi.Name()), isDir: fi.IsDir(), size: fi.Size(), - modTime: fi.ModTime().Format("2006-01-02T15:04:05.000-07:00"), + modTime: fi.ModTime().Format(time.RFC3339), }) for _, info := range children { @@ -81,7 +81,7 @@ func filesToXML(urlPath string, fi os.FileInfo, children []os.FileInfo) stringbu escName: xmlEscape(nm), isDir: info.IsDir(), size: info.Size(), - modTime: info.ModTime().Format("2006-01-02T15:04:05.000-07:00"), + modTime: info.ModTime().Format(time.RFC3339), }) } diff --git a/pkg/webdav/propfind.go b/pkg/webdav/propfind.go index 62ccd51..f6a22ae 100644 --- a/pkg/webdav/propfind.go +++ b/pkg/webdav/propfind.go @@ -55,7 +55,6 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) { rawEntries = append(rawEntries, h.getChildren(cleanPath)...) } - now := time.Now().UTC().Format("2006-01-02T15:04:05.000-07:00") entries := make([]entry, 0, len(rawEntries)+1) // Add the current file itself entries = append(entries, entry{ @@ -63,7 +62,7 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) { escName: xmlEscape(fi.Name()), isDir: fi.IsDir(), size: fi.Size(), - modTime: fi.ModTime().Format("2006-01-02T15:04:05.000-07:00"), + modTime: fi.ModTime().Format(time.RFC3339), }) for _, info := range rawEntries { @@ -79,7 +78,7 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) { escName: xmlEscape(nm), isDir: info.IsDir(), size: info.Size(), - modTime: info.ModTime().Format("2006-01-02T15:04:05.000-07:00"), + modTime: info.ModTime().Format(time.RFC3339), }) } @@ -108,7 +107,7 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) { } _, _ = sb.WriteString(``) - _, _ = sb.WriteString(now) + _, _ = sb.WriteString(e.modTime) _, _ = sb.WriteString(``) _, _ = sb.WriteString(``) diff --git a/pkg/webdav/templates/directory.html b/pkg/webdav/templates/directory.html index 803907e..dd52936 100644 --- a/pkg/webdav/templates/directory.html +++ b/pkg/webdav/templates/directory.html @@ -106,6 +106,19 @@ {{- end}} {{$isBadPath := hasSuffix .Path "__bad__"}} + {{- if and $isBadPath (gt (len .Children) 0) }} +
  • +   +   +   + +
  • + {{- end}} {{- range $i, $file := .Children}}
  • @@ -118,7 +131,7 @@ {{- if and $.CanDelete }}