Files
seanime-docker/seanime-2.9.10/internal/debrid/client/download.go
2025-09-20 14:08:38 +01:00

444 lines
14 KiB
Go

package debrid_client
import (
"context"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"runtime"
"seanime/internal/debrid/debrid"
"seanime/internal/events"
"seanime/internal/hook"
"seanime/internal/notifier"
"seanime/internal/util"
"seanime/internal/util/result"
"strings"
"sync"
"time"
)
func (r *Repository) launchDownloadLoop(ctx context.Context) {
r.logger.Trace().Msg("debrid: Starting download loop")
go func() {
for {
select {
case <-ctx.Done():
r.logger.Trace().Msg("debrid: Download loop destroy request received")
// Destroy the loop
return
case <-time.After(time.Minute * 1):
// Every minute, check if there are any completed downloads
provider, found := r.provider.Get()
if !found {
continue
}
// Get the list of completed downloads
items, err := provider.GetTorrents()
if err != nil {
r.logger.Err(err).Msg("debrid: Failed to get torrents")
continue
}
readyItems := make([]*debrid.TorrentItem, 0)
for _, item := range items {
if item.IsReady {
readyItems = append(readyItems, item)
}
}
dbItems, err := r.db.GetDebridTorrentItems()
if err != nil {
r.logger.Err(err).Msg("debrid: Failed to get debrid torrent items")
continue
}
for _, dbItem := range dbItems {
// Check if the item is ready for download
for _, readyItem := range readyItems {
if dbItem.TorrentItemID == readyItem.ID {
r.logger.Debug().Str("torrentItemId", dbItem.TorrentItemID).Msg("debrid: Torrent is ready for download")
// Remove the item from the database
err = r.db.DeleteDebridTorrentItemByDbId(dbItem.ID)
if err != nil {
r.logger.Err(err).Msg("debrid: Failed to remove debrid torrent item")
continue
}
time.Sleep(1 * time.Second)
// Download the torrent locally
err = r.downloadTorrentItem(readyItem.ID, readyItem.Name, dbItem.Destination)
if err != nil {
r.logger.Err(err).Msg("debrid: Failed to download torrent")
continue
}
}
}
}
}
}
}()
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (r *Repository) DownloadTorrent(item debrid.TorrentItem, destination string) error {
return r.downloadTorrentItem(item.ID, item.Name, destination)
}
type downloadStatus struct {
TotalBytes int64
TotalSize int64
}
func (r *Repository) downloadTorrentItem(tId string, torrentName string, destination string) (err error) {
defer util.HandlePanicInModuleWithError("debrid/client/downloadTorrentItem", &err)
provider, err := r.GetProvider()
if err != nil {
return err
}
r.logger.Debug().Str("torrentName", torrentName).Str("destination", destination).Msg("debrid: Downloading torrent")
// Get the download URL
downloadUrl, err := provider.GetTorrentDownloadUrl(debrid.DownloadTorrentOptions{
ID: tId,
})
if err != nil {
return err
}
event := &DebridLocalDownloadRequestedEvent{
TorrentName: torrentName,
Destination: destination,
DownloadUrl: downloadUrl,
}
err = hook.GlobalHookManager.OnDebridLocalDownloadRequested().Trigger(event)
if err != nil {
return err
}
if event.DefaultPrevented {
r.logger.Debug().Msg("debrid: Download prevented by hook")
return nil
}
ctx, cancel := context.WithCancel(context.Background())
r.ctxMap.Set(tId, cancel)
go func(ctx context.Context) {
defer func() {
cancel()
r.ctxMap.Delete(tId)
}()
wg := sync.WaitGroup{}
downloadUrls := strings.Split(downloadUrl, ",")
downloadMap := result.NewResultMap[string, downloadStatus]()
for _, url := range downloadUrls {
wg.Add(1)
go func(ctx context.Context, url string) {
defer wg.Done()
// Download the file
ok := r.downloadFile(ctx, tId, url, destination, downloadMap)
if !ok {
return
}
}(ctx, url)
}
wg.Wait()
r.sendDownloadCompletedEvent(tId)
notifier.GlobalNotifier.Notify(notifier.Debrid, fmt.Sprintf("Downloaded %q", torrentName))
}(ctx)
// Send a starting event
r.wsEventManager.SendEvent(events.DebridDownloadProgress, map[string]interface{}{
"status": "downloading",
"itemID": tId,
"totalBytes": "0 B",
"totalSize": "-",
"speed": "",
})
return nil
}
func (r *Repository) downloadFile(ctx context.Context, tId string, downloadUrl string, destination string, downloadMap *result.Map[string, downloadStatus]) (ok bool) {
defer util.HandlePanicInModuleThen("debrid/client/downloadFile", func() {
ok = false
})
// Create a cancellable HTTP request
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadUrl, nil)
if err != nil {
r.logger.Err(err).Str("downloadUrl", downloadUrl).Msg("debrid: Failed to create request")
return false
}
_ = os.MkdirAll(destination, os.ModePerm)
// Download the files to a temporary folder
tmpDirPath, err := os.MkdirTemp(destination, ".tmp-")
if err != nil {
r.logger.Err(err).Str("destination", destination).Msg("debrid: Failed to create temp folder")
r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Failed to create temp folder: %v", err))
return false
}
defer os.RemoveAll(tmpDirPath) // Clean up temp folder on exit
if runtime.GOOS == "windows" {
r.logger.Debug().Str("tmpDirPath", tmpDirPath).Msg("debrid: Hiding temp folder")
util.HideFile(tmpDirPath)
time.Sleep(time.Millisecond * 500)
}
// Execute the request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
r.logger.Err(err).Str("downloadUrl", downloadUrl).Msg("debrid: Failed to execute request")
r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Failed to execute download request: %v", err))
return false
}
defer resp.Body.Close()
// e.g. "my-torrent.zip", "downloaded_torrent"
filename := "downloaded_torrent"
ext := ""
// Try to get the file name from the Content-Disposition header
hFilename, err := getFilenameFromHeaders(downloadUrl)
if err == nil {
r.logger.Warn().Str("newFilename", hFilename).Str("defaultFilename", filename).Msg("debrid: Filename found in headers, overriding default")
filename = hFilename
}
if ct := resp.Header.Get("Content-Type"); ct != "" {
mediaType, _, err := mime.ParseMediaType(ct)
if err == nil {
switch mediaType {
case "application/zip":
ext = ".zip"
case "application/x-rar-compressed":
ext = ".rar"
default:
}
r.logger.Debug().Str("mediaType", mediaType).Str("ext", ext).Msg("debrid: Detected media type and extension")
}
}
if filename == "downloaded_torrent" && ext != "" {
filename = fmt.Sprintf("%s%s", filename, ext)
}
// Check if the download URL has the extension
urlExt := filepath.Ext(downloadUrl)
if filename == "downloaded_torrent" && urlExt != "" {
filename = filepath.Base(downloadUrl)
filename, _ = url.PathUnescape(filename)
ext = urlExt
r.logger.Warn().Str("urlExt", urlExt).Str("filename", filename).Str("downloadUrl", downloadUrl).Msg("debrid: Extension found in URL, using it as file extension and file name")
}
r.logger.Debug().Str("filename", filename).Str("ext", ext).Msg("debrid: Starting download")
// Create a file in the temporary folder to store the download
// e.g. "/tmp/torrent-123456789/my-torrent.zip"
tmpDownloadedFilePath := filepath.Join(tmpDirPath, filename)
file, err := os.Create(tmpDownloadedFilePath)
if err != nil {
r.logger.Err(err).Str("tmpDownloadedFilePath", tmpDownloadedFilePath).Msg("debrid: Failed to create temp file")
r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Failed to create temp file: %v", err))
return false
}
totalSize := resp.ContentLength
speed := 0
lastSent := time.Now()
// Copy response body to the temporary file
buffer := make([]byte, 32*1024)
var totalBytes int64
var lastBytes int64
for {
n, err := resp.Body.Read(buffer)
if n > 0 {
_, writeErr := file.Write(buffer[:n])
if writeErr != nil {
_ = file.Close()
r.logger.Err(writeErr).Str("tmpDownloadedFilePath", tmpDownloadedFilePath).Msg("debrid: Failed to write to temp file")
r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Download failed / Failed to write to temp file: %v", writeErr))
r.sendDownloadCancelledEvent(tId, downloadUrl, downloadMap)
return false
}
totalBytes += int64(n)
if totalSize > 0 {
speed = int((totalBytes - lastBytes) / 1024) // KB/s
lastBytes = totalBytes
}
downloadMap.Set(downloadUrl, downloadStatus{
TotalBytes: totalBytes,
TotalSize: totalSize,
})
if time.Since(lastSent) > time.Second*2 {
_totalBytes := uint64(0)
_totalSize := uint64(0)
downloadMap.Range(func(key string, value downloadStatus) bool {
_totalBytes += uint64(value.TotalBytes)
_totalSize += uint64(value.TotalSize)
return true
})
// Notify progress
r.wsEventManager.SendEvent(events.DebridDownloadProgress, map[string]interface{}{
"status": "downloading",
"itemID": tId,
"totalBytes": util.Bytes(_totalBytes),
"totalSize": util.Bytes(_totalSize),
"speed": speed,
})
lastSent = time.Now()
}
}
if err != nil {
if err == io.EOF {
break
}
if errors.Is(err, context.Canceled) {
_ = file.Close()
r.logger.Debug().Msg("debrid: Download cancelled")
r.sendDownloadCancelledEvent(tId, downloadUrl, downloadMap)
return false
}
_ = file.Close()
r.logger.Err(err).Str("downloadUrl", downloadUrl).Msg("debrid: Failed to read from response body")
r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Download failed / Failed to read from response body: %v", err))
r.sendDownloadCancelledEvent(tId, downloadUrl, downloadMap)
return false
}
}
_ = file.Close()
downloadMap.Delete(downloadUrl)
if len(downloadMap.Values()) == 0 {
r.wsEventManager.SendEvent(events.DebridDownloadProgress, map[string]interface{}{
"status": "downloading",
"itemID": tId,
"totalBytes": "Extracting...",
"totalSize": "-",
"speed": "",
})
}
r.logger.Debug().Msg("debrid: Download completed")
switch runtime.GOOS {
case "windows":
time.Sleep(time.Second * 1)
}
// Extract the downloaded file
var extractedDir string
switch ext {
case ".zip":
extractedDir, err = unzipFile(tmpDownloadedFilePath, tmpDirPath)
r.logger.Debug().Str("extractedDir", extractedDir).Msg("debrid: Extracted zip file")
case ".rar":
extractedDir, err = unrarFile(tmpDownloadedFilePath, tmpDirPath)
r.logger.Debug().Str("extractedDir", extractedDir).Msg("debrid: Extracted rar file")
default:
r.logger.Debug().Str("tmpDownloadedFilePath", tmpDownloadedFilePath).Str("destination", destination).Msg("debrid: No extraction needed, moving file directly")
// Move the file directly to the destination
err = moveFolderOrFileTo(tmpDownloadedFilePath, destination)
if err != nil {
r.logger.Err(err).Str("tmpDownloadedFilePath", tmpDownloadedFilePath).Str("destination", destination).Msg("debrid: Failed to move downloaded file")
r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Failed to move downloaded file: %v", err))
r.sendDownloadCancelledEvent(tId, downloadUrl, downloadMap)
return false
}
return true
}
if err != nil {
r.logger.Err(err).Str("tmpDownloadedFilePath", tmpDownloadedFilePath).Msg("debrid: Failed to extract downloaded file")
r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Failed to extract downloaded file: %v", err))
r.sendDownloadCancelledEvent(tId, downloadUrl, downloadMap)
return false
}
r.logger.Debug().Msg("debrid: Extraction completed, deleting temporary files")
// Delete the downloaded file
err = os.Remove(tmpDownloadedFilePath)
if err != nil {
r.logger.Err(err).Str("tmpDownloadedFilePath", tmpDownloadedFilePath).Msg("debrid: Failed to delete downloaded file")
// Do not stop here, continue with the extracted files
}
r.logger.Debug().Str("extractedDir", extractedDir).Str("destination", destination).Msg("debrid: Moving extracted files to destination")
// Move the extracted files to the destination
err = moveContentsTo(extractedDir, destination)
if err != nil {
r.logger.Err(err).Str("extractedDir", extractedDir).Str("destination", destination).Msg("debrid: Failed to move downloaded files")
r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Failed to move downloaded files: %v", err))
r.sendDownloadCancelledEvent(tId, downloadUrl, downloadMap)
return false
}
return true
}
func (r *Repository) sendDownloadCancelledEvent(tId string, url string, downloadMap *result.Map[string, downloadStatus]) {
downloadMap.Delete(url)
if len(downloadMap.Values()) == 0 {
r.wsEventManager.SendEvent(events.DebridDownloadProgress, map[string]interface{}{
"status": "cancelled",
"itemID": tId,
})
}
}
func (r *Repository) sendDownloadCompletedEvent(tId string) {
r.wsEventManager.SendEvent(events.DebridDownloadProgress, map[string]interface{}{
"status": "completed",
"itemID": tId,
})
}
func getFilenameFromHeaders(url string) (string, error) {
resp, err := http.Head(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
// Get the Content-Disposition header
contentDisposition := resp.Header.Get("Content-Disposition")
if contentDisposition == "" {
return "", fmt.Errorf("no Content-Disposition header found")
}
// Use a regex to extract the filename from Content-Disposition
re := regexp.MustCompile(`filename="(.+)"`)
matches := re.FindStringSubmatch(contentDisposition)
if len(matches) > 1 {
return matches[1], nil
}
return "", fmt.Errorf("filename not found in Content-Disposition header")
}