Implementing a streaming setup with Usenet
This commit is contained in:
@@ -1,472 +1,8 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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{
|
||||
Transport: streamingTransport,
|
||||
Timeout: 0,
|
||||
}
|
||||
|
||||
type streamError struct {
|
||||
Err error
|
||||
StatusCode int
|
||||
IsClientDisconnection bool
|
||||
}
|
||||
|
||||
func (e *streamError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *streamError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type File struct {
|
||||
name string
|
||||
torrentName string
|
||||
link string
|
||||
downloadLink string
|
||||
size int64
|
||||
isDir bool
|
||||
fileId string
|
||||
isRar bool
|
||||
metadataOnly bool
|
||||
content []byte
|
||||
children []os.FileInfo // For directories
|
||||
cache *store.Cache
|
||||
modTime time.Time
|
||||
|
||||
// Minimal state for interface compliance only
|
||||
readOffset int64 // Only used for Read() method compliance
|
||||
}
|
||||
|
||||
// File interface implementations for File
|
||||
|
||||
func (f *File) Close() error {
|
||||
if f.isDir {
|
||||
return nil // No resources to close for directories
|
||||
}
|
||||
|
||||
// For files, we don't have any resources to close either
|
||||
// This is just to satisfy the os.File interface
|
||||
f.content = nil
|
||||
f.children = nil
|
||||
f.downloadLink = ""
|
||||
f.readOffset = 0
|
||||
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) servePreloadedContent(w http.ResponseWriter, r *http.Request) error {
|
||||
content := f.content
|
||||
size := int64(len(content))
|
||||
|
||||
// Handle range requests for preloaded content
|
||||
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
|
||||
ranges, err := parseRange(rangeHeader, size)
|
||||
if err != nil || len(ranges) != 1 {
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size))
|
||||
return &streamError{Err: fmt.Errorf("invalid range"), StatusCode: http.StatusRequestedRangeNotSatisfiable}
|
||||
}
|
||||
|
||||
start, end := ranges[0].start, ranges[0].end
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size))
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", end-start+1))
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
|
||||
_, err = w.Write(content[start : end+1])
|
||||
return err
|
||||
}
|
||||
|
||||
// Full content
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
_, err := w.Write(content)
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *File) StreamResponse(w http.ResponseWriter, r *http.Request) error {
|
||||
// Handle preloaded content files
|
||||
if f.content != nil {
|
||||
return f.servePreloadedContent(w, r)
|
||||
}
|
||||
|
||||
// Try streaming with retry logic
|
||||
return f.streamWithRetry(w, r, 0)
|
||||
}
|
||||
|
||||
func (f *File) streamWithRetry(w http.ResponseWriter, r *http.Request, retryCount int) error {
|
||||
const maxRetries = 3
|
||||
_log := f.cache.Logger()
|
||||
|
||||
// Get download link (with caching optimization)
|
||||
downloadLink, err := f.getDownloadLink()
|
||||
if err != nil {
|
||||
return &streamError{Err: err, StatusCode: http.StatusPreconditionFailed}
|
||||
}
|
||||
|
||||
if downloadLink == "" {
|
||||
return &streamError{Err: fmt.Errorf("empty download link"), StatusCode: http.StatusNotFound}
|
||||
}
|
||||
|
||||
// Create upstream request with streaming optimizations
|
||||
upstreamReq, err := http.NewRequest("GET", downloadLink, nil)
|
||||
if err != nil {
|
||||
return &streamError{Err: err, StatusCode: http.StatusInternalServerError}
|
||||
}
|
||||
|
||||
setVideoStreamingHeaders(upstreamReq)
|
||||
|
||||
// Handle range requests (critical for video seeking)
|
||||
isRangeRequest := f.handleRangeRequest(upstreamReq, r, w)
|
||||
if isRangeRequest == -1 {
|
||||
return &streamError{Err: fmt.Errorf("invalid range"), StatusCode: http.StatusRequestedRangeNotSatisfiable}
|
||||
}
|
||||
|
||||
resp, err := sharedClient.Do(upstreamReq)
|
||||
if err != nil {
|
||||
return &streamError{Err: err, StatusCode: http.StatusServiceUnavailable}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Handle upstream errors with retry logic
|
||||
shouldRetry, retryErr := f.handleUpstream(resp, retryCount, maxRetries)
|
||||
if shouldRetry && retryCount < maxRetries {
|
||||
// Retry with new download link
|
||||
_log.Debug().
|
||||
Int("retry_count", retryCount+1).
|
||||
Str("file", f.name).
|
||||
Msg("Retrying stream request")
|
||||
return f.streamWithRetry(w, r, retryCount+1)
|
||||
}
|
||||
if retryErr != nil {
|
||||
return retryErr
|
||||
}
|
||||
|
||||
setVideoResponseHeaders(w, resp, isRangeRequest == 1)
|
||||
|
||||
return f.streamBuffer(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) {
|
||||
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
_log := f.cache.Logger()
|
||||
|
||||
// Clean up response body properly
|
||||
cleanupResp := func(resp *http.Response) {
|
||||
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)
|
||||
cleanupResp(resp)
|
||||
|
||||
if readErr != nil {
|
||||
_log.Error().Err(readErr).Msg("Failed to read response body")
|
||||
return false, &streamError{
|
||||
Err: fmt.Errorf("failed to read error response: %w", readErr),
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
bodyStr := string(body)
|
||||
if strings.Contains(bodyStr, "you have exceeded your traffic") {
|
||||
_log.Debug().
|
||||
Str("file", f.name).
|
||||
Int("retry_count", retryCount).
|
||||
Msg("Bandwidth exceeded. Marking link as invalid")
|
||||
|
||||
f.cache.MarkDownloadLinkAsInvalid(f.link, f.downloadLink, "bandwidth_exceeded")
|
||||
|
||||
// Retry with a different API key if available and we haven't exceeded retries
|
||||
if retryCount < maxRetries {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, &streamError{
|
||||
Err: fmt.Errorf("bandwidth exceeded after %d retries", retryCount),
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
return false, &streamError{
|
||||
Err: fmt.Errorf("service unavailable: %s", bodyStr),
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
}
|
||||
|
||||
case http.StatusNotFound:
|
||||
cleanupResp(resp)
|
||||
|
||||
_log.Debug().
|
||||
Str("file", f.name).
|
||||
Int("retry_count", retryCount).
|
||||
Msg("Link not found (404). Marking link as invalid and regenerating")
|
||||
|
||||
f.cache.MarkDownloadLinkAsInvalid(f.link, f.downloadLink, "link_not_found")
|
||||
|
||||
// Try to regenerate download link if we haven't exceeded retries
|
||||
if retryCount < maxRetries {
|
||||
// Clear cached link to force regeneration
|
||||
f.downloadLink = ""
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, &streamError{
|
||||
Err: fmt.Errorf("file not found after %d retries", retryCount),
|
||||
StatusCode: http.StatusNotFound,
|
||||
}
|
||||
|
||||
default:
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
cleanupResp(resp)
|
||||
|
||||
_log.Error().
|
||||
Int("status_code", resp.StatusCode).
|
||||
Str("file", f.name).
|
||||
Str("response_body", string(body)).
|
||||
Msg("Unexpected upstream error")
|
||||
|
||||
return false, &streamError{
|
||||
Err: fmt.Errorf("upstream error %d: %s", resp.StatusCode, string(body)),
|
||||
StatusCode: http.StatusBadGateway,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *File) handleRangeRequest(upstreamReq *http.Request, r *http.Request, w http.ResponseWriter) int {
|
||||
rangeHeader := r.Header.Get("Range")
|
||||
if rangeHeader == "" {
|
||||
// For video files, apply byte range if exists
|
||||
if byteRange, _ := f.getDownloadByteRange(); byteRange != nil {
|
||||
upstreamReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", byteRange[0], byteRange[1]))
|
||||
}
|
||||
return 0 // No range request
|
||||
}
|
||||
|
||||
// Parse range request
|
||||
ranges, err := parseRange(rangeHeader, f.size)
|
||||
if err != nil || len(ranges) != 1 {
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", f.size))
|
||||
return -1 // Invalid range
|
||||
}
|
||||
|
||||
// Apply byte range offset if exists
|
||||
byteRange, _ := f.getDownloadByteRange()
|
||||
start, end := ranges[0].start, ranges[0].end
|
||||
|
||||
if byteRange != nil {
|
||||
start += byteRange[0]
|
||||
end += byteRange[0]
|
||||
}
|
||||
|
||||
upstreamReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end))
|
||||
return 1 // Valid range request
|
||||
}
|
||||
|
||||
/*
|
||||
These are the methods that implement the os.File interface for the File type.
|
||||
Only Stat and ReadDir are used
|
||||
*/
|
||||
|
||||
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) Read(p []byte) (n int, err error) {
|
||||
if f.isDir {
|
||||
return 0, os.ErrInvalid
|
||||
}
|
||||
|
||||
if f.metadataOnly {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
// For preloaded content files (like version.txt)
|
||||
if f.content != nil {
|
||||
if f.readOffset >= int64(len(f.content)) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n = copy(p, f.content[f.readOffset:])
|
||||
f.readOffset += int64(n)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// For streaming files, return an error to force use of StreamResponse
|
||||
return 0, fmt.Errorf("use StreamResponse method for streaming files")
|
||||
}
|
||||
|
||||
func (f *File) Seek(offset int64, whence int) (int64, error) {
|
||||
if f.isDir {
|
||||
return 0, os.ErrInvalid
|
||||
}
|
||||
|
||||
// Only handle seeking for preloaded content
|
||||
if f.content != nil {
|
||||
newOffset := f.readOffset
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
newOffset = offset
|
||||
case io.SeekCurrent:
|
||||
newOffset += offset
|
||||
case io.SeekEnd:
|
||||
newOffset = int64(len(f.content)) + offset
|
||||
default:
|
||||
return 0, os.ErrInvalid
|
||||
}
|
||||
|
||||
if newOffset < 0 {
|
||||
newOffset = 0
|
||||
}
|
||||
if newOffset > int64(len(f.content)) {
|
||||
newOffset = int64(len(f.content))
|
||||
}
|
||||
|
||||
f.readOffset = newOffset
|
||||
return f.readOffset, nil
|
||||
}
|
||||
|
||||
// For streaming files, return error to force use of StreamResponse
|
||||
return 0, fmt.Errorf("use StreamResponse method for streaming files")
|
||||
}
|
||||
|
||||
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
|
||||
type File interface {
|
||||
Name() string
|
||||
Size() int64
|
||||
IsDir() bool
|
||||
ModTime() string
|
||||
}
|
||||
|
||||
@@ -240,3 +240,28 @@ func setVideoResponseHeaders(w http.ResponseWriter, resp *http.Response, isRange
|
||||
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
}
|
||||
|
||||
func getContentType(fileName string) string {
|
||||
contentType := "application/octet-stream"
|
||||
|
||||
// Determine content type based on file extension
|
||||
switch {
|
||||
case strings.HasSuffix(fileName, ".mp4"):
|
||||
contentType = "video/mp4"
|
||||
case strings.HasSuffix(fileName, ".mkv"):
|
||||
contentType = "video/x-matroska"
|
||||
case strings.HasSuffix(fileName, ".avi"):
|
||||
contentType = "video/x-msvideo"
|
||||
case strings.HasSuffix(fileName, ".mov"):
|
||||
contentType = "video/quicktime"
|
||||
case strings.HasSuffix(fileName, ".m4v"):
|
||||
contentType = "video/x-m4v"
|
||||
case strings.HasSuffix(fileName, ".ts"):
|
||||
contentType = "video/mp2t"
|
||||
case strings.HasSuffix(fileName, ".srt"):
|
||||
contentType = "application/x-subrip"
|
||||
case strings.HasSuffix(fileName, ".vtt"):
|
||||
contentType = "text/vtt"
|
||||
}
|
||||
return contentType
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package webdav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stanNthe5/stringbuf"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -18,7 +19,7 @@ const (
|
||||
metadataOnlyKey contextKey = "metadataOnly"
|
||||
)
|
||||
|
||||
func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) {
|
||||
func handlePropfind(h Handler, logger zerolog.Logger, w http.ResponseWriter, r *http.Request) {
|
||||
// Setup context for metadata only
|
||||
ctx := context.WithValue(r.Context(), metadataOnlyKey, true)
|
||||
r = r.WithContext(ctx)
|
||||
@@ -37,7 +38,7 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) {
|
||||
// Always include the resource itself
|
||||
f, err := h.OpenFile(r.Context(), cleanPath, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
h.logger.Error().Err(err).Str("path", cleanPath).Msg("Failed to open file")
|
||||
logger.Error().Err(err).Str("path", cleanPath).Msg("Failed to open file")
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -45,14 +46,14 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
h.logger.Error().Err(err).Msg("Failed to stat file")
|
||||
logger.Error().Err(err).Msg("Failed to stat file")
|
||||
http.Error(w, "Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var rawEntries []os.FileInfo
|
||||
if fi.IsDir() {
|
||||
rawEntries = append(rawEntries, h.getChildren(cleanPath)...)
|
||||
rawEntries = append(rawEntries, h.GetChildren(cleanPath)...)
|
||||
}
|
||||
|
||||
entries := make([]entry, 0, len(rawEntries)+1)
|
||||
|
||||
472
pkg/webdav/torrent_file.go
Normal file
472
pkg/webdav/torrent_file.go
Normal file
@@ -0,0 +1,472 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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{
|
||||
Transport: streamingTransport,
|
||||
Timeout: 0,
|
||||
}
|
||||
|
||||
type streamError struct {
|
||||
Err error
|
||||
StatusCode int
|
||||
IsClientDisconnection bool
|
||||
}
|
||||
|
||||
func (e *streamError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *streamError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
type TorrentFile struct {
|
||||
name string
|
||||
torrentName string
|
||||
link string
|
||||
downloadLink string
|
||||
size int64
|
||||
isDir bool
|
||||
fileId string
|
||||
isRar bool
|
||||
metadataOnly bool
|
||||
content []byte
|
||||
children []os.FileInfo // For directories
|
||||
cache *store.Cache
|
||||
modTime time.Time
|
||||
|
||||
// Minimal state for interface compliance only
|
||||
readOffset int64 // Only used for Read() method compliance
|
||||
}
|
||||
|
||||
// TorrentFile interface implementations for TorrentFile
|
||||
|
||||
func (f *TorrentFile) Close() error {
|
||||
if f.isDir {
|
||||
return nil // No resources to close for directories
|
||||
}
|
||||
|
||||
// For files, we don't have any resources to close either
|
||||
// This is just to satisfy the os.TorrentFile interface
|
||||
f.content = nil
|
||||
f.children = nil
|
||||
f.downloadLink = ""
|
||||
f.readOffset = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *TorrentFile) 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 *TorrentFile) getDownloadByteRange() (*[2]int64, error) {
|
||||
byteRange, err := f.cache.GetDownloadByteRange(f.torrentName, f.name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return byteRange, nil
|
||||
}
|
||||
|
||||
func (f *TorrentFile) servePreloadedContent(w http.ResponseWriter, r *http.Request) error {
|
||||
content := f.content
|
||||
size := int64(len(content))
|
||||
|
||||
// Handle range requests for preloaded content
|
||||
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
|
||||
ranges, err := parseRange(rangeHeader, size)
|
||||
if err != nil || len(ranges) != 1 {
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size))
|
||||
return &streamError{Err: fmt.Errorf("invalid range"), StatusCode: http.StatusRequestedRangeNotSatisfiable}
|
||||
}
|
||||
|
||||
start, end := ranges[0].start, ranges[0].end
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size))
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", end-start+1))
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
|
||||
_, err = w.Write(content[start : end+1])
|
||||
return err
|
||||
}
|
||||
|
||||
// Full content
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
_, err := w.Write(content)
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *TorrentFile) StreamResponse(w http.ResponseWriter, r *http.Request) error {
|
||||
// Handle preloaded content files
|
||||
if f.content != nil {
|
||||
return f.servePreloadedContent(w, r)
|
||||
}
|
||||
|
||||
// Try streaming with retry logic
|
||||
return f.streamWithRetry(w, r, 0)
|
||||
}
|
||||
|
||||
func (f *TorrentFile) streamWithRetry(w http.ResponseWriter, r *http.Request, retryCount int) error {
|
||||
const maxRetries = 3
|
||||
_log := f.cache.Logger()
|
||||
|
||||
// Get download link (with caching optimization)
|
||||
downloadLink, err := f.getDownloadLink()
|
||||
if err != nil {
|
||||
return &streamError{Err: err, StatusCode: http.StatusPreconditionFailed}
|
||||
}
|
||||
|
||||
if downloadLink == "" {
|
||||
return &streamError{Err: fmt.Errorf("empty download link"), StatusCode: http.StatusNotFound}
|
||||
}
|
||||
|
||||
// Create upstream request with streaming optimizations
|
||||
upstreamReq, err := http.NewRequest("GET", downloadLink, nil)
|
||||
if err != nil {
|
||||
return &streamError{Err: err, StatusCode: http.StatusInternalServerError}
|
||||
}
|
||||
|
||||
setVideoStreamingHeaders(upstreamReq)
|
||||
|
||||
// Handle range requests (critical for video seeking)
|
||||
isRangeRequest := f.handleRangeRequest(upstreamReq, r, w)
|
||||
if isRangeRequest == -1 {
|
||||
return &streamError{Err: fmt.Errorf("invalid range"), StatusCode: http.StatusRequestedRangeNotSatisfiable}
|
||||
}
|
||||
|
||||
resp, err := sharedClient.Do(upstreamReq)
|
||||
if err != nil {
|
||||
return &streamError{Err: err, StatusCode: http.StatusServiceUnavailable}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Handle upstream errors with retry logic
|
||||
shouldRetry, retryErr := f.handleUpstream(resp, retryCount, maxRetries)
|
||||
if shouldRetry && retryCount < maxRetries {
|
||||
// Retry with new download link
|
||||
_log.Debug().
|
||||
Int("retry_count", retryCount+1).
|
||||
Str("file", f.name).
|
||||
Msg("Retrying stream request")
|
||||
return f.streamWithRetry(w, r, retryCount+1)
|
||||
}
|
||||
if retryErr != nil {
|
||||
return retryErr
|
||||
}
|
||||
|
||||
setVideoResponseHeaders(w, resp, isRangeRequest == 1)
|
||||
|
||||
return f.streamBuffer(w, resp.Body)
|
||||
}
|
||||
|
||||
func (f *TorrentFile) 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 *TorrentFile) handleUpstream(resp *http.Response, retryCount, maxRetries int) (shouldRetry bool, err error) {
|
||||
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
_log := f.cache.Logger()
|
||||
|
||||
// Clean up response body properly
|
||||
cleanupResp := func(resp *http.Response) {
|
||||
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)
|
||||
cleanupResp(resp)
|
||||
|
||||
if readErr != nil {
|
||||
_log.Error().Err(readErr).Msg("Failed to read response body")
|
||||
return false, &streamError{
|
||||
Err: fmt.Errorf("failed to read error response: %w", readErr),
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
bodyStr := string(body)
|
||||
if strings.Contains(bodyStr, "you have exceeded your traffic") {
|
||||
_log.Debug().
|
||||
Str("file", f.name).
|
||||
Int("retry_count", retryCount).
|
||||
Msg("Bandwidth exceeded. Marking link as invalid")
|
||||
|
||||
f.cache.MarkDownloadLinkAsInvalid(f.link, f.downloadLink, "bandwidth_exceeded")
|
||||
|
||||
// Retry with a different API key if available and we haven't exceeded retries
|
||||
if retryCount < maxRetries {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, &streamError{
|
||||
Err: fmt.Errorf("bandwidth exceeded after %d retries", retryCount),
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
return false, &streamError{
|
||||
Err: fmt.Errorf("service unavailable: %s", bodyStr),
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
}
|
||||
|
||||
case http.StatusNotFound:
|
||||
cleanupResp(resp)
|
||||
|
||||
_log.Debug().
|
||||
Str("file", f.name).
|
||||
Int("retry_count", retryCount).
|
||||
Msg("Link not found (404). Marking link as invalid and regenerating")
|
||||
|
||||
f.cache.MarkDownloadLinkAsInvalid(f.link, f.downloadLink, "link_not_found")
|
||||
|
||||
// Try to regenerate download link if we haven't exceeded retries
|
||||
if retryCount < maxRetries {
|
||||
// Clear cached link to force regeneration
|
||||
f.downloadLink = ""
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, &streamError{
|
||||
Err: fmt.Errorf("file not found after %d retries", retryCount),
|
||||
StatusCode: http.StatusNotFound,
|
||||
}
|
||||
|
||||
default:
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
cleanupResp(resp)
|
||||
|
||||
_log.Error().
|
||||
Int("status_code", resp.StatusCode).
|
||||
Str("file", f.name).
|
||||
Str("response_body", string(body)).
|
||||
Msg("Unexpected upstream error")
|
||||
|
||||
return false, &streamError{
|
||||
Err: fmt.Errorf("upstream error %d: %s", resp.StatusCode, string(body)),
|
||||
StatusCode: http.StatusBadGateway,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *TorrentFile) handleRangeRequest(upstreamReq *http.Request, r *http.Request, w http.ResponseWriter) int {
|
||||
rangeHeader := r.Header.Get("Range")
|
||||
if rangeHeader == "" {
|
||||
// For video files, apply byte range if exists
|
||||
if byteRange, _ := f.getDownloadByteRange(); byteRange != nil {
|
||||
upstreamReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", byteRange[0], byteRange[1]))
|
||||
}
|
||||
return 0 // No range request
|
||||
}
|
||||
|
||||
// Parse range request
|
||||
ranges, err := parseRange(rangeHeader, f.size)
|
||||
if err != nil || len(ranges) != 1 {
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", f.size))
|
||||
return -1 // Invalid range
|
||||
}
|
||||
|
||||
// Apply byte range offset if exists
|
||||
byteRange, _ := f.getDownloadByteRange()
|
||||
start, end := ranges[0].start, ranges[0].end
|
||||
|
||||
if byteRange != nil {
|
||||
start += byteRange[0]
|
||||
end += byteRange[0]
|
||||
}
|
||||
|
||||
upstreamReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end))
|
||||
return 1 // Valid range request
|
||||
}
|
||||
|
||||
/*
|
||||
These are the methods that implement the os.TorrentFile interface for the TorrentFile type.
|
||||
Only Stat and ReadDir are used
|
||||
*/
|
||||
|
||||
func (f *TorrentFile) 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 *TorrentFile) Read(p []byte) (n int, err error) {
|
||||
if f.isDir {
|
||||
return 0, os.ErrInvalid
|
||||
}
|
||||
|
||||
if f.metadataOnly {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
// For preloaded content files (like version.txt)
|
||||
if f.content != nil {
|
||||
if f.readOffset >= int64(len(f.content)) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n = copy(p, f.content[f.readOffset:])
|
||||
f.readOffset += int64(n)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// For streaming files, return an error to force use of StreamResponse
|
||||
return 0, fmt.Errorf("use StreamResponse method for streaming files")
|
||||
}
|
||||
|
||||
func (f *TorrentFile) Seek(offset int64, whence int) (int64, error) {
|
||||
if f.isDir {
|
||||
return 0, os.ErrInvalid
|
||||
}
|
||||
|
||||
// Only handle seeking for preloaded content
|
||||
if f.content != nil {
|
||||
newOffset := f.readOffset
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
newOffset = offset
|
||||
case io.SeekCurrent:
|
||||
newOffset += offset
|
||||
case io.SeekEnd:
|
||||
newOffset = int64(len(f.content)) + offset
|
||||
default:
|
||||
return 0, os.ErrInvalid
|
||||
}
|
||||
|
||||
if newOffset < 0 {
|
||||
newOffset = 0
|
||||
}
|
||||
if newOffset > int64(len(f.content)) {
|
||||
newOffset = int64(len(f.content))
|
||||
}
|
||||
|
||||
f.readOffset = newOffset
|
||||
return f.readOffset, nil
|
||||
}
|
||||
|
||||
// For streaming files, return error to force use of StreamResponse
|
||||
return 0, fmt.Errorf("use StreamResponse method for streaming files")
|
||||
}
|
||||
|
||||
func (f *TorrentFile) Write(p []byte) (n int, err error) {
|
||||
return 0, os.ErrPermission
|
||||
}
|
||||
|
||||
func (f *TorrentFile) 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
|
||||
}
|
||||
@@ -24,17 +24,17 @@ import (
|
||||
|
||||
const DeleteAllBadTorrentKey = "DELETE_ALL_BAD_TORRENTS"
|
||||
|
||||
type Handler struct {
|
||||
Name string
|
||||
type TorrentHandler struct {
|
||||
name string
|
||||
logger zerolog.Logger
|
||||
cache *store.Cache
|
||||
URLBase string
|
||||
RootPath string
|
||||
}
|
||||
|
||||
func NewHandler(name, urlBase string, cache *store.Cache, logger zerolog.Logger) *Handler {
|
||||
h := &Handler{
|
||||
Name: name,
|
||||
func NewTorrentHandler(name, urlBase string, cache *store.Cache, logger zerolog.Logger) Handler {
|
||||
h := &TorrentHandler{
|
||||
name: name,
|
||||
cache: cache,
|
||||
logger: logger,
|
||||
URLBase: urlBase,
|
||||
@@ -43,15 +43,18 @@ func NewHandler(name, urlBase string, cache *store.Cache, logger zerolog.Logger)
|
||||
return h
|
||||
}
|
||||
|
||||
// Mkdir implements webdav.FileSystem
|
||||
func (h *Handler) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
|
||||
return os.ErrPermission // Read-only filesystem
|
||||
func (ht *TorrentHandler) Start(ctx context.Context) error {
|
||||
return ht.cache.Start(ctx)
|
||||
}
|
||||
|
||||
func (h *Handler) readinessMiddleware(next http.Handler) http.Handler {
|
||||
func (ht *TorrentHandler) Type() string {
|
||||
return "torrent"
|
||||
}
|
||||
|
||||
func (ht *TorrentHandler) Readiness(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-h.cache.IsReady():
|
||||
case <-ht.cache.IsReady():
|
||||
// WebDAV is ready, proceed
|
||||
next.ServeHTTP(w, r)
|
||||
default:
|
||||
@@ -62,13 +65,23 @@ func (h *Handler) readinessMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// Name returns the name of the handler
|
||||
func (ht *TorrentHandler) Name() string {
|
||||
return ht.name
|
||||
}
|
||||
|
||||
// Mkdir implements webdav.FileSystem
|
||||
func (ht *TorrentHandler) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
|
||||
return os.ErrPermission // Read-only filesystem
|
||||
}
|
||||
|
||||
// RemoveAll implements webdav.FileSystem
|
||||
func (h *Handler) RemoveAll(ctx context.Context, name string) error {
|
||||
func (ht *TorrentHandler) RemoveAll(ctx context.Context, name string) error {
|
||||
if !strings.HasPrefix(name, "/") {
|
||||
name = "/" + name
|
||||
}
|
||||
name = utils.PathUnescape(path.Clean(name))
|
||||
rootDir := path.Clean(h.RootPath)
|
||||
rootDir := path.Clean(ht.RootPath)
|
||||
|
||||
if name == rootDir {
|
||||
return os.ErrPermission
|
||||
@@ -80,33 +93,33 @@ func (h *Handler) RemoveAll(ctx context.Context, name string) error {
|
||||
}
|
||||
|
||||
// Check if the name is a parent path
|
||||
if _, ok := h.isParentPath(name); ok {
|
||||
if _, ok := ht.isParentPath(name); ok {
|
||||
return os.ErrPermission
|
||||
}
|
||||
|
||||
// Check if the name is a torrent folder
|
||||
rel := strings.TrimPrefix(name, rootDir+"/")
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) == 2 && utils.Contains(h.getParentItems(), parts[0]) {
|
||||
if len(parts) == 2 && utils.Contains(ht.getParentItems(), parts[0]) {
|
||||
torrentName := parts[1]
|
||||
torrent := h.cache.GetTorrentByName(torrentName)
|
||||
torrent := ht.cache.GetTorrentByName(torrentName)
|
||||
if torrent == nil {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
// Remove the torrent from the cache and debrid
|
||||
h.cache.OnRemove(torrent.Id)
|
||||
ht.cache.OnRemove(torrent.Id)
|
||||
return nil
|
||||
}
|
||||
// If we reach here, it means the path is a file
|
||||
if len(parts) >= 2 {
|
||||
if utils.Contains(h.getParentItems(), parts[0]) {
|
||||
if utils.Contains(ht.getParentItems(), parts[0]) {
|
||||
torrentName := parts[1]
|
||||
cached := h.cache.GetTorrentByName(torrentName)
|
||||
cached := ht.cache.GetTorrentByName(torrentName)
|
||||
if cached != nil && len(parts) >= 3 {
|
||||
filename := filepath.Clean(path.Join(parts[2:]...))
|
||||
if file, ok := cached.GetFile(filename); ok {
|
||||
if err := h.cache.RemoveFile(cached.Id, file.Name); err != nil {
|
||||
h.logger.Error().Err(err).Msgf("Failed to remove file %s from torrent %s", file.Name, torrentName)
|
||||
if err := ht.cache.RemoveFile(cached.Id, file.Name); err != nil {
|
||||
ht.logger.Error().Err(err).Msgf("Failed to remove file %s from torrent %s", file.Name, torrentName)
|
||||
return err
|
||||
}
|
||||
// If the file was successfully removed, we can return nil
|
||||
@@ -120,29 +133,29 @@ func (h *Handler) RemoveAll(ctx context.Context, name string) error {
|
||||
}
|
||||
|
||||
// Rename implements webdav.FileSystem
|
||||
func (h *Handler) Rename(ctx context.Context, oldName, newName string) error {
|
||||
func (ht *TorrentHandler) Rename(ctx context.Context, oldName, newName string) error {
|
||||
return os.ErrPermission // Read-only filesystem
|
||||
}
|
||||
|
||||
func (h *Handler) getTorrentsFolders(folder string) []os.FileInfo {
|
||||
return h.cache.GetListing(folder)
|
||||
func (ht *TorrentHandler) getTorrentsFolders(folder string) []os.FileInfo {
|
||||
return ht.cache.GetListing(folder)
|
||||
}
|
||||
|
||||
func (h *Handler) getParentItems() []string {
|
||||
func (ht *TorrentHandler) getParentItems() []string {
|
||||
parents := []string{"__all__", "torrents", "__bad__"}
|
||||
|
||||
// Add custom folders
|
||||
parents = append(parents, h.cache.GetCustomFolders()...)
|
||||
parents = append(parents, ht.cache.GetCustomFolders()...)
|
||||
|
||||
// version.txt
|
||||
parents = append(parents, "version.txt")
|
||||
return parents
|
||||
}
|
||||
|
||||
func (h *Handler) getParentFiles() []os.FileInfo {
|
||||
func (ht *TorrentHandler) getParentFiles() []os.FileInfo {
|
||||
now := time.Now()
|
||||
rootFiles := make([]os.FileInfo, 0, len(h.getParentItems()))
|
||||
for _, item := range h.getParentItems() {
|
||||
rootFiles := make([]os.FileInfo, 0, len(ht.getParentItems()))
|
||||
for _, item := range ht.getParentItems() {
|
||||
f := &FileInfo{
|
||||
name: item,
|
||||
size: 0,
|
||||
@@ -159,49 +172,49 @@ func (h *Handler) getParentFiles() []os.FileInfo {
|
||||
return rootFiles
|
||||
}
|
||||
|
||||
// returns the os.FileInfo slice for “depth-1” children of cleanPath
|
||||
func (h *Handler) getChildren(name string) []os.FileInfo {
|
||||
// GetChildren returns the os.FileInfo slice for “depth-1” children of cleanPath
|
||||
func (ht *TorrentHandler) GetChildren(name string) []os.FileInfo {
|
||||
|
||||
if name[0] != '/' {
|
||||
name = "/" + name
|
||||
}
|
||||
name = utils.PathUnescape(path.Clean(name))
|
||||
root := path.Clean(h.RootPath)
|
||||
root := path.Clean(ht.RootPath)
|
||||
|
||||
// top‐level “parents” (e.g. __all__, torrents etc)
|
||||
if name == root {
|
||||
return h.getParentFiles()
|
||||
return ht.getParentFiles()
|
||||
}
|
||||
// one level down (e.g. /root/parentFolder)
|
||||
if parent, ok := h.isParentPath(name); ok {
|
||||
return h.getTorrentsFolders(parent)
|
||||
if parent, ok := ht.isParentPath(name); ok {
|
||||
return ht.getTorrentsFolders(parent)
|
||||
}
|
||||
// torrent-folder level (e.g. /root/parentFolder/torrentName)
|
||||
rel := strings.TrimPrefix(name, root+"/")
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) == 2 && utils.Contains(h.getParentItems(), parts[0]) {
|
||||
if len(parts) == 2 && utils.Contains(ht.getParentItems(), parts[0]) {
|
||||
torrentName := parts[1]
|
||||
if t := h.cache.GetTorrentByName(torrentName); t != nil {
|
||||
return h.getFileInfos(t)
|
||||
if t := ht.cache.GetTorrentByName(torrentName); t != nil {
|
||||
return ht.getFileInfos(t)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
|
||||
func (ht *TorrentHandler) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
|
||||
if !strings.HasPrefix(name, "/") {
|
||||
name = "/" + name
|
||||
}
|
||||
name = utils.PathUnescape(path.Clean(name))
|
||||
rootDir := path.Clean(h.RootPath)
|
||||
rootDir := path.Clean(ht.RootPath)
|
||||
metadataOnly := ctx.Value(metadataOnlyKey) != nil
|
||||
now := time.Now()
|
||||
|
||||
// 1) special case version.txt
|
||||
if name == path.Join(rootDir, "version.txt") {
|
||||
versionInfo := version.GetInfo().String()
|
||||
return &File{
|
||||
cache: h.cache,
|
||||
return &TorrentFile{
|
||||
cache: ht.cache,
|
||||
isDir: false,
|
||||
content: []byte(versionInfo),
|
||||
name: "version.txt",
|
||||
@@ -211,14 +224,14 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 2) directory case: ask getChildren
|
||||
if children := h.getChildren(name); children != nil {
|
||||
// 2) directory case: ask Children
|
||||
if children := ht.GetChildren(name); children != nil {
|
||||
displayName := filepath.Clean(path.Base(name))
|
||||
if name == rootDir {
|
||||
displayName = "/"
|
||||
}
|
||||
return &File{
|
||||
cache: h.cache,
|
||||
return &TorrentFile{
|
||||
cache: ht.cache,
|
||||
isDir: true,
|
||||
children: children,
|
||||
name: displayName,
|
||||
@@ -233,14 +246,14 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F
|
||||
rel := strings.TrimPrefix(name, rootDir+"/")
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) >= 2 {
|
||||
if utils.Contains(h.getParentItems(), parts[0]) {
|
||||
if utils.Contains(ht.getParentItems(), parts[0]) {
|
||||
torrentName := parts[1]
|
||||
cached := h.cache.GetTorrentByName(torrentName)
|
||||
cached := ht.cache.GetTorrentByName(torrentName)
|
||||
if cached != nil && len(parts) >= 3 {
|
||||
filename := filepath.Clean(path.Join(parts[2:]...))
|
||||
if file, ok := cached.GetFile(filename); ok && !file.Deleted {
|
||||
return &File{
|
||||
cache: h.cache,
|
||||
return &TorrentFile{
|
||||
cache: ht.cache,
|
||||
torrentName: torrentName,
|
||||
fileId: file.Id,
|
||||
isDir: false,
|
||||
@@ -255,21 +268,19 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h.logger.Info().Msgf("File not found: %s", name)
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
// Stat implements webdav.FileSystem
|
||||
func (h *Handler) Stat(ctx context.Context, name string) (os.FileInfo, error) {
|
||||
f, err := h.OpenFile(ctx, name, os.O_RDONLY, 0)
|
||||
func (ht *TorrentHandler) Stat(ctx context.Context, name string) (os.FileInfo, error) {
|
||||
f, err := ht.OpenFile(ctx, name, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.Stat()
|
||||
}
|
||||
|
||||
func (h *Handler) getFileInfos(torrent *store.CachedTorrent) []os.FileInfo {
|
||||
func (ht *TorrentHandler) getFileInfos(torrent *store.CachedTorrent) []os.FileInfo {
|
||||
torrentFiles := torrent.GetFiles()
|
||||
files := make([]os.FileInfo, 0, len(torrentFiles))
|
||||
|
||||
@@ -294,33 +305,33 @@ func (h *Handler) getFileInfos(torrent *store.CachedTorrent) []os.FileInfo {
|
||||
return files
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
func (ht *TorrentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
h.handleGet(w, r)
|
||||
ht.handleGet(w, r)
|
||||
return
|
||||
case "HEAD":
|
||||
h.handleHead(w, r)
|
||||
ht.handleHead(w, r)
|
||||
return
|
||||
case "OPTIONS":
|
||||
h.handleOptions(w, r)
|
||||
ht.handleOptions(w, r)
|
||||
return
|
||||
case "PROPFIND":
|
||||
h.handlePropfind(w, r)
|
||||
ht.handlePropfind(w, r)
|
||||
return
|
||||
case "DELETE":
|
||||
if err := h.handleDelete(w, r); err == nil {
|
||||
if err := ht.handleDelete(w, r); err == nil {
|
||||
return
|
||||
}
|
||||
// fallthrough to default
|
||||
}
|
||||
handler := &webdav.Handler{
|
||||
FileSystem: h,
|
||||
FileSystem: ht,
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
Logger: func(r *http.Request, err error) {
|
||||
if err != nil {
|
||||
h.logger.Trace().
|
||||
ht.logger.Trace().
|
||||
Err(err).
|
||||
Str("method", r.Method).
|
||||
Str("path", r.URL.Path).
|
||||
@@ -331,33 +342,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func getContentType(fileName string) string {
|
||||
contentType := "application/octet-stream"
|
||||
|
||||
// Determine content type based on file extension
|
||||
switch {
|
||||
case strings.HasSuffix(fileName, ".mp4"):
|
||||
contentType = "video/mp4"
|
||||
case strings.HasSuffix(fileName, ".mkv"):
|
||||
contentType = "video/x-matroska"
|
||||
case strings.HasSuffix(fileName, ".avi"):
|
||||
contentType = "video/x-msvideo"
|
||||
case strings.HasSuffix(fileName, ".mov"):
|
||||
contentType = "video/quicktime"
|
||||
case strings.HasSuffix(fileName, ".m4v"):
|
||||
contentType = "video/x-m4v"
|
||||
case strings.HasSuffix(fileName, ".ts"):
|
||||
contentType = "video/mp2t"
|
||||
case strings.HasSuffix(fileName, ".srt"):
|
||||
contentType = "application/x-subrip"
|
||||
case strings.HasSuffix(fileName, ".vtt"):
|
||||
contentType = "text/vtt"
|
||||
}
|
||||
return contentType
|
||||
}
|
||||
|
||||
func (h *Handler) isParentPath(urlPath string) (string, bool) {
|
||||
parents := h.getParentItems()
|
||||
func (ht *TorrentHandler) isParentPath(urlPath string) (string, bool) {
|
||||
parents := ht.getParentItems()
|
||||
lastComponent := path.Base(urlPath)
|
||||
for _, p := range parents {
|
||||
if p == lastComponent {
|
||||
@@ -367,9 +353,9 @@ func (h *Handler) isParentPath(urlPath string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (h *Handler) serveDirectory(w http.ResponseWriter, r *http.Request, file webdav.File) {
|
||||
func (ht *TorrentHandler) serveDirectory(w http.ResponseWriter, r *http.Request, file webdav.File) {
|
||||
var children []os.FileInfo
|
||||
if f, ok := file.(*File); ok {
|
||||
if f, ok := file.(*TorrentFile); ok {
|
||||
children = f.children
|
||||
} else {
|
||||
var err error
|
||||
@@ -385,7 +371,7 @@ func (h *Handler) serveDirectory(w http.ResponseWriter, r *http.Request, file we
|
||||
parentPath := path.Dir(cleanPath)
|
||||
showParent := cleanPath != "/" && parentPath != "." && parentPath != cleanPath
|
||||
isBadPath := strings.HasSuffix(cleanPath, "__bad__")
|
||||
_, canDelete := h.isParentPath(cleanPath)
|
||||
_, canDelete := ht.isParentPath(cleanPath)
|
||||
|
||||
// Prepare template data
|
||||
data := struct {
|
||||
@@ -402,7 +388,7 @@ func (h *Handler) serveDirectory(w http.ResponseWriter, r *http.Request, file we
|
||||
ParentPath: parentPath,
|
||||
ShowParent: showParent,
|
||||
Children: children,
|
||||
URLBase: h.URLBase,
|
||||
URLBase: ht.URLBase,
|
||||
IsBadPath: isBadPath,
|
||||
CanDelete: canDelete,
|
||||
DeleteAllBadTorrentKey: DeleteAllBadTorrentKey,
|
||||
@@ -416,8 +402,8 @@ func (h *Handler) serveDirectory(w http.ResponseWriter, r *http.Request, file we
|
||||
|
||||
// Handlers
|
||||
|
||||
func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
fRaw, err := h.OpenFile(r.Context(), r.URL.Path, os.O_RDONLY, 0)
|
||||
func (ht *TorrentHandler) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
fRaw, err := ht.OpenFile(r.Context(), r.URL.Path, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -431,7 +417,7 @@ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if fi.IsDir() {
|
||||
h.serveDirectory(w, r, fRaw)
|
||||
ht.serveDirectory(w, r, fRaw)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -448,9 +434,9 @@ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Handle File struct with direct streaming
|
||||
if file, ok := fRaw.(*File); ok {
|
||||
if file, ok := fRaw.(*TorrentFile); ok {
|
||||
// Handle nginx proxy (X-Accel-Redirect)
|
||||
if file.content == nil && !file.isRar && h.cache.StreamWithRclone() {
|
||||
if file.content == nil && !file.isRar && ht.cache.StreamWithRclone() {
|
||||
link, err := file.getDownloadLink()
|
||||
if err != nil || link == "" {
|
||||
http.Error(w, "Could not fetch download link", http.StatusPreconditionFailed)
|
||||
@@ -475,7 +461,7 @@ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
if streamErr.StatusCode > 0 && !hasHeadersWritten(w) {
|
||||
http.Error(w, streamErr.Error(), streamErr.StatusCode)
|
||||
} else {
|
||||
h.logger.Error().
|
||||
ht.logger.Error().
|
||||
Err(streamErr.Err).
|
||||
Str("path", r.URL.Path).
|
||||
Msg("Stream error")
|
||||
@@ -485,7 +471,7 @@ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
if !hasHeadersWritten(w) {
|
||||
http.Error(w, "Stream error", http.StatusInternalServerError)
|
||||
} else {
|
||||
h.logger.Error().
|
||||
ht.logger.Error().
|
||||
Err(err).
|
||||
Str("path", r.URL.Path).
|
||||
Msg("Stream error after headers written")
|
||||
@@ -505,10 +491,14 @@ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleHead(w http.ResponseWriter, r *http.Request) {
|
||||
f, err := h.OpenFile(r.Context(), r.URL.Path, os.O_RDONLY, 0)
|
||||
func (ht *TorrentHandler) handlePropfind(w http.ResponseWriter, r *http.Request) {
|
||||
handlePropfind(ht, ht.logger, w, r)
|
||||
}
|
||||
|
||||
func (ht *TorrentHandler) handleHead(w http.ResponseWriter, r *http.Request) {
|
||||
f, err := ht.OpenFile(r.Context(), r.URL.Path, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
h.logger.Error().Err(err).Str("path", r.URL.Path).Msg("Failed to open file")
|
||||
ht.logger.Error().Err(err).Str("path", r.URL.Path).Msg("Failed to open file")
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -521,7 +511,7 @@ func (h *Handler) handleHead(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
h.logger.Error().Err(err).Msg("Failed to stat file")
|
||||
ht.logger.Error().Err(err).Msg("Failed to stat file")
|
||||
http.Error(w, "Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -532,14 +522,14 @@ func (h *Handler) handleHead(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *Handler) handleOptions(w http.ResponseWriter, r *http.Request) {
|
||||
func (ht *TorrentHandler) handleOptions(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Allow", "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND")
|
||||
w.Header().Set("DAV", "1, 2")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
func (ht *TorrentHandler) handleDelete(w http.ResponseWriter, r *http.Request) error {
|
||||
cleanPath := path.Clean(r.URL.Path) // Remove any leading slashes
|
||||
|
||||
_, torrentId := path.Split(cleanPath)
|
||||
@@ -548,25 +538,25 @@ func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) error {
|
||||
}
|
||||
|
||||
if torrentId == DeleteAllBadTorrentKey {
|
||||
return h.handleDeleteAll(w)
|
||||
return ht.handleDeleteAll(w)
|
||||
}
|
||||
|
||||
return h.handleDeleteById(w, torrentId)
|
||||
return ht.handleDeleteById(w, torrentId)
|
||||
}
|
||||
|
||||
func (h *Handler) handleDeleteById(w http.ResponseWriter, tId string) error {
|
||||
cachedTorrent := h.cache.GetTorrent(tId)
|
||||
func (ht *TorrentHandler) handleDeleteById(w http.ResponseWriter, tId string) error {
|
||||
cachedTorrent := ht.cache.GetTorrent(tId)
|
||||
if cachedTorrent == nil {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
|
||||
h.cache.OnRemove(cachedTorrent.Id)
|
||||
ht.cache.OnRemove(cachedTorrent.Id)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) handleDeleteAll(w http.ResponseWriter) error {
|
||||
badTorrents := h.cache.GetListing("__bad__")
|
||||
func (ht *TorrentHandler) handleDeleteAll(w http.ResponseWriter) error {
|
||||
badTorrents := ht.cache.GetListing("__bad__")
|
||||
if len(badTorrents) == 0 {
|
||||
http.Error(w, "No bad torrents to delete", http.StatusNotFound)
|
||||
return nil
|
||||
@@ -574,9 +564,9 @@ func (h *Handler) handleDeleteAll(w http.ResponseWriter) error {
|
||||
|
||||
for _, fi := range badTorrents {
|
||||
tName := strings.TrimSpace(strings.SplitN(fi.Name(), "||", 2)[0])
|
||||
t := h.cache.GetTorrentByName(tName)
|
||||
t := ht.cache.GetTorrentByName(tName)
|
||||
if t != nil {
|
||||
h.cache.OnRemove(t.Id)
|
||||
ht.cache.OnRemove(t.Id)
|
||||
}
|
||||
}
|
||||
|
||||
263
pkg/webdav/usenet_file.go
Normal file
263
pkg/webdav/usenet_file.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/pkg/usenet"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UsenetFile struct {
|
||||
name string
|
||||
nzbID string
|
||||
downloadLink string
|
||||
size int64
|
||||
isDir bool
|
||||
fileId string
|
||||
metadataOnly bool
|
||||
content []byte
|
||||
children []os.FileInfo // For directories
|
||||
usenet usenet.Usenet
|
||||
modTime time.Time
|
||||
readOffset int64
|
||||
rPipe io.ReadCloser
|
||||
}
|
||||
|
||||
// UsenetFile interface implementations for UsenetFile
|
||||
|
||||
func (f *UsenetFile) Close() error {
|
||||
if f.isDir {
|
||||
return nil // No resources to close for directories
|
||||
}
|
||||
|
||||
f.content = nil
|
||||
f.children = nil
|
||||
f.downloadLink = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *UsenetFile) servePreloadedContent(w http.ResponseWriter, r *http.Request) error {
|
||||
content := f.content
|
||||
size := int64(len(content))
|
||||
|
||||
// Handle range requests for preloaded content
|
||||
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
|
||||
ranges, err := parseRange(rangeHeader, size)
|
||||
if err != nil || len(ranges) != 1 {
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size))
|
||||
return &streamError{Err: fmt.Errorf("invalid range"), StatusCode: http.StatusRequestedRangeNotSatisfiable}
|
||||
}
|
||||
|
||||
start, end := ranges[0].start, ranges[0].end
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size))
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", end-start+1))
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
|
||||
_, err = w.Write(content[start : end+1])
|
||||
return err
|
||||
}
|
||||
|
||||
// Full content
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
_, err := w.Write(content)
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *UsenetFile) StreamResponse(w http.ResponseWriter, r *http.Request) error {
|
||||
// Handle preloaded content files
|
||||
if f.content != nil {
|
||||
return f.servePreloadedContent(w, r)
|
||||
}
|
||||
|
||||
// Try streaming with retry logic
|
||||
return f.streamWithRetry(w, r, 0)
|
||||
}
|
||||
|
||||
func (f *UsenetFile) streamWithRetry(w http.ResponseWriter, r *http.Request, retryCount int) error {
|
||||
start, end := f.getRange(r)
|
||||
|
||||
if retryCount == 0 {
|
||||
contentLength := end - start + 1
|
||||
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", contentLength))
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
|
||||
if r.Header.Get("Range") != "" {
|
||||
contentRange := fmt.Sprintf("bytes %d-%d/%d", start, end, f.size)
|
||||
w.Header().Set("Content-Range", contentRange)
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
err := f.usenet.Stream(r.Context(), f.nzbID, f.name, start, end, w)
|
||||
|
||||
if err != nil {
|
||||
if isConnectionError(err) || strings.Contains(err.Error(), "client disconnected") {
|
||||
return nil
|
||||
}
|
||||
// Don't treat cancellation as an error - it's expected for seek operations
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
return &streamError{Err: fmt.Errorf("failed to stream file %s: %w", f.name, err), StatusCode: http.StatusInternalServerError}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isConnectionError checks if the error is due to client disconnection
|
||||
func isConnectionError(err error) bool {
|
||||
errStr := err.Error()
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) {
|
||||
return true // EOF or context cancellation is a common disconnection error
|
||||
}
|
||||
return strings.Contains(errStr, "broken pipe") ||
|
||||
strings.Contains(errStr, "connection reset by peer")
|
||||
}
|
||||
|
||||
func (f *UsenetFile) getRange(r *http.Request) (int64, int64) {
|
||||
rangeHeader := r.Header.Get("Range")
|
||||
if rangeHeader == "" {
|
||||
// No range header - return full file range (0 to size-1)
|
||||
return 0, f.size - 1
|
||||
}
|
||||
|
||||
// Parse the range header for this specific file
|
||||
ranges, err := parseRange(rangeHeader, f.size)
|
||||
if err != nil || len(ranges) != 1 {
|
||||
return -1, -1
|
||||
}
|
||||
|
||||
// Return the requested range (this is relative to the file, not the entire NZB)
|
||||
start, end := ranges[0].start, ranges[0].end
|
||||
if start < 0 || end < 0 || start > end || end >= f.size {
|
||||
return -1, -1 // Invalid range
|
||||
}
|
||||
|
||||
return start, end
|
||||
}
|
||||
|
||||
func (f *UsenetFile) Stat() (os.FileInfo, error) {
|
||||
if f.isDir {
|
||||
return &FileInfo{
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
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 *UsenetFile) Read(p []byte) (int, error) {
|
||||
if f.isDir {
|
||||
return 0, os.ErrInvalid
|
||||
}
|
||||
|
||||
// preloaded content (unchanged)
|
||||
if f.metadataOnly {
|
||||
return 0, io.EOF
|
||||
}
|
||||
if f.content != nil {
|
||||
if f.readOffset >= int64(len(f.content)) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(p, f.content[f.readOffset:])
|
||||
f.readOffset += int64(n)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
if f.rPipe == nil {
|
||||
pr, pw := io.Pipe()
|
||||
f.rPipe = pr
|
||||
|
||||
// start fetch from current offset
|
||||
go func(start int64) {
|
||||
err := f.usenet.Stream(context.Background(), f.nzbID, f.name, start, f.size-1, pw)
|
||||
if err := pw.CloseWithError(err); err != nil {
|
||||
return
|
||||
}
|
||||
}(f.readOffset)
|
||||
}
|
||||
|
||||
n, err := f.rPipe.Read(p)
|
||||
f.readOffset += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Seek simply moves the readOffset pointer within [0…size]
|
||||
func (f *UsenetFile) Seek(offset int64, whence int) (int64, error) {
|
||||
if f.isDir {
|
||||
return 0, os.ErrInvalid
|
||||
}
|
||||
|
||||
// preload path (unchanged)
|
||||
var newOffset int64
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
newOffset = offset
|
||||
case io.SeekCurrent:
|
||||
newOffset = f.readOffset + 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
|
||||
}
|
||||
|
||||
// drop in-flight stream
|
||||
if f.rPipe != nil {
|
||||
f.rPipe.Close()
|
||||
f.rPipe = nil
|
||||
}
|
||||
f.readOffset = newOffset
|
||||
return f.readOffset, nil
|
||||
}
|
||||
|
||||
func (f *UsenetFile) Write(_ []byte) (n int, err error) {
|
||||
return 0, os.ErrPermission
|
||||
}
|
||||
|
||||
func (f *UsenetFile) 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
|
||||
}
|
||||
529
pkg/webdav/usenet_handler.go
Normal file
529
pkg/webdav/usenet_handler.go
Normal file
@@ -0,0 +1,529 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/pkg/usenet"
|
||||
"golang.org/x/net/webdav"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/version"
|
||||
)
|
||||
|
||||
type UsenetHandler struct {
|
||||
name string
|
||||
logger zerolog.Logger
|
||||
usenet usenet.Usenet
|
||||
URLBase string
|
||||
RootPath string
|
||||
}
|
||||
|
||||
func NewUsenetHandler(name, urlBase string, usenet usenet.Usenet, logger zerolog.Logger) Handler {
|
||||
h := &UsenetHandler{
|
||||
name: name,
|
||||
usenet: usenet,
|
||||
logger: logger,
|
||||
URLBase: urlBase,
|
||||
RootPath: path.Join(urlBase, "webdav", name),
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (hu *UsenetHandler) Type() string {
|
||||
return "usenet"
|
||||
}
|
||||
|
||||
func (hu *UsenetHandler) Name() string {
|
||||
return hu.name
|
||||
}
|
||||
|
||||
func (hu *UsenetHandler) Start(ctx context.Context) error {
|
||||
return hu.usenet.Start(ctx)
|
||||
}
|
||||
|
||||
func (hu *UsenetHandler) Readiness(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-hu.usenet.IsReady():
|
||||
// WebDAV is ready, proceed
|
||||
next.ServeHTTP(w, r)
|
||||
default:
|
||||
// WebDAV is still initializing
|
||||
w.Header().Set("Retry-After", "5")
|
||||
http.Error(w, "WebDAV service is initializing, please try again shortly", http.StatusServiceUnavailable)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Mkdir implements webdav.FileSystem
|
||||
func (hu *UsenetHandler) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
|
||||
return os.ErrPermission // Read-only filesystem
|
||||
}
|
||||
|
||||
// RemoveAll implements webdav.FileSystem
|
||||
func (hu *UsenetHandler) RemoveAll(ctx context.Context, name string) error {
|
||||
if !strings.HasPrefix(name, "/") {
|
||||
name = "/" + name
|
||||
}
|
||||
name = utils.PathUnescape(path.Clean(name))
|
||||
rootDir := path.Clean(hu.RootPath)
|
||||
|
||||
if name == rootDir {
|
||||
return os.ErrPermission
|
||||
}
|
||||
|
||||
// Skip if it's version.txt
|
||||
if name == path.Join(rootDir, "version.txt") {
|
||||
return os.ErrPermission
|
||||
}
|
||||
|
||||
// Check if the name is a parent path
|
||||
if _, ok := hu.isParentPath(name); ok {
|
||||
return os.ErrPermission
|
||||
}
|
||||
|
||||
// Check if the name is a torrent folder
|
||||
rel := strings.TrimPrefix(name, rootDir+"/")
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) == 2 && utils.Contains(hu.getParentItems(), parts[0]) {
|
||||
nzb := hu.usenet.Store().GetByName(parts[1])
|
||||
if nzb == nil {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
// Remove the nzb from the store
|
||||
if err := hu.usenet.Store().Delete(nzb.ID); err != nil {
|
||||
hu.logger.Error().Err(err).Msgf("Failed to remove torrent %s", parts[1])
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// If we reach here, it means the path is a file
|
||||
if len(parts) >= 2 {
|
||||
if utils.Contains(hu.getParentItems(), parts[0]) {
|
||||
cached := hu.usenet.Store().GetByName(parts[1])
|
||||
if cached != nil && len(parts) >= 3 {
|
||||
filename := filepath.Clean(path.Join(parts[2:]...))
|
||||
if file := cached.GetFileByName(filename); file != nil {
|
||||
if err := hu.usenet.Store().RemoveFile(cached.ID, file.Name); err != nil {
|
||||
hu.logger.Error().Err(err).Msgf("Failed to remove file %s from torrent %s", file.Name, parts[1])
|
||||
return err
|
||||
}
|
||||
// If the file was successfully removed, we can return nil
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rename implements webdav.FileSystem
|
||||
func (hu *UsenetHandler) Rename(ctx context.Context, oldName, newName string) error {
|
||||
return os.ErrPermission // Read-only filesystem
|
||||
}
|
||||
|
||||
func (hu *UsenetHandler) getTorrentsFolders(folder string) []os.FileInfo {
|
||||
return hu.usenet.Store().GetListing(folder)
|
||||
}
|
||||
|
||||
func (hu *UsenetHandler) getParentItems() []string {
|
||||
parents := []string{"__all__", "__bad__"}
|
||||
|
||||
// version.txt
|
||||
parents = append(parents, "version.txt")
|
||||
return parents
|
||||
}
|
||||
|
||||
func (hu *UsenetHandler) getParentFiles() []os.FileInfo {
|
||||
now := time.Now()
|
||||
rootFiles := make([]os.FileInfo, 0, len(hu.getParentItems()))
|
||||
for _, item := range hu.getParentItems() {
|
||||
f := &FileInfo{
|
||||
name: item,
|
||||
size: 0,
|
||||
mode: 0755 | os.ModeDir,
|
||||
modTime: now,
|
||||
isDir: true,
|
||||
}
|
||||
if item == "version.txt" {
|
||||
f.isDir = false
|
||||
f.size = int64(len(version.GetInfo().String()))
|
||||
}
|
||||
rootFiles = append(rootFiles, f)
|
||||
}
|
||||
return rootFiles
|
||||
}
|
||||
|
||||
// GetChildren returns the os.FileInfo slice for “depth-1” children of cleanPath
|
||||
func (hu *UsenetHandler) GetChildren(name string) []os.FileInfo {
|
||||
|
||||
if name[0] != '/' {
|
||||
name = "/" + name
|
||||
}
|
||||
name = utils.PathUnescape(path.Clean(name))
|
||||
root := path.Clean(hu.RootPath)
|
||||
|
||||
// top‐level “parents” (e.g. __all__, torrents etc)
|
||||
if name == root {
|
||||
return hu.getParentFiles()
|
||||
}
|
||||
if parent, ok := hu.isParentPath(name); ok {
|
||||
return hu.getTorrentsFolders(parent)
|
||||
}
|
||||
// torrent-folder level (e.g. /root/parentFolder/torrentName)
|
||||
rel := strings.TrimPrefix(name, root+"/")
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) == 2 && utils.Contains(hu.getParentItems(), parts[0]) {
|
||||
if u := hu.usenet.Store().GetByName(parts[1]); u != nil {
|
||||
return hu.getFileInfos(u)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (hu *UsenetHandler) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
|
||||
if !strings.HasPrefix(name, "/") {
|
||||
name = "/" + name
|
||||
}
|
||||
name = utils.PathUnescape(path.Clean(name))
|
||||
rootDir := path.Clean(hu.RootPath)
|
||||
metadataOnly := ctx.Value(metadataOnlyKey) != nil
|
||||
now := time.Now()
|
||||
|
||||
// 1) special case version.txt
|
||||
if name == path.Join(rootDir, "version.txt") {
|
||||
versionInfo := version.GetInfo().String()
|
||||
return &UsenetFile{
|
||||
usenet: hu.usenet,
|
||||
isDir: false,
|
||||
content: []byte(versionInfo),
|
||||
name: "version.txt",
|
||||
size: int64(len(versionInfo)),
|
||||
metadataOnly: metadataOnly,
|
||||
modTime: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 2) directory case: ask GetChildren
|
||||
if children := hu.GetChildren(name); children != nil {
|
||||
displayName := filepath.Clean(path.Base(name))
|
||||
if name == rootDir {
|
||||
displayName = "/"
|
||||
}
|
||||
return &UsenetFile{
|
||||
usenet: hu.usenet,
|
||||
isDir: true,
|
||||
children: children,
|
||||
name: displayName,
|
||||
size: 0,
|
||||
metadataOnly: metadataOnly,
|
||||
modTime: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 3) file‐within‐torrent case
|
||||
// everything else must be a file under a torrent folder
|
||||
rel := strings.TrimPrefix(name, rootDir+"/")
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) >= 2 {
|
||||
if utils.Contains(hu.getParentItems(), parts[0]) {
|
||||
cached := hu.usenet.Store().GetByName(parts[1])
|
||||
if cached != nil && len(parts) >= 3 {
|
||||
filename := filepath.Clean(path.Join(parts[2:]...))
|
||||
if file := cached.GetFileByName(filename); file != nil {
|
||||
return &UsenetFile{
|
||||
usenet: hu.usenet,
|
||||
nzbID: cached.ID,
|
||||
fileId: file.Name,
|
||||
isDir: false,
|
||||
name: file.Name,
|
||||
size: file.Size,
|
||||
metadataOnly: metadataOnly,
|
||||
modTime: cached.AddedOn,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
// Stat implements webdav.FileSystem
|
||||
func (hu *UsenetHandler) Stat(ctx context.Context, name string) (os.FileInfo, error) {
|
||||
f, err := hu.OpenFile(ctx, name, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.Stat()
|
||||
}
|
||||
|
||||
func (hu *UsenetHandler) getFileInfos(nzb *usenet.NZB) []os.FileInfo {
|
||||
nzbFiles := nzb.GetFiles()
|
||||
files := make([]os.FileInfo, 0, len(nzbFiles))
|
||||
|
||||
sort.Slice(nzbFiles, func(i, j int) bool {
|
||||
return nzbFiles[i].Name < nzbFiles[j].Name
|
||||
})
|
||||
|
||||
for _, file := range nzbFiles {
|
||||
files = append(files, &FileInfo{
|
||||
name: file.Name,
|
||||
size: file.Size,
|
||||
mode: 0644,
|
||||
modTime: nzb.AddedOn,
|
||||
isDir: false,
|
||||
})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func (hu *UsenetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
hu.handleGet(w, r)
|
||||
return
|
||||
case "HEAD":
|
||||
hu.handleHead(w, r)
|
||||
return
|
||||
case "OPTIONS":
|
||||
hu.handleOptions(w, r)
|
||||
return
|
||||
case "PROPFIND":
|
||||
hu.handlePropfind(w, r)
|
||||
return
|
||||
case "DELETE":
|
||||
if err := hu.handleDelete(w, r); err == nil {
|
||||
return
|
||||
}
|
||||
// fallthrough to default
|
||||
}
|
||||
handler := &webdav.Handler{
|
||||
FileSystem: hu,
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
Logger: func(r *http.Request, err error) {
|
||||
if err != nil {
|
||||
hu.logger.Trace().
|
||||
Err(err).
|
||||
Str("method", r.Method).
|
||||
Str("path", r.URL.Path).
|
||||
Msg("WebDAV error")
|
||||
}
|
||||
},
|
||||
}
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (hu *UsenetHandler) isParentPath(urlPath string) (string, bool) {
|
||||
parents := hu.getParentItems()
|
||||
lastComponent := path.Base(urlPath)
|
||||
for _, p := range parents {
|
||||
if p == lastComponent {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (hu *UsenetHandler) serveDirectory(w http.ResponseWriter, r *http.Request, file webdav.File) {
|
||||
var children []os.FileInfo
|
||||
if f, ok := file.(*UsenetFile); ok {
|
||||
children = f.children
|
||||
} else {
|
||||
var err error
|
||||
children, err = file.Readdir(-1)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to list directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Clean and prepare the path
|
||||
cleanPath := path.Clean(r.URL.Path)
|
||||
parentPath := path.Dir(cleanPath)
|
||||
showParent := cleanPath != "/" && parentPath != "." && parentPath != cleanPath
|
||||
isBadPath := strings.HasSuffix(cleanPath, "__bad__")
|
||||
_, canDelete := hu.isParentPath(cleanPath)
|
||||
|
||||
// Prepare template data
|
||||
data := struct {
|
||||
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: hu.URLBase,
|
||||
IsBadPath: isBadPath,
|
||||
CanDelete: canDelete,
|
||||
DeleteAllBadTorrentKey: DeleteAllBadTorrentKey,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tplDirectory.ExecuteTemplate(w, "directory.html", data); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handlers
|
||||
|
||||
func (hu *UsenetHandler) handlePropfind(w http.ResponseWriter, r *http.Request) {
|
||||
handlePropfind(hu, hu.logger, w, r)
|
||||
}
|
||||
|
||||
func (hu *UsenetHandler) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
fRaw, err := hu.OpenFile(r.Context(), r.URL.Path, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer fRaw.Close()
|
||||
|
||||
fi, err := fRaw.Stat()
|
||||
if err != nil {
|
||||
http.Error(w, "Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if fi.IsDir() {
|
||||
hu.serveDirectory(w, r, fRaw)
|
||||
return
|
||||
}
|
||||
|
||||
// Set common headers
|
||||
etag := fmt.Sprintf("\"%x-%x\"", fi.ModTime().Unix(), fi.Size())
|
||||
ext := path.Ext(fi.Name())
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Header().Set("Last-Modified", fi.ModTime().UTC().Format(http.TimeFormat))
|
||||
w.Header().Set("Content-Type", getContentType(ext))
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
// Handle File struct with direct streaming
|
||||
if file, ok := fRaw.(*UsenetFile); ok {
|
||||
if err := file.StreamResponse(w, r); err != nil {
|
||||
var streamErr *streamError
|
||||
if errors.As(err, &streamErr) {
|
||||
// Handle client disconnections silently (just debug log)
|
||||
if errors.Is(streamErr.Err, context.Canceled) || errors.Is(streamErr.Err, context.DeadlineExceeded) || streamErr.IsClientDisconnection {
|
||||
return // Don't log as error or try to write response
|
||||
}
|
||||
|
||||
if streamErr.StatusCode > 0 && !hasHeadersWritten(w) {
|
||||
return
|
||||
} else {
|
||||
hu.logger.Error().
|
||||
Err(streamErr.Err).
|
||||
Str("path", r.URL.Path).
|
||||
Msg("Stream error")
|
||||
}
|
||||
} else {
|
||||
// Generic error
|
||||
if !hasHeadersWritten(w) {
|
||||
http.Error(w, "Stream error", http.StatusInternalServerError)
|
||||
return
|
||||
} else {
|
||||
hu.logger.Error().
|
||||
Err(err).
|
||||
Str("path", r.URL.Path).
|
||||
Msg("Stream error after headers written")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to ServeContent for other webdav.File implementations
|
||||
if rs, ok := fRaw.(io.ReadSeeker); ok {
|
||||
http.ServeContent(w, r, fi.Name(), fi.ModTime(), rs)
|
||||
} else {
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size()))
|
||||
_, _ = io.Copy(w, fRaw)
|
||||
}
|
||||
}
|
||||
|
||||
func (hu *UsenetHandler) handleHead(w http.ResponseWriter, r *http.Request) {
|
||||
f, err := hu.OpenFile(r.Context(), r.URL.Path, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
hu.logger.Error().Err(err).Str("path", r.URL.Path).Msg("Failed to open file")
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer func(f webdav.File) {
|
||||
err := f.Close()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}(f)
|
||||
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
hu.logger.Error().Err(err).Msg("Failed to stat file")
|
||||
http.Error(w, "Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", fi.Size()))
|
||||
w.Header().Set("Last-Modified", fi.ModTime().UTC().Format(http.TimeFormat))
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (hu *UsenetHandler) handleOptions(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Allow", "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleDelete deletes a torrent by id, or all bad torrents if the id is DeleteAllBadTorrentKey
|
||||
func (hu *UsenetHandler) handleDelete(w http.ResponseWriter, r *http.Request) error {
|
||||
cleanPath := path.Clean(r.URL.Path) // Remove any leading slashes
|
||||
|
||||
_, torrentId := path.Split(cleanPath)
|
||||
if torrentId == "" {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
|
||||
if torrentId == DeleteAllBadTorrentKey {
|
||||
return hu.handleDeleteAll(w)
|
||||
}
|
||||
|
||||
return hu.handleDeleteById(w, torrentId)
|
||||
}
|
||||
|
||||
func (hu *UsenetHandler) handleDeleteById(w http.ResponseWriter, nzID string) error {
|
||||
cached := hu.usenet.Store().Get(nzID)
|
||||
if cached == nil {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
|
||||
err := hu.usenet.Store().Delete(nzID)
|
||||
if err != nil {
|
||||
hu.logger.Error().Err(err).Str("nzbID", nzID).Msg("Failed to delete NZB")
|
||||
http.Error(w, "Failed to delete NZB", http.StatusInternalServerError)
|
||||
return err
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (hu *UsenetHandler) handleDeleteAll(w http.ResponseWriter) error {
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
@@ -6,8 +6,12 @@ import (
|
||||
"fmt"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/pkg/store"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/store"
|
||||
"github.com/sirrobot01/decypharr/pkg/usenet"
|
||||
"golang.org/x/net/webdav"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -33,6 +37,10 @@ var (
|
||||
}
|
||||
return strings.Join(segments, "/")
|
||||
},
|
||||
"split": strings.Split,
|
||||
"sub": func(a, b int) int {
|
||||
return a - b
|
||||
},
|
||||
"formatSize": func(bytes int64) string {
|
||||
const (
|
||||
KB = 1024
|
||||
@@ -84,21 +92,50 @@ func init() {
|
||||
chi.RegisterMethod("UNLOCK")
|
||||
}
|
||||
|
||||
type WebDav struct {
|
||||
Handlers []*Handler
|
||||
URLBase string
|
||||
type Handler interface {
|
||||
http.Handler
|
||||
Start(ctx context.Context) error
|
||||
Readiness(next http.Handler) http.Handler
|
||||
Name() string
|
||||
OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error)
|
||||
GetChildren(name string) []os.FileInfo
|
||||
Type() string
|
||||
}
|
||||
|
||||
func New() *WebDav {
|
||||
type WebDav struct {
|
||||
Handlers []Handler
|
||||
URLBase string
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
func New(debridCaches map[string]*store.Cache, usenet usenet.Usenet) *WebDav {
|
||||
urlBase := config.Get().URLBase
|
||||
w := &WebDav{
|
||||
Handlers: make([]*Handler, 0),
|
||||
Handlers: make([]Handler, 0),
|
||||
URLBase: urlBase,
|
||||
logger: logger.New("webdav"),
|
||||
}
|
||||
for name, c := range store.Get().Debrid().Caches() {
|
||||
h := NewHandler(name, urlBase, c, c.Logger())
|
||||
|
||||
// Set debrid handlers
|
||||
for name, c := range debridCaches {
|
||||
h := NewTorrentHandler(name, urlBase, c, c.Logger())
|
||||
if h == nil {
|
||||
w.logger.Warn().Msgf("Debrid handler for %s is nil, skipping", name)
|
||||
continue
|
||||
}
|
||||
w.Handlers = append(w.Handlers, h)
|
||||
}
|
||||
|
||||
// Set usenet handlers
|
||||
if usenet != nil {
|
||||
usenetHandler := NewUsenetHandler("usenet", urlBase, usenet, usenet.Logger())
|
||||
if usenetHandler != nil {
|
||||
w.Handlers = append(w.Handlers, usenetHandler)
|
||||
} else {
|
||||
w.logger.Warn().Msg("Usenet handler is nil, skipping")
|
||||
}
|
||||
}
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
@@ -119,9 +156,9 @@ func (wd *WebDav) Start(ctx context.Context) error {
|
||||
|
||||
for _, h := range wd.Handlers {
|
||||
wg.Add(1)
|
||||
go func(h *Handler) {
|
||||
go func(h Handler) {
|
||||
defer wg.Done()
|
||||
if err := h.cache.Start(ctx); err != nil {
|
||||
if err := h.Start(ctx); err != nil {
|
||||
select {
|
||||
case errChan <- err:
|
||||
default:
|
||||
@@ -152,8 +189,8 @@ func (wd *WebDav) Start(ctx context.Context) error {
|
||||
|
||||
func (wd *WebDav) mountHandlers(r chi.Router) {
|
||||
for _, h := range wd.Handlers {
|
||||
r.Route("/"+h.Name, func(r chi.Router) {
|
||||
r.Use(h.readinessMiddleware)
|
||||
r.Route("/"+h.Name(), func(r chi.Router) {
|
||||
r.Use(h.Readiness)
|
||||
r.Mount("/", h)
|
||||
}) // Mount to /name since router is already prefixed with /webdav
|
||||
}
|
||||
@@ -166,11 +203,7 @@ func (wd *WebDav) setupRootHandler(r chi.Router) {
|
||||
|
||||
func (wd *WebDav) commonMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("DAV", "1, 2")
|
||||
w.Header().Set("Allow", "OPTIONS, PROPFIND, GET, HEAD, POST, PUT, DELETE, MKCOL, PROPPATCH, COPY, MOVE, LOCK, UNLOCK")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "OPTIONS, PROPFIND, GET, HEAD, POST, PUT, DELETE, MKCOL, PROPPATCH, COPY, MOVE, LOCK, UNLOCK")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Depth, Content-Type, Authorization")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
@@ -181,7 +214,7 @@ func (wd *WebDav) handleGetRoot() http.HandlerFunc {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
data := struct {
|
||||
Handlers []*Handler
|
||||
Handlers []Handler
|
||||
URLBase string
|
||||
}{
|
||||
Handlers: wd.Handlers,
|
||||
@@ -205,7 +238,7 @@ func (wd *WebDav) handleWebdavRoot() http.HandlerFunc {
|
||||
children := make([]os.FileInfo, 0, len(wd.Handlers))
|
||||
for _, h := range wd.Handlers {
|
||||
children = append(children, &FileInfo{
|
||||
name: h.Name,
|
||||
name: h.Name(),
|
||||
size: 0,
|
||||
mode: 0755 | os.ModeDir,
|
||||
modTime: time.Now(),
|
||||
|
||||
Reference in New Issue
Block a user