9 Commits

Author SHA1 Message Date
Mukhtar Akere
fa6920f94a Merge branch 'beta'
Some checks failed
GoReleaser / goreleaser (push) Has been cancelled
Release Docker Build / docker (push) Has been cancelled
2025-07-09 05:14:39 +01:00
Mukhtar Akere
dba5604d79 fix refresh rclone http client 2025-07-07 00:08:48 +01:00
iPromKnight
f656b7e4e2 feat: Allow deleting all __bad__ with a single button (#98) 2025-07-04 20:13:12 +01:00
Mukhtar Akere
c7b07137c5 Fix repair bug 2025-07-03 23:36:30 +01:00
Mukhtar Akere
c0aa4eaeba Fix modtime bug 2025-07-02 01:17:31 +01:00
Mukhtar Akere
2c90e518aa fix playback issues 2025-07-01 16:10:23 +01:00
Mukhtar Akere
dec7d93272 fix streaming 2025-07-01 15:28:19 +01:00
Mukhtar Akere
8d092615db Update stream client; Add repair strategy 2025-07-01 04:42:33 +01:00
iPromKnight
a4ee0973cc fix: AllDebrid webdav compatibility, and uncached downloads (#97) 2025-07-01 04:10:21 +01:00
14 changed files with 251 additions and 120 deletions

View File

@@ -12,6 +12,13 @@ import (
"sync" "sync"
) )
type RepairStrategy string
const (
RepairStrategyPerFile RepairStrategy = "per_file"
RepairStrategyPerTorrent RepairStrategy = "per_torrent"
)
var ( var (
instance *Config instance *Config
once sync.Once once sync.Once
@@ -60,13 +67,14 @@ type Arr struct {
} }
type Repair struct { type Repair struct {
Enabled bool `json:"enabled,omitempty"` Enabled bool `json:"enabled,omitempty"`
Interval string `json:"interval,omitempty"` Interval string `json:"interval,omitempty"`
ZurgURL string `json:"zurg_url,omitempty"` ZurgURL string `json:"zurg_url,omitempty"`
AutoProcess bool `json:"auto_process,omitempty"` AutoProcess bool `json:"auto_process,omitempty"`
UseWebDav bool `json:"use_webdav,omitempty"` UseWebDav bool `json:"use_webdav,omitempty"`
Workers int `json:"workers,omitempty"` Workers int `json:"workers,omitempty"`
ReInsert bool `json:"reinsert,omitempty"` ReInsert bool `json:"reinsert,omitempty"`
Strategy RepairStrategy `json:"strategy,omitempty"`
} }
type Auth struct { type Auth struct {
@@ -352,6 +360,11 @@ func (c *Config) setDefaults() {
c.URLBase += "/" c.URLBase += "/"
} }
// Set repair defaults
if c.Repair.Strategy == "" {
c.Repair.Strategy = RepairStrategyPerTorrent
}
// Load the auth file // Load the auth file
c.Auth = c.GetAuth() c.Auth = c.GetAuth()
} }

View File

@@ -115,8 +115,10 @@ func (a *Arr) Validate() error {
if err != nil { if err != nil {
return err return err
} }
if resp.StatusCode != http.StatusOK { defer resp.Body.Close()
return fmt.Errorf("arr test failed: %s", resp.Status) // 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 return nil
} }

View File

@@ -309,7 +309,7 @@ func (ad *AllDebrid) GetFileDownloadLinks(t *types.Torrent) error {
errCh <- err errCh <- err
return return
} }
if link != nil { if link == nil {
errCh <- fmt.Errorf("download link is empty") errCh <- fmt.Errorf("download link is empty")
return return
} }

View File

@@ -1,5 +1,10 @@
package alldebrid package alldebrid
import (
"encoding/json"
"fmt"
)
type errorResponse struct { type errorResponse struct {
Code string `json:"code"` Code string `json:"code"`
Message string `json:"message"` Message string `json:"message"`
@@ -32,6 +37,8 @@ type magnetInfo struct {
Files []MagnetFile `json:"files"` Files []MagnetFile `json:"files"`
} }
type Magnets []magnetInfo
type TorrentInfoResponse struct { type TorrentInfoResponse struct {
Status string `json:"status"` Status string `json:"status"`
Data struct { Data struct {
@@ -43,7 +50,7 @@ type TorrentInfoResponse struct {
type TorrentsListResponse struct { type TorrentsListResponse struct {
Status string `json:"status"` Status string `json:"status"`
Data struct { Data struct {
Magnets []magnetInfo `json:"magnets"` Magnets Magnets `json:"magnets"`
} `json:"data"` } `json:"data"`
Error *errorResponse `json:"error"` Error *errorResponse `json:"error"`
} }
@@ -81,3 +88,27 @@ type DownloadLink struct {
} `json:"data"` } `json:"data"`
Error *errorResponse `json:"error"` 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")
}

View File

@@ -136,15 +136,7 @@ func (c *Cache) refreshRclone() error {
return nil return nil
} }
client := &http.Client{ client := http.DefaultClient
Timeout: 60 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 60 * time.Second,
DisableCompression: false,
MaxIdleConnsPerHost: 5,
},
}
// Create form data // Create form data
data := c.buildRcloneRequestData() data := c.buildRcloneRequestData()

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"github.com/sirrobot01/decypharr/internal/config"
"github.com/sirrobot01/decypharr/internal/utils" "github.com/sirrobot01/decypharr/internal/utils"
"github.com/sirrobot01/decypharr/pkg/debrid/types" "github.com/sirrobot01/decypharr/pkg/debrid/types"
"sync" "sync"
@@ -60,6 +61,7 @@ func (c *Cache) markAsSuccessfullyReinserted(torrentId string) {
func (c *Cache) GetBrokenFiles(t *CachedTorrent, filenames []string) []string { func (c *Cache) GetBrokenFiles(t *CachedTorrent, filenames []string) []string {
files := make(map[string]types.File) files := make(map[string]types.File)
repairStrategy := config.Get().Repair.Strategy
brokenFiles := make([]string, 0) brokenFiles := make([]string, 0)
if len(filenames) > 0 { if len(filenames) > 0 {
for name, f := range t.Files { 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()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
// Use a mutex to protect brokenFiles slice and torrent-wide failure flag
var mu sync.Mutex
torrentWideFailed := false
wg.Add(len(files)) wg.Add(len(files))
for _, f := range files { for _, f := range files {
@@ -106,14 +112,33 @@ func (c *Cache) GetBrokenFiles(t *CachedTorrent, filenames []string) []string {
} }
if f.Link == "" { 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 return
} }
if err := c.client.CheckLink(f.Link); err != nil { if err := c.client.CheckLink(f.Link); err != nil {
if errors.Is(err, utils.HosterUnavailableError) { if errors.Is(err, utils.HosterUnavailableError) {
cancel() // Signal all other goroutines to stop mu.Lock()
return 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) }(f)
@@ -121,12 +146,14 @@ func (c *Cache) GetBrokenFiles(t *CachedTorrent, filenames []string) []string {
wg.Wait() wg.Wait()
// If context was cancelled, mark all files as broken // Handle the result based on strategy
if ctx.Err() != nil { if repairStrategy == config.RepairStrategyPerTorrent && torrentWideFailed {
// Mark all files as broken for per_torrent strategy
for _, f := range files { for _, f := range files {
brokenFiles = append(brokenFiles, f.Name) 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 // Try to reinsert the torrent if it's broken
if len(brokenFiles) > 0 && t.Torrent != nil { if len(brokenFiles) > 0 && t.Torrent != nil {

View File

@@ -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 { 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) brokenFiles := make([]arr.ContentFile, 0)
emptyFiles := make([]arr.ContentFile, 0)
r.logger.Debug().Msgf("Checking %s", torrentPath) r.logger.Debug().Msgf("Checking %s", torrentPath)
// Get the debrid client // Get the debrid client
@@ -95,17 +97,18 @@ func (r *Repair) checkTorrentFiles(torrentPath string, files []arr.ContentFile,
debridName := r.findDebridForPath(dir, clients) debridName := r.findDebridForPath(dir, clients)
if debridName == "" { if debridName == "" {
r.logger.Debug().Msgf("No debrid found for %s. Skipping", torrentPath) 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] cache, ok := caches[debridName]
if !ok { if !ok {
r.logger.Debug().Msgf("No cache found for %s. Skipping", debridName) 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) tor, ok := r.torrentsMap.Load(debridName)
if !ok { if !ok {
r.logger.Debug().Msgf("Could not find torrents for %s. Skipping", debridName) r.logger.Debug().Msgf("Could not find torrents for %s. Skipping", debridName)
return emptyFiles
} }
torrentsMap := tor.(map[string]store.CachedTorrent) 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)) torrentName := filepath.Clean(filepath.Base(torrentPath))
torrent, ok := torrentsMap[torrentName] torrent, ok := torrentsMap[torrentName]
if !ok { if !ok {
r.logger.Debug().Msgf("No torrent found for %s. Skipping", torrentName) r.logger.Debug().Msgf("Can't find torrent %s in %s. Marking as broken", torrentName, debridName)
return files // Return all files as broken if torrent not found // Return all files as broken
return files
} }
// Batch check files // Batch check files

View File

@@ -9,6 +9,7 @@ import (
"github.com/sirrobot01/decypharr/internal/utils" "github.com/sirrobot01/decypharr/internal/utils"
debridTypes "github.com/sirrobot01/decypharr/pkg/debrid" debridTypes "github.com/sirrobot01/decypharr/pkg/debrid"
"github.com/sirrobot01/decypharr/pkg/debrid/types" "github.com/sirrobot01/decypharr/pkg/debrid/types"
"math"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
@@ -207,6 +208,9 @@ func (s *Store) partialTorrentUpdate(t *Torrent, debridTorrent *types.Torrent) *
} }
totalSize := debridTorrent.Bytes totalSize := debridTorrent.Bytes
progress := (cmp.Or(debridTorrent.Progress, 0.0)) / 100.0 progress := (cmp.Or(debridTorrent.Progress, 0.0)) / 100.0
if math.IsNaN(progress) || math.IsInf(progress, 0) {
progress = 0
}
sizeCompleted := int64(float64(totalSize) * progress) sizeCompleted := int64(float64(totalSize) * progress)
var speed int64 var speed int64

View File

@@ -337,6 +337,14 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-4 mb-3">
<label class="form-label" for="repair.strategy">Repair Strategy</label>
<select class="form-select" name="repair.strategy" id="repair.strategy">
<option value="per_torrent" selected>Per Torrent</option>
<option value="per_file">Per File</option>
</select>
<small class="form-text text-muted">How to handle repairs, per torrent or per file</small>
</div>
<div class="col-md-4 mb-3"> <div class="col-md-4 mb-3">
<div class="form-check"> <div class="form-check">
<input type="checkbox" class="form-check-input" name="repair.use_webdav" <input type="checkbox" class="form-check-input" name="repair.use_webdav"
@@ -1226,6 +1234,7 @@
enabled: document.querySelector('[name="repair.enabled"]').checked, enabled: document.querySelector('[name="repair.enabled"]').checked,
interval: document.querySelector('[name="repair.interval"]').value, interval: document.querySelector('[name="repair.interval"]').value,
zurg_url: document.querySelector('[name="repair.zurg_url"]').value, zurg_url: document.querySelector('[name="repair.zurg_url"]').value,
strategy: document.querySelector('[name="repair.strategy"]').value,
workers: parseInt(document.querySelector('[name="repair.workers"]').value), workers: parseInt(document.querySelector('[name="repair.workers"]').value),
use_webdav: document.querySelector('[name="repair.use_webdav"]').checked, use_webdav: document.querySelector('[name="repair.use_webdav"]').checked,
auto_process: document.querySelector('[name="repair.auto_process"]').checked auto_process: document.querySelector('[name="repair.auto_process"]').checked

View File

@@ -12,19 +12,24 @@ import (
"github.com/sirrobot01/decypharr/pkg/debrid/store" "github.com/sirrobot01/decypharr/pkg/debrid/store"
) )
var streamingTransport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
MaxIdleConns: 200,
MaxIdleConnsPerHost: 100,
MaxConnsPerHost: 200,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 60 * time.Second, // give the upstream a minute to send headers
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: true, // close after each request
ForceAttemptHTTP2: false, // dont speak HTTP/2
// this line is what truly blocks HTTP/2:
TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper),
}
var sharedClient = &http.Client{ var sharedClient = &http.Client{
Transport: &http.Transport{ Transport: streamingTransport,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, Timeout: 0,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
MaxConnsPerHost: 50,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
},
Timeout: 0,
} }
type streamError struct { type streamError struct {
@@ -143,7 +148,7 @@ func (f *File) StreamResponse(w http.ResponseWriter, r *http.Request) error {
} }
func (f *File) streamWithRetry(w http.ResponseWriter, r *http.Request, retryCount int) error { func (f *File) streamWithRetry(w http.ResponseWriter, r *http.Request, retryCount int) error {
const maxRetries = 0 const maxRetries = 3
_log := f.cache.Logger() _log := f.cache.Logger()
// Get download link (with caching optimization) // Get download link (with caching optimization)
@@ -192,8 +197,47 @@ func (f *File) streamWithRetry(w http.ResponseWriter, r *http.Request, retryCoun
setVideoResponseHeaders(w, resp, isRangeRequest == 1) setVideoResponseHeaders(w, resp, isRangeRequest == 1)
// Stream with optimized buffering for video return f.streamBuffer(w, resp.Body)
return f.streamVideoOptimized(w, resp.Body) }
func (f *File) streamBuffer(w http.ResponseWriter, src io.Reader) error {
flusher, ok := w.(http.Flusher)
if !ok {
return fmt.Errorf("response does not support flushing")
}
smallBuf := make([]byte, 64*1024) // 64 KB
if n, err := src.Read(smallBuf); n > 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) { 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 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. These are the methods that implement the os.File interface for the File type.
Only Stat and ReadDir are used Only Stat and ReadDir are used

View File

@@ -22,6 +22,8 @@ import (
"github.com/sirrobot01/decypharr/pkg/version" "github.com/sirrobot01/decypharr/pkg/version"
) )
const DeleteAllBadTorrentKey = "DELETE_ALL_BAD_TORRENTS"
type Handler struct { type Handler struct {
Name string Name string
logger zerolog.Logger 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]) { if len(parts) == 2 && utils.Contains(h.getParentItems(), parts[0]) {
torrentName := parts[1] torrentName := parts[1]
if t := h.cache.GetTorrentByName(torrentName); t != nil { if t := h.cache.GetTorrentByName(torrentName); t != nil {
return h.getFileInfos(t.Torrent) return h.getFileInfos(t)
} }
} }
return nil return nil
@@ -267,10 +269,9 @@ func (h *Handler) Stat(ctx context.Context, name string) (os.FileInfo, error) {
return f.Stat() return f.Stat()
} }
func (h *Handler) getFileInfos(torrent *types.Torrent) []os.FileInfo { func (h *Handler) getFileInfos(torrent *store.CachedTorrent) []os.FileInfo {
torrentFiles := torrent.GetFiles() torrentFiles := torrent.GetFiles()
files := make([]os.FileInfo, 0, len(torrentFiles)) files := make([]os.FileInfo, 0, len(torrentFiles))
now := time.Now()
// Sort by file name since the order is lost when using the map // Sort by file name since the order is lost when using the map
sortedFiles := make([]*types.File, 0, len(torrentFiles)) sortedFiles := make([]*types.File, 0, len(torrentFiles))
@@ -286,7 +287,7 @@ func (h *Handler) getFileInfos(torrent *types.Torrent) []os.FileInfo {
name: file.Name, name: file.Name,
size: file.Size, size: file.Size,
mode: 0644, mode: 0644,
modTime: now, modTime: torrent.AddedOn,
isDir: false, isDir: false,
}) })
} }
@@ -309,7 +310,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.handlePropfind(w, r) h.handlePropfind(w, r)
return return
case "DELETE": case "DELETE":
if err := h.handleIDDelete(w, r); err == nil { if err := h.handleDelete(w, r); err == nil {
return return
} }
// fallthrough to default // fallthrough to default
@@ -388,21 +389,23 @@ func (h *Handler) serveDirectory(w http.ResponseWriter, r *http.Request, file we
// Prepare template data // Prepare template data
data := struct { data := struct {
Path string Path string
ParentPath string ParentPath string
ShowParent bool ShowParent bool
Children []os.FileInfo Children []os.FileInfo
URLBase string URLBase string
IsBadPath bool IsBadPath bool
CanDelete bool CanDelete bool
DeleteAllBadTorrentKey string
}{ }{
Path: cleanPath, Path: cleanPath,
ParentPath: parentPath, ParentPath: parentPath,
ShowParent: showParent, ShowParent: showParent,
Children: children, Children: children,
URLBase: h.URLBase, URLBase: h.URLBase,
IsBadPath: isBadPath, IsBadPath: isBadPath,
CanDelete: canDelete, CanDelete: canDelete,
DeleteAllBadTorrentKey: DeleteAllBadTorrentKey,
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") 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) w.WriteHeader(http.StatusOK)
} }
// handleDelete deletes a torrent from using id // handleDelete deletes a torrent by id, or all bad torrents if the id is DeleteAllBadTorrentKey
func (h *Handler) handleIDDelete(w http.ResponseWriter, r *http.Request) error { func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) error {
cleanPath := path.Clean(r.URL.Path) // Remove any leading slashes cleanPath := path.Clean(r.URL.Path) // Remove any leading slashes
_, torrentId := path.Split(cleanPath) _, torrentId := path.Split(cleanPath)
@@ -544,7 +547,15 @@ func (h *Handler) handleIDDelete(w http.ResponseWriter, r *http.Request) error {
return os.ErrNotExist 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 { if cachedTorrent == nil {
return os.ErrNotExist return os.ErrNotExist
} }
@@ -553,3 +564,22 @@ func (h *Handler) handleIDDelete(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return nil 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
}

View File

@@ -56,7 +56,7 @@ type entry struct {
func filesToXML(urlPath string, fi os.FileInfo, children []os.FileInfo) stringbuf.StringBuf { 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) entries := make([]entry, 0, len(children)+1)
// Add the current file itself // Add the current file itself
@@ -65,7 +65,7 @@ func filesToXML(urlPath string, fi os.FileInfo, children []os.FileInfo) stringbu
escName: xmlEscape(fi.Name()), escName: xmlEscape(fi.Name()),
isDir: fi.IsDir(), isDir: fi.IsDir(),
size: fi.Size(), 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 { for _, info := range children {
@@ -81,7 +81,7 @@ func filesToXML(urlPath string, fi os.FileInfo, children []os.FileInfo) stringbu
escName: xmlEscape(nm), escName: xmlEscape(nm),
isDir: info.IsDir(), isDir: info.IsDir(),
size: info.Size(), size: info.Size(),
modTime: info.ModTime().Format("2006-01-02T15:04:05.000-07:00"), modTime: info.ModTime().Format(time.RFC3339),
}) })
} }

View File

@@ -55,7 +55,6 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) {
rawEntries = append(rawEntries, h.getChildren(cleanPath)...) 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) entries := make([]entry, 0, len(rawEntries)+1)
// Add the current file itself // Add the current file itself
entries = append(entries, entry{ entries = append(entries, entry{
@@ -63,7 +62,7 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) {
escName: xmlEscape(fi.Name()), escName: xmlEscape(fi.Name()),
isDir: fi.IsDir(), isDir: fi.IsDir(),
size: fi.Size(), 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 { for _, info := range rawEntries {
@@ -79,7 +78,7 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) {
escName: xmlEscape(nm), escName: xmlEscape(nm),
isDir: info.IsDir(), isDir: info.IsDir(),
size: info.Size(), 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(`<d:getlastmodified>`) _, _ = sb.WriteString(`<d:getlastmodified>`)
_, _ = sb.WriteString(now) _, _ = sb.WriteString(e.modTime)
_, _ = sb.WriteString(`</d:getlastmodified>`) _, _ = sb.WriteString(`</d:getlastmodified>`)
_, _ = sb.WriteString(`<d:displayname>`) _, _ = sb.WriteString(`<d:displayname>`)

View File

@@ -106,6 +106,19 @@
</li> </li>
{{- end}} {{- end}}
{{$isBadPath := hasSuffix .Path "__bad__"}} {{$isBadPath := hasSuffix .Path "__bad__"}}
{{- if and $isBadPath (gt (len .Children) 0) }}
<li>
<span class="file-number">&nbsp;</span>
<span class="file-name">&nbsp;</span>
<span class="file-info">&nbsp;</span>
<button
class="delete-btn"
id="delete-all-btn"
data-name="{{.DeleteAllBadTorrentKey}}">
Delete All
</button>
</li>
{{- end}}
{{- range $i, $file := .Children}} {{- range $i, $file := .Children}}
<li class="{{if $isBadPath}}disabled{{end}}"> <li class="{{if $isBadPath}}disabled{{end}}">
<a {{ if not $isBadPath}}href="{{urlpath (printf "%s/%s" $.Path $file.Name)}}"{{end}}> <a {{ if not $isBadPath}}href="{{urlpath (printf "%s/%s" $.Path $file.Name)}}"{{end}}>
@@ -118,7 +131,7 @@
</a> </a>
{{- if and $.CanDelete }} {{- if and $.CanDelete }}
<button <button
class="delete-btn" class="delete-btn delete-with-id-btn"
data-name="{{$file.Name}}" data-name="{{$file.Name}}"
data-path="{{printf "%s/%s" $.Path $file.ID}}"> data-path="{{printf "%s/%s" $.Path $file.ID}}">
Delete Delete
@@ -128,7 +141,7 @@
{{- end}} {{- end}}
</ul> </ul>
<script> <script>
document.querySelectorAll('.delete-btn').forEach(btn=>{ document.querySelectorAll('.delete-with-id-btn').forEach(btn=>{
btn.addEventListener('click', ()=>{ btn.addEventListener('click', ()=>{
let p = btn.getAttribute('data-path'); let p = btn.getAttribute('data-path');
let name = btn.getAttribute('data-name'); let name = btn.getAttribute('data-name');
@@ -137,6 +150,14 @@
.then(_=>location.reload()); .then(_=>location.reload());
}); });
}); });
const deleteAllButton = document.getElementById('delete-all-btn');
deleteAllButton.addEventListener('click', () => {
let p = deleteAllButton.getAttribute('data-name');
if (!confirm('Delete all entries marked Bad?')) return;
fetch(p, { method: 'DELETE' })
.then(_=>location.reload());
});
</script> </script>
</body> </body>
</html> </html>