node build fixed
This commit is contained in:
443
seanime-2.9.10/internal/debrid/client/download.go
Normal file
443
seanime-2.9.10/internal/debrid/client/download.go
Normal file
@@ -0,0 +1,443 @@
|
||||
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")
|
||||
}
|
||||
110
seanime-2.9.10/internal/debrid/client/download_test.go
Normal file
110
seanime-2.9.10/internal/debrid/client/download_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package debrid_client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"os"
|
||||
"seanime/internal/database/db"
|
||||
"seanime/internal/database/models"
|
||||
"seanime/internal/debrid/debrid"
|
||||
"seanime/internal/test_utils"
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTorBoxDownload(t *testing.T) {
|
||||
test_utils.InitTestProvider(t)
|
||||
|
||||
logger := util.NewLogger()
|
||||
database, err := db.NewDatabase(test_utils.ConfigData.Path.DataDir, test_utils.ConfigData.Database.Name, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
repo := GetMockRepository(t, database)
|
||||
|
||||
err = repo.InitializeProvider(&models.DebridSettings{
|
||||
Enabled: true,
|
||||
Provider: "torbox",
|
||||
ApiKey: test_utils.ConfigData.Provider.TorBoxApiKey,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
tempDestinationDir := t.TempDir()
|
||||
|
||||
fmt.Println(tempDestinationDir)
|
||||
|
||||
//
|
||||
// Test download
|
||||
//
|
||||
torrentItemId := "116389"
|
||||
|
||||
err = database.InsertDebridTorrentItem(&models.DebridTorrentItem{
|
||||
TorrentItemID: torrentItemId,
|
||||
Destination: tempDestinationDir,
|
||||
Provider: "torbox",
|
||||
MediaId: 0, // Not yet used
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get the provider
|
||||
provider, err := repo.GetProvider()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get the torrents from the provider
|
||||
torrentItems, err := provider.GetTorrents()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get the torrent item from the database
|
||||
dbTorrentItem, err := database.GetDebridTorrentItemByTorrentItemId(torrentItemId)
|
||||
|
||||
// Select the torrent item from the provider
|
||||
var torrentItem *debrid.TorrentItem
|
||||
for _, item := range torrentItems {
|
||||
if item.ID == dbTorrentItem.TorrentItemID {
|
||||
torrentItem = item
|
||||
}
|
||||
}
|
||||
require.NotNil(t, torrentItem)
|
||||
|
||||
// Check if the torrent is ready
|
||||
require.Truef(t, torrentItem.IsReady, "Torrent is not ready")
|
||||
|
||||
// Remove the item from the database
|
||||
err = database.DeleteDebridTorrentItemByDbId(dbTorrentItem.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Download the torrent
|
||||
err = repo.downloadTorrentItem(dbTorrentItem.TorrentItemID, torrentItem.Name, dbTorrentItem.Destination)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(time.Millisecond * 500)
|
||||
|
||||
// Wait for the download to finish
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case <-time.After(time.Second * 1):
|
||||
isEmpty := true
|
||||
repo.ctxMap.Range(func(key string, value context.CancelFunc) bool {
|
||||
isEmpty = false
|
||||
return true
|
||||
})
|
||||
if isEmpty {
|
||||
break loop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the file exists
|
||||
entries, err := os.ReadDir(tempDestinationDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
fmt.Println("=== Downloaded files ===")
|
||||
|
||||
for _, entry := range entries {
|
||||
util.Spew(entry.Name())
|
||||
}
|
||||
|
||||
require.NotEmpty(t, entries)
|
||||
}
|
||||
391
seanime-2.9.10/internal/debrid/client/finder.go
Normal file
391
seanime-2.9.10/internal/debrid/client/finder.go
Normal file
@@ -0,0 +1,391 @@
|
||||
package debrid_client
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/debrid/debrid"
|
||||
hibiketorrent "seanime/internal/extension/hibike/torrent"
|
||||
"seanime/internal/hook"
|
||||
torrentanalyzer "seanime/internal/torrents/analyzer"
|
||||
itorrent "seanime/internal/torrents/torrent"
|
||||
"seanime/internal/util"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
func (r *Repository) findBestTorrent(provider debrid.Provider, media *anilist.CompleteAnime, episodeNumber int) (selectedTorrent *hibiketorrent.AnimeTorrent, fileId string, err error) {
|
||||
|
||||
defer util.HandlePanicInModuleWithError("debridstream/findBestTorrent", &err)
|
||||
|
||||
r.logger.Debug().Msgf("debridstream: Finding best torrent for %s, Episode %d", media.GetTitleSafe(), episodeNumber)
|
||||
|
||||
providerId := itorrent.ProviderAnimeTosho
|
||||
fallbackProviderId := itorrent.ProviderNyaa
|
||||
|
||||
// Get AnimeTosho provider extension
|
||||
providerExtension, ok := r.torrentRepository.GetAnimeProviderExtension(providerId)
|
||||
if !ok {
|
||||
r.logger.Error().Str("provider", itorrent.ProviderAnimeTosho).Msg("debridstream: AnimeTosho provider extension not found")
|
||||
return nil, "", fmt.Errorf("provider extension not found")
|
||||
}
|
||||
|
||||
searchBatch := false
|
||||
canSearchBatch := !media.IsMovie() && media.IsFinished()
|
||||
if canSearchBatch {
|
||||
searchBatch = true
|
||||
}
|
||||
|
||||
loopCount := 0
|
||||
var currentProvider = providerId
|
||||
|
||||
var data *itorrent.SearchData
|
||||
searchLoop:
|
||||
for {
|
||||
data, err = r.torrentRepository.SearchAnime(context.Background(), itorrent.AnimeSearchOptions{
|
||||
Provider: currentProvider,
|
||||
Type: itorrent.AnimeSearchTypeSmart,
|
||||
Media: media.ToBaseAnime(),
|
||||
Query: "",
|
||||
Batch: searchBatch,
|
||||
EpisodeNumber: episodeNumber,
|
||||
BestReleases: false,
|
||||
Resolution: r.settings.StreamPreferredResolution,
|
||||
})
|
||||
// If we are searching for batches, we don't want to return an error if no torrents are found
|
||||
// We will just search again without the batch flag
|
||||
if err != nil {
|
||||
if !searchBatch {
|
||||
r.logger.Error().Err(err).Msg("debridstream: Error searching torrents")
|
||||
|
||||
// Try fallback provider if we're still on primary provider
|
||||
if currentProvider == providerId {
|
||||
r.logger.Debug().Msgf("debridstream: Primary provider failed, trying fallback provider %s", fallbackProviderId)
|
||||
currentProvider = fallbackProviderId
|
||||
// Get fallback provider extension
|
||||
providerExtension, ok = r.torrentRepository.GetAnimeProviderExtension(currentProvider)
|
||||
if !ok {
|
||||
r.logger.Error().Str("provider", fallbackProviderId).Msg("debridstream: Fallback provider extension not found")
|
||||
return nil, "", fmt.Errorf("fallback provider extension not found")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, "", err
|
||||
}
|
||||
searchBatch = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Get cached
|
||||
hashes := make([]string, 0)
|
||||
for _, t := range data.Torrents {
|
||||
if t.InfoHash == "" {
|
||||
continue
|
||||
}
|
||||
hashes = append(hashes, t.InfoHash)
|
||||
}
|
||||
instantAvail := provider.GetInstantAvailability(hashes)
|
||||
data.DebridInstantAvailability = instantAvail
|
||||
|
||||
// If we are searching for batches, we want to filter out torrents that are not cached
|
||||
if searchBatch {
|
||||
// Nothing found, search again without the batch flag
|
||||
if len(data.Torrents) == 0 {
|
||||
searchBatch = false
|
||||
loopCount++
|
||||
continue
|
||||
}
|
||||
if len(data.DebridInstantAvailability) > 0 {
|
||||
r.logger.Debug().Msg("debridstream: Found cached instant availability")
|
||||
data.Torrents = lo.Filter(data.Torrents, func(t *hibiketorrent.AnimeTorrent, i int) bool {
|
||||
_, isCached := data.DebridInstantAvailability[t.InfoHash]
|
||||
return isCached
|
||||
})
|
||||
break searchLoop
|
||||
}
|
||||
// If we didn't find any cached batches, we will search again without the batch flag
|
||||
searchBatch = false
|
||||
loopCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// If on the first try were looking for file torrents but found no cached ones, we will search again for batches
|
||||
if loopCount == 0 && canSearchBatch && len(data.DebridInstantAvailability) == 0 {
|
||||
searchBatch = true
|
||||
loopCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// Stop looking if either we found cached torrents or no cached batches were found
|
||||
break searchLoop
|
||||
}
|
||||
|
||||
if data == nil || len(data.Torrents) == 0 {
|
||||
// Try fallback provider if we're still on primary provider
|
||||
if currentProvider == providerId {
|
||||
r.logger.Debug().Msgf("debridstream: No torrents found with primary provider, trying fallback provider %s", fallbackProviderId)
|
||||
currentProvider = fallbackProviderId
|
||||
// Get fallback provider extension
|
||||
providerExtension, ok = r.torrentRepository.GetAnimeProviderExtension(currentProvider)
|
||||
if !ok {
|
||||
r.logger.Error().Str("provider", fallbackProviderId).Msg("debridstream: Fallback provider extension not found")
|
||||
return nil, "", fmt.Errorf("fallback provider extension not found")
|
||||
}
|
||||
|
||||
// Try searching with fallback provider (reset searchBatch based on canSearchBatch)
|
||||
searchBatch = false
|
||||
if canSearchBatch {
|
||||
searchBatch = true
|
||||
}
|
||||
loopCount = 0
|
||||
|
||||
// Restart the search with fallback provider
|
||||
goto searchLoop
|
||||
}
|
||||
|
||||
r.logger.Error().Msg("debridstream: No torrents found")
|
||||
return nil, "", fmt.Errorf("no torrents found")
|
||||
}
|
||||
|
||||
// Sort by seeders from highest to lowest
|
||||
slices.SortStableFunc(data.Torrents, func(a, b *hibiketorrent.AnimeTorrent) int {
|
||||
return cmp.Compare(b.Seeders, a.Seeders)
|
||||
})
|
||||
|
||||
// Trigger hook
|
||||
fetchedEvent := &DebridAutoSelectTorrentsFetchedEvent{
|
||||
Torrents: data.Torrents,
|
||||
}
|
||||
_ = hook.GlobalHookManager.OnDebridAutoSelectTorrentsFetched().Trigger(fetchedEvent)
|
||||
data.Torrents = fetchedEvent.Torrents
|
||||
|
||||
r.logger.Debug().Msgf("debridstream: Found %d torrents", len(data.Torrents))
|
||||
|
||||
hashes := make([]string, 0)
|
||||
for _, t := range data.Torrents {
|
||||
if t.InfoHash == "" {
|
||||
continue
|
||||
}
|
||||
hashes = append(hashes, t.InfoHash)
|
||||
}
|
||||
|
||||
// Find cached torrent
|
||||
instantAvail := provider.GetInstantAvailability(hashes)
|
||||
data.DebridInstantAvailability = instantAvail
|
||||
|
||||
// Filter out torrents that are not cached if we have cached instant availability
|
||||
if len(data.DebridInstantAvailability) > 0 {
|
||||
r.logger.Debug().Msg("debridstream: Found cached instant availability")
|
||||
data.Torrents = lo.Filter(data.Torrents, func(t *hibiketorrent.AnimeTorrent, i int) bool {
|
||||
_, isCached := data.DebridInstantAvailability[t.InfoHash]
|
||||
return isCached
|
||||
})
|
||||
}
|
||||
|
||||
tries := 0
|
||||
|
||||
for _, searchT := range data.Torrents {
|
||||
if tries >= 2 {
|
||||
break
|
||||
}
|
||||
|
||||
r.logger.Trace().Msgf("debridstream: Getting torrent magnet for %s", searchT.Name)
|
||||
magnet, err := providerExtension.GetProvider().GetTorrentMagnetLink(searchT)
|
||||
if err != nil {
|
||||
r.logger.Warn().Err(err).Msgf("debridstream: Error scraping magnet link for %s", searchT.Link)
|
||||
tries++
|
||||
continue
|
||||
}
|
||||
|
||||
// Set the magnet link
|
||||
searchT.MagnetLink = magnet
|
||||
|
||||
r.logger.Debug().Msgf("debridstream: Adding torrent %s from magnet", searchT.Link)
|
||||
|
||||
// Get the torrent info
|
||||
// On Real-Debrid, this will add the torrent
|
||||
info, err := provider.GetTorrentInfo(debrid.GetTorrentInfoOptions{
|
||||
MagnetLink: searchT.MagnetLink,
|
||||
InfoHash: searchT.InfoHash,
|
||||
})
|
||||
if err != nil {
|
||||
r.logger.Warn().Err(err).Msgf("debridstream: Error adding torrent %s", searchT.Link)
|
||||
tries++
|
||||
continue
|
||||
}
|
||||
|
||||
filepaths := lo.Map(info.Files, func(f *debrid.TorrentItemFile, _ int) string {
|
||||
return f.Path
|
||||
})
|
||||
|
||||
if len(filepaths) == 0 {
|
||||
r.logger.Error().Msg("debridstream: No files found in the torrent")
|
||||
return nil, "", fmt.Errorf("no files found in the torrent")
|
||||
}
|
||||
|
||||
// Create a new Torrent Analyzer
|
||||
analyzer := torrentanalyzer.NewAnalyzer(&torrentanalyzer.NewAnalyzerOptions{
|
||||
Logger: r.logger,
|
||||
Filepaths: filepaths,
|
||||
Media: media,
|
||||
Platform: r.platform,
|
||||
MetadataProvider: r.metadataProvider,
|
||||
ForceMatch: true,
|
||||
})
|
||||
|
||||
r.logger.Debug().Msgf("debridstream: Analyzing torrent %s", searchT.Link)
|
||||
|
||||
// Analyze torrent files
|
||||
analysis, err := analyzer.AnalyzeTorrentFiles()
|
||||
if err != nil {
|
||||
r.logger.Warn().Err(err).Msg("debridstream: Error analyzing torrent files")
|
||||
// Remove torrent on failure (if it was added)
|
||||
//if info.ID != nil {
|
||||
// go func() {
|
||||
// _ = provider.DeleteTorrent(*info.ID)
|
||||
// }()
|
||||
//}
|
||||
tries++
|
||||
continue
|
||||
}
|
||||
|
||||
r.logger.Debug().Int("count", len(analysis.GetFiles())).Msgf("debridstream: Analyzed torrent %s", searchT.Link)
|
||||
|
||||
r.logger.Debug().Msgf("debridstream: Finding corresponding file for episode %s", strconv.Itoa(episodeNumber))
|
||||
|
||||
analysisFile, found := analysis.GetFileByAniDBEpisode(strconv.Itoa(episodeNumber))
|
||||
// Check if analyzer found the episode
|
||||
if !found {
|
||||
r.logger.Error().Msgf("debridstream: Failed to auto-select episode from torrent %s", searchT.Link)
|
||||
// Remove torrent on failure
|
||||
//if info.ID != nil {
|
||||
// go func() {
|
||||
// _ = provider.DeleteTorrent(*info.ID)
|
||||
// }()
|
||||
//}
|
||||
tries++
|
||||
continue
|
||||
}
|
||||
|
||||
r.logger.Debug().Msgf("debridstream: Found corresponding file for episode %s: %s", strconv.Itoa(episodeNumber), analysisFile.GetLocalFile().Name)
|
||||
|
||||
tFile := info.Files[analysisFile.GetIndex()]
|
||||
r.logger.Debug().Str("file", util.SpewT(tFile)).Msgf("debridstream: Selected file %s", tFile.Name)
|
||||
r.logger.Debug().Msgf("debridstream: Selected torrent %s", searchT.Name)
|
||||
selectedTorrent = searchT
|
||||
fileId = tFile.ID
|
||||
|
||||
//go func() {
|
||||
// _ = provider.DeleteTorrent(*info.ID)
|
||||
//}()
|
||||
break
|
||||
}
|
||||
|
||||
if selectedTorrent == nil {
|
||||
return nil, "", fmt.Errorf("failed to find torrent")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// findBestTorrentFromManualSelection is like findBestTorrent but for a pre-selected torrent
|
||||
func (r *Repository) findBestTorrentFromManualSelection(provider debrid.Provider, t *hibiketorrent.AnimeTorrent, media *anilist.CompleteAnime, episodeNumber int, chosenFileIndex *int) (selectedTorrent *hibiketorrent.AnimeTorrent, fileId string, err error) {
|
||||
|
||||
r.logger.Debug().Msgf("debridstream: Analyzing torrent from %s for %s", t.Link, media.GetTitleSafe())
|
||||
|
||||
// Get the torrent's provider extension
|
||||
providerExtension, ok := r.torrentRepository.GetAnimeProviderExtension(t.Provider)
|
||||
if !ok {
|
||||
r.logger.Error().Str("provider", t.Provider).Msg("debridstream: provider extension not found")
|
||||
return nil, "", fmt.Errorf("provider extension not found")
|
||||
}
|
||||
|
||||
// Check if the torrent is cached
|
||||
if t.InfoHash != "" {
|
||||
instantAvail := provider.GetInstantAvailability([]string{t.InfoHash})
|
||||
if len(instantAvail) == 0 {
|
||||
r.logger.Warn().Msg("debridstream: Torrent is not cached")
|
||||
// We'll still continue since the user specifically selected this torrent
|
||||
}
|
||||
}
|
||||
|
||||
// Get the magnet link
|
||||
magnet, err := providerExtension.GetProvider().GetTorrentMagnetLink(t)
|
||||
if err != nil {
|
||||
r.logger.Error().Err(err).Msgf("debridstream: Error scraping magnet link for %s", t.Link)
|
||||
return nil, "", fmt.Errorf("could not get magnet link from %s", t.Link)
|
||||
}
|
||||
|
||||
// Set the magnet link
|
||||
t.MagnetLink = magnet
|
||||
|
||||
// Get the torrent info from the debrid provider
|
||||
info, err := provider.GetTorrentInfo(debrid.GetTorrentInfoOptions{
|
||||
MagnetLink: t.MagnetLink,
|
||||
InfoHash: t.InfoHash,
|
||||
})
|
||||
if err != nil {
|
||||
r.logger.Error().Err(err).Msgf("debridstream: Error adding torrent %s", t.Link)
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// If the torrent has only one file, return it
|
||||
if len(info.Files) == 1 {
|
||||
return t, info.Files[0].ID, nil
|
||||
}
|
||||
|
||||
var fileIndex int
|
||||
|
||||
// If the file index is already selected
|
||||
if chosenFileIndex != nil {
|
||||
fileIndex = *chosenFileIndex
|
||||
} else {
|
||||
// We know the torrent has multiple files, so we'll need to analyze it
|
||||
filepaths := lo.Map(info.Files, func(f *debrid.TorrentItemFile, _ int) string {
|
||||
return f.Path
|
||||
})
|
||||
|
||||
if len(filepaths) == 0 {
|
||||
r.logger.Error().Msg("debridstream: No files found in the torrent")
|
||||
return nil, "", fmt.Errorf("no files found in the torrent")
|
||||
}
|
||||
|
||||
// Create a new Torrent Analyzer
|
||||
analyzer := torrentanalyzer.NewAnalyzer(&torrentanalyzer.NewAnalyzerOptions{
|
||||
Logger: r.logger,
|
||||
Filepaths: filepaths,
|
||||
Media: media,
|
||||
Platform: r.platform,
|
||||
MetadataProvider: r.metadataProvider,
|
||||
ForceMatch: true,
|
||||
})
|
||||
|
||||
// Analyze torrent files
|
||||
analysis, err := analyzer.AnalyzeTorrentFiles()
|
||||
if err != nil {
|
||||
r.logger.Warn().Err(err).Msg("debridstream: Error analyzing torrent files")
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
analysisFile, found := analysis.GetFileByAniDBEpisode(strconv.Itoa(episodeNumber))
|
||||
// Check if analyzer found the episode
|
||||
if !found {
|
||||
r.logger.Error().Msgf("debridstream: Failed to auto-select episode from torrent %s", t.Name)
|
||||
return nil, "", fmt.Errorf("could not find episode %d in torrent", episodeNumber)
|
||||
}
|
||||
|
||||
r.logger.Debug().Msgf("debridstream: Found corresponding file for episode %s: %s", strconv.Itoa(episodeNumber), analysisFile.GetLocalFile().Name)
|
||||
|
||||
fileIndex = analysisFile.GetIndex()
|
||||
}
|
||||
|
||||
tFile := info.Files[fileIndex]
|
||||
r.logger.Debug().Str("file", util.SpewT(tFile)).Msgf("debridstream: Selected file %s", tFile.Name)
|
||||
r.logger.Debug().Msgf("debridstream: Selected torrent %s", t.Name)
|
||||
|
||||
return t, tFile.ID, nil
|
||||
}
|
||||
44
seanime-2.9.10/internal/debrid/client/hook_events.go
Normal file
44
seanime-2.9.10/internal/debrid/client/hook_events.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package debrid_client
|
||||
|
||||
import (
|
||||
"seanime/internal/api/anilist"
|
||||
hibiketorrent "seanime/internal/extension/hibike/torrent"
|
||||
"seanime/internal/hook_resolver"
|
||||
)
|
||||
|
||||
// DebridAutoSelectTorrentsFetchedEvent is triggered when the torrents are fetched for auto select.
|
||||
// The torrents are sorted by seeders from highest to lowest.
|
||||
// This event is triggered before the top 3 torrents are analyzed.
|
||||
type DebridAutoSelectTorrentsFetchedEvent struct {
|
||||
hook_resolver.Event
|
||||
Torrents []*hibiketorrent.AnimeTorrent
|
||||
}
|
||||
|
||||
// DebridSkipStreamCheckEvent is triggered when the debrid client is about to skip the stream check.
|
||||
// Prevent default to enable the stream check.
|
||||
type DebridSkipStreamCheckEvent struct {
|
||||
hook_resolver.Event
|
||||
StreamURL string `json:"streamURL"`
|
||||
Retries int `json:"retries"`
|
||||
RetryDelay int `json:"retryDelay"` // in seconds
|
||||
}
|
||||
|
||||
// DebridSendStreamToMediaPlayerEvent is triggered when the debrid client is about to send a stream to the media player.
|
||||
// Prevent default to skip the playback.
|
||||
type DebridSendStreamToMediaPlayerEvent struct {
|
||||
hook_resolver.Event
|
||||
WindowTitle string `json:"windowTitle"`
|
||||
StreamURL string `json:"streamURL"`
|
||||
Media *anilist.BaseAnime `json:"media"`
|
||||
AniDbEpisode string `json:"aniDbEpisode"`
|
||||
PlaybackType string `json:"playbackType"`
|
||||
}
|
||||
|
||||
// DebridLocalDownloadRequestedEvent is triggered when Seanime is about to download a debrid torrent locally.
|
||||
// Prevent default to skip the default download and override the download.
|
||||
type DebridLocalDownloadRequestedEvent struct {
|
||||
hook_resolver.Event
|
||||
TorrentName string `json:"torrentName"`
|
||||
Destination string `json:"destination"`
|
||||
DownloadUrl string `json:"downloadUrl"`
|
||||
}
|
||||
45
seanime-2.9.10/internal/debrid/client/mock.go
Normal file
45
seanime-2.9.10/internal/debrid/client/mock.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package debrid_client
|
||||
|
||||
import (
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/continuity"
|
||||
"seanime/internal/database/db"
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/library/playbackmanager"
|
||||
"seanime/internal/platforms/anilist_platform"
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func GetMockRepository(t *testing.T, db *db.Database) *Repository {
|
||||
logger := util.NewLogger()
|
||||
wsEventManager := events.NewWSEventManager(logger)
|
||||
anilistClient := anilist.TestGetMockAnilistClient()
|
||||
platform := anilist_platform.NewAnilistPlatform(anilistClient, logger)
|
||||
metadataProvider := metadata.GetMockProvider(t)
|
||||
playbackManager := playbackmanager.New(&playbackmanager.NewPlaybackManagerOptions{
|
||||
WSEventManager: wsEventManager,
|
||||
Logger: logger,
|
||||
Platform: platform,
|
||||
MetadataProvider: metadataProvider,
|
||||
Database: db,
|
||||
RefreshAnimeCollectionFunc: func() {
|
||||
// Do nothing
|
||||
},
|
||||
DiscordPresence: nil,
|
||||
IsOffline: &[]bool{false}[0],
|
||||
ContinuityManager: continuity.GetMockManager(t, db),
|
||||
})
|
||||
|
||||
r := NewRepository(&NewRepositoryOptions{
|
||||
Logger: logger,
|
||||
WSEventManager: wsEventManager,
|
||||
Database: db,
|
||||
MetadataProvider: metadataProvider,
|
||||
Platform: platform,
|
||||
PlaybackManager: playbackManager,
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
132
seanime-2.9.10/internal/debrid/client/previews.go
Normal file
132
seanime-2.9.10/internal/debrid/client/previews.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package debrid_client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/5rahim/habari"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/debrid/debrid"
|
||||
hibiketorrent "seanime/internal/extension/hibike/torrent"
|
||||
"seanime/internal/util"
|
||||
"seanime/internal/util/comparison"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type (
|
||||
FilePreview struct {
|
||||
Path string `json:"path"`
|
||||
DisplayPath string `json:"displayPath"`
|
||||
DisplayTitle string `json:"displayTitle"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
RelativeEpisodeNumber int `json:"relativeEpisodeNumber"`
|
||||
IsLikely bool `json:"isLikely"`
|
||||
Index int `json:"index"`
|
||||
FileId string `json:"fileId"`
|
||||
}
|
||||
|
||||
GetTorrentFilePreviewsOptions struct {
|
||||
Torrent *hibiketorrent.AnimeTorrent
|
||||
Magnet string
|
||||
EpisodeNumber int
|
||||
AbsoluteOffset int
|
||||
Media *anilist.BaseAnime
|
||||
}
|
||||
)
|
||||
|
||||
func (r *Repository) GetTorrentFilePreviewsFromManualSelection(opts *GetTorrentFilePreviewsOptions) (ret []*FilePreview, err error) {
|
||||
defer util.HandlePanicInModuleWithError("debrid_client/GetTorrentFilePreviewsFromManualSelection", &err)
|
||||
|
||||
if opts.Torrent == nil || opts.Magnet == "" || opts.Media == nil {
|
||||
return nil, fmt.Errorf("torrentstream: Invalid options")
|
||||
}
|
||||
|
||||
r.logger.Trace().Str("hash", opts.Torrent.InfoHash).Msg("debridstream: Getting file previews for torrent selection")
|
||||
|
||||
torrentInfo, err := r.GetTorrentInfo(debrid.GetTorrentInfoOptions{
|
||||
MagnetLink: opts.Magnet,
|
||||
InfoHash: opts.Torrent.InfoHash,
|
||||
})
|
||||
if err != nil {
|
||||
r.logger.Error().Err(err).Msgf("debridstream: Error adding torrent %s", opts.Magnet)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fileMetadataMap := make(map[string]*habari.Metadata)
|
||||
wg := sync.WaitGroup{}
|
||||
mu := sync.RWMutex{}
|
||||
wg.Add(len(torrentInfo.Files))
|
||||
for _, file := range torrentInfo.Files {
|
||||
go func(file *debrid.TorrentItemFile) {
|
||||
defer wg.Done()
|
||||
defer util.HandlePanicInModuleThen("debridstream/GetTorrentFilePreviewsFromManualSelection", func() {})
|
||||
|
||||
metadata := habari.Parse(file.Path)
|
||||
mu.Lock()
|
||||
fileMetadataMap[file.Path] = metadata
|
||||
mu.Unlock()
|
||||
}(file)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
containsAbsoluteEps := false
|
||||
for _, metadata := range fileMetadataMap {
|
||||
if len(metadata.EpisodeNumber) == 1 {
|
||||
ep := util.StringToIntMust(metadata.EpisodeNumber[0])
|
||||
if ep > opts.Media.GetTotalEpisodeCount() {
|
||||
containsAbsoluteEps = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wg = sync.WaitGroup{}
|
||||
mu2 := sync.Mutex{}
|
||||
|
||||
for i, file := range torrentInfo.Files {
|
||||
wg.Add(1)
|
||||
go func(i int, file *debrid.TorrentItemFile) {
|
||||
defer wg.Done()
|
||||
defer util.HandlePanicInModuleThen("debridstream/GetTorrentFilePreviewsFromManualSelection", func() {})
|
||||
|
||||
mu.RLock()
|
||||
metadata, found := fileMetadataMap[file.Path]
|
||||
mu.RUnlock()
|
||||
|
||||
displayTitle := file.Path
|
||||
|
||||
isLikely := false
|
||||
parsedEpisodeNumber := -1
|
||||
|
||||
if found && !comparison.ValueContainsSpecial(file.Name) && !comparison.ValueContainsNC(file.Name) {
|
||||
if len(metadata.EpisodeNumber) == 1 {
|
||||
ep := util.StringToIntMust(metadata.EpisodeNumber[0])
|
||||
parsedEpisodeNumber = ep
|
||||
displayTitle = fmt.Sprintf("Episode %d", ep)
|
||||
if metadata.EpisodeTitle != "" {
|
||||
displayTitle = fmt.Sprintf("%s - %s", displayTitle, metadata.EpisodeTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !containsAbsoluteEps {
|
||||
isLikely = parsedEpisodeNumber == opts.EpisodeNumber
|
||||
}
|
||||
|
||||
mu2.Lock()
|
||||
// Get the file preview
|
||||
ret = append(ret, &FilePreview{
|
||||
Path: file.Path,
|
||||
DisplayPath: file.Path,
|
||||
DisplayTitle: displayTitle,
|
||||
EpisodeNumber: parsedEpisodeNumber,
|
||||
IsLikely: isLikely,
|
||||
FileId: file.ID,
|
||||
Index: i,
|
||||
})
|
||||
mu2.Unlock()
|
||||
}(i, file)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return
|
||||
}
|
||||
255
seanime-2.9.10/internal/debrid/client/repository.go
Normal file
255
seanime-2.9.10/internal/debrid/client/repository.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package debrid_client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/database/db"
|
||||
"seanime/internal/database/models"
|
||||
"seanime/internal/debrid/debrid"
|
||||
"seanime/internal/debrid/realdebrid"
|
||||
"seanime/internal/debrid/torbox"
|
||||
"seanime/internal/directstream"
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/library/playbackmanager"
|
||||
"seanime/internal/platforms/platform"
|
||||
"seanime/internal/torrents/torrent"
|
||||
"seanime/internal/util/result"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/samber/mo"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProviderNotSet = fmt.Errorf("debrid: Provider not set")
|
||||
)
|
||||
|
||||
type (
|
||||
Repository struct {
|
||||
provider mo.Option[debrid.Provider]
|
||||
logger *zerolog.Logger
|
||||
db *db.Database
|
||||
settings *models.DebridSettings
|
||||
wsEventManager events.WSEventManagerInterface
|
||||
ctxMap *result.Map[string, context.CancelFunc]
|
||||
downloadLoopCancelFunc context.CancelFunc
|
||||
torrentRepository *torrent.Repository
|
||||
directStreamManager *directstream.Manager
|
||||
|
||||
playbackManager *playbackmanager.PlaybackManager
|
||||
streamManager *StreamManager
|
||||
completeAnimeCache *anilist.CompleteAnimeCache
|
||||
metadataProvider metadata.Provider
|
||||
platform platform.Platform
|
||||
|
||||
previousStreamOptions mo.Option[*StartStreamOptions]
|
||||
}
|
||||
|
||||
NewRepositoryOptions struct {
|
||||
Logger *zerolog.Logger
|
||||
WSEventManager events.WSEventManagerInterface
|
||||
Database *db.Database
|
||||
|
||||
TorrentRepository *torrent.Repository
|
||||
PlaybackManager *playbackmanager.PlaybackManager
|
||||
DirectStreamManager *directstream.Manager
|
||||
MetadataProvider metadata.Provider
|
||||
Platform platform.Platform
|
||||
}
|
||||
)
|
||||
|
||||
func NewRepository(opts *NewRepositoryOptions) (ret *Repository) {
|
||||
ret = &Repository{
|
||||
provider: mo.None[debrid.Provider](),
|
||||
logger: opts.Logger,
|
||||
wsEventManager: opts.WSEventManager,
|
||||
db: opts.Database,
|
||||
settings: &models.DebridSettings{
|
||||
Enabled: false,
|
||||
},
|
||||
torrentRepository: opts.TorrentRepository,
|
||||
platform: opts.Platform,
|
||||
playbackManager: opts.PlaybackManager,
|
||||
metadataProvider: opts.MetadataProvider,
|
||||
completeAnimeCache: anilist.NewCompleteAnimeCache(),
|
||||
ctxMap: result.NewResultMap[string, context.CancelFunc](),
|
||||
previousStreamOptions: mo.None[*StartStreamOptions](),
|
||||
directStreamManager: opts.DirectStreamManager,
|
||||
}
|
||||
|
||||
ret.streamManager = NewStreamManager(ret)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (r *Repository) startOrStopDownloadLoop() {
|
||||
// Cancel the previous download loop if it's running
|
||||
if r.downloadLoopCancelFunc != nil {
|
||||
r.downloadLoopCancelFunc()
|
||||
}
|
||||
|
||||
// Start the download loop if the provider is set and enabled
|
||||
if r.settings.Enabled && r.provider.IsPresent() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r.downloadLoopCancelFunc = cancel
|
||||
r.launchDownloadLoop(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// InitializeProvider is called each time the settings change
|
||||
func (r *Repository) InitializeProvider(settings *models.DebridSettings) error {
|
||||
r.settings = settings
|
||||
|
||||
if !settings.Enabled {
|
||||
r.provider = mo.None[debrid.Provider]()
|
||||
// Stop the download loop if it's running
|
||||
r.startOrStopDownloadLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
switch settings.Provider {
|
||||
case "torbox":
|
||||
r.provider = mo.Some(torbox.NewTorBox(r.logger))
|
||||
case "realdebrid":
|
||||
r.provider = mo.Some(realdebrid.NewRealDebrid(r.logger))
|
||||
default:
|
||||
r.provider = mo.None[debrid.Provider]()
|
||||
}
|
||||
|
||||
if r.provider.IsAbsent() {
|
||||
r.logger.Warn().Str("provider", settings.Provider).Msg("debrid: No provider set")
|
||||
// Stop the download loop if it's running
|
||||
r.startOrStopDownloadLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Authenticate the provider
|
||||
err := r.provider.MustGet().Authenticate(r.settings.ApiKey)
|
||||
if err != nil {
|
||||
r.logger.Err(err).Msg("debrid: Failed to authenticate")
|
||||
r.provider = mo.None[debrid.Provider]()
|
||||
// Cancel the download loop if it's running
|
||||
if r.downloadLoopCancelFunc != nil {
|
||||
r.downloadLoopCancelFunc()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Start the download loop
|
||||
r.startOrStopDownloadLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetProvider() (debrid.Provider, error) {
|
||||
p, found := r.provider.Get()
|
||||
if !found {
|
||||
return nil, ErrProviderNotSet
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// AddAndQueueTorrent adds a torrent to the debrid service and queues it for automatic download
|
||||
func (r *Repository) AddAndQueueTorrent(opts debrid.AddTorrentOptions, destination string, mId int) (string, error) {
|
||||
provider, err := r.GetProvider()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(destination) {
|
||||
return "", fmt.Errorf("debrid: Failed to add torrent, destination must be an absolute path")
|
||||
}
|
||||
|
||||
// Add the torrent to the debrid service
|
||||
torrentItemId, err := provider.AddTorrent(opts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Add the torrent item to the database (so it can be downloaded automatically once it's ready)
|
||||
// We ignore the error since it's non-critical
|
||||
_ = r.db.InsertDebridTorrentItem(&models.DebridTorrentItem{
|
||||
TorrentItemID: torrentItemId,
|
||||
Destination: destination,
|
||||
Provider: provider.GetSettings().ID,
|
||||
MediaId: mId,
|
||||
})
|
||||
|
||||
return torrentItemId, nil
|
||||
}
|
||||
|
||||
// GetTorrentInfo retrieves information about a torrent.
|
||||
// This is used for file section for debrid streaming.
|
||||
// On Real Debrid, this adds the torrent to the user's account.
|
||||
func (r *Repository) GetTorrentInfo(opts debrid.GetTorrentInfoOptions) (*debrid.TorrentInfo, error) {
|
||||
provider, err := r.GetProvider()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
torrentInfo, err := provider.GetTorrentInfo(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Remove non-video files
|
||||
torrentInfo.Files = debrid.FilterVideoFiles(torrentInfo.Files)
|
||||
|
||||
return torrentInfo, nil
|
||||
}
|
||||
|
||||
func (r *Repository) HasProvider() bool {
|
||||
return r.provider.IsPresent()
|
||||
}
|
||||
|
||||
func (r *Repository) GetSettings() *models.DebridSettings {
|
||||
return r.settings
|
||||
}
|
||||
|
||||
// CancelDownload cancels the download for the given item ID
|
||||
func (r *Repository) CancelDownload(itemID string) error {
|
||||
cancelFunc, found := r.ctxMap.Get(itemID)
|
||||
if !found {
|
||||
return fmt.Errorf("no download found for item ID: %s", itemID)
|
||||
}
|
||||
|
||||
// Call the cancel function to cancel the download
|
||||
if cancelFunc != nil {
|
||||
cancelFunc()
|
||||
}
|
||||
|
||||
r.ctxMap.Delete(itemID)
|
||||
|
||||
// Notify that the download has been cancelled
|
||||
r.wsEventManager.SendEvent(events.DebridDownloadProgress, map[string]interface{}{
|
||||
"status": "cancelled",
|
||||
"itemID": itemID,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) StartStream(ctx context.Context, opts *StartStreamOptions) error {
|
||||
return r.streamManager.startStream(ctx, opts)
|
||||
}
|
||||
|
||||
func (r *Repository) GetStreamURL() (string, bool) {
|
||||
return r.streamManager.currentStreamUrl, r.streamManager.currentStreamUrl != ""
|
||||
}
|
||||
|
||||
func (r *Repository) CancelStream(opts *CancelStreamOptions) {
|
||||
r.streamManager.cancelStream(opts)
|
||||
}
|
||||
|
||||
func (r *Repository) GetPreviousStreamOptions() (*StartStreamOptions, bool) {
|
||||
return r.previousStreamOptions.Get()
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
567
seanime-2.9.10/internal/debrid/client/stream.go
Normal file
567
seanime-2.9.10/internal/debrid/client/stream.go
Normal file
@@ -0,0 +1,567 @@
|
||||
package debrid_client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"seanime/internal/database/db_bridge"
|
||||
"seanime/internal/debrid/debrid"
|
||||
"seanime/internal/directstream"
|
||||
"seanime/internal/events"
|
||||
hibiketorrent "seanime/internal/extension/hibike/torrent"
|
||||
"seanime/internal/hook"
|
||||
"seanime/internal/library/playbackmanager"
|
||||
"seanime/internal/util"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/samber/mo"
|
||||
)
|
||||
|
||||
type (
|
||||
StreamManager struct {
|
||||
repository *Repository
|
||||
currentTorrentItemId string
|
||||
downloadCtxCancelFunc context.CancelFunc
|
||||
|
||||
currentStreamUrl string
|
||||
|
||||
playbackSubscriberCtxCancelFunc context.CancelFunc
|
||||
}
|
||||
|
||||
StreamPlaybackType string
|
||||
|
||||
StreamStatus string
|
||||
|
||||
StreamState struct {
|
||||
Status StreamStatus `json:"status"`
|
||||
TorrentName string `json:"torrentName"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
StartStreamOptions struct {
|
||||
MediaId int
|
||||
EpisodeNumber int // RELATIVE Episode number to identify the file
|
||||
AniDBEpisode string // Anizip episode
|
||||
Torrent *hibiketorrent.AnimeTorrent // Selected torrent
|
||||
FileId string // File ID or index
|
||||
FileIndex *int // Index of the file to stream (Manual selection)
|
||||
UserAgent string
|
||||
ClientId string
|
||||
PlaybackType StreamPlaybackType
|
||||
AutoSelect bool
|
||||
}
|
||||
|
||||
CancelStreamOptions struct {
|
||||
// Whether to remove the torrent from the debrid service
|
||||
RemoveTorrent bool `json:"removeTorrent"`
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
StreamStatusDownloading StreamStatus = "downloading"
|
||||
StreamStatusReady StreamStatus = "ready"
|
||||
StreamStatusFailed StreamStatus = "failed"
|
||||
StreamStatusStarted StreamStatus = "started"
|
||||
)
|
||||
|
||||
func NewStreamManager(repository *Repository) *StreamManager {
|
||||
return &StreamManager{
|
||||
repository: repository,
|
||||
currentTorrentItemId: "",
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const (
|
||||
PlaybackTypeNone StreamPlaybackType = "none"
|
||||
PlaybackTypeNoneAndAwait StreamPlaybackType = "noneAndAwait"
|
||||
PlaybackTypeDefault StreamPlaybackType = "default"
|
||||
PlaybackTypeNativePlayer StreamPlaybackType = "nativeplayer"
|
||||
PlaybackTypeExternalPlayer StreamPlaybackType = "externalPlayerLink"
|
||||
)
|
||||
|
||||
// startStream is called by the client to start streaming a torrent
|
||||
func (s *StreamManager) startStream(ctx context.Context, opts *StartStreamOptions) (err error) {
|
||||
defer util.HandlePanicInModuleWithError("debrid/client/StartStream", &err)
|
||||
|
||||
s.repository.previousStreamOptions = mo.Some(opts)
|
||||
|
||||
s.repository.logger.Info().
|
||||
Str("clientId", opts.ClientId).
|
||||
Any("playbackType", opts.PlaybackType).
|
||||
Int("mediaId", opts.MediaId).Msgf("debridstream: Starting stream for episode %s", opts.AniDBEpisode)
|
||||
|
||||
// Cancel the download context if it's running
|
||||
if s.downloadCtxCancelFunc != nil {
|
||||
s.downloadCtxCancelFunc()
|
||||
s.downloadCtxCancelFunc = nil
|
||||
}
|
||||
|
||||
if s.playbackSubscriberCtxCancelFunc != nil {
|
||||
s.playbackSubscriberCtxCancelFunc()
|
||||
s.playbackSubscriberCtxCancelFunc = nil
|
||||
}
|
||||
|
||||
provider, err := s.repository.GetProvider()
|
||||
if err != nil {
|
||||
return fmt.Errorf("debridstream: Failed to start stream: %w", err)
|
||||
}
|
||||
|
||||
s.repository.wsEventManager.SendEvent(events.ShowIndefiniteLoader, "debridstream")
|
||||
//defer func() {
|
||||
// s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
//}()
|
||||
|
||||
if opts.PlaybackType == PlaybackTypeNativePlayer {
|
||||
s.repository.directStreamManager.PrepareNewStream(opts.ClientId, "Selecting torrent...")
|
||||
}
|
||||
|
||||
//
|
||||
// Get the media info
|
||||
//
|
||||
media, _, err := s.getMediaInfo(ctx, opts.MediaId)
|
||||
if err != nil {
|
||||
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
return err
|
||||
}
|
||||
|
||||
episodeNumber := opts.EpisodeNumber
|
||||
aniDbEpisode := strconv.Itoa(episodeNumber)
|
||||
|
||||
selectedTorrent := opts.Torrent
|
||||
fileId := opts.FileId
|
||||
|
||||
if opts.AutoSelect {
|
||||
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusDownloading,
|
||||
TorrentName: "-",
|
||||
Message: "Selecting best torrent...",
|
||||
})
|
||||
|
||||
st, fi, err := s.repository.findBestTorrent(provider, media, opts.EpisodeNumber)
|
||||
if err != nil {
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusFailed,
|
||||
TorrentName: "-",
|
||||
Message: fmt.Sprintf("Failed to select best torrent, %v", err),
|
||||
})
|
||||
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
return fmt.Errorf("debridstream: Failed to start stream: %w", err)
|
||||
}
|
||||
selectedTorrent = st
|
||||
fileId = fi
|
||||
} else {
|
||||
// Manual selection
|
||||
if selectedTorrent == nil {
|
||||
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
return fmt.Errorf("debridstream: Failed to start stream, no torrent provided")
|
||||
}
|
||||
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusDownloading,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: "Analyzing selected torrent...",
|
||||
})
|
||||
|
||||
// If no fileId is provided, we need to analyze the torrent to find the correct file
|
||||
if fileId == "" {
|
||||
var chosenFileIndex *int
|
||||
if opts.FileIndex != nil {
|
||||
chosenFileIndex = opts.FileIndex
|
||||
}
|
||||
st, fi, err := s.repository.findBestTorrentFromManualSelection(provider, selectedTorrent, media, opts.EpisodeNumber, chosenFileIndex)
|
||||
if err != nil {
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusFailed,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: fmt.Sprintf("Failed to analyze torrent, %v", err),
|
||||
})
|
||||
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
return fmt.Errorf("debridstream: Failed to analyze torrent: %w", err)
|
||||
}
|
||||
selectedTorrent = st
|
||||
fileId = fi
|
||||
}
|
||||
}
|
||||
|
||||
if selectedTorrent == nil {
|
||||
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
return fmt.Errorf("debridstream: Failed to start stream, no torrent provided")
|
||||
}
|
||||
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusDownloading,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: "Adding torrent...",
|
||||
})
|
||||
|
||||
// Add the torrent to the debrid service
|
||||
torrentItemId, err := provider.AddTorrent(debrid.AddTorrentOptions{
|
||||
MagnetLink: selectedTorrent.MagnetLink,
|
||||
InfoHash: selectedTorrent.InfoHash,
|
||||
SelectFileId: fileId, // RD-only, download only the selected file
|
||||
})
|
||||
if err != nil {
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusFailed,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: fmt.Sprintf("Failed to add torrent, %v", err),
|
||||
})
|
||||
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
return fmt.Errorf("debridstream: Failed to add torrent: %w", err)
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// Save the current torrent item id
|
||||
s.currentTorrentItemId = torrentItemId
|
||||
ctx, cancelCtx := context.WithCancel(context.Background())
|
||||
s.downloadCtxCancelFunc = cancelCtx
|
||||
|
||||
readyCh := make(chan struct{})
|
||||
readyOnce := sync.Once{}
|
||||
ready := func() {
|
||||
readyOnce.Do(func() {
|
||||
close(readyCh)
|
||||
})
|
||||
}
|
||||
|
||||
// Launch a goroutine that will listen to the added torrent's status
|
||||
go func(ctx context.Context) {
|
||||
defer util.HandlePanicInModuleThen("debrid/client/StartStream", func() {})
|
||||
defer func() {
|
||||
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
// Cancel the context
|
||||
if s.downloadCtxCancelFunc != nil {
|
||||
s.downloadCtxCancelFunc()
|
||||
s.downloadCtxCancelFunc = nil
|
||||
}
|
||||
}()
|
||||
|
||||
s.repository.logger.Debug().Msg("debridstream: Listening to torrent status")
|
||||
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusDownloading,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: fmt.Sprintf("Downloading torrent..."),
|
||||
})
|
||||
|
||||
itemCh := make(chan debrid.TorrentItem, 1)
|
||||
|
||||
go func() {
|
||||
for item := range itemCh {
|
||||
if opts.PlaybackType == PlaybackTypeNativePlayer {
|
||||
s.repository.directStreamManager.PrepareNewStream(opts.ClientId, fmt.Sprintf("Awaiting stream: %d%%", item.CompletionPercentage))
|
||||
}
|
||||
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusDownloading,
|
||||
TorrentName: item.Name,
|
||||
Message: fmt.Sprintf("Downloading torrent: %d%%", item.CompletionPercentage),
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
// Await the stream URL
|
||||
// For Torbox, this will wait until the entire torrent is downloaded
|
||||
streamUrl, err := provider.GetTorrentStreamUrl(ctx, debrid.StreamTorrentOptions{
|
||||
ID: torrentItemId,
|
||||
FileId: fileId,
|
||||
}, itemCh)
|
||||
|
||||
go func() {
|
||||
close(itemCh)
|
||||
}()
|
||||
|
||||
if ctx.Err() != nil {
|
||||
s.repository.logger.Debug().Msg("debridstream: Context cancelled, stopping stream")
|
||||
ready()
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
s.repository.logger.Err(err).Msg("debridstream: Failed to get stream URL")
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusFailed,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: fmt.Sprintf("Failed to get stream URL, %v", err),
|
||||
})
|
||||
}
|
||||
ready()
|
||||
return
|
||||
}
|
||||
|
||||
skipCheckEvent := &DebridSkipStreamCheckEvent{
|
||||
StreamURL: streamUrl,
|
||||
Retries: 4,
|
||||
RetryDelay: 8,
|
||||
}
|
||||
_ = hook.GlobalHookManager.OnDebridSkipStreamCheck().Trigger(skipCheckEvent)
|
||||
streamUrl = skipCheckEvent.StreamURL
|
||||
|
||||
// Default prevented, we check if we can stream the file
|
||||
if skipCheckEvent.DefaultPrevented {
|
||||
s.repository.logger.Debug().Msg("debridstream: Stream URL received, checking stream file")
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusDownloading,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: "Checking stream file...",
|
||||
})
|
||||
|
||||
retries := 0
|
||||
|
||||
streamUrlCheckLoop:
|
||||
for { // Retry loop for a total of 4 times (32 seconds)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.repository.logger.Debug().Msg("debridstream: Context cancelled, stopping stream")
|
||||
return
|
||||
default:
|
||||
// Check if we can stream the URL
|
||||
if canStream, reason := CanStream(streamUrl); !canStream {
|
||||
if retries >= skipCheckEvent.Retries {
|
||||
s.repository.logger.Error().Msg("debridstream: Cannot stream the file")
|
||||
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusFailed,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: fmt.Sprintf("Cannot stream this file: %s", reason),
|
||||
})
|
||||
return
|
||||
}
|
||||
s.repository.logger.Warn().Msg("debridstream: Rechecking stream file in 8 seconds")
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusDownloading,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: "Checking stream file...",
|
||||
})
|
||||
retries++
|
||||
time.Sleep(time.Duration(skipCheckEvent.RetryDelay) * time.Second)
|
||||
continue
|
||||
}
|
||||
break streamUrlCheckLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.repository.logger.Debug().Msg("debridstream: Stream is ready")
|
||||
|
||||
// Signal to the client that the torrent is ready to stream
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusReady,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: "Ready to stream the file",
|
||||
})
|
||||
|
||||
if ctx.Err() != nil {
|
||||
s.repository.logger.Debug().Msg("debridstream: Context cancelled, stopping stream")
|
||||
ready()
|
||||
return
|
||||
}
|
||||
|
||||
windowTitle := media.GetPreferredTitle()
|
||||
if !media.IsMovieOrSingleEpisode() {
|
||||
windowTitle += fmt.Sprintf(" - Episode %s", aniDbEpisode)
|
||||
}
|
||||
|
||||
event := &DebridSendStreamToMediaPlayerEvent{
|
||||
WindowTitle: windowTitle,
|
||||
StreamURL: streamUrl,
|
||||
Media: media.ToBaseAnime(),
|
||||
AniDbEpisode: aniDbEpisode,
|
||||
PlaybackType: string(opts.PlaybackType),
|
||||
}
|
||||
err = hook.GlobalHookManager.OnDebridSendStreamToMediaPlayer().Trigger(event)
|
||||
if err != nil {
|
||||
s.repository.logger.Err(err).Msg("debridstream: Failed to send stream to media player")
|
||||
}
|
||||
windowTitle = event.WindowTitle
|
||||
streamUrl = event.StreamURL
|
||||
media := event.Media
|
||||
aniDbEpisode := event.AniDbEpisode
|
||||
playbackType := StreamPlaybackType(event.PlaybackType)
|
||||
|
||||
if event.DefaultPrevented {
|
||||
s.repository.logger.Debug().Msg("debridstream: Stream prevented by hook")
|
||||
ready()
|
||||
return
|
||||
}
|
||||
|
||||
s.currentStreamUrl = streamUrl
|
||||
|
||||
switch playbackType {
|
||||
case PlaybackTypeNone:
|
||||
// No playback type selected, just signal to the client that the stream is ready
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusReady,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: "External player link sent",
|
||||
})
|
||||
case PlaybackTypeNoneAndAwait:
|
||||
// No playback type selected, just signal to the client that the stream is ready
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusReady,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: "External player link sent",
|
||||
})
|
||||
ready()
|
||||
|
||||
case PlaybackTypeDefault:
|
||||
//
|
||||
// Start the stream
|
||||
//
|
||||
s.repository.logger.Debug().Msg("debridstream: Starting the media player")
|
||||
|
||||
s.repository.wsEventManager.SendEvent(events.InfoToast, "Sending stream to media player...")
|
||||
s.repository.wsEventManager.SendEvent(events.ShowIndefiniteLoader, "debridstream")
|
||||
|
||||
var playbackSubscriberCtx context.Context
|
||||
playbackSubscriberCtx, s.playbackSubscriberCtxCancelFunc = context.WithCancel(context.Background())
|
||||
playbackSubscriber := s.repository.playbackManager.SubscribeToPlaybackStatus("debridstream")
|
||||
|
||||
// Sends the stream to the media player
|
||||
// DEVNOTE: Events are handled by the torrentstream.Repository module
|
||||
err = s.repository.playbackManager.StartStreamingUsingMediaPlayer(windowTitle, &playbackmanager.StartPlayingOptions{
|
||||
Payload: streamUrl,
|
||||
UserAgent: opts.UserAgent,
|
||||
ClientId: opts.ClientId,
|
||||
}, media, aniDbEpisode)
|
||||
if err != nil {
|
||||
go s.repository.playbackManager.UnsubscribeFromPlaybackStatus("debridstream")
|
||||
if s.playbackSubscriberCtxCancelFunc != nil {
|
||||
s.playbackSubscriberCtxCancelFunc()
|
||||
s.playbackSubscriberCtxCancelFunc = nil
|
||||
}
|
||||
// Failed to start the stream, we'll drop the torrents and stop the server
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusFailed,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: fmt.Sprintf("Failed to send the stream to the media player, %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Listen to the playback status
|
||||
// Reset the current stream url when playback is stopped
|
||||
go func() {
|
||||
defer util.HandlePanicInModuleThen("debridstream/PlaybackSubscriber", func() {})
|
||||
defer func() {
|
||||
if s.playbackSubscriberCtxCancelFunc != nil {
|
||||
s.playbackSubscriberCtxCancelFunc()
|
||||
s.playbackSubscriberCtxCancelFunc = nil
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-playbackSubscriberCtx.Done():
|
||||
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
s.repository.playbackManager.UnsubscribeFromPlaybackStatus("debridstream")
|
||||
s.currentStreamUrl = ""
|
||||
case event := <-playbackSubscriber.EventCh:
|
||||
switch event.(type) {
|
||||
case playbackmanager.StreamStartedEvent:
|
||||
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
case playbackmanager.StreamStoppedEvent:
|
||||
go s.repository.playbackManager.UnsubscribeFromPlaybackStatus("debridstream")
|
||||
s.currentStreamUrl = ""
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
case PlaybackTypeExternalPlayer:
|
||||
// Send the external player link
|
||||
s.repository.wsEventManager.SendEventTo(opts.ClientId, events.ExternalPlayerOpenURL, struct {
|
||||
Url string `json:"url"`
|
||||
MediaId int `json:"mediaId"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
MediaTitle string `json:"mediaTitle"`
|
||||
}{
|
||||
Url: streamUrl,
|
||||
MediaId: opts.MediaId,
|
||||
EpisodeNumber: opts.EpisodeNumber,
|
||||
MediaTitle: media.GetPreferredTitle(),
|
||||
})
|
||||
|
||||
// Signal to the client that the torrent has started playing (remove loading status)
|
||||
// We can't know for sure
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusReady,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: "External player link sent",
|
||||
})
|
||||
case PlaybackTypeNativePlayer:
|
||||
err := s.repository.directStreamManager.PlayDebridStream(ctx, directstream.PlayDebridStreamOptions{
|
||||
StreamUrl: streamUrl,
|
||||
MediaId: media.ID,
|
||||
EpisodeNumber: opts.EpisodeNumber,
|
||||
AnidbEpisode: opts.AniDBEpisode,
|
||||
Media: media,
|
||||
Torrent: selectedTorrent,
|
||||
FileId: fileId,
|
||||
UserAgent: opts.UserAgent,
|
||||
ClientId: opts.ClientId,
|
||||
AutoSelect: false,
|
||||
})
|
||||
if err != nil {
|
||||
s.repository.logger.Error().Err(err).Msg("directstream: Failed to prepare new stream")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer util.HandlePanicInModuleThen("debridstream/AddBatchHistory", func() {})
|
||||
|
||||
_ = db_bridge.InsertTorrentstreamHistory(s.repository.db, media.GetID(), selectedTorrent)
|
||||
}()
|
||||
}(ctx)
|
||||
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusStarted,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: "Stream started",
|
||||
})
|
||||
s.repository.logger.Info().Msg("debridstream: Stream started")
|
||||
|
||||
if opts.PlaybackType == PlaybackTypeNoneAndAwait {
|
||||
s.repository.logger.Debug().Msg("debridstream: Waiting for stream to be ready")
|
||||
<-readyCh
|
||||
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StreamManager) cancelStream(opts *CancelStreamOptions) {
|
||||
if s.downloadCtxCancelFunc != nil {
|
||||
s.downloadCtxCancelFunc()
|
||||
s.downloadCtxCancelFunc = nil
|
||||
}
|
||||
|
||||
s.repository.wsEventManager.SendEvent(events.ShowIndefiniteLoader, "debridstream")
|
||||
|
||||
s.currentStreamUrl = ""
|
||||
|
||||
if opts.RemoveTorrent && s.currentTorrentItemId != "" {
|
||||
// Remove the torrent from the debrid service
|
||||
provider, err := s.repository.GetProvider()
|
||||
if err != nil {
|
||||
s.repository.logger.Err(err).Msg("debridstream: Failed to remove torrent")
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the torrent from the debrid service
|
||||
err = provider.DeleteTorrent(s.currentTorrentItemId)
|
||||
if err != nil {
|
||||
s.repository.logger.Err(err).Msg("debridstream: Failed to remove torrent")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
136
seanime-2.9.10/internal/debrid/client/stream_helpers.go
Normal file
136
seanime-2.9.10/internal/debrid/client/stream_helpers.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package debrid_client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/util"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *StreamManager) getMediaInfo(ctx context.Context, mediaId int) (media *anilist.CompleteAnime, animeMetadata *metadata.AnimeMetadata, err error) {
|
||||
// Get the media
|
||||
var found bool
|
||||
media, found = s.repository.completeAnimeCache.Get(mediaId)
|
||||
if !found {
|
||||
// Fetch the media
|
||||
media, err = s.repository.platform.GetAnimeWithRelations(ctx, mediaId)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("torrentstream: Failed to fetch media: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get the media
|
||||
animeMetadata, err = s.repository.metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, mediaId)
|
||||
if err != nil {
|
||||
//return nil, nil, fmt.Errorf("torrentstream: Could not fetch AniDB media: %w", err)
|
||||
animeMetadata = &metadata.AnimeMetadata{
|
||||
Titles: make(map[string]string),
|
||||
Episodes: make(map[string]*metadata.EpisodeMetadata),
|
||||
EpisodeCount: 0,
|
||||
SpecialCount: 0,
|
||||
Mappings: &metadata.AnimeMappings{
|
||||
AnilistId: media.GetID(),
|
||||
},
|
||||
}
|
||||
animeMetadata.Titles["en"] = media.GetTitleSafe()
|
||||
animeMetadata.Titles["x-jat"] = media.GetRomajiTitleSafe()
|
||||
err = nil
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func CanStream(streamUrl string) (bool, string) {
|
||||
hasExtension, isArchive := IsArchive(streamUrl)
|
||||
|
||||
// If we were able to verify that the stream URL is an archive, we can't stream it
|
||||
if isArchive {
|
||||
return false, "Stream URL is an archive"
|
||||
}
|
||||
|
||||
// If the stream URL has an extension, we can stream it
|
||||
if hasExtension {
|
||||
ext := filepath.Ext(streamUrl)
|
||||
if util.IsValidVideoExtension(ext) {
|
||||
return true, ""
|
||||
}
|
||||
// If the extension is not a valid video extension, we can't stream it
|
||||
return false, "Stream URL is not a valid video extension"
|
||||
}
|
||||
|
||||
// If the stream URL doesn't have an extension, we'll get the headers to check if it's a video
|
||||
// If the headers are not available, we can't stream it
|
||||
|
||||
contentType, err := GetContentType(streamUrl)
|
||||
if err != nil {
|
||||
return false, "Failed to get content type"
|
||||
}
|
||||
|
||||
if strings.HasPrefix(contentType, "video/") {
|
||||
return true, ""
|
||||
}
|
||||
|
||||
return false, fmt.Sprintf("Stream URL of type %q is not a video", contentType)
|
||||
}
|
||||
|
||||
func IsArchive(streamUrl string) (hasExtension bool, isArchive bool) {
|
||||
ext := filepath.Ext(streamUrl)
|
||||
if ext == ".zip" || ext == ".rar" {
|
||||
return true, true
|
||||
}
|
||||
|
||||
if ext != "" {
|
||||
return true, false
|
||||
}
|
||||
|
||||
return false, false
|
||||
}
|
||||
|
||||
func GetContentTypeHead(url string) string {
|
||||
resp, err := http.Head(url)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
return resp.Header.Get("Content-Type")
|
||||
}
|
||||
|
||||
func GetContentType(url string) (string, error) {
|
||||
// Try using HEAD request
|
||||
if cType := GetContentTypeHead(url); cType != "" {
|
||||
return cType, nil
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Only read a small amount of data to determine the content type.
|
||||
req.Header.Set("Range", "bytes=0-511")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read the first 512 bytes
|
||||
buf := make([]byte, 512)
|
||||
n, err := resp.Body.Read(buf)
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Detect content type based on the read bytes
|
||||
contentType := http.DetectContentType(buf[:n])
|
||||
|
||||
return contentType, nil
|
||||
}
|
||||
282
seanime-2.9.10/internal/debrid/client/utils.go
Normal file
282
seanime-2.9.10/internal/debrid/client/utils.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package debrid_client
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/nwaples/rardecode/v2"
|
||||
)
|
||||
|
||||
// Unzips a file to the destination
|
||||
//
|
||||
// Example:
|
||||
// If "file.zip" contains `folder>file.text`, the file will be extracted to "/path/to/dest/{TMP}/folder/file.txt"
|
||||
// unzipFile("file.zip", "/path/to/dest")
|
||||
func unzipFile(src, dest string) (string, error) {
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open zip file: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
// Create a temporary folder to extract the files
|
||||
extractedDir, err := os.MkdirTemp(dest, "extracted-")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp folder: %w", err)
|
||||
}
|
||||
|
||||
// Iterate through the files in the archive
|
||||
for _, f := range r.File {
|
||||
// Get the full path of the file in the destination
|
||||
fpath := filepath.Join(extractedDir, f.Name)
|
||||
// If the file is a directory, create it in the destination
|
||||
if f.FileInfo().IsDir() {
|
||||
_ = os.MkdirAll(fpath, os.ModePerm)
|
||||
continue
|
||||
}
|
||||
// Make sure the parent directory exists (will not return an error if it already exists)
|
||||
if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Open the file in the destination
|
||||
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Open the file in the archive
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
_ = outFile.Close()
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Copy the file from the archive to the destination
|
||||
_, err = io.Copy(outFile, rc)
|
||||
_ = outFile.Close()
|
||||
_ = rc.Close()
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return extractedDir, nil
|
||||
}
|
||||
|
||||
// Unrars a file to the destination
|
||||
//
|
||||
// Example:
|
||||
// If "file.rar" contains a folder "folder" with a file "file.txt", the file will be extracted to "/path/to/dest/{TM}/folder/file.txt"
|
||||
// unrarFile("file.rar", "/path/to/dest")
|
||||
func unrarFile(src, dest string) (string, error) {
|
||||
r, err := rardecode.OpenReader(src)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open rar file: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
// Create a temporary folder to extract the files
|
||||
extractedDir, err := os.MkdirTemp(dest, "extracted-")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp folder: %w", err)
|
||||
}
|
||||
|
||||
// Iterate through the files in the archive
|
||||
for {
|
||||
header, err := r.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Get the full path of the file in the destination
|
||||
fpath := filepath.Join(extractedDir, header.Name)
|
||||
// If the file is a directory, create it in the destination
|
||||
if header.IsDir {
|
||||
_ = os.MkdirAll(fpath, os.ModePerm)
|
||||
continue
|
||||
}
|
||||
|
||||
// Make sure the parent directory exists (will not return an error if it already exists)
|
||||
if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Open the file in the destination
|
||||
outFile, err := os.Create(fpath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Copy the file from the archive to the destination
|
||||
_, err = io.Copy(outFile, r)
|
||||
outFile.Close()
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return extractedDir, nil
|
||||
}
|
||||
|
||||
// Moves a folder or file to the destination
|
||||
//
|
||||
// Example:
|
||||
// moveFolderOrFileTo("/path/to/src/folder", "/path/to/dest") -> "/path/to/dest/folder"
|
||||
func moveFolderOrFileTo(src, dest string) error {
|
||||
// Ensure the destination folder exists
|
||||
if _, err := os.Stat(dest); os.IsNotExist(err) {
|
||||
err := os.MkdirAll(dest, os.ModePerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create destination folder: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
destFolder := filepath.Join(dest, filepath.Base(src))
|
||||
|
||||
// Move the folder by renaming it
|
||||
err := os.Rename(src, destFolder)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to move folder: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Moves the contents of a folder to the destination
|
||||
// It will move ONLY the folder containing multiple files or folders OR a single deeply nested file
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// Case 1:
|
||||
// src/
|
||||
// - Anime/
|
||||
// - Ep1.mkv
|
||||
// - Ep2.mkv
|
||||
// moveContentsTo("/path/to/src", "/path/to/dest") -> "/path/to/dest/Anime"
|
||||
//
|
||||
// Case 2:
|
||||
// src/
|
||||
// - {HASH}/
|
||||
// - Anime/
|
||||
// - Ep1.mkv
|
||||
// - Ep2.mkv
|
||||
// moveContentsTo("/path/to/src", "/path/to/dest") -> "/path/to/dest/Anime"
|
||||
//
|
||||
// Case 3:
|
||||
// src/
|
||||
// - {HASH}/
|
||||
// - Anime/
|
||||
// - Ep1.mkv
|
||||
// moveContentsTo("/path/to/src", "/path/to/dest") -> "/path/to/dest/Ep1.mkv"
|
||||
//
|
||||
// Case 4:
|
||||
// src/
|
||||
// - {HASH}/
|
||||
// - Anime/
|
||||
// - Anime 1/
|
||||
// - Ep1.mkv
|
||||
// - Ep2.mkv
|
||||
// - Anime 2/
|
||||
// - Ep1.mkv
|
||||
// - Ep2.mkv
|
||||
// moveContentsTo("/path/to/src", "/path/to/dest") -> "/path/to/dest/Anime"
|
||||
func moveContentsTo(src, dest string) error {
|
||||
// Ensure the source and destination directories exist
|
||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||
return fmt.Errorf("source directory does not exist: %s", src)
|
||||
}
|
||||
_ = os.MkdirAll(dest, os.ModePerm)
|
||||
|
||||
srcEntries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If the source folder contains multiple files or folders, move its contents to the destination
|
||||
if len(srcEntries) > 1 {
|
||||
for _, srcEntry := range srcEntries {
|
||||
err := moveFolderOrFileTo(filepath.Join(src, srcEntry.Name()), dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
folderMap := make(map[string]int)
|
||||
err = findFolderChildCount(src, folderMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var folderToMove string
|
||||
for folder, count := range folderMap {
|
||||
if count > 1 {
|
||||
if folderToMove == "" || len(folder) < len(folderToMove) {
|
||||
folderToMove = folder
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
//util.Spew(folderToMove)
|
||||
|
||||
// It's a single file, move that file only
|
||||
if folderToMove == "" {
|
||||
fp := getDeeplyNestedFile(src)
|
||||
if fp == "" {
|
||||
return fmt.Errorf("no files found in the source directory")
|
||||
}
|
||||
return moveFolderOrFileTo(fp, dest)
|
||||
}
|
||||
|
||||
// Move the folder containing multiple files or folders
|
||||
err = moveFolderOrFileTo(folderToMove, dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Finds the folder to move to the destination
|
||||
func findFolderChildCount(src string, folderMap map[string]int) error {
|
||||
srcEntries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, srcEntry := range srcEntries {
|
||||
folderMap[src]++
|
||||
if srcEntry.IsDir() {
|
||||
err = findFolderChildCount(filepath.Join(src, srcEntry.Name()), folderMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getDeeplyNestedFile(src string) (fp string) {
|
||||
srcEntries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, srcEntry := range srcEntries {
|
||||
if srcEntry.IsDir() {
|
||||
return getDeeplyNestedFile(filepath.Join(src, srcEntry.Name()))
|
||||
}
|
||||
return filepath.Join(src, srcEntry.Name())
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
200
seanime-2.9.10/internal/debrid/client/utils_test.go
Normal file
200
seanime-2.9.10/internal/debrid/client/utils_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package debrid_client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func PrintPathStructure(path string, indent string) error {
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read directory %s: %w", path, err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
fmt.Println(indent + entry.Name())
|
||||
|
||||
if entry.IsDir() {
|
||||
newIndent := indent + " "
|
||||
newPath := filepath.Join(path, entry.Name())
|
||||
if err := PrintPathStructure(newPath, newIndent); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCreateTempDir(t *testing.T) {
|
||||
|
||||
files := []string{
|
||||
"/12345/Anime/Ep1.mkv",
|
||||
"/12345/Anime/Ep2.mkv",
|
||||
}
|
||||
|
||||
root := "./root"
|
||||
for _, file := range files {
|
||||
path := filepath.Join(root, file)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatalf("failed to create directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("dummy content"), 0644); err != nil {
|
||||
t.Fatalf("failed to create file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
defer os.RemoveAll(root)
|
||||
|
||||
err := PrintPathStructure(root, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
}
|
||||
|
||||
func TestMoveContentsTo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
files []string
|
||||
dest string
|
||||
expected string
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
name: "Case 1: Move folder with files",
|
||||
files: []string{
|
||||
"/Anime/Ep1.mkv",
|
||||
"/Anime/Ep2.mkv",
|
||||
},
|
||||
dest: "./dest",
|
||||
expected: "./dest/Anime",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "Case 2: Move folder with single hash directory",
|
||||
files: []string{
|
||||
"/12345/Anime/Ep1.mkv",
|
||||
"/12345/Anime/Ep2.mkv",
|
||||
},
|
||||
dest: "./dest",
|
||||
expected: "./dest/Anime",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "Case 3: Move single file",
|
||||
files: []string{
|
||||
"/12345/Anime/Ep1.mkv",
|
||||
},
|
||||
dest: "./dest",
|
||||
expected: "./dest/Ep1.mkv",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "Case 5: Source directory does not exist",
|
||||
files: []string{},
|
||||
dest: "./dest",
|
||||
expected: "",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "Case 6: Move single file with hash directory",
|
||||
files: []string{
|
||||
"/12345/Anime/Ep1.mkv",
|
||||
},
|
||||
dest: "./dest",
|
||||
expected: "./dest/Ep1.mkv",
|
||||
},
|
||||
{
|
||||
name: "Case 7",
|
||||
files: []string{
|
||||
"Ep1.mkv",
|
||||
},
|
||||
dest: "./dest",
|
||||
expected: "./dest/Ep1.mkv",
|
||||
},
|
||||
{
|
||||
name: "Case 8",
|
||||
files: []string{
|
||||
"Ep1.mkv",
|
||||
"Ep2.mkv",
|
||||
},
|
||||
dest: "./dest",
|
||||
expected: "./dest/Ep2.mkv",
|
||||
},
|
||||
{
|
||||
name: "Case 9",
|
||||
files: []string{
|
||||
"/12345/Anime/Anime 1/Ep1.mkv",
|
||||
"/12345/Anime/Anime 1/Ep2.mkv",
|
||||
"/12345/Anime/Anime 2/Ep1.mkv",
|
||||
"/12345/Anime/Anime 2/Ep2.mkv",
|
||||
"/12345/Anime 2/Anime 3/Ep1.mkv",
|
||||
"/12345/Anime 2/Anime 3/Ep2.mkv",
|
||||
},
|
||||
dest: "./dest",
|
||||
expected: "./dest/12345",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "Case 10",
|
||||
files: []string{
|
||||
"/Users/r/Downloads/b6aa416f662a2df83c6f5f79da95004ced59b8ef/Tsue to Tsurugi no Wistoria S01 1080p WEBRip DD+ x265-EMBER/[EMBER] Tsue to Tsurugi no Wistoria - 01.mkv",
|
||||
"/Users/r/Downloads/b6aa416f662a2df83c6f5f79da95004ced59b8ef/Tsue to Tsurugi no Wistoria S01 1080p WEBRip DD+ x265-EMBER/[EMBER] Tsue to Tsurugi no Wistoria - 02.mkv",
|
||||
},
|
||||
dest: "./dest",
|
||||
expected: "./dest/Tsue to Tsurugi no Wistoria S01 1080p WEBRip DD+ x265-EMBER",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "Case 11",
|
||||
files: []string{
|
||||
"/Users/rahim/Downloads/80431b4f9a12f4e06616062d3d3973b9ef99b5e6/[SubsPlease] Bocchi the Rock! - 01 (1080p) [E04F4EFB]/[SubsPlease] Bocchi the Rock! - 01 (1080p) [E04F4EFB].mkv",
|
||||
},
|
||||
dest: "./dest",
|
||||
expected: "./dest/[SubsPlease] Bocchi the Rock! - 01 (1080p) [E04F4EFB].mkv",
|
||||
expectErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create the source directory structure
|
||||
root := "./root"
|
||||
for _, file := range tt.files {
|
||||
path := filepath.Join(root, file)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatalf("failed to create directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("dummy content"), 0644); err != nil {
|
||||
t.Fatalf("failed to create file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
defer os.RemoveAll(root) // Cleanup temp dir after test
|
||||
|
||||
PrintPathStructure(root, "")
|
||||
println("-----------------------------")
|
||||
|
||||
// Create the destination directory
|
||||
if err := os.MkdirAll(tt.dest, 0755); err != nil {
|
||||
t.Fatalf("failed to create dest directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tt.dest) // Cleanup dest after test
|
||||
|
||||
// Move the contents
|
||||
err := moveContentsTo(root, tt.dest)
|
||||
|
||||
if (err != nil) != tt.expectErr {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !tt.expectErr {
|
||||
if _, err := os.Stat(tt.expected); os.IsNotExist(err) {
|
||||
t.Errorf("expected directory or file does not exist: %s", tt.expected)
|
||||
}
|
||||
|
||||
PrintPathStructure(tt.dest, "")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
126
seanime-2.9.10/internal/debrid/debrid/debrid.go
Normal file
126
seanime-2.9.10/internal/debrid/debrid/debrid.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package debrid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"seanime/internal/util"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotAuthenticated = fmt.Errorf("not authenticated")
|
||||
ErrFailedToAuthenticate = fmt.Errorf("failed to authenticate")
|
||||
ErrStreamInterrupted = fmt.Errorf("stream interrupted")
|
||||
)
|
||||
|
||||
type (
|
||||
Provider interface {
|
||||
GetSettings() Settings
|
||||
Authenticate(apiKey string) error
|
||||
AddTorrent(opts AddTorrentOptions) (string, error)
|
||||
// GetTorrentStreamUrl returns the stream URL for the torrent file. It should block until the stream URL is available.
|
||||
GetTorrentStreamUrl(ctx context.Context, opts StreamTorrentOptions, itemCh chan TorrentItem) (streamUrl string, err error)
|
||||
// GetTorrentDownloadUrl returns the download URL for the torrent. It should return an error if the torrent is not ready.
|
||||
GetTorrentDownloadUrl(opts DownloadTorrentOptions) (downloadUrl string, err error)
|
||||
// GetInstantAvailability returns a map where the key is the torrent's info hash
|
||||
GetInstantAvailability(hashes []string) map[string]TorrentItemInstantAvailability
|
||||
GetTorrent(id string) (*TorrentItem, error)
|
||||
GetTorrentInfo(opts GetTorrentInfoOptions) (*TorrentInfo, error)
|
||||
GetTorrents() ([]*TorrentItem, error)
|
||||
DeleteTorrent(id string) error
|
||||
}
|
||||
|
||||
AddTorrentOptions struct {
|
||||
MagnetLink string `json:"magnetLink"`
|
||||
InfoHash string `json:"infoHash"`
|
||||
SelectFileId string `json:"selectFileId"` // Real-Debrid only, ID, IDs, or "all"
|
||||
}
|
||||
|
||||
StreamTorrentOptions struct {
|
||||
ID string `json:"id"`
|
||||
FileId string `json:"fileId"` // ID or index of the file to stream
|
||||
}
|
||||
|
||||
GetTorrentInfoOptions struct {
|
||||
MagnetLink string `json:"magnetLink"`
|
||||
InfoHash string `json:"infoHash"`
|
||||
}
|
||||
|
||||
DownloadTorrentOptions struct {
|
||||
ID string `json:"id"`
|
||||
FileId string `json:"fileId"` // ID or index of the file to download
|
||||
}
|
||||
|
||||
// TorrentItem represents a torrent added to a Debrid service
|
||||
TorrentItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"` // Name of the torrent or file
|
||||
Hash string `json:"hash"` // SHA1 hash of the torrent
|
||||
Size int64 `json:"size"` // Size of the selected files (size in bytes)
|
||||
FormattedSize string `json:"formattedSize"` // Formatted size of the selected files
|
||||
CompletionPercentage int `json:"completionPercentage"` // Progress percentage (0 to 100)
|
||||
ETA string `json:"eta"` // Formatted estimated time remaining
|
||||
Status TorrentItemStatus `json:"status"` // Current download status
|
||||
AddedAt string `json:"added"` // Date when the torrent was added, RFC3339 format
|
||||
Speed string `json:"speed,omitempty"` // Current download speed (optional, present in downloading state)
|
||||
Seeders int `json:"seeders,omitempty"` // Number of seeders (optional, present in downloading state)
|
||||
IsReady bool `json:"isReady"` // Whether the torrent is ready to be downloaded
|
||||
Files []*TorrentItemFile `json:"files,omitempty"` // List of files in the torrent (optional)
|
||||
}
|
||||
|
||||
TorrentItemFile struct {
|
||||
ID string `json:"id"` // ID of the file, usually the index
|
||||
Index int `json:"index"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
TorrentItemStatus string
|
||||
|
||||
TorrentItemInstantAvailability struct {
|
||||
CachedFiles map[string]*CachedFile `json:"cachedFiles"` // Key is the file ID (or index)
|
||||
}
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
TorrentInfo struct {
|
||||
ID *string `json:"id"` // ID of the torrent if added to the debrid service
|
||||
Name string `json:"name"`
|
||||
Hash string `json:"hash"`
|
||||
Size int64 `json:"size"`
|
||||
Files []*TorrentItemFile `json:"files"`
|
||||
}
|
||||
|
||||
CachedFile struct {
|
||||
Size int64 `json:"size"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
////////////////////////////////////////////////////////////////////
|
||||
|
||||
Settings struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
TorrentItemStatusDownloading TorrentItemStatus = "downloading"
|
||||
TorrentItemStatusCompleted TorrentItemStatus = "completed"
|
||||
TorrentItemStatusSeeding TorrentItemStatus = "seeding"
|
||||
TorrentItemStatusError TorrentItemStatus = "error"
|
||||
TorrentItemStatusStalled TorrentItemStatus = "stalled"
|
||||
TorrentItemStatusPaused TorrentItemStatus = "paused"
|
||||
TorrentItemStatusOther TorrentItemStatus = "other"
|
||||
)
|
||||
|
||||
func FilterVideoFiles(files []*TorrentItemFile) []*TorrentItemFile {
|
||||
var filtered []*TorrentItemFile
|
||||
for _, file := range files {
|
||||
ext := filepath.Ext(file.Name)
|
||||
if util.IsValidVideoExtension(ext) {
|
||||
filtered = append(filtered, file)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
811
seanime-2.9.10/internal/debrid/realdebrid/realdebrid.go
Normal file
811
seanime-2.9.10/internal/debrid/realdebrid/realdebrid.go
Normal file
@@ -0,0 +1,811 @@
|
||||
package realdebrid
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"seanime/internal/debrid/debrid"
|
||||
"seanime/internal/util"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/samber/mo"
|
||||
)
|
||||
|
||||
type (
|
||||
RealDebrid struct {
|
||||
baseUrl string
|
||||
apiKey mo.Option[string]
|
||||
client *http.Client
|
||||
logger *zerolog.Logger
|
||||
}
|
||||
|
||||
ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDetails string `json:"error_details"`
|
||||
ErrorCode int `json:"error_code"`
|
||||
}
|
||||
|
||||
Torrent struct {
|
||||
ID string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Hash string `json:"hash"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Host string `json:"host"`
|
||||
Split int `json:"split"`
|
||||
Progress float64 `json:"progress"`
|
||||
Status string `json:"status"`
|
||||
Added string `json:"added"`
|
||||
Links []string `json:"links"`
|
||||
Ended string `json:"ended"`
|
||||
Speed int64 `json:"speed"`
|
||||
Seeders int `json:"seeders"`
|
||||
}
|
||||
|
||||
TorrentInfo struct {
|
||||
ID string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
OriginalFilename string `json:"original_filename"`
|
||||
Hash string `json:"hash"`
|
||||
Bytes int64 `json:"bytes"` // Size of selected files
|
||||
OriginalBytes int64 `json:"original_bytes"` // Size of the torrent
|
||||
Host string `json:"host"`
|
||||
Split int `json:"split"`
|
||||
Progress float64 `json:"progress"`
|
||||
Status string `json:"status"`
|
||||
Added string `json:"added"`
|
||||
Files []*TorrentInfoFile `json:"files"`
|
||||
Links []string `json:"links"`
|
||||
Ended string `json:"ended"`
|
||||
Speed int64 `json:"speed"`
|
||||
Seeders int `json:"seeders"`
|
||||
}
|
||||
|
||||
TorrentInfoFile struct {
|
||||
ID int `json:"id"`
|
||||
Path string `json:"path"` // e.g. "/Big Buck Bunny/Big Buck Bunny.mp4"
|
||||
Bytes int64 `json:"bytes"`
|
||||
Selected int `json:"selected"` // 1 if selected, 0 if not
|
||||
}
|
||||
|
||||
InstantAvailabilityItem struct {
|
||||
Hash string `json:"hash"`
|
||||
Files []struct {
|
||||
Filename string `json:"filename"`
|
||||
Filesize int `json:"filesize"`
|
||||
} `json:"files"`
|
||||
}
|
||||
)
|
||||
|
||||
func NewRealDebrid(logger *zerolog.Logger) debrid.Provider {
|
||||
return &RealDebrid{
|
||||
baseUrl: "https://api.real-debrid.com/rest/1.0",
|
||||
apiKey: mo.None[string](),
|
||||
client: &http.Client{
|
||||
Timeout: time.Second * 10,
|
||||
},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func NewRealDebridT(logger *zerolog.Logger) *RealDebrid {
|
||||
return &RealDebrid{
|
||||
baseUrl: "https://api.real-debrid.com/rest/1.0",
|
||||
apiKey: mo.None[string](),
|
||||
client: &http.Client{
|
||||
Timeout: time.Second * 30,
|
||||
},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *RealDebrid) GetSettings() debrid.Settings {
|
||||
return debrid.Settings{
|
||||
ID: "realdebrid",
|
||||
Name: "RealDebrid",
|
||||
}
|
||||
}
|
||||
|
||||
func (t *RealDebrid) doQuery(method, uri string, body io.Reader, contentType string) (ret []byte, err error) {
|
||||
apiKey, found := t.apiKey.Get()
|
||||
if !found {
|
||||
return nil, debrid.ErrNotAuthenticated
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, uri, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Add("Content-Type", contentType)
|
||||
req.Header.Add("Authorization", "Bearer "+apiKey)
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
var errResp ErrorResponse
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
|
||||
t.logger.Error().Err(err).Msg("realdebrid: Failed to decode response")
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
// If the error details are empty, we'll just return the response body
|
||||
if errResp.ErrorDetails == "" && errResp.ErrorCode == 0 {
|
||||
content, _ := io.ReadAll(resp.Body)
|
||||
return content, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to query API: %s, %s", resp.Status, errResp.ErrorDetails)
|
||||
}
|
||||
|
||||
content, _ := io.ReadAll(resp.Body)
|
||||
//fmt.Println(string(content))
|
||||
|
||||
return content, nil
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (t *RealDebrid) Authenticate(apiKey string) error {
|
||||
t.apiKey = mo.Some(apiKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// {
|
||||
// "string": { // First hash
|
||||
// "string": [ // hoster, ex: "rd"
|
||||
// // All file IDs variants
|
||||
// {
|
||||
// "int": { // file ID, you must ask all file IDs from this array on /selectFiles to get instant downloading
|
||||
// "filename": "string",
|
||||
// "filesize": int
|
||||
// },
|
||||
// },
|
||||
type instantAvailabilityResponse map[string]map[string][]map[int]instantAvailabilityFile
|
||||
type instantAvailabilityFile struct {
|
||||
Filename string `json:"filename"`
|
||||
Filesize int64 `json:"filesize"`
|
||||
}
|
||||
|
||||
func (t *RealDebrid) GetInstantAvailability(hashes []string) map[string]debrid.TorrentItemInstantAvailability {
|
||||
|
||||
t.logger.Trace().Strs("hashes", hashes).Msg("realdebrid: Checking instant availability")
|
||||
|
||||
availability := make(map[string]debrid.TorrentItemInstantAvailability)
|
||||
|
||||
if len(hashes) == 0 {
|
||||
return availability
|
||||
}
|
||||
|
||||
return t.getInstantAvailabilityT(hashes, 3, 100)
|
||||
}
|
||||
|
||||
func (t *RealDebrid) getInstantAvailabilityT(hashes []string, retries int, limit int) (ret map[string]debrid.TorrentItemInstantAvailability) {
|
||||
|
||||
ret = make(map[string]debrid.TorrentItemInstantAvailability)
|
||||
|
||||
var hashBatches [][]string
|
||||
|
||||
for i := 0; i < len(hashes); i += limit {
|
||||
end := i + limit
|
||||
if end > len(hashes) {
|
||||
end = len(hashes)
|
||||
}
|
||||
hashBatches = append(hashBatches, hashes[i:end])
|
||||
}
|
||||
|
||||
for _, batch := range hashBatches {
|
||||
|
||||
hashParams := ""
|
||||
for _, hash := range batch {
|
||||
hashParams += "/" + hash
|
||||
}
|
||||
|
||||
resp, err := t.doQuery("GET", t.baseUrl+"/torrents/instantAvailability"+hashParams, nil, "application/json")
|
||||
if err != nil {
|
||||
t.logger.Error().Err(err).Msg("realdebrid: Failed to get instant availability")
|
||||
return
|
||||
}
|
||||
|
||||
//fmt.Println(string(resp))
|
||||
|
||||
var instantAvailability instantAvailabilityResponse
|
||||
err = json.Unmarshal(resp, &instantAvailability)
|
||||
if err != nil {
|
||||
if limit != 1 && retries > 0 {
|
||||
t.logger.Warn().Msg("realdebrid: Retrying instant availability request")
|
||||
return t.getInstantAvailabilityT(hashes, retries-1, int(math.Ceil(float64(limit)/10)))
|
||||
} else {
|
||||
t.logger.Error().Err(err).Msg("realdebrid: Failed to parse instant availability")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for hash, hosters := range instantAvailability {
|
||||
currentHash := ""
|
||||
for _, _hash := range hashes {
|
||||
if strings.EqualFold(hash, _hash) {
|
||||
currentHash = _hash
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if currentHash == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
avail := debrid.TorrentItemInstantAvailability{
|
||||
CachedFiles: make(map[string]*debrid.CachedFile),
|
||||
}
|
||||
|
||||
for hoster, hosterI := range hosters {
|
||||
if hoster != "rd" {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, hosterFiles := range hosterI {
|
||||
for fileId, file := range hosterFiles {
|
||||
avail.CachedFiles[strconv.Itoa(fileId)] = &debrid.CachedFile{
|
||||
Name: file.Filename,
|
||||
Size: file.Filesize,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(avail.CachedFiles) > 0 {
|
||||
ret[currentHash] = avail
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (t *RealDebrid) AddTorrent(opts debrid.AddTorrentOptions) (string, error) {
|
||||
|
||||
// Check if the torrent is already added
|
||||
// If it is, return the torrent ID
|
||||
torrentId := ""
|
||||
if opts.InfoHash != "" {
|
||||
torrents, err := t.getTorrents(false)
|
||||
if err == nil {
|
||||
for _, torrent := range torrents {
|
||||
if torrent.Hash == opts.InfoHash {
|
||||
t.logger.Debug().Str("torrentId", torrent.ID).Msg("realdebrid: Torrent already added")
|
||||
torrentId = torrent.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
|
||||
// If the torrent wasn't already added, add it
|
||||
if torrentId == "" {
|
||||
resp, err := t.addMagnet(opts.MagnetLink)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
torrentId = resp.ID
|
||||
}
|
||||
|
||||
// If a file ID is provided, select the file to start downloading it
|
||||
if opts.SelectFileId != "" {
|
||||
// Select the file to download
|
||||
err := t.selectCachedFiles(torrentId, opts.SelectFileId)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return torrentId, nil
|
||||
}
|
||||
|
||||
// GetTorrentStreamUrl blocks until the torrent is downloaded and returns the stream URL for the torrent file by calling GetTorrentDownloadUrl.
|
||||
func (t *RealDebrid) GetTorrentStreamUrl(ctx context.Context, opts debrid.StreamTorrentOptions, itemCh chan debrid.TorrentItem) (streamUrl string, err error) {
|
||||
|
||||
t.logger.Trace().Str("torrentId", opts.ID).Str("fileId", opts.FileId).Msg("realdebrid: Retrieving stream link")
|
||||
|
||||
doneCh := make(chan struct{})
|
||||
|
||||
go func(ctx context.Context) {
|
||||
defer func() {
|
||||
close(doneCh)
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
err = ctx.Err()
|
||||
return
|
||||
case <-time.After(4 * time.Second):
|
||||
ti, _err := t.getTorrentInfo(opts.ID)
|
||||
if _err != nil {
|
||||
t.logger.Error().Err(_err).Msg("realdebrid: Failed to get torrent")
|
||||
err = fmt.Errorf("realdebrid: Failed to get torrent: %w", _err)
|
||||
return
|
||||
}
|
||||
|
||||
dt := toDebridTorrent(&Torrent{
|
||||
ID: ti.ID,
|
||||
Filename: ti.Filename,
|
||||
Hash: ti.Hash,
|
||||
Bytes: ti.Bytes,
|
||||
Host: ti.Host,
|
||||
Split: ti.Split,
|
||||
Progress: ti.Progress,
|
||||
Status: ti.Status,
|
||||
Added: ti.Added,
|
||||
Links: ti.Links,
|
||||
Ended: ti.Ended,
|
||||
Speed: ti.Speed,
|
||||
Seeders: ti.Seeders,
|
||||
})
|
||||
itemCh <- *dt
|
||||
|
||||
// Check if the torrent is ready
|
||||
if dt.IsReady {
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
files := make([]*TorrentInfoFile, 0)
|
||||
for _, f := range ti.Files {
|
||||
if f.Selected == 1 {
|
||||
files = append(files, f)
|
||||
}
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
err = fmt.Errorf("realdebrid: No files downloaded")
|
||||
return
|
||||
}
|
||||
|
||||
for idx, f := range files {
|
||||
if strconv.Itoa(f.ID) == opts.FileId {
|
||||
resp, err := t.unrestrictLink(ti.Links[idx])
|
||||
if err != nil {
|
||||
t.logger.Error().Err(err).Msg("realdebrid: Failed to get download URL")
|
||||
return
|
||||
}
|
||||
|
||||
streamUrl = resp.Download
|
||||
return
|
||||
}
|
||||
}
|
||||
err = fmt.Errorf("realdebrid: File not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}(ctx)
|
||||
|
||||
<-doneCh
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
type unrestrictLinkResponse struct {
|
||||
ID string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Filesize int64 `json:"filesize"`
|
||||
Link string `json:"link"`
|
||||
Host string `json:"host"`
|
||||
Chunks int `json:"chunks"`
|
||||
Crc int `json:"crc"`
|
||||
Download string `json:"download"` // Generated download link
|
||||
Streamable int `json:"streamable"`
|
||||
}
|
||||
|
||||
// GetTorrentDownloadUrl returns the download URL for the torrent file.
|
||||
// If no opts.FileId is provided, it will return a comma-separated list of download URLs for all selected files in the torrent.
|
||||
func (t *RealDebrid) GetTorrentDownloadUrl(opts debrid.DownloadTorrentOptions) (downloadUrl string, err error) {
|
||||
|
||||
t.logger.Trace().Str("torrentId", opts.ID).Msg("realdebrid: Retrieving download link")
|
||||
|
||||
torrentInfo, err := t.getTorrentInfo(opts.ID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("realdebrid: Failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
files := make([]*TorrentInfoFile, 0)
|
||||
for _, f := range torrentInfo.Files {
|
||||
if f.Selected == 1 {
|
||||
files = append(files, f)
|
||||
}
|
||||
}
|
||||
|
||||
downloadUrl = ""
|
||||
|
||||
if opts.FileId != "" {
|
||||
var file *TorrentInfoFile
|
||||
var link string
|
||||
for idx, f := range files {
|
||||
if strconv.Itoa(f.ID) == opts.FileId {
|
||||
file = f
|
||||
link = torrentInfo.Links[idx]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if file == nil || link == "" {
|
||||
return "", fmt.Errorf("realdebrid: File not found")
|
||||
}
|
||||
|
||||
unrestrictLink, err := t.unrestrictLink(link)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("realdebrid: Failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
return unrestrictLink.Download, nil
|
||||
}
|
||||
|
||||
for idx := range files {
|
||||
link := torrentInfo.Links[idx]
|
||||
unrestrictLink, err := t.unrestrictLink(link)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("realdebrid: Failed to get download URL: %w", err)
|
||||
}
|
||||
if downloadUrl != "" {
|
||||
downloadUrl += ","
|
||||
}
|
||||
downloadUrl += unrestrictLink.Download
|
||||
}
|
||||
|
||||
return downloadUrl, nil
|
||||
}
|
||||
|
||||
func (t *RealDebrid) GetTorrent(id string) (ret *debrid.TorrentItem, err error) {
|
||||
torrent, err := t.getTorrent(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret = toDebridTorrent(torrent)
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// GetTorrentInfo uses the info hash to return the torrent's data.
|
||||
// This adds the torrent to the user's account without downloading it and removes it after getting the info.
|
||||
func (t *RealDebrid) GetTorrentInfo(opts debrid.GetTorrentInfoOptions) (ret *debrid.TorrentInfo, err error) {
|
||||
|
||||
if opts.MagnetLink == "" {
|
||||
return nil, fmt.Errorf("realdebrid: Magnet link is required")
|
||||
}
|
||||
|
||||
// Add the torrent to the user's account without downloading it
|
||||
resp, err := t.addMagnet(opts.MagnetLink)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("realdebrid: Failed to get info: %w", err)
|
||||
}
|
||||
|
||||
torrent, err := t.getTorrentInfo(resp.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
// Remove the torrent
|
||||
err = t.DeleteTorrent(torrent.ID)
|
||||
if err != nil {
|
||||
t.logger.Error().Err(err).Msg("realdebrid: Failed to delete torrent")
|
||||
}
|
||||
}()
|
||||
|
||||
ret = toDebridTorrentInfo(torrent)
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (t *RealDebrid) GetTorrents() (ret []*debrid.TorrentItem, err error) {
|
||||
|
||||
torrents, err := t.getTorrents(true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("realdebrid: Failed to get torrents: %w", err)
|
||||
}
|
||||
|
||||
for _, t := range torrents {
|
||||
ret = append(ret, toDebridTorrent(t))
|
||||
}
|
||||
|
||||
slices.SortFunc(ret, func(i, j *debrid.TorrentItem) int {
|
||||
return cmp.Compare(j.AddedAt, i.AddedAt)
|
||||
})
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// selectCachedFiles
|
||||
// Real Debrid will re-download cached torrent if we select only a few files from the torrent.
|
||||
// To avoid this, we'll select all *cached* files in the torrent if the file we want to download is cached.
|
||||
func (t *RealDebrid) selectCachedFiles(id string, idStr string) (err error) {
|
||||
|
||||
t.logger.Trace().Str("torrentId", id).Str("fileId", "all").Msg("realdebrid: Selecting all files")
|
||||
|
||||
return t._selectFiles(id, "all")
|
||||
|
||||
//t.logger.Trace().Str("torrentId", id).Str("fileId", idStr).Msg("realdebrid: Selecting cached files")
|
||||
//// If the file ID is "all" or a list of IDs, just call selectFiles
|
||||
//if idStr == "all" || strings.Contains(idStr, ",") {
|
||||
// return t._selectFiles(id, idStr)
|
||||
//}
|
||||
//
|
||||
//// Get the torrent info
|
||||
//torrent, err := t.getTorrent(id)
|
||||
//if err != nil {
|
||||
// return err
|
||||
//}
|
||||
//
|
||||
//// Get the instant availability
|
||||
//avail := t.GetInstantAvailability([]string{torrent.Hash})
|
||||
//if _, ok := avail[torrent.Hash]; !ok {
|
||||
// return t._selectFiles(id, idStr)
|
||||
//}
|
||||
//
|
||||
//// Get all cached file IDs
|
||||
//ids := make([]string, 0)
|
||||
//for fileIdStr := range avail[torrent.Hash].CachedFiles {
|
||||
// if fileIdStr != "" {
|
||||
// ids = append(ids, fileIdStr)
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//// If the selected file isn't cached, we'll just download it alone
|
||||
//if !slices.Contains(ids, idStr) {
|
||||
// return t._selectFiles(id, idStr)
|
||||
//}
|
||||
//
|
||||
//// Download all cached files
|
||||
//return t._selectFiles(id, strings.Join(ids, ","))
|
||||
}
|
||||
|
||||
func (t *RealDebrid) _selectFiles(id string, idStr string) (err error) {
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
t.logger.Trace().Str("torrentId", id).Str("fileId", idStr).Msg("realdebrid: Selecting files")
|
||||
|
||||
err = writer.WriteField("files", idStr)
|
||||
if err != nil {
|
||||
t.logger.Error().Err(err).Msg("realdebrid: Failed to write field 'files'")
|
||||
return fmt.Errorf("realdebrid: Failed to select files: %w", err)
|
||||
}
|
||||
|
||||
_, err = t.doQuery("POST", t.baseUrl+fmt.Sprintf("/torrents/selectFiles/%s", id), &body, writer.FormDataContentType())
|
||||
if err != nil {
|
||||
t.logger.Error().Err(err).Msg("realdebrid: Failed to select files")
|
||||
return fmt.Errorf("realdebrid: Failed to select files: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type addMagnetResponse struct {
|
||||
ID string `json:"id"`
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
|
||||
func (t *RealDebrid) addMagnet(magnet string) (ret *addMagnetResponse, err error) {
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
t.logger.Trace().Str("magnetLink", magnet).Msg("realdebrid: Adding torrent")
|
||||
|
||||
err = writer.WriteField("magnet", magnet)
|
||||
if err != nil {
|
||||
t.logger.Error().Err(err).Msg("realdebrid: Failed to write field 'magnet'")
|
||||
return nil, fmt.Errorf("torbox: Failed to add torrent: %w", err)
|
||||
}
|
||||
|
||||
resp, err := t.doQuery("POST", t.baseUrl+"/torrents/addMagnet", &body, writer.FormDataContentType())
|
||||
if err != nil {
|
||||
t.logger.Error().Err(err).Msg("realdebrid: Failed to add torrent")
|
||||
return nil, fmt.Errorf("realdebrid: Failed to add torrent: %w", err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(resp, &ret)
|
||||
if err != nil {
|
||||
t.logger.Error().Err(err).Msg("realdebrid: Failed to parse torrent")
|
||||
return nil, fmt.Errorf("realdebrid: Failed to parse torrent: %w", err)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (t *RealDebrid) unrestrictLink(link string) (ret *unrestrictLinkResponse, err error) {
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
err = writer.WriteField("link", link)
|
||||
if err != nil {
|
||||
t.logger.Error().Err(err).Msg("realdebrid: Failed to write field 'link'")
|
||||
return nil, fmt.Errorf("realdebrid: Failed to unrestrict link: %w", err)
|
||||
}
|
||||
|
||||
resp, err := t.doQuery("POST", t.baseUrl+"/unrestrict/link", &body, writer.FormDataContentType())
|
||||
if err != nil {
|
||||
t.logger.Error().Err(err).Msg("realdebrid: Failed to unrestrict link")
|
||||
return nil, fmt.Errorf("realdebrid: Failed to unrestrict link: %w", err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(resp, &ret)
|
||||
if err != nil {
|
||||
t.logger.Error().Err(err).Msg("realdebrid: Failed to parse unrestrict link")
|
||||
return nil, fmt.Errorf("realdebrid: Failed to parse unrestrict link: %w", err)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (t *RealDebrid) getTorrents(activeOnly bool) (ret []*Torrent, err error) {
|
||||
_url, _ := url.Parse(t.baseUrl + "/torrents")
|
||||
q := _url.Query()
|
||||
if activeOnly {
|
||||
q.Set("filter", "active")
|
||||
} else {
|
||||
q.Set("limit", "500")
|
||||
}
|
||||
|
||||
resp, err := t.doQuery("GET", _url.String(), nil, "application/json")
|
||||
if err != nil {
|
||||
t.logger.Error().Err(err).Msg("realdebrid: Failed to get torrents")
|
||||
return nil, fmt.Errorf("realdebrid: Failed to get torrents: %w", err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(resp, &ret)
|
||||
if err != nil {
|
||||
t.logger.Error().Err(err).Msg("realdebrid: Failed to parse torrents")
|
||||
return nil, fmt.Errorf("realdebrid: Failed to parse torrents: %w", err)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (t *RealDebrid) getTorrent(id string) (ret *Torrent, err error) {
|
||||
|
||||
resp, err := t.doQuery("GET", t.baseUrl+fmt.Sprintf("/torrents/info/%s", id), nil, "application/json")
|
||||
if err != nil {
|
||||
t.logger.Error().Err(err).Msg("realdebrid: Failed to get torrent")
|
||||
return nil, fmt.Errorf("realdebrid: Failed to get torrent: %w", err)
|
||||
}
|
||||
|
||||
var ti TorrentInfo
|
||||
|
||||
err = json.Unmarshal(resp, &ti)
|
||||
if err != nil {
|
||||
t.logger.Error().Err(err).Msg("realdebrid: Failed to parse torrent")
|
||||
return nil, fmt.Errorf("realdebrid: Failed to parse torrent: %w", err)
|
||||
}
|
||||
|
||||
ret = &Torrent{
|
||||
ID: ti.ID,
|
||||
Filename: ti.Filename,
|
||||
Hash: ti.Hash,
|
||||
Bytes: ti.Bytes,
|
||||
Host: ti.Host,
|
||||
Split: ti.Split,
|
||||
Progress: ti.Progress,
|
||||
Status: ti.Status,
|
||||
Added: ti.Added,
|
||||
Links: ti.Links,
|
||||
Ended: ti.Ended,
|
||||
Speed: ti.Speed,
|
||||
Seeders: ti.Seeders,
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (t *RealDebrid) getTorrentInfo(id string) (ret *TorrentInfo, err error) {
|
||||
|
||||
resp, err := t.doQuery("GET", t.baseUrl+fmt.Sprintf("/torrents/info/%s", id), nil, "application/json")
|
||||
if err != nil {
|
||||
t.logger.Error().Err(err).Msg("realdebrid: Failed to get torrent")
|
||||
return nil, fmt.Errorf("realdebrid: Failed to get torrent: %w", err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(resp, &ret)
|
||||
if err != nil {
|
||||
t.logger.Error().Err(err).Msg("realdebrid: Failed to parse torrent")
|
||||
return nil, fmt.Errorf("realdebrid: Failed to parse torrent: %w", err)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func toDebridTorrent(t *Torrent) (ret *debrid.TorrentItem) {
|
||||
|
||||
status := toDebridTorrentStatus(t)
|
||||
|
||||
ret = &debrid.TorrentItem{
|
||||
ID: t.ID,
|
||||
Name: t.Filename,
|
||||
Hash: t.Hash,
|
||||
Size: t.Bytes,
|
||||
FormattedSize: util.Bytes(uint64(t.Bytes)),
|
||||
CompletionPercentage: int(t.Progress),
|
||||
ETA: "",
|
||||
Status: status,
|
||||
AddedAt: t.Added,
|
||||
Speed: util.ToHumanReadableSpeed(int(t.Speed)),
|
||||
Seeders: t.Seeders,
|
||||
IsReady: status == debrid.TorrentItemStatusCompleted,
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func toDebridTorrentInfo(t *TorrentInfo) (ret *debrid.TorrentInfo) {
|
||||
|
||||
var files []*debrid.TorrentItemFile
|
||||
for _, f := range t.Files {
|
||||
name := filepath.Base(f.Path)
|
||||
|
||||
files = append(files, &debrid.TorrentItemFile{
|
||||
ID: strconv.Itoa(f.ID),
|
||||
Index: f.ID,
|
||||
Name: name, // e.g. "Big Buck Bunny.mp4"
|
||||
Path: f.Path, // e.g. "/Big Buck Bunny/Big Buck Bunny.mp4"
|
||||
Size: f.Bytes,
|
||||
})
|
||||
}
|
||||
|
||||
ret = &debrid.TorrentInfo{
|
||||
ID: &t.ID,
|
||||
Name: t.Filename,
|
||||
Hash: t.Hash,
|
||||
Size: t.OriginalBytes,
|
||||
Files: files,
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func toDebridTorrentStatus(t *Torrent) debrid.TorrentItemStatus {
|
||||
switch t.Status {
|
||||
case "downloading", "queued":
|
||||
return debrid.TorrentItemStatusDownloading
|
||||
case "waiting_files_selection", "magnet_conversion":
|
||||
return debrid.TorrentItemStatusStalled
|
||||
case "downloaded", "dead":
|
||||
return debrid.TorrentItemStatusCompleted
|
||||
case "uploading":
|
||||
return debrid.TorrentItemStatusSeeding
|
||||
case "paused":
|
||||
return debrid.TorrentItemStatusPaused
|
||||
default:
|
||||
return debrid.TorrentItemStatusOther
|
||||
}
|
||||
}
|
||||
|
||||
func (t *RealDebrid) DeleteTorrent(id string) error {
|
||||
|
||||
_, err := t.doQuery("DELETE", t.baseUrl+fmt.Sprintf("/torrents/delete/%s", id), nil, "application/json")
|
||||
if err != nil {
|
||||
t.logger.Error().Err(err).Msg("realdebrid: Failed to delete torrent")
|
||||
return fmt.Errorf("realdebrid: Failed to delete torrent: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
150
seanime-2.9.10/internal/debrid/realdebrid/realdebrid_test.go
Normal file
150
seanime-2.9.10/internal/debrid/realdebrid/realdebrid_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package realdebrid
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"seanime/internal/debrid/debrid"
|
||||
"seanime/internal/test_utils"
|
||||
"seanime/internal/util"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTorBox_GetTorrents(t *testing.T) {
|
||||
test_utils.InitTestProvider(t)
|
||||
logger := util.NewLogger()
|
||||
|
||||
rd := NewRealDebrid(logger)
|
||||
|
||||
err := rd.Authenticate(test_utils.ConfigData.Provider.RealDebridApiKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
fmt.Println("=== All torrents ===")
|
||||
|
||||
torrents, err := rd.GetTorrents()
|
||||
require.NoError(t, err)
|
||||
|
||||
util.Spew(torrents)
|
||||
}
|
||||
|
||||
func TestTorBox_AddTorrent(t *testing.T) {
|
||||
t.Skip("Skipping test that adds a torrent to RealDebrid")
|
||||
|
||||
test_utils.InitTestProvider(t)
|
||||
|
||||
// Already added
|
||||
magnet := "magnet:?xt=urn:btih:80431b4f9a12f4e06616062d3d3973b9ef99b5e6&dn=%5BSubsPlease%5D%20Bocchi%20the%20Rock%21%20-%2001%20%281080p%29%20%5BE04F4EFB%5D.mkv&tr=http%3A%2F%2Fnyaa.tracker.wf%3A7777%2Fannounce&tr=udp%3A%2F%2Fopen.stealth.si%3A80%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce&tr=udp%3A%2F%2Fexodus.desync.com%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.torrent.eu.org%3A451%2Fannounce"
|
||||
|
||||
logger := util.NewLogger()
|
||||
|
||||
rd := NewRealDebrid(logger)
|
||||
|
||||
err := rd.Authenticate(test_utils.ConfigData.Provider.RealDebridApiKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
torrentId, err := rd.AddTorrent(debrid.AddTorrentOptions{
|
||||
MagnetLink: magnet,
|
||||
InfoHash: "80431b4f9a12f4e06616062d3d3973b9ef99b5e6",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
torrentId2, err := rd.AddTorrent(debrid.AddTorrentOptions{
|
||||
MagnetLink: magnet,
|
||||
InfoHash: "80431b4f9a12f4e06616062d3d3973b9ef99b5e6",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, torrentId, torrentId2)
|
||||
|
||||
fmt.Println(torrentId)
|
||||
}
|
||||
|
||||
func TestTorBox_getTorrentInfo(t *testing.T) {
|
||||
|
||||
test_utils.InitTestProvider(t)
|
||||
|
||||
logger := util.NewLogger()
|
||||
|
||||
rd := NewRealDebridT(logger)
|
||||
|
||||
err := rd.Authenticate(test_utils.ConfigData.Provider.RealDebridApiKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
ti, err := rd.getTorrentInfo("W3IWF5TX3AE6G")
|
||||
require.NoError(t, err)
|
||||
|
||||
util.Spew(ti)
|
||||
}
|
||||
|
||||
func TestTorBox_GetDownloadUrl(t *testing.T) {
|
||||
|
||||
test_utils.InitTestProvider(t)
|
||||
|
||||
logger := util.NewLogger()
|
||||
|
||||
rd := NewRealDebridT(logger)
|
||||
|
||||
err := rd.Authenticate(test_utils.ConfigData.Provider.RealDebridApiKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
urls, err := rd.GetTorrentDownloadUrl(debrid.DownloadTorrentOptions{
|
||||
ID: "W3IWF5TX3AE6G",
|
||||
FileId: "11",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
util.Spew(strings.Split(urls, ","))
|
||||
}
|
||||
|
||||
func TestTorBox_InstantAvailability(t *testing.T) {
|
||||
|
||||
test_utils.InitTestProvider(t)
|
||||
|
||||
logger := util.NewLogger()
|
||||
|
||||
rd := NewRealDebridT(logger)
|
||||
|
||||
err := rd.Authenticate(test_utils.ConfigData.Provider.RealDebridApiKey)
|
||||
require.NoError(t, err)
|
||||
avail := rd.GetInstantAvailability([]string{"9f4961a9c71eeb53abce2ef2afc587b452dee5eb"})
|
||||
require.NoError(t, err)
|
||||
|
||||
util.Spew(avail)
|
||||
}
|
||||
|
||||
func TestTorBox_ChooseFileAndDownload(t *testing.T) {
|
||||
//t.Skip("Skipping test that adds a torrent to RealDebrid")
|
||||
|
||||
test_utils.InitTestProvider(t)
|
||||
|
||||
magnet := "magnet:?xt=urn:btih:80431b4f9a12f4e06616062d3d3973b9ef99b5e6&dn=%5BSubsPlease%5D%20Bocchi%20the%20Rock%21%20-%2001%20%281080p%29%20%5BE04F4EFB%5D.mkv&tr=http%3A%2F%2Fnyaa.tracker.wf%3A7777%2Fannounce&tr=udp%3A%2F%2Fopen.stealth.si%3A80%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce&tr=udp%3A%2F%2Fexodus.desync.com%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.torrent.eu.org%3A451%2Fannounce"
|
||||
|
||||
logger := util.NewLogger()
|
||||
|
||||
rd := NewRealDebrid(logger)
|
||||
|
||||
err := rd.Authenticate(test_utils.ConfigData.Provider.RealDebridApiKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should add the torrent and get the torrent info
|
||||
torrentInfo, err := rd.GetTorrentInfo(debrid.GetTorrentInfoOptions{
|
||||
MagnetLink: magnet,
|
||||
InfoHash: "80431b4f9a12f4e06616062d3d3973b9ef99b5e6",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// The torrent should have one file
|
||||
require.Len(t, torrentInfo.Files, 1)
|
||||
|
||||
file := torrentInfo.Files[0]
|
||||
|
||||
// Download the file
|
||||
resp, err := rd.AddTorrent(debrid.AddTorrentOptions{
|
||||
MagnetLink: magnet,
|
||||
InfoHash: "80431b4f9a12f4e06616062d3d3973b9ef99b5e6",
|
||||
SelectFileId: file.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
util.Spew(resp)
|
||||
}
|
||||
627
seanime-2.9.10/internal/debrid/torbox/torbox.go
Normal file
627
seanime-2.9.10/internal/debrid/torbox/torbox.go
Normal file
@@ -0,0 +1,627 @@
|
||||
package torbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"seanime/internal/constants"
|
||||
"seanime/internal/debrid/debrid"
|
||||
"seanime/internal/util"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/samber/mo"
|
||||
)
|
||||
|
||||
type (
|
||||
TorBox struct {
|
||||
baseUrl string
|
||||
apiKey mo.Option[string]
|
||||
client *http.Client
|
||||
logger *zerolog.Logger
|
||||
}
|
||||
|
||||
Response struct {
|
||||
Success bool `json:"success"`
|
||||
Detail string `json:"detail"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
File struct {
|
||||
ID int `json:"id"`
|
||||
MD5 string `json:"md5"`
|
||||
S3Path string `json:"s3_path"`
|
||||
Name string `json:"name"`
|
||||
Size int `json:"size"`
|
||||
MimeType string `json:"mimetype"`
|
||||
ShortName string `json:"short_name"`
|
||||
}
|
||||
|
||||
Torrent struct {
|
||||
ID int `json:"id"`
|
||||
Hash string `json:"hash"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Magnet string `json:"magnet"`
|
||||
Size int64 `json:"size"`
|
||||
Active bool `json:"active"`
|
||||
AuthID string `json:"auth_id"`
|
||||
DownloadState string `json:"download_state"`
|
||||
Seeds int `json:"seeds"`
|
||||
Peers int `json:"peers"`
|
||||
Ratio float64 `json:"ratio"`
|
||||
Progress float64 `json:"progress"`
|
||||
DownloadSpeed float64 `json:"download_speed"`
|
||||
UploadSpeed float64 `json:"upload_speed"`
|
||||
Name string `json:"name"`
|
||||
ETA int64 `json:"eta"`
|
||||
Server float64 `json:"server"`
|
||||
TorrentFile bool `json:"torrent_file"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
DownloadPresent bool `json:"download_present"`
|
||||
DownloadFinished bool `json:"download_finished"`
|
||||
Files []*File `json:"files"`
|
||||
InactiveCheck int `json:"inactive_check"`
|
||||
Availability float64 `json:"availability"`
|
||||
}
|
||||
|
||||
TorrentInfo struct {
|
||||
Name string `json:"name"`
|
||||
Hash string `json:"hash"`
|
||||
Size int64 `json:"size"`
|
||||
Files []*TorrentInfoFile `json:"files"`
|
||||
}
|
||||
|
||||
TorrentInfoFile struct {
|
||||
Name string `json:"name"` // e.g. "Big Buck Bunny/Big Buck Bunny.mp4"
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
InstantAvailabilityItem struct {
|
||||
Name string `json:"name"`
|
||||
Hash string `json:"hash"`
|
||||
Size int64 `json:"size"`
|
||||
Files []struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
} `json:"files"`
|
||||
}
|
||||
)
|
||||
|
||||
func NewTorBox(logger *zerolog.Logger) debrid.Provider {
|
||||
return &TorBox{
|
||||
baseUrl: "https://api.torbox.app/v1/api",
|
||||
apiKey: mo.None[string](),
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
MaxIdleConnsPerHost: 5,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TorBox) GetSettings() debrid.Settings {
|
||||
return debrid.Settings{
|
||||
ID: "torbox",
|
||||
Name: "TorBox",
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TorBox) doQuery(method, uri string, body io.Reader, contentType string) (*Response, error) {
|
||||
return t.doQueryCtx(context.Background(), method, uri, body, contentType)
|
||||
}
|
||||
|
||||
func (t *TorBox) doQueryCtx(ctx context.Context, method, uri string, body io.Reader, contentType string) (*Response, error) {
|
||||
apiKey, found := t.apiKey.Get()
|
||||
if !found {
|
||||
return nil, debrid.ErrNotAuthenticated
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, uri, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Add("Content-Type", contentType)
|
||||
req.Header.Add("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Add("User-Agent", "Seanime/"+constants.Version)
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
bodyB, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("request failed: code %d, body: %s", resp.StatusCode, string(bodyB))
|
||||
}
|
||||
|
||||
bodyB, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.logger.Error().Err(err).Msg("torbox: Failed to read response body")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ret Response
|
||||
if err := json.Unmarshal(bodyB, &ret); err != nil {
|
||||
trimmedBody := string(bodyB)
|
||||
if len(trimmedBody) > 2000 {
|
||||
trimmedBody = trimmedBody[:2000] + "..."
|
||||
}
|
||||
t.logger.Error().Err(err).Msg("torbox: Failed to decode response, response body: " + trimmedBody)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !ret.Success {
|
||||
return nil, fmt.Errorf("request failed: %s", ret.Detail)
|
||||
}
|
||||
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (t *TorBox) Authenticate(apiKey string) error {
|
||||
t.apiKey = mo.Some(apiKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TorBox) GetInstantAvailability(hashes []string) map[string]debrid.TorrentItemInstantAvailability {
|
||||
|
||||
t.logger.Trace().Strs("hashes", hashes).Msg("torbox: Checking instant availability")
|
||||
|
||||
availability := make(map[string]debrid.TorrentItemInstantAvailability)
|
||||
|
||||
if len(hashes) == 0 {
|
||||
return availability
|
||||
}
|
||||
|
||||
var hashBatches [][]string
|
||||
|
||||
for i := 0; i < len(hashes); i += 100 {
|
||||
end := i + 100
|
||||
if end > len(hashes) {
|
||||
end = len(hashes)
|
||||
}
|
||||
hashBatches = append(hashBatches, hashes[i:end])
|
||||
}
|
||||
|
||||
for _, batch := range hashBatches {
|
||||
resp, err := t.doQuery("GET", t.baseUrl+fmt.Sprintf("/torrents/checkcached?hash=%s&format=list&list_files=true", strings.Join(batch, ",")), nil, "application/json")
|
||||
if err != nil {
|
||||
return availability
|
||||
}
|
||||
|
||||
marshaledData, _ := json.Marshal(resp.Data)
|
||||
|
||||
var items []*InstantAvailabilityItem
|
||||
err = json.Unmarshal(marshaledData, &items)
|
||||
if err != nil {
|
||||
return availability
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
availability[item.Hash] = debrid.TorrentItemInstantAvailability{
|
||||
CachedFiles: make(map[string]*debrid.CachedFile),
|
||||
}
|
||||
|
||||
for idx, file := range item.Files {
|
||||
availability[item.Hash].CachedFiles[strconv.Itoa(idx)] = &debrid.CachedFile{
|
||||
Name: file.Name,
|
||||
Size: file.Size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return availability
|
||||
}
|
||||
|
||||
func (t *TorBox) AddTorrent(opts debrid.AddTorrentOptions) (string, error) {
|
||||
|
||||
// Check if the torrent is already added by checking existing torrents
|
||||
if opts.InfoHash != "" {
|
||||
// First check if it's already in our account using a more efficient approach
|
||||
torrents, err := t.getTorrents()
|
||||
if err == nil {
|
||||
for _, torrent := range torrents {
|
||||
if torrent.Hash == opts.InfoHash {
|
||||
return strconv.Itoa(torrent.ID), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// Small delay to avoid rate limiting
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
t.logger.Trace().Str("magnetLink", opts.MagnetLink).Msg("torbox: Adding torrent")
|
||||
|
||||
err := writer.WriteField("magnet", opts.MagnetLink)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("torbox: Failed to add torrent: %w", err)
|
||||
}
|
||||
|
||||
err = writer.WriteField("seed", "1")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("torbox: Failed to add torrent: %w", err)
|
||||
}
|
||||
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("torbox: Failed to add torrent: %w", err)
|
||||
}
|
||||
|
||||
resp, err := t.doQuery("POST", t.baseUrl+"/torrents/createtorrent", &body, writer.FormDataContentType())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("torbox: Failed to add torrent: %w", err)
|
||||
}
|
||||
|
||||
type data struct {
|
||||
ID int `json:"torrent_id"`
|
||||
Name string `json:"name"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
marshaledData, _ := json.Marshal(resp.Data)
|
||||
|
||||
var d data
|
||||
err = json.Unmarshal(marshaledData, &d)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("torbox: Failed to add torrent: %w", err)
|
||||
}
|
||||
|
||||
t.logger.Debug().Str("torrentId", strconv.Itoa(d.ID)).Str("torrentName", d.Name).Str("torrentHash", d.Hash).Msg("torbox: Torrent added")
|
||||
|
||||
return strconv.Itoa(d.ID), nil
|
||||
}
|
||||
|
||||
// GetTorrentStreamUrl blocks until the torrent is downloaded and returns the stream URL for the torrent file by calling GetTorrentDownloadUrl.
|
||||
func (t *TorBox) GetTorrentStreamUrl(ctx context.Context, opts debrid.StreamTorrentOptions, itemCh chan debrid.TorrentItem) (streamUrl string, err error) {
|
||||
|
||||
t.logger.Trace().Str("torrentId", opts.ID).Str("fileId", opts.FileId).Msg("torbox: Retrieving stream link")
|
||||
|
||||
doneCh := make(chan struct{})
|
||||
|
||||
go func(ctx context.Context) {
|
||||
defer func() {
|
||||
close(doneCh)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
err = ctx.Err()
|
||||
return
|
||||
case <-time.After(4 * time.Second):
|
||||
torrent, _err := t.GetTorrent(opts.ID)
|
||||
if _err != nil {
|
||||
t.logger.Error().Err(_err).Msg("torbox: Failed to get torrent")
|
||||
err = fmt.Errorf("torbox: Failed to get torrent: %w", _err)
|
||||
return
|
||||
}
|
||||
|
||||
itemCh <- *torrent
|
||||
|
||||
// Check if the torrent is ready
|
||||
if torrent.IsReady {
|
||||
time.Sleep(1 * time.Second)
|
||||
downloadUrl, err := t.GetTorrentDownloadUrl(debrid.DownloadTorrentOptions{
|
||||
ID: opts.ID,
|
||||
FileId: opts.FileId, // Filename
|
||||
})
|
||||
if err != nil {
|
||||
t.logger.Error().Err(err).Msg("torbox: Failed to get download URL")
|
||||
return
|
||||
}
|
||||
|
||||
streamUrl = downloadUrl
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}(ctx)
|
||||
|
||||
<-doneCh
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (t *TorBox) GetTorrentDownloadUrl(opts debrid.DownloadTorrentOptions) (downloadUrl string, err error) {
|
||||
|
||||
t.logger.Trace().Str("torrentId", opts.ID).Msg("torbox: Retrieving download link")
|
||||
|
||||
apiKey, found := t.apiKey.Get()
|
||||
if !found {
|
||||
return "", fmt.Errorf("torbox: Failed to get download URL: %w", debrid.ErrNotAuthenticated)
|
||||
}
|
||||
|
||||
url := t.baseUrl + fmt.Sprintf("/torrents/requestdl?token=%s&torrent_id=%s&zip_link=true", apiKey, opts.ID)
|
||||
if opts.FileId != "" {
|
||||
// Get the actual file ID
|
||||
torrent, err := t.getTorrent(opts.ID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("torbox: Failed to get download URL: %w", err)
|
||||
}
|
||||
var fId string
|
||||
for _, f := range torrent.Files {
|
||||
if f.ShortName == opts.FileId {
|
||||
fId = strconv.Itoa(f.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
if fId == "" {
|
||||
return "", fmt.Errorf("torbox: Failed to get download URL, file not found")
|
||||
}
|
||||
url = t.baseUrl + fmt.Sprintf("/torrents/requestdl?token=%s&torrent_id=%s&file_id=%s", apiKey, opts.ID, fId)
|
||||
}
|
||||
|
||||
resp, err := t.doQuery("GET", url, nil, "application/json")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("torbox: Failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
marshaledData, _ := json.Marshal(resp.Data)
|
||||
|
||||
var d string
|
||||
err = json.Unmarshal(marshaledData, &d)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("torbox: Failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
t.logger.Debug().Str("downloadUrl", d).Msg("torbox: Download link retrieved")
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (t *TorBox) GetTorrent(id string) (ret *debrid.TorrentItem, err error) {
|
||||
torrent, err := t.getTorrent(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret = toDebridTorrent(torrent)
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (t *TorBox) getTorrent(id string) (ret *Torrent, err error) {
|
||||
|
||||
resp, err := t.doQuery("GET", t.baseUrl+fmt.Sprintf("/torrents/mylist?bypass_cache=true&id=%s", id), nil, "application/json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("torbox: Failed to get torrent: %w", err)
|
||||
}
|
||||
|
||||
marshaledData, _ := json.Marshal(resp.Data)
|
||||
|
||||
err = json.Unmarshal(marshaledData, &ret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("torbox: Failed to parse torrent: %w", err)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// GetTorrentInfo uses the info hash to return the torrent's data.
|
||||
// For cached torrents, it uses the /checkcached endpoint for faster response.
|
||||
// For uncached torrents, it falls back to /torrentinfo endpoint.
|
||||
func (t *TorBox) GetTorrentInfo(opts debrid.GetTorrentInfoOptions) (ret *debrid.TorrentInfo, err error) {
|
||||
|
||||
if opts.InfoHash == "" {
|
||||
return nil, fmt.Errorf("torbox: No info hash provided")
|
||||
}
|
||||
|
||||
resp, err := t.doQuery("GET", t.baseUrl+fmt.Sprintf("/torrents/checkcached?hash=%s&format=object&list_files=true", opts.InfoHash), nil, "application/json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("torbox: Failed to check cached torrent: %w", err)
|
||||
}
|
||||
|
||||
// If the torrent is cached
|
||||
if resp.Data != nil {
|
||||
data, ok := resp.Data.(map[string]interface{})
|
||||
if ok {
|
||||
if torrentData, exists := data[opts.InfoHash]; exists {
|
||||
marshaledData, _ := json.Marshal(torrentData)
|
||||
|
||||
var torrent TorrentInfo
|
||||
err = json.Unmarshal(marshaledData, &torrent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("torbox: Failed to parse cached torrent: %w", err)
|
||||
}
|
||||
|
||||
ret = toDebridTorrentInfo(&torrent)
|
||||
return ret, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not cached, fall back
|
||||
resp, err = t.doQuery("GET", t.baseUrl+fmt.Sprintf("/torrents/torrentinfo?hash=%s&timeout=15", opts.InfoHash), nil, "application/json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("torbox: Failed to get torrent info: %w", err)
|
||||
}
|
||||
|
||||
// DEVNOTE: Handle incorrect TorBox API response
|
||||
data, ok := resp.Data.(map[string]interface{})
|
||||
if ok {
|
||||
if _, ok := data["data"]; ok {
|
||||
if _, ok := data["data"].(map[string]interface{}); ok {
|
||||
data = data["data"].(map[string]interface{})
|
||||
} else {
|
||||
return nil, fmt.Errorf("torbox: Failed to parse response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
marshaledData, _ := json.Marshal(data)
|
||||
|
||||
var torrent TorrentInfo
|
||||
err = json.Unmarshal(marshaledData, &torrent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("torbox: Failed to parse torrent: %w", err)
|
||||
}
|
||||
|
||||
ret = toDebridTorrentInfo(&torrent)
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (t *TorBox) GetTorrents() (ret []*debrid.TorrentItem, err error) {
|
||||
|
||||
torrents, err := t.getTorrents()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("torbox: Failed to get torrents: %w", err)
|
||||
}
|
||||
|
||||
// Limit the number of torrents to 500
|
||||
if len(torrents) > 500 {
|
||||
torrents = torrents[:500]
|
||||
}
|
||||
|
||||
for _, t := range torrents {
|
||||
ret = append(ret, toDebridTorrent(t))
|
||||
}
|
||||
|
||||
slices.SortFunc(ret, func(i, j *debrid.TorrentItem) int {
|
||||
return cmp.Compare(j.AddedAt, i.AddedAt)
|
||||
})
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (t *TorBox) getTorrents() (ret []*Torrent, err error) {
|
||||
|
||||
resp, err := t.doQuery("GET", t.baseUrl+"/torrents/mylist?bypass_cache=true", nil, "application/json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("torbox: Failed to get torrents: %w", err)
|
||||
}
|
||||
|
||||
marshaledData, _ := json.Marshal(resp.Data)
|
||||
|
||||
err = json.Unmarshal(marshaledData, &ret)
|
||||
if err != nil {
|
||||
t.logger.Error().Err(err).Msg("Failed to parse torrents")
|
||||
return nil, fmt.Errorf("torbox: Failed to parse torrents: %w", err)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func toDebridTorrent(t *Torrent) (ret *debrid.TorrentItem) {
|
||||
|
||||
addedAt, _ := time.Parse(time.RFC3339Nano, t.CreatedAt)
|
||||
|
||||
completionPercentage := int(t.Progress * 100)
|
||||
|
||||
ret = &debrid.TorrentItem{
|
||||
ID: strconv.Itoa(t.ID),
|
||||
Name: t.Name,
|
||||
Hash: t.Hash,
|
||||
Size: t.Size,
|
||||
FormattedSize: util.Bytes(uint64(t.Size)),
|
||||
CompletionPercentage: completionPercentage,
|
||||
ETA: util.FormatETA(int(t.ETA)),
|
||||
Status: toDebridTorrentStatus(t),
|
||||
AddedAt: addedAt.Format(time.RFC3339),
|
||||
Speed: util.ToHumanReadableSpeed(int(t.DownloadSpeed)),
|
||||
Seeders: t.Seeds,
|
||||
IsReady: t.DownloadPresent,
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func toDebridTorrentInfo(t *TorrentInfo) (ret *debrid.TorrentInfo) {
|
||||
|
||||
var files []*debrid.TorrentItemFile
|
||||
for idx, f := range t.Files {
|
||||
nameParts := strings.Split(f.Name, "/")
|
||||
var name string
|
||||
|
||||
if len(nameParts) == 1 {
|
||||
name = nameParts[0]
|
||||
} else {
|
||||
name = nameParts[len(nameParts)-1]
|
||||
}
|
||||
|
||||
files = append(files, &debrid.TorrentItemFile{
|
||||
ID: name, // Set the ID to the og name so GetStreamUrl can use that to get the real file ID
|
||||
Index: idx,
|
||||
Name: name, // e.g. "Big Buck Bunny.mp4"
|
||||
Path: fmt.Sprintf("/%s", f.Name), // e.g. "/Big Buck Bunny/Big Buck Bunny.mp4"
|
||||
Size: f.Size,
|
||||
})
|
||||
}
|
||||
|
||||
ret = &debrid.TorrentInfo{
|
||||
Name: t.Name,
|
||||
Hash: t.Hash,
|
||||
Size: t.Size,
|
||||
Files: files,
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func toDebridTorrentStatus(t *Torrent) debrid.TorrentItemStatus {
|
||||
if t.DownloadFinished && t.DownloadPresent {
|
||||
switch t.DownloadState {
|
||||
case "uploading":
|
||||
return debrid.TorrentItemStatusSeeding
|
||||
default:
|
||||
return debrid.TorrentItemStatusCompleted
|
||||
}
|
||||
}
|
||||
|
||||
switch t.DownloadState {
|
||||
case "downloading", "metaDL":
|
||||
return debrid.TorrentItemStatusDownloading
|
||||
case "stalled", "stalled (no seeds)":
|
||||
return debrid.TorrentItemStatusStalled
|
||||
case "completed", "cached":
|
||||
return debrid.TorrentItemStatusCompleted
|
||||
case "uploading":
|
||||
return debrid.TorrentItemStatusSeeding
|
||||
case "paused":
|
||||
return debrid.TorrentItemStatusPaused
|
||||
default:
|
||||
return debrid.TorrentItemStatusOther
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TorBox) DeleteTorrent(id string) error {
|
||||
|
||||
type body = struct {
|
||||
ID int `json:"torrent_id"`
|
||||
Operation string `json:"operation"`
|
||||
}
|
||||
|
||||
b := body{
|
||||
ID: util.StringToIntMust(id),
|
||||
Operation: "delete",
|
||||
}
|
||||
|
||||
marshaledData, _ := json.Marshal(b)
|
||||
|
||||
_, err := t.doQuery("POST", t.baseUrl+fmt.Sprintf("/torrents/controltorrent"), bytes.NewReader(marshaledData), "application/json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("torbox: Failed to delete torrent: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
67
seanime-2.9.10/internal/debrid/torbox/torbox_test.go
Normal file
67
seanime-2.9.10/internal/debrid/torbox/torbox_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package torbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"seanime/internal/debrid/debrid"
|
||||
"seanime/internal/test_utils"
|
||||
"seanime/internal/util"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTorBox_GetTorrents(t *testing.T) {
|
||||
test_utils.InitTestProvider(t)
|
||||
logger := util.NewLogger()
|
||||
|
||||
tb := NewTorBox(logger)
|
||||
|
||||
err := tb.Authenticate(test_utils.ConfigData.Provider.TorBoxApiKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
fmt.Println("=== All torrents ===")
|
||||
|
||||
torrents, err := tb.GetTorrents()
|
||||
require.NoError(t, err)
|
||||
|
||||
util.Spew(torrents)
|
||||
|
||||
fmt.Println("=== Selecting torrent ===")
|
||||
|
||||
torrent, err := tb.GetTorrent(strconv.Itoa(98926))
|
||||
require.NoError(t, err)
|
||||
|
||||
util.Spew(torrent)
|
||||
|
||||
fmt.Println("=== Download link ===")
|
||||
|
||||
downloadUrl, err := tb.GetTorrentDownloadUrl(debrid.DownloadTorrentOptions{
|
||||
ID: strconv.Itoa(98926),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
fmt.Println(downloadUrl)
|
||||
}
|
||||
|
||||
func TestTorBox_AddTorrent(t *testing.T) {
|
||||
t.Skip("Skipping test that adds a torrent to TorBox")
|
||||
|
||||
test_utils.InitTestProvider(t)
|
||||
|
||||
// Already added
|
||||
magnet := "magnet:?xt=urn:btih:80431b4f9a12f4e06616062d3d3973b9ef99b5e6&dn=%5BSubsPlease%5D%20Bocchi%20the%20Rock%21%20-%2001%20%281080p%29%20%5BE04F4EFB%5D.mkv&tr=http%3A%2F%2Fnyaa.tracker.wf%3A7777%2Fannounce&tr=udp%3A%2F%2Fopen.stealth.si%3A80%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce&tr=udp%3A%2F%2Fexodus.desync.com%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.torrent.eu.org%3A451%2Fannounce"
|
||||
|
||||
logger := util.NewLogger()
|
||||
|
||||
tb := NewTorBox(logger)
|
||||
|
||||
err := tb.Authenticate(test_utils.ConfigData.Provider.TorBoxApiKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
torrentId, err := tb.AddTorrent(debrid.AddTorrentOptions{
|
||||
MagnetLink: magnet,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
fmt.Println(torrentId)
|
||||
}
|
||||
Reference in New Issue
Block a user