node build fixed
This commit is contained in:
443
seanime-2.9.10/internal/debrid/client/download.go
Normal file
443
seanime-2.9.10/internal/debrid/client/download.go
Normal file
@@ -0,0 +1,443 @@
|
||||
package debrid_client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"seanime/internal/debrid/debrid"
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/hook"
|
||||
"seanime/internal/notifier"
|
||||
"seanime/internal/util"
|
||||
"seanime/internal/util/result"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (r *Repository) launchDownloadLoop(ctx context.Context) {
|
||||
r.logger.Trace().Msg("debrid: Starting download loop")
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
r.logger.Trace().Msg("debrid: Download loop destroy request received")
|
||||
// Destroy the loop
|
||||
return
|
||||
case <-time.After(time.Minute * 1):
|
||||
// Every minute, check if there are any completed downloads
|
||||
provider, found := r.provider.Get()
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the list of completed downloads
|
||||
items, err := provider.GetTorrents()
|
||||
if err != nil {
|
||||
r.logger.Err(err).Msg("debrid: Failed to get torrents")
|
||||
continue
|
||||
}
|
||||
|
||||
readyItems := make([]*debrid.TorrentItem, 0)
|
||||
for _, item := range items {
|
||||
if item.IsReady {
|
||||
readyItems = append(readyItems, item)
|
||||
}
|
||||
}
|
||||
|
||||
dbItems, err := r.db.GetDebridTorrentItems()
|
||||
if err != nil {
|
||||
r.logger.Err(err).Msg("debrid: Failed to get debrid torrent items")
|
||||
continue
|
||||
}
|
||||
|
||||
for _, dbItem := range dbItems {
|
||||
// Check if the item is ready for download
|
||||
for _, readyItem := range readyItems {
|
||||
if dbItem.TorrentItemID == readyItem.ID {
|
||||
r.logger.Debug().Str("torrentItemId", dbItem.TorrentItemID).Msg("debrid: Torrent is ready for download")
|
||||
// Remove the item from the database
|
||||
err = r.db.DeleteDebridTorrentItemByDbId(dbItem.ID)
|
||||
if err != nil {
|
||||
r.logger.Err(err).Msg("debrid: Failed to remove debrid torrent item")
|
||||
continue
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
// Download the torrent locally
|
||||
err = r.downloadTorrentItem(readyItem.ID, readyItem.Name, dbItem.Destination)
|
||||
if err != nil {
|
||||
r.logger.Err(err).Msg("debrid: Failed to download torrent")
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (r *Repository) DownloadTorrent(item debrid.TorrentItem, destination string) error {
|
||||
return r.downloadTorrentItem(item.ID, item.Name, destination)
|
||||
}
|
||||
|
||||
type downloadStatus struct {
|
||||
TotalBytes int64
|
||||
TotalSize int64
|
||||
}
|
||||
|
||||
func (r *Repository) downloadTorrentItem(tId string, torrentName string, destination string) (err error) {
|
||||
defer util.HandlePanicInModuleWithError("debrid/client/downloadTorrentItem", &err)
|
||||
|
||||
provider, err := r.GetProvider()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.logger.Debug().Str("torrentName", torrentName).Str("destination", destination).Msg("debrid: Downloading torrent")
|
||||
|
||||
// Get the download URL
|
||||
downloadUrl, err := provider.GetTorrentDownloadUrl(debrid.DownloadTorrentOptions{
|
||||
ID: tId,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
event := &DebridLocalDownloadRequestedEvent{
|
||||
TorrentName: torrentName,
|
||||
Destination: destination,
|
||||
DownloadUrl: downloadUrl,
|
||||
}
|
||||
err = hook.GlobalHookManager.OnDebridLocalDownloadRequested().Trigger(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if event.DefaultPrevented {
|
||||
r.logger.Debug().Msg("debrid: Download prevented by hook")
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r.ctxMap.Set(tId, cancel)
|
||||
|
||||
go func(ctx context.Context) {
|
||||
defer func() {
|
||||
cancel()
|
||||
r.ctxMap.Delete(tId)
|
||||
}()
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
downloadUrls := strings.Split(downloadUrl, ",")
|
||||
downloadMap := result.NewResultMap[string, downloadStatus]()
|
||||
|
||||
for _, url := range downloadUrls {
|
||||
wg.Add(1)
|
||||
go func(ctx context.Context, url string) {
|
||||
defer wg.Done()
|
||||
|
||||
// Download the file
|
||||
ok := r.downloadFile(ctx, tId, url, destination, downloadMap)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
}(ctx, url)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
r.sendDownloadCompletedEvent(tId)
|
||||
notifier.GlobalNotifier.Notify(notifier.Debrid, fmt.Sprintf("Downloaded %q", torrentName))
|
||||
}(ctx)
|
||||
|
||||
// Send a starting event
|
||||
r.wsEventManager.SendEvent(events.DebridDownloadProgress, map[string]interface{}{
|
||||
"status": "downloading",
|
||||
"itemID": tId,
|
||||
"totalBytes": "0 B",
|
||||
"totalSize": "-",
|
||||
"speed": "",
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) downloadFile(ctx context.Context, tId string, downloadUrl string, destination string, downloadMap *result.Map[string, downloadStatus]) (ok bool) {
|
||||
defer util.HandlePanicInModuleThen("debrid/client/downloadFile", func() {
|
||||
ok = false
|
||||
})
|
||||
|
||||
// Create a cancellable HTTP request
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadUrl, nil)
|
||||
if err != nil {
|
||||
r.logger.Err(err).Str("downloadUrl", downloadUrl).Msg("debrid: Failed to create request")
|
||||
return false
|
||||
}
|
||||
|
||||
_ = os.MkdirAll(destination, os.ModePerm)
|
||||
|
||||
// Download the files to a temporary folder
|
||||
tmpDirPath, err := os.MkdirTemp(destination, ".tmp-")
|
||||
if err != nil {
|
||||
r.logger.Err(err).Str("destination", destination).Msg("debrid: Failed to create temp folder")
|
||||
r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Failed to create temp folder: %v", err))
|
||||
return false
|
||||
}
|
||||
defer os.RemoveAll(tmpDirPath) // Clean up temp folder on exit
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
r.logger.Debug().Str("tmpDirPath", tmpDirPath).Msg("debrid: Hiding temp folder")
|
||||
util.HideFile(tmpDirPath)
|
||||
time.Sleep(time.Millisecond * 500)
|
||||
}
|
||||
|
||||
// Execute the request
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
r.logger.Err(err).Str("downloadUrl", downloadUrl).Msg("debrid: Failed to execute request")
|
||||
r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Failed to execute download request: %v", err))
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// e.g. "my-torrent.zip", "downloaded_torrent"
|
||||
filename := "downloaded_torrent"
|
||||
ext := ""
|
||||
|
||||
// Try to get the file name from the Content-Disposition header
|
||||
hFilename, err := getFilenameFromHeaders(downloadUrl)
|
||||
if err == nil {
|
||||
r.logger.Warn().Str("newFilename", hFilename).Str("defaultFilename", filename).Msg("debrid: Filename found in headers, overriding default")
|
||||
filename = hFilename
|
||||
}
|
||||
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "" {
|
||||
mediaType, _, err := mime.ParseMediaType(ct)
|
||||
if err == nil {
|
||||
switch mediaType {
|
||||
case "application/zip":
|
||||
ext = ".zip"
|
||||
case "application/x-rar-compressed":
|
||||
ext = ".rar"
|
||||
default:
|
||||
}
|
||||
r.logger.Debug().Str("mediaType", mediaType).Str("ext", ext).Msg("debrid: Detected media type and extension")
|
||||
}
|
||||
}
|
||||
|
||||
if filename == "downloaded_torrent" && ext != "" {
|
||||
filename = fmt.Sprintf("%s%s", filename, ext)
|
||||
}
|
||||
|
||||
// Check if the download URL has the extension
|
||||
urlExt := filepath.Ext(downloadUrl)
|
||||
if filename == "downloaded_torrent" && urlExt != "" {
|
||||
filename = filepath.Base(downloadUrl)
|
||||
filename, _ = url.PathUnescape(filename)
|
||||
ext = urlExt
|
||||
r.logger.Warn().Str("urlExt", urlExt).Str("filename", filename).Str("downloadUrl", downloadUrl).Msg("debrid: Extension found in URL, using it as file extension and file name")
|
||||
}
|
||||
|
||||
r.logger.Debug().Str("filename", filename).Str("ext", ext).Msg("debrid: Starting download")
|
||||
|
||||
// Create a file in the temporary folder to store the download
|
||||
// e.g. "/tmp/torrent-123456789/my-torrent.zip"
|
||||
tmpDownloadedFilePath := filepath.Join(tmpDirPath, filename)
|
||||
file, err := os.Create(tmpDownloadedFilePath)
|
||||
if err != nil {
|
||||
r.logger.Err(err).Str("tmpDownloadedFilePath", tmpDownloadedFilePath).Msg("debrid: Failed to create temp file")
|
||||
r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Failed to create temp file: %v", err))
|
||||
return false
|
||||
}
|
||||
|
||||
totalSize := resp.ContentLength
|
||||
speed := 0
|
||||
|
||||
lastSent := time.Now()
|
||||
|
||||
// Copy response body to the temporary file
|
||||
buffer := make([]byte, 32*1024)
|
||||
var totalBytes int64
|
||||
var lastBytes int64
|
||||
for {
|
||||
n, err := resp.Body.Read(buffer)
|
||||
if n > 0 {
|
||||
_, writeErr := file.Write(buffer[:n])
|
||||
if writeErr != nil {
|
||||
_ = file.Close()
|
||||
r.logger.Err(writeErr).Str("tmpDownloadedFilePath", tmpDownloadedFilePath).Msg("debrid: Failed to write to temp file")
|
||||
r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Download failed / Failed to write to temp file: %v", writeErr))
|
||||
r.sendDownloadCancelledEvent(tId, downloadUrl, downloadMap)
|
||||
return false
|
||||
}
|
||||
totalBytes += int64(n)
|
||||
if totalSize > 0 {
|
||||
speed = int((totalBytes - lastBytes) / 1024) // KB/s
|
||||
lastBytes = totalBytes
|
||||
}
|
||||
|
||||
downloadMap.Set(downloadUrl, downloadStatus{
|
||||
TotalBytes: totalBytes,
|
||||
TotalSize: totalSize,
|
||||
})
|
||||
|
||||
if time.Since(lastSent) > time.Second*2 {
|
||||
_totalBytes := uint64(0)
|
||||
_totalSize := uint64(0)
|
||||
downloadMap.Range(func(key string, value downloadStatus) bool {
|
||||
_totalBytes += uint64(value.TotalBytes)
|
||||
_totalSize += uint64(value.TotalSize)
|
||||
return true
|
||||
})
|
||||
// Notify progress
|
||||
r.wsEventManager.SendEvent(events.DebridDownloadProgress, map[string]interface{}{
|
||||
"status": "downloading",
|
||||
"itemID": tId,
|
||||
"totalBytes": util.Bytes(_totalBytes),
|
||||
"totalSize": util.Bytes(_totalSize),
|
||||
"speed": speed,
|
||||
})
|
||||
lastSent = time.Now()
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
_ = file.Close()
|
||||
r.logger.Debug().Msg("debrid: Download cancelled")
|
||||
r.sendDownloadCancelledEvent(tId, downloadUrl, downloadMap)
|
||||
return false
|
||||
}
|
||||
_ = file.Close()
|
||||
r.logger.Err(err).Str("downloadUrl", downloadUrl).Msg("debrid: Failed to read from response body")
|
||||
r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Download failed / Failed to read from response body: %v", err))
|
||||
r.sendDownloadCancelledEvent(tId, downloadUrl, downloadMap)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
_ = file.Close()
|
||||
|
||||
downloadMap.Delete(downloadUrl)
|
||||
|
||||
if len(downloadMap.Values()) == 0 {
|
||||
r.wsEventManager.SendEvent(events.DebridDownloadProgress, map[string]interface{}{
|
||||
"status": "downloading",
|
||||
"itemID": tId,
|
||||
"totalBytes": "Extracting...",
|
||||
"totalSize": "-",
|
||||
"speed": "",
|
||||
})
|
||||
}
|
||||
|
||||
r.logger.Debug().Msg("debrid: Download completed")
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
time.Sleep(time.Second * 1)
|
||||
}
|
||||
|
||||
// Extract the downloaded file
|
||||
var extractedDir string
|
||||
switch ext {
|
||||
case ".zip":
|
||||
extractedDir, err = unzipFile(tmpDownloadedFilePath, tmpDirPath)
|
||||
r.logger.Debug().Str("extractedDir", extractedDir).Msg("debrid: Extracted zip file")
|
||||
case ".rar":
|
||||
extractedDir, err = unrarFile(tmpDownloadedFilePath, tmpDirPath)
|
||||
r.logger.Debug().Str("extractedDir", extractedDir).Msg("debrid: Extracted rar file")
|
||||
default:
|
||||
r.logger.Debug().Str("tmpDownloadedFilePath", tmpDownloadedFilePath).Str("destination", destination).Msg("debrid: No extraction needed, moving file directly")
|
||||
// Move the file directly to the destination
|
||||
err = moveFolderOrFileTo(tmpDownloadedFilePath, destination)
|
||||
if err != nil {
|
||||
r.logger.Err(err).Str("tmpDownloadedFilePath", tmpDownloadedFilePath).Str("destination", destination).Msg("debrid: Failed to move downloaded file")
|
||||
r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Failed to move downloaded file: %v", err))
|
||||
r.sendDownloadCancelledEvent(tId, downloadUrl, downloadMap)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if err != nil {
|
||||
r.logger.Err(err).Str("tmpDownloadedFilePath", tmpDownloadedFilePath).Msg("debrid: Failed to extract downloaded file")
|
||||
r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Failed to extract downloaded file: %v", err))
|
||||
r.sendDownloadCancelledEvent(tId, downloadUrl, downloadMap)
|
||||
return false
|
||||
}
|
||||
|
||||
r.logger.Debug().Msg("debrid: Extraction completed, deleting temporary files")
|
||||
|
||||
// Delete the downloaded file
|
||||
err = os.Remove(tmpDownloadedFilePath)
|
||||
if err != nil {
|
||||
r.logger.Err(err).Str("tmpDownloadedFilePath", tmpDownloadedFilePath).Msg("debrid: Failed to delete downloaded file")
|
||||
// Do not stop here, continue with the extracted files
|
||||
}
|
||||
|
||||
r.logger.Debug().Str("extractedDir", extractedDir).Str("destination", destination).Msg("debrid: Moving extracted files to destination")
|
||||
|
||||
// Move the extracted files to the destination
|
||||
err = moveContentsTo(extractedDir, destination)
|
||||
if err != nil {
|
||||
r.logger.Err(err).Str("extractedDir", extractedDir).Str("destination", destination).Msg("debrid: Failed to move downloaded files")
|
||||
r.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("debrid: Failed to move downloaded files: %v", err))
|
||||
r.sendDownloadCancelledEvent(tId, downloadUrl, downloadMap)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *Repository) sendDownloadCancelledEvent(tId string, url string, downloadMap *result.Map[string, downloadStatus]) {
|
||||
downloadMap.Delete(url)
|
||||
|
||||
if len(downloadMap.Values()) == 0 {
|
||||
r.wsEventManager.SendEvent(events.DebridDownloadProgress, map[string]interface{}{
|
||||
"status": "cancelled",
|
||||
"itemID": tId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repository) sendDownloadCompletedEvent(tId string) {
|
||||
r.wsEventManager.SendEvent(events.DebridDownloadProgress, map[string]interface{}{
|
||||
"status": "completed",
|
||||
"itemID": tId,
|
||||
})
|
||||
}
|
||||
|
||||
func getFilenameFromHeaders(url string) (string, error) {
|
||||
resp, err := http.Head(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Get the Content-Disposition header
|
||||
contentDisposition := resp.Header.Get("Content-Disposition")
|
||||
if contentDisposition == "" {
|
||||
return "", fmt.Errorf("no Content-Disposition header found")
|
||||
}
|
||||
|
||||
// Use a regex to extract the filename from Content-Disposition
|
||||
re := regexp.MustCompile(`filename="(.+)"`)
|
||||
matches := re.FindStringSubmatch(contentDisposition)
|
||||
if len(matches) > 1 {
|
||||
return matches[1], nil
|
||||
}
|
||||
return "", fmt.Errorf("filename not found in Content-Disposition header")
|
||||
}
|
||||
110
seanime-2.9.10/internal/debrid/client/download_test.go
Normal file
110
seanime-2.9.10/internal/debrid/client/download_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package debrid_client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"os"
|
||||
"seanime/internal/database/db"
|
||||
"seanime/internal/database/models"
|
||||
"seanime/internal/debrid/debrid"
|
||||
"seanime/internal/test_utils"
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTorBoxDownload(t *testing.T) {
|
||||
test_utils.InitTestProvider(t)
|
||||
|
||||
logger := util.NewLogger()
|
||||
database, err := db.NewDatabase(test_utils.ConfigData.Path.DataDir, test_utils.ConfigData.Database.Name, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
repo := GetMockRepository(t, database)
|
||||
|
||||
err = repo.InitializeProvider(&models.DebridSettings{
|
||||
Enabled: true,
|
||||
Provider: "torbox",
|
||||
ApiKey: test_utils.ConfigData.Provider.TorBoxApiKey,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
tempDestinationDir := t.TempDir()
|
||||
|
||||
fmt.Println(tempDestinationDir)
|
||||
|
||||
//
|
||||
// Test download
|
||||
//
|
||||
torrentItemId := "116389"
|
||||
|
||||
err = database.InsertDebridTorrentItem(&models.DebridTorrentItem{
|
||||
TorrentItemID: torrentItemId,
|
||||
Destination: tempDestinationDir,
|
||||
Provider: "torbox",
|
||||
MediaId: 0, // Not yet used
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get the provider
|
||||
provider, err := repo.GetProvider()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get the torrents from the provider
|
||||
torrentItems, err := provider.GetTorrents()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get the torrent item from the database
|
||||
dbTorrentItem, err := database.GetDebridTorrentItemByTorrentItemId(torrentItemId)
|
||||
|
||||
// Select the torrent item from the provider
|
||||
var torrentItem *debrid.TorrentItem
|
||||
for _, item := range torrentItems {
|
||||
if item.ID == dbTorrentItem.TorrentItemID {
|
||||
torrentItem = item
|
||||
}
|
||||
}
|
||||
require.NotNil(t, torrentItem)
|
||||
|
||||
// Check if the torrent is ready
|
||||
require.Truef(t, torrentItem.IsReady, "Torrent is not ready")
|
||||
|
||||
// Remove the item from the database
|
||||
err = database.DeleteDebridTorrentItemByDbId(dbTorrentItem.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Download the torrent
|
||||
err = repo.downloadTorrentItem(dbTorrentItem.TorrentItemID, torrentItem.Name, dbTorrentItem.Destination)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(time.Millisecond * 500)
|
||||
|
||||
// Wait for the download to finish
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case <-time.After(time.Second * 1):
|
||||
isEmpty := true
|
||||
repo.ctxMap.Range(func(key string, value context.CancelFunc) bool {
|
||||
isEmpty = false
|
||||
return true
|
||||
})
|
||||
if isEmpty {
|
||||
break loop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the file exists
|
||||
entries, err := os.ReadDir(tempDestinationDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
fmt.Println("=== Downloaded files ===")
|
||||
|
||||
for _, entry := range entries {
|
||||
util.Spew(entry.Name())
|
||||
}
|
||||
|
||||
require.NotEmpty(t, entries)
|
||||
}
|
||||
391
seanime-2.9.10/internal/debrid/client/finder.go
Normal file
391
seanime-2.9.10/internal/debrid/client/finder.go
Normal file
@@ -0,0 +1,391 @@
|
||||
package debrid_client
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/debrid/debrid"
|
||||
hibiketorrent "seanime/internal/extension/hibike/torrent"
|
||||
"seanime/internal/hook"
|
||||
torrentanalyzer "seanime/internal/torrents/analyzer"
|
||||
itorrent "seanime/internal/torrents/torrent"
|
||||
"seanime/internal/util"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
func (r *Repository) findBestTorrent(provider debrid.Provider, media *anilist.CompleteAnime, episodeNumber int) (selectedTorrent *hibiketorrent.AnimeTorrent, fileId string, err error) {
|
||||
|
||||
defer util.HandlePanicInModuleWithError("debridstream/findBestTorrent", &err)
|
||||
|
||||
r.logger.Debug().Msgf("debridstream: Finding best torrent for %s, Episode %d", media.GetTitleSafe(), episodeNumber)
|
||||
|
||||
providerId := itorrent.ProviderAnimeTosho
|
||||
fallbackProviderId := itorrent.ProviderNyaa
|
||||
|
||||
// Get AnimeTosho provider extension
|
||||
providerExtension, ok := r.torrentRepository.GetAnimeProviderExtension(providerId)
|
||||
if !ok {
|
||||
r.logger.Error().Str("provider", itorrent.ProviderAnimeTosho).Msg("debridstream: AnimeTosho provider extension not found")
|
||||
return nil, "", fmt.Errorf("provider extension not found")
|
||||
}
|
||||
|
||||
searchBatch := false
|
||||
canSearchBatch := !media.IsMovie() && media.IsFinished()
|
||||
if canSearchBatch {
|
||||
searchBatch = true
|
||||
}
|
||||
|
||||
loopCount := 0
|
||||
var currentProvider = providerId
|
||||
|
||||
var data *itorrent.SearchData
|
||||
searchLoop:
|
||||
for {
|
||||
data, err = r.torrentRepository.SearchAnime(context.Background(), itorrent.AnimeSearchOptions{
|
||||
Provider: currentProvider,
|
||||
Type: itorrent.AnimeSearchTypeSmart,
|
||||
Media: media.ToBaseAnime(),
|
||||
Query: "",
|
||||
Batch: searchBatch,
|
||||
EpisodeNumber: episodeNumber,
|
||||
BestReleases: false,
|
||||
Resolution: r.settings.StreamPreferredResolution,
|
||||
})
|
||||
// If we are searching for batches, we don't want to return an error if no torrents are found
|
||||
// We will just search again without the batch flag
|
||||
if err != nil {
|
||||
if !searchBatch {
|
||||
r.logger.Error().Err(err).Msg("debridstream: Error searching torrents")
|
||||
|
||||
// Try fallback provider if we're still on primary provider
|
||||
if currentProvider == providerId {
|
||||
r.logger.Debug().Msgf("debridstream: Primary provider failed, trying fallback provider %s", fallbackProviderId)
|
||||
currentProvider = fallbackProviderId
|
||||
// Get fallback provider extension
|
||||
providerExtension, ok = r.torrentRepository.GetAnimeProviderExtension(currentProvider)
|
||||
if !ok {
|
||||
r.logger.Error().Str("provider", fallbackProviderId).Msg("debridstream: Fallback provider extension not found")
|
||||
return nil, "", fmt.Errorf("fallback provider extension not found")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, "", err
|
||||
}
|
||||
searchBatch = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Get cached
|
||||
hashes := make([]string, 0)
|
||||
for _, t := range data.Torrents {
|
||||
if t.InfoHash == "" {
|
||||
continue
|
||||
}
|
||||
hashes = append(hashes, t.InfoHash)
|
||||
}
|
||||
instantAvail := provider.GetInstantAvailability(hashes)
|
||||
data.DebridInstantAvailability = instantAvail
|
||||
|
||||
// If we are searching for batches, we want to filter out torrents that are not cached
|
||||
if searchBatch {
|
||||
// Nothing found, search again without the batch flag
|
||||
if len(data.Torrents) == 0 {
|
||||
searchBatch = false
|
||||
loopCount++
|
||||
continue
|
||||
}
|
||||
if len(data.DebridInstantAvailability) > 0 {
|
||||
r.logger.Debug().Msg("debridstream: Found cached instant availability")
|
||||
data.Torrents = lo.Filter(data.Torrents, func(t *hibiketorrent.AnimeTorrent, i int) bool {
|
||||
_, isCached := data.DebridInstantAvailability[t.InfoHash]
|
||||
return isCached
|
||||
})
|
||||
break searchLoop
|
||||
}
|
||||
// If we didn't find any cached batches, we will search again without the batch flag
|
||||
searchBatch = false
|
||||
loopCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// If on the first try were looking for file torrents but found no cached ones, we will search again for batches
|
||||
if loopCount == 0 && canSearchBatch && len(data.DebridInstantAvailability) == 0 {
|
||||
searchBatch = true
|
||||
loopCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// Stop looking if either we found cached torrents or no cached batches were found
|
||||
break searchLoop
|
||||
}
|
||||
|
||||
if data == nil || len(data.Torrents) == 0 {
|
||||
// Try fallback provider if we're still on primary provider
|
||||
if currentProvider == providerId {
|
||||
r.logger.Debug().Msgf("debridstream: No torrents found with primary provider, trying fallback provider %s", fallbackProviderId)
|
||||
currentProvider = fallbackProviderId
|
||||
// Get fallback provider extension
|
||||
providerExtension, ok = r.torrentRepository.GetAnimeProviderExtension(currentProvider)
|
||||
if !ok {
|
||||
r.logger.Error().Str("provider", fallbackProviderId).Msg("debridstream: Fallback provider extension not found")
|
||||
return nil, "", fmt.Errorf("fallback provider extension not found")
|
||||
}
|
||||
|
||||
// Try searching with fallback provider (reset searchBatch based on canSearchBatch)
|
||||
searchBatch = false
|
||||
if canSearchBatch {
|
||||
searchBatch = true
|
||||
}
|
||||
loopCount = 0
|
||||
|
||||
// Restart the search with fallback provider
|
||||
goto searchLoop
|
||||
}
|
||||
|
||||
r.logger.Error().Msg("debridstream: No torrents found")
|
||||
return nil, "", fmt.Errorf("no torrents found")
|
||||
}
|
||||
|
||||
// Sort by seeders from highest to lowest
|
||||
slices.SortStableFunc(data.Torrents, func(a, b *hibiketorrent.AnimeTorrent) int {
|
||||
return cmp.Compare(b.Seeders, a.Seeders)
|
||||
})
|
||||
|
||||
// Trigger hook
|
||||
fetchedEvent := &DebridAutoSelectTorrentsFetchedEvent{
|
||||
Torrents: data.Torrents,
|
||||
}
|
||||
_ = hook.GlobalHookManager.OnDebridAutoSelectTorrentsFetched().Trigger(fetchedEvent)
|
||||
data.Torrents = fetchedEvent.Torrents
|
||||
|
||||
r.logger.Debug().Msgf("debridstream: Found %d torrents", len(data.Torrents))
|
||||
|
||||
hashes := make([]string, 0)
|
||||
for _, t := range data.Torrents {
|
||||
if t.InfoHash == "" {
|
||||
continue
|
||||
}
|
||||
hashes = append(hashes, t.InfoHash)
|
||||
}
|
||||
|
||||
// Find cached torrent
|
||||
instantAvail := provider.GetInstantAvailability(hashes)
|
||||
data.DebridInstantAvailability = instantAvail
|
||||
|
||||
// Filter out torrents that are not cached if we have cached instant availability
|
||||
if len(data.DebridInstantAvailability) > 0 {
|
||||
r.logger.Debug().Msg("debridstream: Found cached instant availability")
|
||||
data.Torrents = lo.Filter(data.Torrents, func(t *hibiketorrent.AnimeTorrent, i int) bool {
|
||||
_, isCached := data.DebridInstantAvailability[t.InfoHash]
|
||||
return isCached
|
||||
})
|
||||
}
|
||||
|
||||
tries := 0
|
||||
|
||||
for _, searchT := range data.Torrents {
|
||||
if tries >= 2 {
|
||||
break
|
||||
}
|
||||
|
||||
r.logger.Trace().Msgf("debridstream: Getting torrent magnet for %s", searchT.Name)
|
||||
magnet, err := providerExtension.GetProvider().GetTorrentMagnetLink(searchT)
|
||||
if err != nil {
|
||||
r.logger.Warn().Err(err).Msgf("debridstream: Error scraping magnet link for %s", searchT.Link)
|
||||
tries++
|
||||
continue
|
||||
}
|
||||
|
||||
// Set the magnet link
|
||||
searchT.MagnetLink = magnet
|
||||
|
||||
r.logger.Debug().Msgf("debridstream: Adding torrent %s from magnet", searchT.Link)
|
||||
|
||||
// Get the torrent info
|
||||
// On Real-Debrid, this will add the torrent
|
||||
info, err := provider.GetTorrentInfo(debrid.GetTorrentInfoOptions{
|
||||
MagnetLink: searchT.MagnetLink,
|
||||
InfoHash: searchT.InfoHash,
|
||||
})
|
||||
if err != nil {
|
||||
r.logger.Warn().Err(err).Msgf("debridstream: Error adding torrent %s", searchT.Link)
|
||||
tries++
|
||||
continue
|
||||
}
|
||||
|
||||
filepaths := lo.Map(info.Files, func(f *debrid.TorrentItemFile, _ int) string {
|
||||
return f.Path
|
||||
})
|
||||
|
||||
if len(filepaths) == 0 {
|
||||
r.logger.Error().Msg("debridstream: No files found in the torrent")
|
||||
return nil, "", fmt.Errorf("no files found in the torrent")
|
||||
}
|
||||
|
||||
// Create a new Torrent Analyzer
|
||||
analyzer := torrentanalyzer.NewAnalyzer(&torrentanalyzer.NewAnalyzerOptions{
|
||||
Logger: r.logger,
|
||||
Filepaths: filepaths,
|
||||
Media: media,
|
||||
Platform: r.platform,
|
||||
MetadataProvider: r.metadataProvider,
|
||||
ForceMatch: true,
|
||||
})
|
||||
|
||||
r.logger.Debug().Msgf("debridstream: Analyzing torrent %s", searchT.Link)
|
||||
|
||||
// Analyze torrent files
|
||||
analysis, err := analyzer.AnalyzeTorrentFiles()
|
||||
if err != nil {
|
||||
r.logger.Warn().Err(err).Msg("debridstream: Error analyzing torrent files")
|
||||
// Remove torrent on failure (if it was added)
|
||||
//if info.ID != nil {
|
||||
// go func() {
|
||||
// _ = provider.DeleteTorrent(*info.ID)
|
||||
// }()
|
||||
//}
|
||||
tries++
|
||||
continue
|
||||
}
|
||||
|
||||
r.logger.Debug().Int("count", len(analysis.GetFiles())).Msgf("debridstream: Analyzed torrent %s", searchT.Link)
|
||||
|
||||
r.logger.Debug().Msgf("debridstream: Finding corresponding file for episode %s", strconv.Itoa(episodeNumber))
|
||||
|
||||
analysisFile, found := analysis.GetFileByAniDBEpisode(strconv.Itoa(episodeNumber))
|
||||
// Check if analyzer found the episode
|
||||
if !found {
|
||||
r.logger.Error().Msgf("debridstream: Failed to auto-select episode from torrent %s", searchT.Link)
|
||||
// Remove torrent on failure
|
||||
//if info.ID != nil {
|
||||
// go func() {
|
||||
// _ = provider.DeleteTorrent(*info.ID)
|
||||
// }()
|
||||
//}
|
||||
tries++
|
||||
continue
|
||||
}
|
||||
|
||||
r.logger.Debug().Msgf("debridstream: Found corresponding file for episode %s: %s", strconv.Itoa(episodeNumber), analysisFile.GetLocalFile().Name)
|
||||
|
||||
tFile := info.Files[analysisFile.GetIndex()]
|
||||
r.logger.Debug().Str("file", util.SpewT(tFile)).Msgf("debridstream: Selected file %s", tFile.Name)
|
||||
r.logger.Debug().Msgf("debridstream: Selected torrent %s", searchT.Name)
|
||||
selectedTorrent = searchT
|
||||
fileId = tFile.ID
|
||||
|
||||
//go func() {
|
||||
// _ = provider.DeleteTorrent(*info.ID)
|
||||
//}()
|
||||
break
|
||||
}
|
||||
|
||||
if selectedTorrent == nil {
|
||||
return nil, "", fmt.Errorf("failed to find torrent")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// findBestTorrentFromManualSelection is like findBestTorrent but for a pre-selected torrent
|
||||
func (r *Repository) findBestTorrentFromManualSelection(provider debrid.Provider, t *hibiketorrent.AnimeTorrent, media *anilist.CompleteAnime, episodeNumber int, chosenFileIndex *int) (selectedTorrent *hibiketorrent.AnimeTorrent, fileId string, err error) {
|
||||
|
||||
r.logger.Debug().Msgf("debridstream: Analyzing torrent from %s for %s", t.Link, media.GetTitleSafe())
|
||||
|
||||
// Get the torrent's provider extension
|
||||
providerExtension, ok := r.torrentRepository.GetAnimeProviderExtension(t.Provider)
|
||||
if !ok {
|
||||
r.logger.Error().Str("provider", t.Provider).Msg("debridstream: provider extension not found")
|
||||
return nil, "", fmt.Errorf("provider extension not found")
|
||||
}
|
||||
|
||||
// Check if the torrent is cached
|
||||
if t.InfoHash != "" {
|
||||
instantAvail := provider.GetInstantAvailability([]string{t.InfoHash})
|
||||
if len(instantAvail) == 0 {
|
||||
r.logger.Warn().Msg("debridstream: Torrent is not cached")
|
||||
// We'll still continue since the user specifically selected this torrent
|
||||
}
|
||||
}
|
||||
|
||||
// Get the magnet link
|
||||
magnet, err := providerExtension.GetProvider().GetTorrentMagnetLink(t)
|
||||
if err != nil {
|
||||
r.logger.Error().Err(err).Msgf("debridstream: Error scraping magnet link for %s", t.Link)
|
||||
return nil, "", fmt.Errorf("could not get magnet link from %s", t.Link)
|
||||
}
|
||||
|
||||
// Set the magnet link
|
||||
t.MagnetLink = magnet
|
||||
|
||||
// Get the torrent info from the debrid provider
|
||||
info, err := provider.GetTorrentInfo(debrid.GetTorrentInfoOptions{
|
||||
MagnetLink: t.MagnetLink,
|
||||
InfoHash: t.InfoHash,
|
||||
})
|
||||
if err != nil {
|
||||
r.logger.Error().Err(err).Msgf("debridstream: Error adding torrent %s", t.Link)
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// If the torrent has only one file, return it
|
||||
if len(info.Files) == 1 {
|
||||
return t, info.Files[0].ID, nil
|
||||
}
|
||||
|
||||
var fileIndex int
|
||||
|
||||
// If the file index is already selected
|
||||
if chosenFileIndex != nil {
|
||||
fileIndex = *chosenFileIndex
|
||||
} else {
|
||||
// We know the torrent has multiple files, so we'll need to analyze it
|
||||
filepaths := lo.Map(info.Files, func(f *debrid.TorrentItemFile, _ int) string {
|
||||
return f.Path
|
||||
})
|
||||
|
||||
if len(filepaths) == 0 {
|
||||
r.logger.Error().Msg("debridstream: No files found in the torrent")
|
||||
return nil, "", fmt.Errorf("no files found in the torrent")
|
||||
}
|
||||
|
||||
// Create a new Torrent Analyzer
|
||||
analyzer := torrentanalyzer.NewAnalyzer(&torrentanalyzer.NewAnalyzerOptions{
|
||||
Logger: r.logger,
|
||||
Filepaths: filepaths,
|
||||
Media: media,
|
||||
Platform: r.platform,
|
||||
MetadataProvider: r.metadataProvider,
|
||||
ForceMatch: true,
|
||||
})
|
||||
|
||||
// Analyze torrent files
|
||||
analysis, err := analyzer.AnalyzeTorrentFiles()
|
||||
if err != nil {
|
||||
r.logger.Warn().Err(err).Msg("debridstream: Error analyzing torrent files")
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
analysisFile, found := analysis.GetFileByAniDBEpisode(strconv.Itoa(episodeNumber))
|
||||
// Check if analyzer found the episode
|
||||
if !found {
|
||||
r.logger.Error().Msgf("debridstream: Failed to auto-select episode from torrent %s", t.Name)
|
||||
return nil, "", fmt.Errorf("could not find episode %d in torrent", episodeNumber)
|
||||
}
|
||||
|
||||
r.logger.Debug().Msgf("debridstream: Found corresponding file for episode %s: %s", strconv.Itoa(episodeNumber), analysisFile.GetLocalFile().Name)
|
||||
|
||||
fileIndex = analysisFile.GetIndex()
|
||||
}
|
||||
|
||||
tFile := info.Files[fileIndex]
|
||||
r.logger.Debug().Str("file", util.SpewT(tFile)).Msgf("debridstream: Selected file %s", tFile.Name)
|
||||
r.logger.Debug().Msgf("debridstream: Selected torrent %s", t.Name)
|
||||
|
||||
return t, tFile.ID, nil
|
||||
}
|
||||
44
seanime-2.9.10/internal/debrid/client/hook_events.go
Normal file
44
seanime-2.9.10/internal/debrid/client/hook_events.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package debrid_client
|
||||
|
||||
import (
|
||||
"seanime/internal/api/anilist"
|
||||
hibiketorrent "seanime/internal/extension/hibike/torrent"
|
||||
"seanime/internal/hook_resolver"
|
||||
)
|
||||
|
||||
// DebridAutoSelectTorrentsFetchedEvent is triggered when the torrents are fetched for auto select.
|
||||
// The torrents are sorted by seeders from highest to lowest.
|
||||
// This event is triggered before the top 3 torrents are analyzed.
|
||||
type DebridAutoSelectTorrentsFetchedEvent struct {
|
||||
hook_resolver.Event
|
||||
Torrents []*hibiketorrent.AnimeTorrent
|
||||
}
|
||||
|
||||
// DebridSkipStreamCheckEvent is triggered when the debrid client is about to skip the stream check.
|
||||
// Prevent default to enable the stream check.
|
||||
type DebridSkipStreamCheckEvent struct {
|
||||
hook_resolver.Event
|
||||
StreamURL string `json:"streamURL"`
|
||||
Retries int `json:"retries"`
|
||||
RetryDelay int `json:"retryDelay"` // in seconds
|
||||
}
|
||||
|
||||
// DebridSendStreamToMediaPlayerEvent is triggered when the debrid client is about to send a stream to the media player.
|
||||
// Prevent default to skip the playback.
|
||||
type DebridSendStreamToMediaPlayerEvent struct {
|
||||
hook_resolver.Event
|
||||
WindowTitle string `json:"windowTitle"`
|
||||
StreamURL string `json:"streamURL"`
|
||||
Media *anilist.BaseAnime `json:"media"`
|
||||
AniDbEpisode string `json:"aniDbEpisode"`
|
||||
PlaybackType string `json:"playbackType"`
|
||||
}
|
||||
|
||||
// DebridLocalDownloadRequestedEvent is triggered when Seanime is about to download a debrid torrent locally.
|
||||
// Prevent default to skip the default download and override the download.
|
||||
type DebridLocalDownloadRequestedEvent struct {
|
||||
hook_resolver.Event
|
||||
TorrentName string `json:"torrentName"`
|
||||
Destination string `json:"destination"`
|
||||
DownloadUrl string `json:"downloadUrl"`
|
||||
}
|
||||
45
seanime-2.9.10/internal/debrid/client/mock.go
Normal file
45
seanime-2.9.10/internal/debrid/client/mock.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package debrid_client
|
||||
|
||||
import (
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/continuity"
|
||||
"seanime/internal/database/db"
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/library/playbackmanager"
|
||||
"seanime/internal/platforms/anilist_platform"
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func GetMockRepository(t *testing.T, db *db.Database) *Repository {
|
||||
logger := util.NewLogger()
|
||||
wsEventManager := events.NewWSEventManager(logger)
|
||||
anilistClient := anilist.TestGetMockAnilistClient()
|
||||
platform := anilist_platform.NewAnilistPlatform(anilistClient, logger)
|
||||
metadataProvider := metadata.GetMockProvider(t)
|
||||
playbackManager := playbackmanager.New(&playbackmanager.NewPlaybackManagerOptions{
|
||||
WSEventManager: wsEventManager,
|
||||
Logger: logger,
|
||||
Platform: platform,
|
||||
MetadataProvider: metadataProvider,
|
||||
Database: db,
|
||||
RefreshAnimeCollectionFunc: func() {
|
||||
// Do nothing
|
||||
},
|
||||
DiscordPresence: nil,
|
||||
IsOffline: &[]bool{false}[0],
|
||||
ContinuityManager: continuity.GetMockManager(t, db),
|
||||
})
|
||||
|
||||
r := NewRepository(&NewRepositoryOptions{
|
||||
Logger: logger,
|
||||
WSEventManager: wsEventManager,
|
||||
Database: db,
|
||||
MetadataProvider: metadataProvider,
|
||||
Platform: platform,
|
||||
PlaybackManager: playbackManager,
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
132
seanime-2.9.10/internal/debrid/client/previews.go
Normal file
132
seanime-2.9.10/internal/debrid/client/previews.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package debrid_client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/5rahim/habari"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/debrid/debrid"
|
||||
hibiketorrent "seanime/internal/extension/hibike/torrent"
|
||||
"seanime/internal/util"
|
||||
"seanime/internal/util/comparison"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type (
|
||||
FilePreview struct {
|
||||
Path string `json:"path"`
|
||||
DisplayPath string `json:"displayPath"`
|
||||
DisplayTitle string `json:"displayTitle"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
RelativeEpisodeNumber int `json:"relativeEpisodeNumber"`
|
||||
IsLikely bool `json:"isLikely"`
|
||||
Index int `json:"index"`
|
||||
FileId string `json:"fileId"`
|
||||
}
|
||||
|
||||
GetTorrentFilePreviewsOptions struct {
|
||||
Torrent *hibiketorrent.AnimeTorrent
|
||||
Magnet string
|
||||
EpisodeNumber int
|
||||
AbsoluteOffset int
|
||||
Media *anilist.BaseAnime
|
||||
}
|
||||
)
|
||||
|
||||
func (r *Repository) GetTorrentFilePreviewsFromManualSelection(opts *GetTorrentFilePreviewsOptions) (ret []*FilePreview, err error) {
|
||||
defer util.HandlePanicInModuleWithError("debrid_client/GetTorrentFilePreviewsFromManualSelection", &err)
|
||||
|
||||
if opts.Torrent == nil || opts.Magnet == "" || opts.Media == nil {
|
||||
return nil, fmt.Errorf("torrentstream: Invalid options")
|
||||
}
|
||||
|
||||
r.logger.Trace().Str("hash", opts.Torrent.InfoHash).Msg("debridstream: Getting file previews for torrent selection")
|
||||
|
||||
torrentInfo, err := r.GetTorrentInfo(debrid.GetTorrentInfoOptions{
|
||||
MagnetLink: opts.Magnet,
|
||||
InfoHash: opts.Torrent.InfoHash,
|
||||
})
|
||||
if err != nil {
|
||||
r.logger.Error().Err(err).Msgf("debridstream: Error adding torrent %s", opts.Magnet)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fileMetadataMap := make(map[string]*habari.Metadata)
|
||||
wg := sync.WaitGroup{}
|
||||
mu := sync.RWMutex{}
|
||||
wg.Add(len(torrentInfo.Files))
|
||||
for _, file := range torrentInfo.Files {
|
||||
go func(file *debrid.TorrentItemFile) {
|
||||
defer wg.Done()
|
||||
defer util.HandlePanicInModuleThen("debridstream/GetTorrentFilePreviewsFromManualSelection", func() {})
|
||||
|
||||
metadata := habari.Parse(file.Path)
|
||||
mu.Lock()
|
||||
fileMetadataMap[file.Path] = metadata
|
||||
mu.Unlock()
|
||||
}(file)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
containsAbsoluteEps := false
|
||||
for _, metadata := range fileMetadataMap {
|
||||
if len(metadata.EpisodeNumber) == 1 {
|
||||
ep := util.StringToIntMust(metadata.EpisodeNumber[0])
|
||||
if ep > opts.Media.GetTotalEpisodeCount() {
|
||||
containsAbsoluteEps = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wg = sync.WaitGroup{}
|
||||
mu2 := sync.Mutex{}
|
||||
|
||||
for i, file := range torrentInfo.Files {
|
||||
wg.Add(1)
|
||||
go func(i int, file *debrid.TorrentItemFile) {
|
||||
defer wg.Done()
|
||||
defer util.HandlePanicInModuleThen("debridstream/GetTorrentFilePreviewsFromManualSelection", func() {})
|
||||
|
||||
mu.RLock()
|
||||
metadata, found := fileMetadataMap[file.Path]
|
||||
mu.RUnlock()
|
||||
|
||||
displayTitle := file.Path
|
||||
|
||||
isLikely := false
|
||||
parsedEpisodeNumber := -1
|
||||
|
||||
if found && !comparison.ValueContainsSpecial(file.Name) && !comparison.ValueContainsNC(file.Name) {
|
||||
if len(metadata.EpisodeNumber) == 1 {
|
||||
ep := util.StringToIntMust(metadata.EpisodeNumber[0])
|
||||
parsedEpisodeNumber = ep
|
||||
displayTitle = fmt.Sprintf("Episode %d", ep)
|
||||
if metadata.EpisodeTitle != "" {
|
||||
displayTitle = fmt.Sprintf("%s - %s", displayTitle, metadata.EpisodeTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !containsAbsoluteEps {
|
||||
isLikely = parsedEpisodeNumber == opts.EpisodeNumber
|
||||
}
|
||||
|
||||
mu2.Lock()
|
||||
// Get the file preview
|
||||
ret = append(ret, &FilePreview{
|
||||
Path: file.Path,
|
||||
DisplayPath: file.Path,
|
||||
DisplayTitle: displayTitle,
|
||||
EpisodeNumber: parsedEpisodeNumber,
|
||||
IsLikely: isLikely,
|
||||
FileId: file.ID,
|
||||
Index: i,
|
||||
})
|
||||
mu2.Unlock()
|
||||
}(i, file)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return
|
||||
}
|
||||
255
seanime-2.9.10/internal/debrid/client/repository.go
Normal file
255
seanime-2.9.10/internal/debrid/client/repository.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package debrid_client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/database/db"
|
||||
"seanime/internal/database/models"
|
||||
"seanime/internal/debrid/debrid"
|
||||
"seanime/internal/debrid/realdebrid"
|
||||
"seanime/internal/debrid/torbox"
|
||||
"seanime/internal/directstream"
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/library/playbackmanager"
|
||||
"seanime/internal/platforms/platform"
|
||||
"seanime/internal/torrents/torrent"
|
||||
"seanime/internal/util/result"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/samber/mo"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProviderNotSet = fmt.Errorf("debrid: Provider not set")
|
||||
)
|
||||
|
||||
type (
|
||||
Repository struct {
|
||||
provider mo.Option[debrid.Provider]
|
||||
logger *zerolog.Logger
|
||||
db *db.Database
|
||||
settings *models.DebridSettings
|
||||
wsEventManager events.WSEventManagerInterface
|
||||
ctxMap *result.Map[string, context.CancelFunc]
|
||||
downloadLoopCancelFunc context.CancelFunc
|
||||
torrentRepository *torrent.Repository
|
||||
directStreamManager *directstream.Manager
|
||||
|
||||
playbackManager *playbackmanager.PlaybackManager
|
||||
streamManager *StreamManager
|
||||
completeAnimeCache *anilist.CompleteAnimeCache
|
||||
metadataProvider metadata.Provider
|
||||
platform platform.Platform
|
||||
|
||||
previousStreamOptions mo.Option[*StartStreamOptions]
|
||||
}
|
||||
|
||||
NewRepositoryOptions struct {
|
||||
Logger *zerolog.Logger
|
||||
WSEventManager events.WSEventManagerInterface
|
||||
Database *db.Database
|
||||
|
||||
TorrentRepository *torrent.Repository
|
||||
PlaybackManager *playbackmanager.PlaybackManager
|
||||
DirectStreamManager *directstream.Manager
|
||||
MetadataProvider metadata.Provider
|
||||
Platform platform.Platform
|
||||
}
|
||||
)
|
||||
|
||||
func NewRepository(opts *NewRepositoryOptions) (ret *Repository) {
|
||||
ret = &Repository{
|
||||
provider: mo.None[debrid.Provider](),
|
||||
logger: opts.Logger,
|
||||
wsEventManager: opts.WSEventManager,
|
||||
db: opts.Database,
|
||||
settings: &models.DebridSettings{
|
||||
Enabled: false,
|
||||
},
|
||||
torrentRepository: opts.TorrentRepository,
|
||||
platform: opts.Platform,
|
||||
playbackManager: opts.PlaybackManager,
|
||||
metadataProvider: opts.MetadataProvider,
|
||||
completeAnimeCache: anilist.NewCompleteAnimeCache(),
|
||||
ctxMap: result.NewResultMap[string, context.CancelFunc](),
|
||||
previousStreamOptions: mo.None[*StartStreamOptions](),
|
||||
directStreamManager: opts.DirectStreamManager,
|
||||
}
|
||||
|
||||
ret.streamManager = NewStreamManager(ret)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (r *Repository) startOrStopDownloadLoop() {
|
||||
// Cancel the previous download loop if it's running
|
||||
if r.downloadLoopCancelFunc != nil {
|
||||
r.downloadLoopCancelFunc()
|
||||
}
|
||||
|
||||
// Start the download loop if the provider is set and enabled
|
||||
if r.settings.Enabled && r.provider.IsPresent() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r.downloadLoopCancelFunc = cancel
|
||||
r.launchDownloadLoop(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// InitializeProvider is called each time the settings change
|
||||
func (r *Repository) InitializeProvider(settings *models.DebridSettings) error {
|
||||
r.settings = settings
|
||||
|
||||
if !settings.Enabled {
|
||||
r.provider = mo.None[debrid.Provider]()
|
||||
// Stop the download loop if it's running
|
||||
r.startOrStopDownloadLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
switch settings.Provider {
|
||||
case "torbox":
|
||||
r.provider = mo.Some(torbox.NewTorBox(r.logger))
|
||||
case "realdebrid":
|
||||
r.provider = mo.Some(realdebrid.NewRealDebrid(r.logger))
|
||||
default:
|
||||
r.provider = mo.None[debrid.Provider]()
|
||||
}
|
||||
|
||||
if r.provider.IsAbsent() {
|
||||
r.logger.Warn().Str("provider", settings.Provider).Msg("debrid: No provider set")
|
||||
// Stop the download loop if it's running
|
||||
r.startOrStopDownloadLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Authenticate the provider
|
||||
err := r.provider.MustGet().Authenticate(r.settings.ApiKey)
|
||||
if err != nil {
|
||||
r.logger.Err(err).Msg("debrid: Failed to authenticate")
|
||||
r.provider = mo.None[debrid.Provider]()
|
||||
// Cancel the download loop if it's running
|
||||
if r.downloadLoopCancelFunc != nil {
|
||||
r.downloadLoopCancelFunc()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Start the download loop
|
||||
r.startOrStopDownloadLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetProvider() (debrid.Provider, error) {
|
||||
p, found := r.provider.Get()
|
||||
if !found {
|
||||
return nil, ErrProviderNotSet
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// AddAndQueueTorrent adds a torrent to the debrid service and queues it for automatic download
|
||||
func (r *Repository) AddAndQueueTorrent(opts debrid.AddTorrentOptions, destination string, mId int) (string, error) {
|
||||
provider, err := r.GetProvider()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(destination) {
|
||||
return "", fmt.Errorf("debrid: Failed to add torrent, destination must be an absolute path")
|
||||
}
|
||||
|
||||
// Add the torrent to the debrid service
|
||||
torrentItemId, err := provider.AddTorrent(opts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Add the torrent item to the database (so it can be downloaded automatically once it's ready)
|
||||
// We ignore the error since it's non-critical
|
||||
_ = r.db.InsertDebridTorrentItem(&models.DebridTorrentItem{
|
||||
TorrentItemID: torrentItemId,
|
||||
Destination: destination,
|
||||
Provider: provider.GetSettings().ID,
|
||||
MediaId: mId,
|
||||
})
|
||||
|
||||
return torrentItemId, nil
|
||||
}
|
||||
|
||||
// GetTorrentInfo retrieves information about a torrent.
|
||||
// This is used for file section for debrid streaming.
|
||||
// On Real Debrid, this adds the torrent to the user's account.
|
||||
func (r *Repository) GetTorrentInfo(opts debrid.GetTorrentInfoOptions) (*debrid.TorrentInfo, error) {
|
||||
provider, err := r.GetProvider()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
torrentInfo, err := provider.GetTorrentInfo(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Remove non-video files
|
||||
torrentInfo.Files = debrid.FilterVideoFiles(torrentInfo.Files)
|
||||
|
||||
return torrentInfo, nil
|
||||
}
|
||||
|
||||
func (r *Repository) HasProvider() bool {
|
||||
return r.provider.IsPresent()
|
||||
}
|
||||
|
||||
func (r *Repository) GetSettings() *models.DebridSettings {
|
||||
return r.settings
|
||||
}
|
||||
|
||||
// CancelDownload cancels the download for the given item ID
|
||||
func (r *Repository) CancelDownload(itemID string) error {
|
||||
cancelFunc, found := r.ctxMap.Get(itemID)
|
||||
if !found {
|
||||
return fmt.Errorf("no download found for item ID: %s", itemID)
|
||||
}
|
||||
|
||||
// Call the cancel function to cancel the download
|
||||
if cancelFunc != nil {
|
||||
cancelFunc()
|
||||
}
|
||||
|
||||
r.ctxMap.Delete(itemID)
|
||||
|
||||
// Notify that the download has been cancelled
|
||||
r.wsEventManager.SendEvent(events.DebridDownloadProgress, map[string]interface{}{
|
||||
"status": "cancelled",
|
||||
"itemID": itemID,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) StartStream(ctx context.Context, opts *StartStreamOptions) error {
|
||||
return r.streamManager.startStream(ctx, opts)
|
||||
}
|
||||
|
||||
func (r *Repository) GetStreamURL() (string, bool) {
|
||||
return r.streamManager.currentStreamUrl, r.streamManager.currentStreamUrl != ""
|
||||
}
|
||||
|
||||
func (r *Repository) CancelStream(opts *CancelStreamOptions) {
|
||||
r.streamManager.cancelStream(opts)
|
||||
}
|
||||
|
||||
func (r *Repository) GetPreviousStreamOptions() (*StartStreamOptions, bool) {
|
||||
return r.previousStreamOptions.Get()
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
567
seanime-2.9.10/internal/debrid/client/stream.go
Normal file
567
seanime-2.9.10/internal/debrid/client/stream.go
Normal file
@@ -0,0 +1,567 @@
|
||||
package debrid_client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"seanime/internal/database/db_bridge"
|
||||
"seanime/internal/debrid/debrid"
|
||||
"seanime/internal/directstream"
|
||||
"seanime/internal/events"
|
||||
hibiketorrent "seanime/internal/extension/hibike/torrent"
|
||||
"seanime/internal/hook"
|
||||
"seanime/internal/library/playbackmanager"
|
||||
"seanime/internal/util"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/samber/mo"
|
||||
)
|
||||
|
||||
type (
|
||||
StreamManager struct {
|
||||
repository *Repository
|
||||
currentTorrentItemId string
|
||||
downloadCtxCancelFunc context.CancelFunc
|
||||
|
||||
currentStreamUrl string
|
||||
|
||||
playbackSubscriberCtxCancelFunc context.CancelFunc
|
||||
}
|
||||
|
||||
StreamPlaybackType string
|
||||
|
||||
StreamStatus string
|
||||
|
||||
StreamState struct {
|
||||
Status StreamStatus `json:"status"`
|
||||
TorrentName string `json:"torrentName"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
StartStreamOptions struct {
|
||||
MediaId int
|
||||
EpisodeNumber int // RELATIVE Episode number to identify the file
|
||||
AniDBEpisode string // Anizip episode
|
||||
Torrent *hibiketorrent.AnimeTorrent // Selected torrent
|
||||
FileId string // File ID or index
|
||||
FileIndex *int // Index of the file to stream (Manual selection)
|
||||
UserAgent string
|
||||
ClientId string
|
||||
PlaybackType StreamPlaybackType
|
||||
AutoSelect bool
|
||||
}
|
||||
|
||||
CancelStreamOptions struct {
|
||||
// Whether to remove the torrent from the debrid service
|
||||
RemoveTorrent bool `json:"removeTorrent"`
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
StreamStatusDownloading StreamStatus = "downloading"
|
||||
StreamStatusReady StreamStatus = "ready"
|
||||
StreamStatusFailed StreamStatus = "failed"
|
||||
StreamStatusStarted StreamStatus = "started"
|
||||
)
|
||||
|
||||
func NewStreamManager(repository *Repository) *StreamManager {
|
||||
return &StreamManager{
|
||||
repository: repository,
|
||||
currentTorrentItemId: "",
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const (
|
||||
PlaybackTypeNone StreamPlaybackType = "none"
|
||||
PlaybackTypeNoneAndAwait StreamPlaybackType = "noneAndAwait"
|
||||
PlaybackTypeDefault StreamPlaybackType = "default"
|
||||
PlaybackTypeNativePlayer StreamPlaybackType = "nativeplayer"
|
||||
PlaybackTypeExternalPlayer StreamPlaybackType = "externalPlayerLink"
|
||||
)
|
||||
|
||||
// startStream is called by the client to start streaming a torrent
|
||||
func (s *StreamManager) startStream(ctx context.Context, opts *StartStreamOptions) (err error) {
|
||||
defer util.HandlePanicInModuleWithError("debrid/client/StartStream", &err)
|
||||
|
||||
s.repository.previousStreamOptions = mo.Some(opts)
|
||||
|
||||
s.repository.logger.Info().
|
||||
Str("clientId", opts.ClientId).
|
||||
Any("playbackType", opts.PlaybackType).
|
||||
Int("mediaId", opts.MediaId).Msgf("debridstream: Starting stream for episode %s", opts.AniDBEpisode)
|
||||
|
||||
// Cancel the download context if it's running
|
||||
if s.downloadCtxCancelFunc != nil {
|
||||
s.downloadCtxCancelFunc()
|
||||
s.downloadCtxCancelFunc = nil
|
||||
}
|
||||
|
||||
if s.playbackSubscriberCtxCancelFunc != nil {
|
||||
s.playbackSubscriberCtxCancelFunc()
|
||||
s.playbackSubscriberCtxCancelFunc = nil
|
||||
}
|
||||
|
||||
provider, err := s.repository.GetProvider()
|
||||
if err != nil {
|
||||
return fmt.Errorf("debridstream: Failed to start stream: %w", err)
|
||||
}
|
||||
|
||||
s.repository.wsEventManager.SendEvent(events.ShowIndefiniteLoader, "debridstream")
|
||||
//defer func() {
|
||||
// s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
//}()
|
||||
|
||||
if opts.PlaybackType == PlaybackTypeNativePlayer {
|
||||
s.repository.directStreamManager.PrepareNewStream(opts.ClientId, "Selecting torrent...")
|
||||
}
|
||||
|
||||
//
|
||||
// Get the media info
|
||||
//
|
||||
media, _, err := s.getMediaInfo(ctx, opts.MediaId)
|
||||
if err != nil {
|
||||
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
return err
|
||||
}
|
||||
|
||||
episodeNumber := opts.EpisodeNumber
|
||||
aniDbEpisode := strconv.Itoa(episodeNumber)
|
||||
|
||||
selectedTorrent := opts.Torrent
|
||||
fileId := opts.FileId
|
||||
|
||||
if opts.AutoSelect {
|
||||
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusDownloading,
|
||||
TorrentName: "-",
|
||||
Message: "Selecting best torrent...",
|
||||
})
|
||||
|
||||
st, fi, err := s.repository.findBestTorrent(provider, media, opts.EpisodeNumber)
|
||||
if err != nil {
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusFailed,
|
||||
TorrentName: "-",
|
||||
Message: fmt.Sprintf("Failed to select best torrent, %v", err),
|
||||
})
|
||||
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
return fmt.Errorf("debridstream: Failed to start stream: %w", err)
|
||||
}
|
||||
selectedTorrent = st
|
||||
fileId = fi
|
||||
} else {
|
||||
// Manual selection
|
||||
if selectedTorrent == nil {
|
||||
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
return fmt.Errorf("debridstream: Failed to start stream, no torrent provided")
|
||||
}
|
||||
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusDownloading,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: "Analyzing selected torrent...",
|
||||
})
|
||||
|
||||
// If no fileId is provided, we need to analyze the torrent to find the correct file
|
||||
if fileId == "" {
|
||||
var chosenFileIndex *int
|
||||
if opts.FileIndex != nil {
|
||||
chosenFileIndex = opts.FileIndex
|
||||
}
|
||||
st, fi, err := s.repository.findBestTorrentFromManualSelection(provider, selectedTorrent, media, opts.EpisodeNumber, chosenFileIndex)
|
||||
if err != nil {
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusFailed,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: fmt.Sprintf("Failed to analyze torrent, %v", err),
|
||||
})
|
||||
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
return fmt.Errorf("debridstream: Failed to analyze torrent: %w", err)
|
||||
}
|
||||
selectedTorrent = st
|
||||
fileId = fi
|
||||
}
|
||||
}
|
||||
|
||||
if selectedTorrent == nil {
|
||||
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
return fmt.Errorf("debridstream: Failed to start stream, no torrent provided")
|
||||
}
|
||||
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusDownloading,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: "Adding torrent...",
|
||||
})
|
||||
|
||||
// Add the torrent to the debrid service
|
||||
torrentItemId, err := provider.AddTorrent(debrid.AddTorrentOptions{
|
||||
MagnetLink: selectedTorrent.MagnetLink,
|
||||
InfoHash: selectedTorrent.InfoHash,
|
||||
SelectFileId: fileId, // RD-only, download only the selected file
|
||||
})
|
||||
if err != nil {
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusFailed,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: fmt.Sprintf("Failed to add torrent, %v", err),
|
||||
})
|
||||
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
return fmt.Errorf("debridstream: Failed to add torrent: %w", err)
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// Save the current torrent item id
|
||||
s.currentTorrentItemId = torrentItemId
|
||||
ctx, cancelCtx := context.WithCancel(context.Background())
|
||||
s.downloadCtxCancelFunc = cancelCtx
|
||||
|
||||
readyCh := make(chan struct{})
|
||||
readyOnce := sync.Once{}
|
||||
ready := func() {
|
||||
readyOnce.Do(func() {
|
||||
close(readyCh)
|
||||
})
|
||||
}
|
||||
|
||||
// Launch a goroutine that will listen to the added torrent's status
|
||||
go func(ctx context.Context) {
|
||||
defer util.HandlePanicInModuleThen("debrid/client/StartStream", func() {})
|
||||
defer func() {
|
||||
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
// Cancel the context
|
||||
if s.downloadCtxCancelFunc != nil {
|
||||
s.downloadCtxCancelFunc()
|
||||
s.downloadCtxCancelFunc = nil
|
||||
}
|
||||
}()
|
||||
|
||||
s.repository.logger.Debug().Msg("debridstream: Listening to torrent status")
|
||||
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusDownloading,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: fmt.Sprintf("Downloading torrent..."),
|
||||
})
|
||||
|
||||
itemCh := make(chan debrid.TorrentItem, 1)
|
||||
|
||||
go func() {
|
||||
for item := range itemCh {
|
||||
if opts.PlaybackType == PlaybackTypeNativePlayer {
|
||||
s.repository.directStreamManager.PrepareNewStream(opts.ClientId, fmt.Sprintf("Awaiting stream: %d%%", item.CompletionPercentage))
|
||||
}
|
||||
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusDownloading,
|
||||
TorrentName: item.Name,
|
||||
Message: fmt.Sprintf("Downloading torrent: %d%%", item.CompletionPercentage),
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
// Await the stream URL
|
||||
// For Torbox, this will wait until the entire torrent is downloaded
|
||||
streamUrl, err := provider.GetTorrentStreamUrl(ctx, debrid.StreamTorrentOptions{
|
||||
ID: torrentItemId,
|
||||
FileId: fileId,
|
||||
}, itemCh)
|
||||
|
||||
go func() {
|
||||
close(itemCh)
|
||||
}()
|
||||
|
||||
if ctx.Err() != nil {
|
||||
s.repository.logger.Debug().Msg("debridstream: Context cancelled, stopping stream")
|
||||
ready()
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
s.repository.logger.Err(err).Msg("debridstream: Failed to get stream URL")
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusFailed,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: fmt.Sprintf("Failed to get stream URL, %v", err),
|
||||
})
|
||||
}
|
||||
ready()
|
||||
return
|
||||
}
|
||||
|
||||
skipCheckEvent := &DebridSkipStreamCheckEvent{
|
||||
StreamURL: streamUrl,
|
||||
Retries: 4,
|
||||
RetryDelay: 8,
|
||||
}
|
||||
_ = hook.GlobalHookManager.OnDebridSkipStreamCheck().Trigger(skipCheckEvent)
|
||||
streamUrl = skipCheckEvent.StreamURL
|
||||
|
||||
// Default prevented, we check if we can stream the file
|
||||
if skipCheckEvent.DefaultPrevented {
|
||||
s.repository.logger.Debug().Msg("debridstream: Stream URL received, checking stream file")
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusDownloading,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: "Checking stream file...",
|
||||
})
|
||||
|
||||
retries := 0
|
||||
|
||||
streamUrlCheckLoop:
|
||||
for { // Retry loop for a total of 4 times (32 seconds)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.repository.logger.Debug().Msg("debridstream: Context cancelled, stopping stream")
|
||||
return
|
||||
default:
|
||||
// Check if we can stream the URL
|
||||
if canStream, reason := CanStream(streamUrl); !canStream {
|
||||
if retries >= skipCheckEvent.Retries {
|
||||
s.repository.logger.Error().Msg("debridstream: Cannot stream the file")
|
||||
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusFailed,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: fmt.Sprintf("Cannot stream this file: %s", reason),
|
||||
})
|
||||
return
|
||||
}
|
||||
s.repository.logger.Warn().Msg("debridstream: Rechecking stream file in 8 seconds")
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusDownloading,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: "Checking stream file...",
|
||||
})
|
||||
retries++
|
||||
time.Sleep(time.Duration(skipCheckEvent.RetryDelay) * time.Second)
|
||||
continue
|
||||
}
|
||||
break streamUrlCheckLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.repository.logger.Debug().Msg("debridstream: Stream is ready")
|
||||
|
||||
// Signal to the client that the torrent is ready to stream
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusReady,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: "Ready to stream the file",
|
||||
})
|
||||
|
||||
if ctx.Err() != nil {
|
||||
s.repository.logger.Debug().Msg("debridstream: Context cancelled, stopping stream")
|
||||
ready()
|
||||
return
|
||||
}
|
||||
|
||||
windowTitle := media.GetPreferredTitle()
|
||||
if !media.IsMovieOrSingleEpisode() {
|
||||
windowTitle += fmt.Sprintf(" - Episode %s", aniDbEpisode)
|
||||
}
|
||||
|
||||
event := &DebridSendStreamToMediaPlayerEvent{
|
||||
WindowTitle: windowTitle,
|
||||
StreamURL: streamUrl,
|
||||
Media: media.ToBaseAnime(),
|
||||
AniDbEpisode: aniDbEpisode,
|
||||
PlaybackType: string(opts.PlaybackType),
|
||||
}
|
||||
err = hook.GlobalHookManager.OnDebridSendStreamToMediaPlayer().Trigger(event)
|
||||
if err != nil {
|
||||
s.repository.logger.Err(err).Msg("debridstream: Failed to send stream to media player")
|
||||
}
|
||||
windowTitle = event.WindowTitle
|
||||
streamUrl = event.StreamURL
|
||||
media := event.Media
|
||||
aniDbEpisode := event.AniDbEpisode
|
||||
playbackType := StreamPlaybackType(event.PlaybackType)
|
||||
|
||||
if event.DefaultPrevented {
|
||||
s.repository.logger.Debug().Msg("debridstream: Stream prevented by hook")
|
||||
ready()
|
||||
return
|
||||
}
|
||||
|
||||
s.currentStreamUrl = streamUrl
|
||||
|
||||
switch playbackType {
|
||||
case PlaybackTypeNone:
|
||||
// No playback type selected, just signal to the client that the stream is ready
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusReady,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: "External player link sent",
|
||||
})
|
||||
case PlaybackTypeNoneAndAwait:
|
||||
// No playback type selected, just signal to the client that the stream is ready
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusReady,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: "External player link sent",
|
||||
})
|
||||
ready()
|
||||
|
||||
case PlaybackTypeDefault:
|
||||
//
|
||||
// Start the stream
|
||||
//
|
||||
s.repository.logger.Debug().Msg("debridstream: Starting the media player")
|
||||
|
||||
s.repository.wsEventManager.SendEvent(events.InfoToast, "Sending stream to media player...")
|
||||
s.repository.wsEventManager.SendEvent(events.ShowIndefiniteLoader, "debridstream")
|
||||
|
||||
var playbackSubscriberCtx context.Context
|
||||
playbackSubscriberCtx, s.playbackSubscriberCtxCancelFunc = context.WithCancel(context.Background())
|
||||
playbackSubscriber := s.repository.playbackManager.SubscribeToPlaybackStatus("debridstream")
|
||||
|
||||
// Sends the stream to the media player
|
||||
// DEVNOTE: Events are handled by the torrentstream.Repository module
|
||||
err = s.repository.playbackManager.StartStreamingUsingMediaPlayer(windowTitle, &playbackmanager.StartPlayingOptions{
|
||||
Payload: streamUrl,
|
||||
UserAgent: opts.UserAgent,
|
||||
ClientId: opts.ClientId,
|
||||
}, media, aniDbEpisode)
|
||||
if err != nil {
|
||||
go s.repository.playbackManager.UnsubscribeFromPlaybackStatus("debridstream")
|
||||
if s.playbackSubscriberCtxCancelFunc != nil {
|
||||
s.playbackSubscriberCtxCancelFunc()
|
||||
s.playbackSubscriberCtxCancelFunc = nil
|
||||
}
|
||||
// Failed to start the stream, we'll drop the torrents and stop the server
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusFailed,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: fmt.Sprintf("Failed to send the stream to the media player, %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Listen to the playback status
|
||||
// Reset the current stream url when playback is stopped
|
||||
go func() {
|
||||
defer util.HandlePanicInModuleThen("debridstream/PlaybackSubscriber", func() {})
|
||||
defer func() {
|
||||
if s.playbackSubscriberCtxCancelFunc != nil {
|
||||
s.playbackSubscriberCtxCancelFunc()
|
||||
s.playbackSubscriberCtxCancelFunc = nil
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-playbackSubscriberCtx.Done():
|
||||
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
s.repository.playbackManager.UnsubscribeFromPlaybackStatus("debridstream")
|
||||
s.currentStreamUrl = ""
|
||||
case event := <-playbackSubscriber.EventCh:
|
||||
switch event.(type) {
|
||||
case playbackmanager.StreamStartedEvent:
|
||||
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
case playbackmanager.StreamStoppedEvent:
|
||||
go s.repository.playbackManager.UnsubscribeFromPlaybackStatus("debridstream")
|
||||
s.currentStreamUrl = ""
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
case PlaybackTypeExternalPlayer:
|
||||
// Send the external player link
|
||||
s.repository.wsEventManager.SendEventTo(opts.ClientId, events.ExternalPlayerOpenURL, struct {
|
||||
Url string `json:"url"`
|
||||
MediaId int `json:"mediaId"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
MediaTitle string `json:"mediaTitle"`
|
||||
}{
|
||||
Url: streamUrl,
|
||||
MediaId: opts.MediaId,
|
||||
EpisodeNumber: opts.EpisodeNumber,
|
||||
MediaTitle: media.GetPreferredTitle(),
|
||||
})
|
||||
|
||||
// Signal to the client that the torrent has started playing (remove loading status)
|
||||
// We can't know for sure
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusReady,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: "External player link sent",
|
||||
})
|
||||
case PlaybackTypeNativePlayer:
|
||||
err := s.repository.directStreamManager.PlayDebridStream(ctx, directstream.PlayDebridStreamOptions{
|
||||
StreamUrl: streamUrl,
|
||||
MediaId: media.ID,
|
||||
EpisodeNumber: opts.EpisodeNumber,
|
||||
AnidbEpisode: opts.AniDBEpisode,
|
||||
Media: media,
|
||||
Torrent: selectedTorrent,
|
||||
FileId: fileId,
|
||||
UserAgent: opts.UserAgent,
|
||||
ClientId: opts.ClientId,
|
||||
AutoSelect: false,
|
||||
})
|
||||
if err != nil {
|
||||
s.repository.logger.Error().Err(err).Msg("directstream: Failed to prepare new stream")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer util.HandlePanicInModuleThen("debridstream/AddBatchHistory", func() {})
|
||||
|
||||
_ = db_bridge.InsertTorrentstreamHistory(s.repository.db, media.GetID(), selectedTorrent)
|
||||
}()
|
||||
}(ctx)
|
||||
|
||||
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
||||
Status: StreamStatusStarted,
|
||||
TorrentName: selectedTorrent.Name,
|
||||
Message: "Stream started",
|
||||
})
|
||||
s.repository.logger.Info().Msg("debridstream: Stream started")
|
||||
|
||||
if opts.PlaybackType == PlaybackTypeNoneAndAwait {
|
||||
s.repository.logger.Debug().Msg("debridstream: Waiting for stream to be ready")
|
||||
<-readyCh
|
||||
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StreamManager) cancelStream(opts *CancelStreamOptions) {
|
||||
if s.downloadCtxCancelFunc != nil {
|
||||
s.downloadCtxCancelFunc()
|
||||
s.downloadCtxCancelFunc = nil
|
||||
}
|
||||
|
||||
s.repository.wsEventManager.SendEvent(events.ShowIndefiniteLoader, "debridstream")
|
||||
|
||||
s.currentStreamUrl = ""
|
||||
|
||||
if opts.RemoveTorrent && s.currentTorrentItemId != "" {
|
||||
// Remove the torrent from the debrid service
|
||||
provider, err := s.repository.GetProvider()
|
||||
if err != nil {
|
||||
s.repository.logger.Err(err).Msg("debridstream: Failed to remove torrent")
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the torrent from the debrid service
|
||||
err = provider.DeleteTorrent(s.currentTorrentItemId)
|
||||
if err != nil {
|
||||
s.repository.logger.Err(err).Msg("debridstream: Failed to remove torrent")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
136
seanime-2.9.10/internal/debrid/client/stream_helpers.go
Normal file
136
seanime-2.9.10/internal/debrid/client/stream_helpers.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package debrid_client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/util"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *StreamManager) getMediaInfo(ctx context.Context, mediaId int) (media *anilist.CompleteAnime, animeMetadata *metadata.AnimeMetadata, err error) {
|
||||
// Get the media
|
||||
var found bool
|
||||
media, found = s.repository.completeAnimeCache.Get(mediaId)
|
||||
if !found {
|
||||
// Fetch the media
|
||||
media, err = s.repository.platform.GetAnimeWithRelations(ctx, mediaId)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("torrentstream: Failed to fetch media: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get the media
|
||||
animeMetadata, err = s.repository.metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, mediaId)
|
||||
if err != nil {
|
||||
//return nil, nil, fmt.Errorf("torrentstream: Could not fetch AniDB media: %w", err)
|
||||
animeMetadata = &metadata.AnimeMetadata{
|
||||
Titles: make(map[string]string),
|
||||
Episodes: make(map[string]*metadata.EpisodeMetadata),
|
||||
EpisodeCount: 0,
|
||||
SpecialCount: 0,
|
||||
Mappings: &metadata.AnimeMappings{
|
||||
AnilistId: media.GetID(),
|
||||
},
|
||||
}
|
||||
animeMetadata.Titles["en"] = media.GetTitleSafe()
|
||||
animeMetadata.Titles["x-jat"] = media.GetRomajiTitleSafe()
|
||||
err = nil
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func CanStream(streamUrl string) (bool, string) {
|
||||
hasExtension, isArchive := IsArchive(streamUrl)
|
||||
|
||||
// If we were able to verify that the stream URL is an archive, we can't stream it
|
||||
if isArchive {
|
||||
return false, "Stream URL is an archive"
|
||||
}
|
||||
|
||||
// If the stream URL has an extension, we can stream it
|
||||
if hasExtension {
|
||||
ext := filepath.Ext(streamUrl)
|
||||
if util.IsValidVideoExtension(ext) {
|
||||
return true, ""
|
||||
}
|
||||
// If the extension is not a valid video extension, we can't stream it
|
||||
return false, "Stream URL is not a valid video extension"
|
||||
}
|
||||
|
||||
// If the stream URL doesn't have an extension, we'll get the headers to check if it's a video
|
||||
// If the headers are not available, we can't stream it
|
||||
|
||||
contentType, err := GetContentType(streamUrl)
|
||||
if err != nil {
|
||||
return false, "Failed to get content type"
|
||||
}
|
||||
|
||||
if strings.HasPrefix(contentType, "video/") {
|
||||
return true, ""
|
||||
}
|
||||
|
||||
return false, fmt.Sprintf("Stream URL of type %q is not a video", contentType)
|
||||
}
|
||||
|
||||
func IsArchive(streamUrl string) (hasExtension bool, isArchive bool) {
|
||||
ext := filepath.Ext(streamUrl)
|
||||
if ext == ".zip" || ext == ".rar" {
|
||||
return true, true
|
||||
}
|
||||
|
||||
if ext != "" {
|
||||
return true, false
|
||||
}
|
||||
|
||||
return false, false
|
||||
}
|
||||
|
||||
func GetContentTypeHead(url string) string {
|
||||
resp, err := http.Head(url)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
return resp.Header.Get("Content-Type")
|
||||
}
|
||||
|
||||
func GetContentType(url string) (string, error) {
|
||||
// Try using HEAD request
|
||||
if cType := GetContentTypeHead(url); cType != "" {
|
||||
return cType, nil
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Only read a small amount of data to determine the content type.
|
||||
req.Header.Set("Range", "bytes=0-511")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read the first 512 bytes
|
||||
buf := make([]byte, 512)
|
||||
n, err := resp.Body.Read(buf)
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Detect content type based on the read bytes
|
||||
contentType := http.DetectContentType(buf[:n])
|
||||
|
||||
return contentType, nil
|
||||
}
|
||||
282
seanime-2.9.10/internal/debrid/client/utils.go
Normal file
282
seanime-2.9.10/internal/debrid/client/utils.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package debrid_client
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/nwaples/rardecode/v2"
|
||||
)
|
||||
|
||||
// Unzips a file to the destination
|
||||
//
|
||||
// Example:
|
||||
// If "file.zip" contains `folder>file.text`, the file will be extracted to "/path/to/dest/{TMP}/folder/file.txt"
|
||||
// unzipFile("file.zip", "/path/to/dest")
|
||||
func unzipFile(src, dest string) (string, error) {
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open zip file: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
// Create a temporary folder to extract the files
|
||||
extractedDir, err := os.MkdirTemp(dest, "extracted-")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp folder: %w", err)
|
||||
}
|
||||
|
||||
// Iterate through the files in the archive
|
||||
for _, f := range r.File {
|
||||
// Get the full path of the file in the destination
|
||||
fpath := filepath.Join(extractedDir, f.Name)
|
||||
// If the file is a directory, create it in the destination
|
||||
if f.FileInfo().IsDir() {
|
||||
_ = os.MkdirAll(fpath, os.ModePerm)
|
||||
continue
|
||||
}
|
||||
// Make sure the parent directory exists (will not return an error if it already exists)
|
||||
if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Open the file in the destination
|
||||
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Open the file in the archive
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
_ = outFile.Close()
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Copy the file from the archive to the destination
|
||||
_, err = io.Copy(outFile, rc)
|
||||
_ = outFile.Close()
|
||||
_ = rc.Close()
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return extractedDir, nil
|
||||
}
|
||||
|
||||
// Unrars a file to the destination
|
||||
//
|
||||
// Example:
|
||||
// If "file.rar" contains a folder "folder" with a file "file.txt", the file will be extracted to "/path/to/dest/{TM}/folder/file.txt"
|
||||
// unrarFile("file.rar", "/path/to/dest")
|
||||
func unrarFile(src, dest string) (string, error) {
|
||||
r, err := rardecode.OpenReader(src)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open rar file: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
// Create a temporary folder to extract the files
|
||||
extractedDir, err := os.MkdirTemp(dest, "extracted-")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp folder: %w", err)
|
||||
}
|
||||
|
||||
// Iterate through the files in the archive
|
||||
for {
|
||||
header, err := r.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Get the full path of the file in the destination
|
||||
fpath := filepath.Join(extractedDir, header.Name)
|
||||
// If the file is a directory, create it in the destination
|
||||
if header.IsDir {
|
||||
_ = os.MkdirAll(fpath, os.ModePerm)
|
||||
continue
|
||||
}
|
||||
|
||||
// Make sure the parent directory exists (will not return an error if it already exists)
|
||||
if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Open the file in the destination
|
||||
outFile, err := os.Create(fpath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Copy the file from the archive to the destination
|
||||
_, err = io.Copy(outFile, r)
|
||||
outFile.Close()
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return extractedDir, nil
|
||||
}
|
||||
|
||||
// Moves a folder or file to the destination
|
||||
//
|
||||
// Example:
|
||||
// moveFolderOrFileTo("/path/to/src/folder", "/path/to/dest") -> "/path/to/dest/folder"
|
||||
func moveFolderOrFileTo(src, dest string) error {
|
||||
// Ensure the destination folder exists
|
||||
if _, err := os.Stat(dest); os.IsNotExist(err) {
|
||||
err := os.MkdirAll(dest, os.ModePerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create destination folder: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
destFolder := filepath.Join(dest, filepath.Base(src))
|
||||
|
||||
// Move the folder by renaming it
|
||||
err := os.Rename(src, destFolder)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to move folder: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Moves the contents of a folder to the destination
|
||||
// It will move ONLY the folder containing multiple files or folders OR a single deeply nested file
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// Case 1:
|
||||
// src/
|
||||
// - Anime/
|
||||
// - Ep1.mkv
|
||||
// - Ep2.mkv
|
||||
// moveContentsTo("/path/to/src", "/path/to/dest") -> "/path/to/dest/Anime"
|
||||
//
|
||||
// Case 2:
|
||||
// src/
|
||||
// - {HASH}/
|
||||
// - Anime/
|
||||
// - Ep1.mkv
|
||||
// - Ep2.mkv
|
||||
// moveContentsTo("/path/to/src", "/path/to/dest") -> "/path/to/dest/Anime"
|
||||
//
|
||||
// Case 3:
|
||||
// src/
|
||||
// - {HASH}/
|
||||
// - Anime/
|
||||
// - Ep1.mkv
|
||||
// moveContentsTo("/path/to/src", "/path/to/dest") -> "/path/to/dest/Ep1.mkv"
|
||||
//
|
||||
// Case 4:
|
||||
// src/
|
||||
// - {HASH}/
|
||||
// - Anime/
|
||||
// - Anime 1/
|
||||
// - Ep1.mkv
|
||||
// - Ep2.mkv
|
||||
// - Anime 2/
|
||||
// - Ep1.mkv
|
||||
// - Ep2.mkv
|
||||
// moveContentsTo("/path/to/src", "/path/to/dest") -> "/path/to/dest/Anime"
|
||||
func moveContentsTo(src, dest string) error {
|
||||
// Ensure the source and destination directories exist
|
||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||
return fmt.Errorf("source directory does not exist: %s", src)
|
||||
}
|
||||
_ = os.MkdirAll(dest, os.ModePerm)
|
||||
|
||||
srcEntries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If the source folder contains multiple files or folders, move its contents to the destination
|
||||
if len(srcEntries) > 1 {
|
||||
for _, srcEntry := range srcEntries {
|
||||
err := moveFolderOrFileTo(filepath.Join(src, srcEntry.Name()), dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
folderMap := make(map[string]int)
|
||||
err = findFolderChildCount(src, folderMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var folderToMove string
|
||||
for folder, count := range folderMap {
|
||||
if count > 1 {
|
||||
if folderToMove == "" || len(folder) < len(folderToMove) {
|
||||
folderToMove = folder
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
//util.Spew(folderToMove)
|
||||
|
||||
// It's a single file, move that file only
|
||||
if folderToMove == "" {
|
||||
fp := getDeeplyNestedFile(src)
|
||||
if fp == "" {
|
||||
return fmt.Errorf("no files found in the source directory")
|
||||
}
|
||||
return moveFolderOrFileTo(fp, dest)
|
||||
}
|
||||
|
||||
// Move the folder containing multiple files or folders
|
||||
err = moveFolderOrFileTo(folderToMove, dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Finds the folder to move to the destination
|
||||
func findFolderChildCount(src string, folderMap map[string]int) error {
|
||||
srcEntries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, srcEntry := range srcEntries {
|
||||
folderMap[src]++
|
||||
if srcEntry.IsDir() {
|
||||
err = findFolderChildCount(filepath.Join(src, srcEntry.Name()), folderMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getDeeplyNestedFile(src string) (fp string) {
|
||||
srcEntries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, srcEntry := range srcEntries {
|
||||
if srcEntry.IsDir() {
|
||||
return getDeeplyNestedFile(filepath.Join(src, srcEntry.Name()))
|
||||
}
|
||||
return filepath.Join(src, srcEntry.Name())
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
200
seanime-2.9.10/internal/debrid/client/utils_test.go
Normal file
200
seanime-2.9.10/internal/debrid/client/utils_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package debrid_client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func PrintPathStructure(path string, indent string) error {
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read directory %s: %w", path, err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
fmt.Println(indent + entry.Name())
|
||||
|
||||
if entry.IsDir() {
|
||||
newIndent := indent + " "
|
||||
newPath := filepath.Join(path, entry.Name())
|
||||
if err := PrintPathStructure(newPath, newIndent); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCreateTempDir(t *testing.T) {
|
||||
|
||||
files := []string{
|
||||
"/12345/Anime/Ep1.mkv",
|
||||
"/12345/Anime/Ep2.mkv",
|
||||
}
|
||||
|
||||
root := "./root"
|
||||
for _, file := range files {
|
||||
path := filepath.Join(root, file)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatalf("failed to create directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("dummy content"), 0644); err != nil {
|
||||
t.Fatalf("failed to create file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
defer os.RemoveAll(root)
|
||||
|
||||
err := PrintPathStructure(root, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
}
|
||||
|
||||
func TestMoveContentsTo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
files []string
|
||||
dest string
|
||||
expected string
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
name: "Case 1: Move folder with files",
|
||||
files: []string{
|
||||
"/Anime/Ep1.mkv",
|
||||
"/Anime/Ep2.mkv",
|
||||
},
|
||||
dest: "./dest",
|
||||
expected: "./dest/Anime",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "Case 2: Move folder with single hash directory",
|
||||
files: []string{
|
||||
"/12345/Anime/Ep1.mkv",
|
||||
"/12345/Anime/Ep2.mkv",
|
||||
},
|
||||
dest: "./dest",
|
||||
expected: "./dest/Anime",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "Case 3: Move single file",
|
||||
files: []string{
|
||||
"/12345/Anime/Ep1.mkv",
|
||||
},
|
||||
dest: "./dest",
|
||||
expected: "./dest/Ep1.mkv",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "Case 5: Source directory does not exist",
|
||||
files: []string{},
|
||||
dest: "./dest",
|
||||
expected: "",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "Case 6: Move single file with hash directory",
|
||||
files: []string{
|
||||
"/12345/Anime/Ep1.mkv",
|
||||
},
|
||||
dest: "./dest",
|
||||
expected: "./dest/Ep1.mkv",
|
||||
},
|
||||
{
|
||||
name: "Case 7",
|
||||
files: []string{
|
||||
"Ep1.mkv",
|
||||
},
|
||||
dest: "./dest",
|
||||
expected: "./dest/Ep1.mkv",
|
||||
},
|
||||
{
|
||||
name: "Case 8",
|
||||
files: []string{
|
||||
"Ep1.mkv",
|
||||
"Ep2.mkv",
|
||||
},
|
||||
dest: "./dest",
|
||||
expected: "./dest/Ep2.mkv",
|
||||
},
|
||||
{
|
||||
name: "Case 9",
|
||||
files: []string{
|
||||
"/12345/Anime/Anime 1/Ep1.mkv",
|
||||
"/12345/Anime/Anime 1/Ep2.mkv",
|
||||
"/12345/Anime/Anime 2/Ep1.mkv",
|
||||
"/12345/Anime/Anime 2/Ep2.mkv",
|
||||
"/12345/Anime 2/Anime 3/Ep1.mkv",
|
||||
"/12345/Anime 2/Anime 3/Ep2.mkv",
|
||||
},
|
||||
dest: "./dest",
|
||||
expected: "./dest/12345",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "Case 10",
|
||||
files: []string{
|
||||
"/Users/r/Downloads/b6aa416f662a2df83c6f5f79da95004ced59b8ef/Tsue to Tsurugi no Wistoria S01 1080p WEBRip DD+ x265-EMBER/[EMBER] Tsue to Tsurugi no Wistoria - 01.mkv",
|
||||
"/Users/r/Downloads/b6aa416f662a2df83c6f5f79da95004ced59b8ef/Tsue to Tsurugi no Wistoria S01 1080p WEBRip DD+ x265-EMBER/[EMBER] Tsue to Tsurugi no Wistoria - 02.mkv",
|
||||
},
|
||||
dest: "./dest",
|
||||
expected: "./dest/Tsue to Tsurugi no Wistoria S01 1080p WEBRip DD+ x265-EMBER",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "Case 11",
|
||||
files: []string{
|
||||
"/Users/rahim/Downloads/80431b4f9a12f4e06616062d3d3973b9ef99b5e6/[SubsPlease] Bocchi the Rock! - 01 (1080p) [E04F4EFB]/[SubsPlease] Bocchi the Rock! - 01 (1080p) [E04F4EFB].mkv",
|
||||
},
|
||||
dest: "./dest",
|
||||
expected: "./dest/[SubsPlease] Bocchi the Rock! - 01 (1080p) [E04F4EFB].mkv",
|
||||
expectErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create the source directory structure
|
||||
root := "./root"
|
||||
for _, file := range tt.files {
|
||||
path := filepath.Join(root, file)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatalf("failed to create directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("dummy content"), 0644); err != nil {
|
||||
t.Fatalf("failed to create file %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
defer os.RemoveAll(root) // Cleanup temp dir after test
|
||||
|
||||
PrintPathStructure(root, "")
|
||||
println("-----------------------------")
|
||||
|
||||
// Create the destination directory
|
||||
if err := os.MkdirAll(tt.dest, 0755); err != nil {
|
||||
t.Fatalf("failed to create dest directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tt.dest) // Cleanup dest after test
|
||||
|
||||
// Move the contents
|
||||
err := moveContentsTo(root, tt.dest)
|
||||
|
||||
if (err != nil) != tt.expectErr {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !tt.expectErr {
|
||||
if _, err := os.Stat(tt.expected); os.IsNotExist(err) {
|
||||
t.Errorf("expected directory or file does not exist: %s", tt.expected)
|
||||
}
|
||||
|
||||
PrintPathStructure(tt.dest, "")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user