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