Files
decypharr/pkg/webdav/file.go
Mukhtar Akere 9c6c44d785 - 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
2025-06-02 12:57:36 +01:00

357 lines
8.0 KiB
Go

package webdav
import (
"crypto/tls"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/sirrobot01/decypharr/pkg/debrid/store"
)
var sharedClient = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
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 File struct {
cache *store.Cache
fileId string
torrentName string
modTime time.Time
size int64
offset int64
isDir bool
children []os.FileInfo
reader io.ReadCloser
seekPending bool
content []byte
name string
metadataOnly bool
downloadLink string
link string
}
// File interface implementations for File
func (f *File) Close() error {
if f.reader != nil {
f.reader.Close()
f.reader = nil
}
return nil
}
func (f *File) getDownloadLink() (string, error) {
// Check if we already have a final URL cached
if f.downloadLink != "" && isValidURL(f.downloadLink) {
return f.downloadLink, nil
}
downloadLink, err := f.cache.GetDownloadLink(f.torrentName, f.name, f.link)
if err != nil {
return "", err
}
if downloadLink != "" && isValidURL(downloadLink) {
f.downloadLink = downloadLink
return downloadLink, nil
}
return "", os.ErrNotExist
}
func (f *File) getDownloadByteRange() (*[2]int64, error) {
byteRange, err := f.cache.GetDownloadByteRange(f.torrentName, f.name)
if err != nil {
return nil, err
}
return byteRange, nil
}
func (f *File) stream() (*http.Response, error) {
client := sharedClient
_log := f.cache.GetLogger()
downloadLink, err := f.getDownloadLink()
if err != nil {
_log.Trace().Msgf("Failed to get download link for %s: %v", f.name, err)
return nil, err
}
if downloadLink == "" {
_log.Trace().Msgf("Failed to get download link for %s. Empty download link", f.name)
return nil, fmt.Errorf("empty download link")
}
byteRange, err := f.getDownloadByteRange()
if err != nil {
_log.Trace().Msgf("Failed to get download byte range for %s: %v", f.name, err)
return nil, err
}
req, err := http.NewRequest("GET", downloadLink, nil)
if err != nil {
_log.Trace().Msgf("Failed to create HTTP request: %v", err)
return nil, err
}
if byteRange == nil {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", max(0, f.offset)))
} else {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", byteRange[0]+max(0, f.offset)))
}
// Make the request
resp, err := client.Do(req)
if err != nil {
_log.Trace().Msgf("HTTP request failed: %v", err)
return nil, err
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
f.downloadLink = ""
cleanupResp := func() {
if resp.Body != nil {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}
switch resp.StatusCode {
case http.StatusServiceUnavailable:
// Read the body to check for specific error messages
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
_log.Trace().Msgf("Failed to read response body: %v", readErr)
return nil, fmt.Errorf("failed to read error response: %w", readErr)
}
bodyStr := string(body)
if strings.Contains(bodyStr, "You can not download this file because you have exceeded your traffic on this hoster") {
_log.Trace().Msgf("Bandwidth exceeded for %s. Download token will be disabled if you have more than one", f.name)
f.cache.MarkDownloadLinkAsInvalid(f.link, downloadLink, "bandwidth_exceeded")
// Retry with a different API key if it's available
return f.stream()
}
return nil, fmt.Errorf("service unavailable: %s", bodyStr)
case http.StatusNotFound:
cleanupResp()
// Mark download link as not found
// Regenerate a new download link
_log.Trace().Msgf("File not found (404) for %s. Marking link as invalid and regenerating", f.name)
f.cache.MarkDownloadLinkAsInvalid(f.link, downloadLink, "link_not_found")
// Generate a new download link
downloadLink, err := f.getDownloadLink()
if err != nil {
_log.Trace().Msgf("Failed to get download link for %s. %s", f.name, err)
return nil, err
}
if downloadLink == "" {
_log.Trace().Msgf("Failed to get download link for %s", f.name)
return nil, fmt.Errorf("failed to regenerate download link")
}
req, err := http.NewRequest("GET", downloadLink, nil)
if err != nil {
return nil, err
}
// Set the range header again
if byteRange == nil {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", max(0, f.offset)))
} else {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", byteRange[0]+max(0, f.offset)))
}
newResp, err := client.Do(req)
if err != nil {
return nil, err
}
if newResp.StatusCode != http.StatusOK && newResp.StatusCode != http.StatusPartialContent {
cleanupBody := func() {
if newResp.Body != nil {
_, _ = io.Copy(io.Discard, newResp.Body)
newResp.Body.Close()
}
}
cleanupBody()
_log.Trace().Msgf("Regenerated link also failed with status %d", newResp.StatusCode)
f.cache.MarkDownloadLinkAsInvalid(f.link, downloadLink, "link_not_found")
return nil, fmt.Errorf("failed with status code %d even after link regeneration", newResp.StatusCode)
}
return newResp, nil
default:
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
_log.Trace().Msgf("Unexpected status code %d for %s: %s", resp.StatusCode, f.name, string(body))
return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body))
}
}
return resp, nil
}
func (f *File) Read(p []byte) (n int, err error) {
if f.isDir {
return 0, os.ErrInvalid
}
if f.metadataOnly {
return 0, io.EOF
}
if f.content != nil {
if f.offset >= int64(len(f.content)) {
return 0, io.EOF
}
n = copy(p, f.content[f.offset:])
f.offset += int64(n)
return n, nil
}
// If we haven't started streaming the file yet or need to reposition
if f.reader == nil || f.seekPending {
if f.reader != nil {
f.reader.Close()
f.reader = nil
}
// Make the request to get the file
resp, err := f.stream()
if err != nil {
return 0, err
}
if resp == nil {
return 0, fmt.Errorf("stream returned nil response")
}
f.reader = resp.Body
f.seekPending = false
}
n, err = f.reader.Read(p)
f.offset += int64(n)
if err != nil {
f.reader.Close()
f.reader = nil
}
return n, err
}
func (f *File) Seek(offset int64, whence int) (int64, error) {
if f.isDir {
return 0, os.ErrInvalid
}
newOffset := f.offset
switch whence {
case io.SeekStart:
newOffset = offset
case io.SeekCurrent:
newOffset += offset
case io.SeekEnd:
newOffset = f.size + offset
default:
return 0, os.ErrInvalid
}
if newOffset < 0 {
newOffset = 0
}
if newOffset > f.size {
newOffset = f.size
}
// Only mark seek as pending if position actually changed
if newOffset != f.offset {
f.offset = newOffset
f.seekPending = true
}
return f.offset, nil
}
func (f *File) Stat() (os.FileInfo, error) {
if f.isDir {
return &FileInfo{
name: f.name,
size: 0,
mode: 0755 | os.ModeDir,
modTime: f.modTime,
isDir: true,
}, nil
}
return &FileInfo{
name: f.name,
size: f.size,
mode: 0644,
modTime: f.modTime,
isDir: false,
}, nil
}
func (f *File) ReadAt(p []byte, off int64) (n int, err error) {
// Save current position
// Seek to requested position
_, err = f.Seek(off, io.SeekStart)
if err != nil {
return 0, err
}
// Read the data
n, err = f.Read(p)
return n, err
}
func (f *File) Write(p []byte) (n int, err error) {
return 0, os.ErrPermission
}
func (f *File) Readdir(count int) ([]os.FileInfo, error) {
if !f.isDir {
return nil, os.ErrInvalid
}
if count <= 0 {
return f.children, nil
}
if len(f.children) == 0 {
return nil, io.EOF
}
if count > len(f.children) {
count = len(f.children)
}
files := f.children[:count]
f.children = f.children[count:]
return files, nil
}