node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

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

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

View 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
}

View 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"`
}

View 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
}

View 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
}

View 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()
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

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

View 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
}

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

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

View 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
}

View 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
}

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

View 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
}

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