Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa6920f94a | ||
|
|
dba5604d79 | ||
|
|
f656b7e4e2 | ||
|
|
c7b07137c5 | ||
|
|
c0aa4eaeba | ||
|
|
2c90e518aa | ||
|
|
dec7d93272 | ||
|
|
8d092615db | ||
|
|
a4ee0973cc |
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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, // don’t 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>`)
|
||||||
|
|||||||
@@ -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"> </span>
|
||||||
|
<span class="file-name"> </span>
|
||||||
|
<span class="file-info"> </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>
|
||||||
Reference in New Issue
Block a user