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") }