init adding rclone
This commit is contained in:
@@ -0,0 +1,306 @@
|
|||||||
|
package alldebrid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/common"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/request"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||||
|
|
||||||
|
"net/http"
|
||||||
|
gourl "net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AllDebrid struct {
|
||||||
|
Name string
|
||||||
|
Host string `json:"host"`
|
||||||
|
APIKey string
|
||||||
|
DownloadUncached bool
|
||||||
|
client *request.RLHTTPClient
|
||||||
|
cache *common.Cache
|
||||||
|
MountPath string
|
||||||
|
logger zerolog.Logger
|
||||||
|
CheckCached bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ad *AllDebrid) GetMountPath() string {
|
||||||
|
return ad.MountPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ad *AllDebrid) GetName() string {
|
||||||
|
return ad.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ad *AllDebrid) GetLogger() zerolog.Logger {
|
||||||
|
return ad.logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ad *AllDebrid) IsAvailable(infohashes []string) map[string]bool {
|
||||||
|
// Check if the infohashes are available in the local cache
|
||||||
|
hashes, result := torrent.GetLocalCache(infohashes, ad.cache)
|
||||||
|
|
||||||
|
if len(hashes) == 0 {
|
||||||
|
// Either all the infohashes are locally cached or none are
|
||||||
|
ad.cache.AddMultiple(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Divide hashes into groups of 100
|
||||||
|
// AllDebrid does not support checking cached infohashes
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ad *AllDebrid) SubmitMagnet(torrent *torrent.Torrent) (*torrent.Torrent, error) {
|
||||||
|
url := fmt.Sprintf("%s/magnet/upload", ad.Host)
|
||||||
|
query := gourl.Values{}
|
||||||
|
query.Add("magnets[]", torrent.Magnet.Link)
|
||||||
|
url += "?" + query.Encode()
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
resp, err := ad.client.MakeRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var data UploadMagnetResponse
|
||||||
|
err = json.Unmarshal(resp, &data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
magnets := data.Data.Magnets
|
||||||
|
if len(magnets) == 0 {
|
||||||
|
return nil, fmt.Errorf("error adding torrent")
|
||||||
|
}
|
||||||
|
magnet := magnets[0]
|
||||||
|
torrentId := strconv.Itoa(magnet.ID)
|
||||||
|
ad.logger.Info().Msgf("Torrent: %s added with id: %s", torrent.Name, torrentId)
|
||||||
|
torrent.Id = torrentId
|
||||||
|
|
||||||
|
return torrent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAlldebridStatus(statusCode int) string {
|
||||||
|
switch {
|
||||||
|
case statusCode == 4:
|
||||||
|
return "downloaded"
|
||||||
|
case statusCode >= 0 && statusCode <= 3:
|
||||||
|
return "downloading"
|
||||||
|
default:
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func flattenFiles(files []MagnetFile, parentPath string, index *int) []torrent.File {
|
||||||
|
result := make([]torrent.File, 0)
|
||||||
|
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
currentPath := f.Name
|
||||||
|
if parentPath != "" {
|
||||||
|
currentPath = filepath.Join(parentPath, f.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.Elements != nil {
|
||||||
|
// This is a folder, recurse into it
|
||||||
|
result = append(result, flattenFiles(f.Elements, currentPath, index)...)
|
||||||
|
} else {
|
||||||
|
// This is a file
|
||||||
|
fileName := filepath.Base(f.Name)
|
||||||
|
if common.RegexMatch(common.SAMPLEMATCH, fileName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !cfg.IsAllowedFile(fileName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg.IsSizeAllowed(f.Size) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
*index++
|
||||||
|
file := torrent.File{
|
||||||
|
Id: strconv.Itoa(*index),
|
||||||
|
Name: fileName,
|
||||||
|
Size: f.Size,
|
||||||
|
Path: currentPath,
|
||||||
|
}
|
||||||
|
result = append(result, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ad *AllDebrid) GetTorrent(id string) (*torrent.Torrent, error) {
|
||||||
|
t := &torrent.Torrent{}
|
||||||
|
url := fmt.Sprintf("%s/magnet/status?id=%s", ad.Host, id)
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
resp, err := ad.client.MakeRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
var res TorrentInfoResponse
|
||||||
|
err = json.Unmarshal(resp, &res)
|
||||||
|
if err != nil {
|
||||||
|
ad.logger.Info().Msgf("Error unmarshalling torrent info: %s", err)
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
data := res.Data.Magnets
|
||||||
|
status := getAlldebridStatus(data.StatusCode)
|
||||||
|
name := data.Filename
|
||||||
|
t.Id = id
|
||||||
|
t.Name = name
|
||||||
|
t.Status = status
|
||||||
|
t.Filename = name
|
||||||
|
t.OriginalFilename = name
|
||||||
|
t.Folder = name
|
||||||
|
t.MountPath = ad.MountPath
|
||||||
|
t.Debrid = ad.Name
|
||||||
|
t.DownloadLinks = make(map[string]torrent.DownloadLinks)
|
||||||
|
if status == "downloaded" {
|
||||||
|
t.Bytes = data.Size
|
||||||
|
|
||||||
|
t.Progress = float64((data.Downloaded / data.Size) * 100)
|
||||||
|
t.Speed = data.DownloadSpeed
|
||||||
|
t.Seeders = data.Seeders
|
||||||
|
index := -1
|
||||||
|
files := flattenFiles(data.Files, "", &index)
|
||||||
|
parentFolder := data.Filename
|
||||||
|
if data.NbLinks == 1 {
|
||||||
|
// All debrid doesn't return the parent folder for single file torrents
|
||||||
|
parentFolder = ""
|
||||||
|
}
|
||||||
|
t.OriginalFilename = parentFolder
|
||||||
|
t.Files = files
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ad *AllDebrid) CheckStatus(torrent *torrent.Torrent, isSymlink bool) (*torrent.Torrent, error) {
|
||||||
|
for {
|
||||||
|
tb, err := ad.GetTorrent(torrent.Id)
|
||||||
|
|
||||||
|
torrent = tb
|
||||||
|
|
||||||
|
if err != nil || tb == nil {
|
||||||
|
return tb, err
|
||||||
|
}
|
||||||
|
status := torrent.Status
|
||||||
|
if status == "downloaded" {
|
||||||
|
ad.logger.Info().Msgf("Torrent: %s downloaded", torrent.Name)
|
||||||
|
if !isSymlink {
|
||||||
|
err = ad.GetDownloadLinks(torrent)
|
||||||
|
if err != nil {
|
||||||
|
return torrent, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
} else if status == "downloading" {
|
||||||
|
if !ad.DownloadUncached {
|
||||||
|
go ad.DeleteTorrent(torrent)
|
||||||
|
return torrent, fmt.Errorf("torrent: %s not cached", torrent.Name)
|
||||||
|
}
|
||||||
|
// Break out of the loop if the torrent is downloading.
|
||||||
|
// This is necessary to prevent infinite loop since we moved to sync downloading and async processing
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
return torrent, fmt.Errorf("torrent: %s has error", torrent.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
return torrent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ad *AllDebrid) DeleteTorrent(torrent *torrent.Torrent) {
|
||||||
|
url := fmt.Sprintf("%s/magnet/delete?id=%s", ad.Host, torrent.Id)
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
_, err := ad.client.MakeRequest(req)
|
||||||
|
if err == nil {
|
||||||
|
ad.logger.Info().Msgf("Torrent: %s deleted", torrent.Name)
|
||||||
|
} else {
|
||||||
|
ad.logger.Info().Msgf("Error deleting torrent: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ad *AllDebrid) GetDownloadLinks(t *torrent.Torrent) error {
|
||||||
|
downloadLinks := make(map[string]torrent.DownloadLinks)
|
||||||
|
for _, file := range t.Files {
|
||||||
|
url := fmt.Sprintf("%s/link/unlock", ad.Host)
|
||||||
|
query := gourl.Values{}
|
||||||
|
query.Add("link", file.Link)
|
||||||
|
url += "?" + query.Encode()
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
resp, err := ad.client.MakeRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var data DownloadLink
|
||||||
|
if err = json.Unmarshal(resp, &data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
link := data.Data.Link
|
||||||
|
|
||||||
|
dl := torrent.DownloadLinks{
|
||||||
|
Link: file.Link,
|
||||||
|
Filename: data.Data.Filename,
|
||||||
|
DownloadLink: link,
|
||||||
|
}
|
||||||
|
downloadLinks[file.Id] = dl
|
||||||
|
}
|
||||||
|
t.DownloadLinks = downloadLinks
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ad *AllDebrid) GetDownloadLink(t *torrent.Torrent, file *torrent.File) *torrent.DownloadLinks {
|
||||||
|
url := fmt.Sprintf("%s/link/unlock", ad.Host)
|
||||||
|
query := gourl.Values{}
|
||||||
|
query.Add("link", file.Link)
|
||||||
|
url += "?" + query.Encode()
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
resp, err := ad.client.MakeRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var data DownloadLink
|
||||||
|
if err = json.Unmarshal(resp, &data); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
link := data.Data.Link
|
||||||
|
return &torrent.DownloadLinks{
|
||||||
|
DownloadLink: link,
|
||||||
|
Link: file.Link,
|
||||||
|
Filename: data.Data.Filename,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ad *AllDebrid) GetCheckCached() bool {
|
||||||
|
return ad.CheckCached
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ad *AllDebrid) GetTorrents() ([]*torrent.Torrent, error) {
|
||||||
|
return nil, fmt.Errorf("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(dc config.Debrid, cache *common.Cache) *AllDebrid {
|
||||||
|
rl := request.ParseRateLimit(dc.RateLimit)
|
||||||
|
headers := map[string]string{
|
||||||
|
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||||
|
}
|
||||||
|
client := request.NewRLHTTPClient(rl, headers)
|
||||||
|
return &AllDebrid{
|
||||||
|
Name: "alldebrid",
|
||||||
|
Host: dc.Host,
|
||||||
|
APIKey: dc.APIKey,
|
||||||
|
DownloadUncached: dc.DownloadUncached,
|
||||||
|
client: client,
|
||||||
|
cache: cache,
|
||||||
|
MountPath: dc.Folder,
|
||||||
|
logger: logger.NewLogger(dc.Name, config.GetConfig().LogLevel, os.Stdout),
|
||||||
|
CheckCached: dc.CheckCached,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package alldebrid
|
||||||
|
|
||||||
|
type errorResponse struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MagnetFile struct {
|
||||||
|
Name string `json:"n"`
|
||||||
|
Size int64 `json:"s"`
|
||||||
|
Link string `json:"l"`
|
||||||
|
Elements []MagnetFile `json:"e"`
|
||||||
|
}
|
||||||
|
type magnetInfo struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StatusCode int `json:"statusCode"`
|
||||||
|
UploadDate int `json:"uploadDate"`
|
||||||
|
Downloaded int64 `json:"downloaded"`
|
||||||
|
Uploaded int64 `json:"uploaded"`
|
||||||
|
DownloadSpeed int64 `json:"downloadSpeed"`
|
||||||
|
UploadSpeed int64 `json:"uploadSpeed"`
|
||||||
|
Seeders int `json:"seeders"`
|
||||||
|
CompletionDate int `json:"completionDate"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Notified bool `json:"notified"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
NbLinks int `json:"nbLinks"`
|
||||||
|
Files []MagnetFile `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TorrentInfoResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Data struct {
|
||||||
|
Magnets magnetInfo `json:"magnets"`
|
||||||
|
} `json:"data"`
|
||||||
|
Error *errorResponse `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadMagnetResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Data struct {
|
||||||
|
Magnets []struct {
|
||||||
|
Magnet string `json:"magnet"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
FilenameOriginal string `json:"filename_original"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
} `json:"magnets"`
|
||||||
|
}
|
||||||
|
Error *errorResponse `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadLink struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Data struct {
|
||||||
|
Link string `json:"link"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Streaming []interface{} `json:"streaming"`
|
||||||
|
Paws bool `json:"paws"`
|
||||||
|
Filesize int `json:"filesize"`
|
||||||
|
Id string `json:"id"`
|
||||||
|
Path []struct {
|
||||||
|
Name string `json:"n"`
|
||||||
|
Size int `json:"s"`
|
||||||
|
} `json:"path"`
|
||||||
|
} `json:"data"`
|
||||||
|
Error *errorResponse `json:"error"`
|
||||||
|
}
|
||||||
Vendored
+360
@@ -0,0 +1,360 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/engine"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DownloadLinkCache struct {
|
||||||
|
Link string `json:"download_link"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CachedTorrent struct {
|
||||||
|
*torrent.Torrent
|
||||||
|
LastRead time.Time `json:"last_read"`
|
||||||
|
IsComplete bool `json:"is_complete"`
|
||||||
|
DownloadLinks map[string]DownloadLinkCache `json:"download_links"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_logInstance zerolog.Logger
|
||||||
|
once sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func getLogger() zerolog.Logger {
|
||||||
|
once.Do(func() {
|
||||||
|
_logInstance = logger.NewLogger("cache", "info", os.Stdout)
|
||||||
|
})
|
||||||
|
return _logInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cache struct {
|
||||||
|
dir string
|
||||||
|
client engine.Service
|
||||||
|
torrents *sync.Map // key: torrent.Id, value: *CachedTorrent
|
||||||
|
torrentsNames *sync.Map // key: torrent.Name, value: torrent.Id
|
||||||
|
LastUpdated time.Time `json:"last_updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Manager struct {
|
||||||
|
caches map[string]*Cache
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager(debridService *engine.Engine) *Manager {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
cm := &Manager{
|
||||||
|
caches: make(map[string]*Cache),
|
||||||
|
}
|
||||||
|
for _, debrid := range debridService.GetDebrids() {
|
||||||
|
c := New(debrid, cfg.Path)
|
||||||
|
cm.caches[debrid.GetName()] = c
|
||||||
|
}
|
||||||
|
return cm
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) GetCaches() map[string]*Cache {
|
||||||
|
return m.caches
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) GetCache(debridName string) *Cache {
|
||||||
|
return m.caches[debridName]
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(debridService engine.Service, basePath string) *Cache {
|
||||||
|
return &Cache{
|
||||||
|
dir: filepath.Join(basePath, "cache", debridService.GetName(), "torrents"),
|
||||||
|
torrents: &sync.Map{},
|
||||||
|
torrentsNames: &sync.Map{},
|
||||||
|
client: debridService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) Start() error {
|
||||||
|
_logger := getLogger()
|
||||||
|
_logger.Info().Msg("Starting cache for: " + c.client.GetName())
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
return fmt.Errorf("failed to load cache: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Sync(); err != nil {
|
||||||
|
return fmt.Errorf("failed to sync cache: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) Load() error {
|
||||||
|
_logger := getLogger()
|
||||||
|
|
||||||
|
if err := os.MkdirAll(c.dir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := os.ReadDir(c.dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read cache directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if file.IsDir() || filepath.Ext(file.Name()) != ".json" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join(c.dir, file.Name())
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
_logger.Debug().Err(err).Msgf("Failed to read file: %s", filePath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var ct CachedTorrent
|
||||||
|
if err := json.Unmarshal(data, &ct); err != nil {
|
||||||
|
_logger.Debug().Err(err).Msgf("Failed to unmarshal file: %s", filePath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(ct.Files) > 0 {
|
||||||
|
c.torrents.Store(ct.Torrent.Id, &ct)
|
||||||
|
c.torrentsNames.Store(ct.Torrent.Name, ct.Torrent.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) GetTorrent(id string) *CachedTorrent {
|
||||||
|
if value, ok := c.torrents.Load(id); ok {
|
||||||
|
return value.(*CachedTorrent)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) GetTorrentByName(name string) *CachedTorrent {
|
||||||
|
if id, ok := c.torrentsNames.Load(name); ok {
|
||||||
|
return c.GetTorrent(id.(string))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) SaveTorrent(ct *CachedTorrent) error {
|
||||||
|
data, err := json.MarshalIndent(ct, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal torrent: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := ct.Torrent.Id + ".json"
|
||||||
|
filePath := filepath.Join(c.dir, fileName)
|
||||||
|
tmpFile := filePath + ".tmp"
|
||||||
|
|
||||||
|
f, err := os.Create(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temp file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
w := bufio.NewWriter(f)
|
||||||
|
if _, err := w.Write(data); err != nil {
|
||||||
|
return fmt.Errorf("failed to write data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.Flush(); err != nil {
|
||||||
|
return fmt.Errorf("failed to flush data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.Rename(tmpFile, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) SaveAll() error {
|
||||||
|
const batchSize = 100
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
_logger := getLogger()
|
||||||
|
|
||||||
|
tasks := make(chan *CachedTorrent, batchSize)
|
||||||
|
|
||||||
|
for i := 0; i < runtime.NumCPU(); i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for ct := range tasks {
|
||||||
|
if err := c.SaveTorrent(ct); err != nil {
|
||||||
|
_logger.Error().Err(err).Msg("failed to save torrent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.torrents.Range(func(_, value interface{}) bool {
|
||||||
|
tasks <- value.(*CachedTorrent)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
close(tasks)
|
||||||
|
wg.Wait()
|
||||||
|
c.LastUpdated = time.Now()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) Sync() error {
|
||||||
|
_logger := getLogger()
|
||||||
|
torrents, err := c.client.GetTorrents()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to sync torrents: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
workers := runtime.NumCPU() * 200
|
||||||
|
workChan := make(chan *torrent.Torrent, len(torrents))
|
||||||
|
errChan := make(chan error, len(torrents))
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for t := range workChan {
|
||||||
|
if err := c.processTorrent(t); err != nil {
|
||||||
|
errChan <- err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range torrents {
|
||||||
|
workChan <- t
|
||||||
|
}
|
||||||
|
close(workChan)
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(errChan)
|
||||||
|
|
||||||
|
for err := range errChan {
|
||||||
|
_logger.Error().Err(err).Msg("sync error")
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.Info().Msgf("Synced %d torrents", len(torrents))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) processTorrent(t *torrent.Torrent) error {
|
||||||
|
if existing, ok := c.torrents.Load(t.Id); ok {
|
||||||
|
ct := existing.(*CachedTorrent)
|
||||||
|
if ct.IsComplete {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.AddTorrent(t)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) AddTorrent(t *torrent.Torrent) {
|
||||||
|
_logger := getLogger()
|
||||||
|
|
||||||
|
if len(t.Files) == 0 {
|
||||||
|
tNew, err := c.client.GetTorrent(t.Id)
|
||||||
|
_logger.Debug().Msgf("Getting torrent files for %s", t.Id)
|
||||||
|
if err != nil {
|
||||||
|
_logger.Debug().Msgf("Failed to get torrent files for %s: %v", t.Id, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t = tNew
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(t.Files) == 0 {
|
||||||
|
_logger.Debug().Msgf("No files found for %s", t.Id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ct := &CachedTorrent{
|
||||||
|
Torrent: t,
|
||||||
|
LastRead: time.Now(),
|
||||||
|
IsComplete: len(t.Files) > 0,
|
||||||
|
DownloadLinks: make(map[string]DownloadLinkCache),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.torrents.Store(t.Id, ct)
|
||||||
|
c.torrentsNames.Store(t.Name, t.Id)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := c.SaveTorrent(ct); err != nil {
|
||||||
|
_logger.Debug().Err(err).Msgf("Failed to save torrent %s", t.Id)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) RefreshTorrent(torrentId string) *CachedTorrent {
|
||||||
|
_logger := getLogger()
|
||||||
|
|
||||||
|
t, err := c.client.GetTorrent(torrentId)
|
||||||
|
if err != nil {
|
||||||
|
_logger.Debug().Msgf("Failed to get torrent files for %s: %v", torrentId, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(t.Files) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ct := &CachedTorrent{
|
||||||
|
Torrent: t,
|
||||||
|
LastRead: time.Now(),
|
||||||
|
IsComplete: len(t.Files) > 0,
|
||||||
|
DownloadLinks: make(map[string]DownloadLinkCache),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.torrents.Store(t.Id, ct)
|
||||||
|
c.torrentsNames.Store(t.Name, t.Id)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := c.SaveTorrent(ct); err != nil {
|
||||||
|
_logger.Debug().Err(err).Msgf("Failed to save torrent %s", t.Id)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ct
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) GetFileDownloadLink(t *CachedTorrent, file *torrent.File) (string, error) {
|
||||||
|
_logger := getLogger()
|
||||||
|
|
||||||
|
if linkCache, ok := t.DownloadLinks[file.Id]; ok {
|
||||||
|
return linkCache.Link, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if file.Link == "" {
|
||||||
|
t = c.RefreshTorrent(t.Id)
|
||||||
|
if t == nil {
|
||||||
|
return "", fmt.Errorf("torrent not found")
|
||||||
|
}
|
||||||
|
file = t.Torrent.GetFile(file.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.Debug().Msgf("Getting download link for %s", t.Name)
|
||||||
|
link := c.client.GetDownloadLink(t.Torrent, file)
|
||||||
|
if link == nil {
|
||||||
|
return "", fmt.Errorf("download link not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.DownloadLinks[file.Id] = DownloadLinkCache{
|
||||||
|
Link: link.DownloadLink,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := c.SaveTorrent(t); err != nil {
|
||||||
|
_logger.Debug().Err(err).Msgf("Failed to save torrent %s", t.Id)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return link.DownloadLink, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) GetTorrents() *sync.Map {
|
||||||
|
return c.torrents
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
package debrid_link
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/common"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/request"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||||
|
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DebridLink struct {
|
||||||
|
Name string
|
||||||
|
Host string `json:"host"`
|
||||||
|
APIKey string
|
||||||
|
DownloadUncached bool
|
||||||
|
client *request.RLHTTPClient
|
||||||
|
cache *common.Cache
|
||||||
|
MountPath string
|
||||||
|
logger zerolog.Logger
|
||||||
|
CheckCached bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dl *DebridLink) GetMountPath() string {
|
||||||
|
return dl.MountPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dl *DebridLink) GetName() string {
|
||||||
|
return dl.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dl *DebridLink) GetLogger() zerolog.Logger {
|
||||||
|
return dl.logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dl *DebridLink) IsAvailable(infohashes []string) map[string]bool {
|
||||||
|
// Check if the infohashes are available in the local cache
|
||||||
|
hashes, result := torrent.GetLocalCache(infohashes, dl.cache)
|
||||||
|
|
||||||
|
if len(hashes) == 0 {
|
||||||
|
// Either all the infohashes are locally cached or none are
|
||||||
|
dl.cache.AddMultiple(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Divide hashes into groups of 100
|
||||||
|
for i := 0; i < len(hashes); i += 100 {
|
||||||
|
end := i + 100
|
||||||
|
if end > len(hashes) {
|
||||||
|
end = len(hashes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out empty strings
|
||||||
|
validHashes := make([]string, 0, end-i)
|
||||||
|
for _, hash := range hashes[i:end] {
|
||||||
|
if hash != "" {
|
||||||
|
validHashes = append(validHashes, hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no valid hashes in this batch, continue to the next batch
|
||||||
|
if len(validHashes) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
hashStr := strings.Join(validHashes, ",")
|
||||||
|
url := fmt.Sprintf("%s/seedbox/cached/%s", dl.Host, hashStr)
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
resp, err := dl.client.MakeRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
dl.logger.Info().Msgf("Error checking availability: %v", err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
var data AvailableResponse
|
||||||
|
err = json.Unmarshal(resp, &data)
|
||||||
|
if err != nil {
|
||||||
|
dl.logger.Info().Msgf("Error marshalling availability: %v", err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if data.Value == nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
value := *data.Value
|
||||||
|
for _, h := range hashes[i:end] {
|
||||||
|
_, exists := value[h]
|
||||||
|
if exists {
|
||||||
|
result[h] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dl.cache.AddMultiple(result) // Add the results to the cache
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dl *DebridLink) GetTorrent(id string) (*torrent.Torrent, error) {
|
||||||
|
t := &torrent.Torrent{}
|
||||||
|
url := fmt.Sprintf("%s/seedbox/list?ids=%s", dl.Host, id)
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
resp, err := dl.client.MakeRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
var res TorrentInfo
|
||||||
|
err = json.Unmarshal(resp, &res)
|
||||||
|
if err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
if res.Success == false {
|
||||||
|
return t, fmt.Errorf("error getting torrent")
|
||||||
|
}
|
||||||
|
if res.Value == nil {
|
||||||
|
return t, fmt.Errorf("torrent not found")
|
||||||
|
}
|
||||||
|
dt := *res.Value
|
||||||
|
|
||||||
|
if len(dt) == 0 {
|
||||||
|
return t, fmt.Errorf("torrent not found")
|
||||||
|
}
|
||||||
|
data := dt[0]
|
||||||
|
status := "downloading"
|
||||||
|
if data.Status == 100 {
|
||||||
|
status = "downloaded"
|
||||||
|
}
|
||||||
|
name := common.RemoveInvalidChars(data.Name)
|
||||||
|
t.Id = data.ID
|
||||||
|
t.Name = name
|
||||||
|
t.Bytes = data.TotalSize
|
||||||
|
t.Folder = name
|
||||||
|
t.Progress = data.DownloadPercent
|
||||||
|
t.Status = status
|
||||||
|
t.Speed = data.DownloadSpeed
|
||||||
|
t.Seeders = data.PeersConnected
|
||||||
|
t.Filename = name
|
||||||
|
t.OriginalFilename = name
|
||||||
|
files := make([]torrent.File, len(data.Files))
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
for i, f := range data.Files {
|
||||||
|
if !cfg.IsSizeAllowed(f.Size) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files[i] = torrent.File{
|
||||||
|
Id: f.ID,
|
||||||
|
Name: f.Name,
|
||||||
|
Size: f.Size,
|
||||||
|
Path: f.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Files = files
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dl *DebridLink) SubmitMagnet(t *torrent.Torrent) (*torrent.Torrent, error) {
|
||||||
|
url := fmt.Sprintf("%s/seedbox/add", dl.Host)
|
||||||
|
payload := map[string]string{"url": t.Magnet.Link}
|
||||||
|
jsonPayload, _ := json.Marshal(payload)
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonPayload))
|
||||||
|
resp, err := dl.client.MakeRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var res SubmitTorrentInfo
|
||||||
|
err = json.Unmarshal(resp, &res)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if res.Success == false || res.Value == nil {
|
||||||
|
return nil, fmt.Errorf("error adding torrent")
|
||||||
|
}
|
||||||
|
data := *res.Value
|
||||||
|
status := "downloading"
|
||||||
|
log.Printf("Torrent: %s added with id: %s", t.Name, data.ID)
|
||||||
|
name := common.RemoveInvalidChars(data.Name)
|
||||||
|
t.Id = data.ID
|
||||||
|
t.Name = name
|
||||||
|
t.Bytes = data.TotalSize
|
||||||
|
t.Folder = name
|
||||||
|
t.Progress = data.DownloadPercent
|
||||||
|
t.Status = status
|
||||||
|
t.Speed = data.DownloadSpeed
|
||||||
|
t.Seeders = data.PeersConnected
|
||||||
|
t.Filename = name
|
||||||
|
t.OriginalFilename = name
|
||||||
|
t.MountPath = dl.MountPath
|
||||||
|
t.Debrid = dl.Name
|
||||||
|
t.DownloadLinks = make(map[string]torrent.DownloadLinks)
|
||||||
|
files := make([]torrent.File, len(data.Files))
|
||||||
|
for i, f := range data.Files {
|
||||||
|
files[i] = torrent.File{
|
||||||
|
Id: f.ID,
|
||||||
|
Name: f.Name,
|
||||||
|
Size: f.Size,
|
||||||
|
Path: f.Name,
|
||||||
|
Link: f.DownloadURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Files = files
|
||||||
|
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dl *DebridLink) CheckStatus(torrent *torrent.Torrent, isSymlink bool) (*torrent.Torrent, error) {
|
||||||
|
for {
|
||||||
|
t, err := dl.GetTorrent(torrent.Id)
|
||||||
|
torrent = t
|
||||||
|
if err != nil || torrent == nil {
|
||||||
|
return torrent, err
|
||||||
|
}
|
||||||
|
status := torrent.Status
|
||||||
|
if status == "downloaded" {
|
||||||
|
dl.logger.Info().Msgf("Torrent: %s downloaded", torrent.Name)
|
||||||
|
err = dl.GetDownloadLinks(torrent)
|
||||||
|
if err != nil {
|
||||||
|
return torrent, err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
} else if status == "downloading" {
|
||||||
|
if !dl.DownloadUncached {
|
||||||
|
go dl.DeleteTorrent(torrent)
|
||||||
|
return torrent, fmt.Errorf("torrent: %s not cached", torrent.Name)
|
||||||
|
}
|
||||||
|
// Break out of the loop if the torrent is downloading.
|
||||||
|
// This is necessary to prevent infinite loop since we moved to sync downloading and async processing
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
return torrent, fmt.Errorf("torrent: %s has error", torrent.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
return torrent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dl *DebridLink) DeleteTorrent(torrent *torrent.Torrent) {
|
||||||
|
url := fmt.Sprintf("%s/seedbox/%s/remove", dl.Host, torrent.Id)
|
||||||
|
req, _ := http.NewRequest(http.MethodDelete, url, nil)
|
||||||
|
_, err := dl.client.MakeRequest(req)
|
||||||
|
if err == nil {
|
||||||
|
dl.logger.Info().Msgf("Torrent: %s deleted", torrent.Name)
|
||||||
|
} else {
|
||||||
|
dl.logger.Info().Msgf("Error deleting torrent: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dl *DebridLink) GetDownloadLinks(t *torrent.Torrent) error {
|
||||||
|
downloadLinks := make(map[string]torrent.DownloadLinks)
|
||||||
|
for _, f := range t.Files {
|
||||||
|
dl := torrent.DownloadLinks{
|
||||||
|
Link: f.Link,
|
||||||
|
Filename: f.Name,
|
||||||
|
}
|
||||||
|
downloadLinks[f.Id] = dl
|
||||||
|
}
|
||||||
|
t.DownloadLinks = downloadLinks
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dl *DebridLink) GetDownloadLink(t *torrent.Torrent, file *torrent.File) *torrent.DownloadLinks {
|
||||||
|
dlLink, ok := t.DownloadLinks[file.Id]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &dlLink
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dl *DebridLink) GetCheckCached() bool {
|
||||||
|
return dl.CheckCached
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(dc config.Debrid, cache *common.Cache) *DebridLink {
|
||||||
|
rl := request.ParseRateLimit(dc.RateLimit)
|
||||||
|
headers := map[string]string{
|
||||||
|
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
client := request.NewRLHTTPClient(rl, headers)
|
||||||
|
return &DebridLink{
|
||||||
|
Name: "debridlink",
|
||||||
|
Host: dc.Host,
|
||||||
|
APIKey: dc.APIKey,
|
||||||
|
DownloadUncached: dc.DownloadUncached,
|
||||||
|
client: client,
|
||||||
|
cache: cache,
|
||||||
|
MountPath: dc.Folder,
|
||||||
|
logger: logger.NewLogger(dc.Name, config.GetConfig().LogLevel, os.Stdout),
|
||||||
|
CheckCached: dc.CheckCached,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dl *DebridLink) GetTorrents() ([]*torrent.Torrent, error) {
|
||||||
|
return nil, fmt.Errorf("not implemented")
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package debrid_link
|
||||||
|
|
||||||
|
type APIResponse[T any] struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Value *T `json:"value"` // Use pointer to allow nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type AvailableResponse APIResponse[map[string]map[string]struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
HashString string `json:"hashString"`
|
||||||
|
Files []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
} `json:"files"`
|
||||||
|
}]
|
||||||
|
|
||||||
|
type debridLinkTorrentInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
HashString string `json:"hashString"`
|
||||||
|
UploadRatio float64 `json:"uploadRatio"`
|
||||||
|
ServerID string `json:"serverId"`
|
||||||
|
Wait bool `json:"wait"`
|
||||||
|
PeersConnected int `json:"peersConnected"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
TotalSize int64 `json:"totalSize"`
|
||||||
|
Files []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DownloadURL string `json:"downloadUrl"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
DownloadPercent int `json:"downloadPercent"`
|
||||||
|
} `json:"files"`
|
||||||
|
Trackers []struct {
|
||||||
|
Announce string `json:"announce"`
|
||||||
|
} `json:"trackers"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
DownloadPercent float64 `json:"downloadPercent"`
|
||||||
|
DownloadSpeed int64 `json:"downloadSpeed"`
|
||||||
|
UploadSpeed int64 `json:"uploadSpeed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TorrentInfo APIResponse[[]debridLinkTorrentInfo]
|
||||||
|
|
||||||
|
type SubmitTorrentInfo APIResponse[debridLinkTorrentInfo]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
package debrid
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
type Engine struct {
|
||||||
|
Debrids []Service
|
||||||
|
LastUsed int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Engine) Get() Service {
|
||||||
|
if d.LastUsed == 0 {
|
||||||
|
return d.Debrids[0]
|
||||||
|
}
|
||||||
|
return d.Debrids[d.LastUsed]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Engine) GetByName(name string) Service {
|
||||||
|
for _, deb := range d.Debrids {
|
||||||
|
if deb.GetName() == name {
|
||||||
|
return deb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Engine) GetDebrids() []Service {
|
||||||
|
return d.Debrids
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service interface {
|
||||||
|
SubmitMagnet(tr *torrent.Torrent) (*torrent.Torrent, error)
|
||||||
|
CheckStatus(tr *torrent.Torrent, isSymlink bool) (*torrent.Torrent, error)
|
||||||
|
GetDownloadLinks(tr *torrent.Torrent) error
|
||||||
|
GetDownloadLink(tr *torrent.Torrent, file *torrent.File) *torrent.DownloadLinks
|
||||||
|
DeleteTorrent(tr *torrent.Torrent)
|
||||||
|
IsAvailable(infohashes []string) map[string]bool
|
||||||
|
GetMountPath() string
|
||||||
|
GetCheckCached() bool
|
||||||
|
GetTorrent(id string) (*torrent.Torrent, error)
|
||||||
|
GetTorrents() ([]*torrent.Torrent, error)
|
||||||
|
GetName() string
|
||||||
|
GetLogger() zerolog.Logger
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
package realdebrid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/common"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/request"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/utils"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||||
|
"net/http"
|
||||||
|
gourl "net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RealDebrid struct {
|
||||||
|
Name string
|
||||||
|
Host string `json:"host"`
|
||||||
|
APIKey string
|
||||||
|
DownloadUncached bool
|
||||||
|
client *request.RLHTTPClient
|
||||||
|
cache *common.Cache
|
||||||
|
MountPath string
|
||||||
|
logger zerolog.Logger
|
||||||
|
CheckCached bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RealDebrid) GetMountPath() string {
|
||||||
|
return r.MountPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RealDebrid) GetName() string {
|
||||||
|
return r.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RealDebrid) GetLogger() zerolog.Logger {
|
||||||
|
return r.logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTorrentFiles returns a list of torrent files from the torrent info
|
||||||
|
// validate is used to determine if the files should be validated
|
||||||
|
// if validate is false, selected files will be returned
|
||||||
|
func GetTorrentFiles(data TorrentInfo, validate bool) []torrent.File {
|
||||||
|
files := make([]torrent.File, 0)
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
idx := 0
|
||||||
|
for _, f := range data.Files {
|
||||||
|
|
||||||
|
name := filepath.Base(f.Path)
|
||||||
|
|
||||||
|
if validate {
|
||||||
|
if utils.RegexMatch(utils.SAMPLEMATCH, name) {
|
||||||
|
// Skip sample files
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !cfg.IsAllowedFile(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !cfg.IsSizeAllowed(f.Bytes) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if f.Selected == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileId := f.ID
|
||||||
|
_link := ""
|
||||||
|
if len(data.Links) > idx {
|
||||||
|
_link = data.Links[idx]
|
||||||
|
}
|
||||||
|
file := torrent.File{
|
||||||
|
Name: name,
|
||||||
|
Path: name,
|
||||||
|
Size: f.Bytes,
|
||||||
|
Id: strconv.Itoa(fileId),
|
||||||
|
Link: _link,
|
||||||
|
}
|
||||||
|
files = append(files, file)
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RealDebrid) IsAvailable(infohashes []string) map[string]bool {
|
||||||
|
// Check if the infohashes are available in the local cache
|
||||||
|
hashes, result := torrent.GetLocalCache(infohashes, r.cache)
|
||||||
|
|
||||||
|
if len(hashes) == 0 {
|
||||||
|
// Either all the infohashes are locally cached or none are
|
||||||
|
r.cache.AddMultiple(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Divide hashes into groups of 100
|
||||||
|
for i := 0; i < len(hashes); i += 200 {
|
||||||
|
end := i + 200
|
||||||
|
if end > len(hashes) {
|
||||||
|
end = len(hashes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out empty strings
|
||||||
|
validHashes := make([]string, 0, end-i)
|
||||||
|
for _, hash := range hashes[i:end] {
|
||||||
|
if hash != "" {
|
||||||
|
validHashes = append(validHashes, hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no valid hashes in this batch, continue to the next batch
|
||||||
|
if len(validHashes) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
hashStr := strings.Join(validHashes, "/")
|
||||||
|
url := fmt.Sprintf("%s/torrents/instantAvailability/%s", r.Host, hashStr)
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
resp, err := r.client.MakeRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Info().Msgf("Error checking availability: %v", err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
var data AvailabilityResponse
|
||||||
|
err = json.Unmarshal(resp, &data)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Info().Msgf("Error marshalling availability: %v", err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
for _, h := range hashes[i:end] {
|
||||||
|
hosters, exists := data[strings.ToLower(h)]
|
||||||
|
if exists && len(hosters.Rd) > 0 {
|
||||||
|
result[h] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.cache.AddMultiple(result) // Add the results to the cache
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RealDebrid) SubmitMagnet(t *torrent.Torrent) (*torrent.Torrent, error) {
|
||||||
|
url := fmt.Sprintf("%s/torrents/addMagnet", r.Host)
|
||||||
|
payload := gourl.Values{
|
||||||
|
"magnet": {t.Magnet.Link},
|
||||||
|
}
|
||||||
|
var data AddMagnetSchema
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
|
||||||
|
resp, err := r.client.MakeRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(resp, &data)
|
||||||
|
r.logger.Info().Msgf("Torrent: %s added with id: %s", t.Name, data.Id)
|
||||||
|
t.Id = data.Id
|
||||||
|
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RealDebrid) GetTorrent(id string) (*torrent.Torrent, error) {
|
||||||
|
t := &torrent.Torrent{}
|
||||||
|
url := fmt.Sprintf("%s/torrents/info/%s", r.Host, id)
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
resp, err := r.client.MakeRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
var data TorrentInfo
|
||||||
|
err = json.Unmarshal(resp, &data)
|
||||||
|
if err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
name := common.RemoveInvalidChars(data.OriginalFilename)
|
||||||
|
t.Id = id
|
||||||
|
t.Name = name
|
||||||
|
t.Bytes = data.Bytes
|
||||||
|
t.Folder = name
|
||||||
|
t.Progress = data.Progress
|
||||||
|
t.Status = data.Status
|
||||||
|
t.Speed = data.Speed
|
||||||
|
t.Seeders = data.Seeders
|
||||||
|
t.Filename = data.Filename
|
||||||
|
t.OriginalFilename = data.OriginalFilename
|
||||||
|
t.Links = data.Links
|
||||||
|
t.MountPath = r.MountPath
|
||||||
|
t.Debrid = r.Name
|
||||||
|
t.DownloadLinks = make(map[string]torrent.DownloadLinks)
|
||||||
|
files := GetTorrentFiles(data, false) // Get selected files
|
||||||
|
t.Files = files
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RealDebrid) CheckStatus(t *torrent.Torrent, isSymlink bool) (*torrent.Torrent, error) {
|
||||||
|
url := fmt.Sprintf("%s/torrents/info/%s", r.Host, t.Id)
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
for {
|
||||||
|
resp, err := r.client.MakeRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Info().Msgf("ERROR Checking file: %v", err)
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
var data TorrentInfo
|
||||||
|
err = json.Unmarshal(resp, &data)
|
||||||
|
status := data.Status
|
||||||
|
name := common.RemoveInvalidChars(data.OriginalFilename)
|
||||||
|
t.Name = name // Important because some magnet changes the name
|
||||||
|
t.Folder = name
|
||||||
|
t.Filename = data.Filename
|
||||||
|
t.OriginalFilename = data.OriginalFilename
|
||||||
|
t.Bytes = data.Bytes
|
||||||
|
t.Progress = data.Progress
|
||||||
|
t.Speed = data.Speed
|
||||||
|
t.Seeders = data.Seeders
|
||||||
|
t.Links = data.Links
|
||||||
|
t.Status = status
|
||||||
|
downloadingStatus := []string{"downloading", "magnet_conversion", "queued", "compressing", "uploading"}
|
||||||
|
if status == "waiting_files_selection" {
|
||||||
|
files := GetTorrentFiles(data, true) // Validate files to be selected
|
||||||
|
t.Files = files
|
||||||
|
if len(files) == 0 {
|
||||||
|
return t, fmt.Errorf("no video files found")
|
||||||
|
}
|
||||||
|
filesId := make([]string, 0)
|
||||||
|
for _, f := range files {
|
||||||
|
filesId = append(filesId, f.Id)
|
||||||
|
}
|
||||||
|
p := gourl.Values{
|
||||||
|
"files": {strings.Join(filesId, ",")},
|
||||||
|
}
|
||||||
|
payload := strings.NewReader(p.Encode())
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/torrents/selectFiles/%s", r.Host, t.Id), payload)
|
||||||
|
_, err = r.client.MakeRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
} else if status == "downloaded" {
|
||||||
|
files := GetTorrentFiles(data, false) // Get selected files
|
||||||
|
t.Files = files
|
||||||
|
r.logger.Info().Msgf("Torrent: %s downloaded to RD", t.Name)
|
||||||
|
if !isSymlink {
|
||||||
|
err = r.GetDownloadLinks(t)
|
||||||
|
if err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
} else if slices.Contains(downloadingStatus, status) {
|
||||||
|
if !r.DownloadUncached {
|
||||||
|
go r.DeleteTorrent(t)
|
||||||
|
return t, fmt.Errorf("torrent: %s not cached", t.Name)
|
||||||
|
}
|
||||||
|
// Break out of the loop if the torrent is downloading.
|
||||||
|
// This is necessary to prevent infinite loop since we moved to sync downloading and async processing
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
return t, fmt.Errorf("torrent: %s has error: %s", t.Name, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RealDebrid) DeleteTorrent(torrent *torrent.Torrent) {
|
||||||
|
url := fmt.Sprintf("%s/torrents/delete/%s", r.Host, torrent.Id)
|
||||||
|
req, _ := http.NewRequest(http.MethodDelete, url, nil)
|
||||||
|
_, err := r.client.MakeRequest(req)
|
||||||
|
if err == nil {
|
||||||
|
r.logger.Info().Msgf("Torrent: %s deleted", torrent.Name)
|
||||||
|
} else {
|
||||||
|
r.logger.Info().Msgf("Error deleting torrent: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RealDebrid) GetDownloadLinks(t *torrent.Torrent) error {
|
||||||
|
url := fmt.Sprintf("%s/unrestrict/link/", r.Host)
|
||||||
|
downloadLinks := make(map[string]torrent.DownloadLinks)
|
||||||
|
for _, f := range t.Files {
|
||||||
|
dlLink := t.DownloadLinks[f.Id]
|
||||||
|
if f.Link == "" || dlLink.DownloadLink != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload := gourl.Values{
|
||||||
|
"link": {f.Link},
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
|
||||||
|
resp, err := r.client.MakeRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var data UnrestrictResponse
|
||||||
|
if err = json.Unmarshal(resp, &data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
download := torrent.DownloadLinks{
|
||||||
|
Link: data.Link,
|
||||||
|
Filename: data.Filename,
|
||||||
|
DownloadLink: data.Download,
|
||||||
|
}
|
||||||
|
downloadLinks[f.Id] = download
|
||||||
|
}
|
||||||
|
t.DownloadLinks = downloadLinks
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RealDebrid) GetDownloadLink(t *torrent.Torrent, file *torrent.File) *torrent.DownloadLinks {
|
||||||
|
url := fmt.Sprintf("%s/unrestrict/link/", r.Host)
|
||||||
|
payload := gourl.Values{
|
||||||
|
"link": {file.Link},
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
|
||||||
|
resp, err := r.client.MakeRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var data UnrestrictResponse
|
||||||
|
if err = json.Unmarshal(resp, &data); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &torrent.DownloadLinks{
|
||||||
|
Link: data.Link,
|
||||||
|
Filename: data.Filename,
|
||||||
|
DownloadLink: data.Download,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RealDebrid) GetCheckCached() bool {
|
||||||
|
return r.CheckCached
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RealDebrid) getTorrents(offset int, limit int) ([]*torrent.Torrent, error) {
|
||||||
|
url := fmt.Sprintf("%s/torrents?limit=%d", r.Host, limit)
|
||||||
|
if offset > 0 {
|
||||||
|
url = fmt.Sprintf("%s&offset=%d", url, offset)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
resp, err := r.client.MakeRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var data []TorrentsResponse
|
||||||
|
if err = json.Unmarshal(resp, &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
torrents := make([]*torrent.Torrent, 0)
|
||||||
|
for _, t := range data {
|
||||||
|
|
||||||
|
torrents = append(torrents, &torrent.Torrent{
|
||||||
|
Id: t.Id,
|
||||||
|
Name: t.Filename,
|
||||||
|
Bytes: t.Bytes,
|
||||||
|
Progress: t.Progress,
|
||||||
|
Status: t.Status,
|
||||||
|
Filename: t.Filename,
|
||||||
|
OriginalFilename: t.Filename,
|
||||||
|
Links: t.Links,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return torrents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RealDebrid) GetTorrents() ([]*torrent.Torrent, error) {
|
||||||
|
torrents := make([]*torrent.Torrent, 0)
|
||||||
|
offset := 0
|
||||||
|
limit := 5000
|
||||||
|
for {
|
||||||
|
ts, err := r.getTorrents(offset, limit)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if len(ts) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
torrents = append(torrents, ts...)
|
||||||
|
offset = len(torrents)
|
||||||
|
}
|
||||||
|
return torrents, nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(dc config.Debrid, cache *common.Cache) *RealDebrid {
|
||||||
|
rl := request.ParseRateLimit(dc.RateLimit)
|
||||||
|
headers := map[string]string{
|
||||||
|
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||||
|
}
|
||||||
|
client := request.NewRLHTTPClient(rl, headers)
|
||||||
|
return &RealDebrid{
|
||||||
|
Name: "realdebrid",
|
||||||
|
Host: dc.Host,
|
||||||
|
APIKey: dc.APIKey,
|
||||||
|
DownloadUncached: dc.DownloadUncached,
|
||||||
|
client: client,
|
||||||
|
cache: cache,
|
||||||
|
MountPath: dc.Folder,
|
||||||
|
logger: logger.NewLogger(dc.Name, config.GetConfig().LogLevel, os.Stdout),
|
||||||
|
CheckCached: dc.CheckCached,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package realdebrid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AvailabilityResponse map[string]Hoster
|
||||||
|
|
||||||
|
func (r *AvailabilityResponse) UnmarshalJSON(data []byte) error {
|
||||||
|
// First, try to unmarshal as an object
|
||||||
|
var objectData map[string]Hoster
|
||||||
|
err := json.Unmarshal(data, &objectData)
|
||||||
|
if err == nil {
|
||||||
|
*r = objectData
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If that fails, try to unmarshal as an array
|
||||||
|
var arrayData []map[string]Hoster
|
||||||
|
err = json.Unmarshal(data, &arrayData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal as both object and array: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's an array, use the first element
|
||||||
|
if len(arrayData) > 0 {
|
||||||
|
*r = arrayData[0]
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's an empty array, initialize as an empty map
|
||||||
|
*r = make(map[string]Hoster)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Hoster struct {
|
||||||
|
Rd []map[string]FileVariant `json:"rd"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hoster) UnmarshalJSON(data []byte) error {
|
||||||
|
// Attempt to unmarshal into the expected structure (an object with an "rd" key)
|
||||||
|
type Alias Hoster
|
||||||
|
var obj Alias
|
||||||
|
if err := json.Unmarshal(data, &obj); err == nil {
|
||||||
|
*h = Hoster(obj)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If unmarshalling into an object fails, check if it's an empty array
|
||||||
|
var arr []interface{}
|
||||||
|
if err := json.Unmarshal(data, &arr); err == nil && len(arr) == 0 {
|
||||||
|
// It's an empty array; initialize with no entries
|
||||||
|
*h = Hoster{Rd: nil}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If both attempts fail, return an error
|
||||||
|
return fmt.Errorf("hoster: cannot unmarshal JSON data: %s", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileVariant struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Filesize int `json:"filesize"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddMagnetSchema struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Uri string `json:"uri"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TorrentInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
OriginalFilename string `json:"original_filename"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
OriginalBytes int64 `json:"original_bytes"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Split int `json:"split"`
|
||||||
|
Progress float64 `json:"progress"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Added string `json:"added"`
|
||||||
|
Files []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
Selected int `json:"selected"`
|
||||||
|
} `json:"files"`
|
||||||
|
Links []string `json:"links"`
|
||||||
|
Ended string `json:"ended,omitempty"`
|
||||||
|
Speed int64 `json:"speed,omitempty"`
|
||||||
|
Seeders int `json:"seeders,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnrestrictResponse struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
MimeType string `json:"mimeType"`
|
||||||
|
Filesize int `json:"filesize"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Chunks int `json:"chunks"`
|
||||||
|
Crc int `json:"crc"`
|
||||||
|
Download string `json:"download"`
|
||||||
|
Streamable int `json:"streamable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TorrentsResponse struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Split int64 `json:"split"`
|
||||||
|
Progress float64 `json:"progress"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Added time.Time `json:"added"`
|
||||||
|
Links []string `json:"links"`
|
||||||
|
Ended time.Time `json:"ended"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
package torbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/common"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/request"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||||
|
|
||||||
|
"log"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
gourl "net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Torbox struct {
|
||||||
|
Name string
|
||||||
|
Host string `json:"host"`
|
||||||
|
APIKey string
|
||||||
|
DownloadUncached bool
|
||||||
|
client *request.RLHTTPClient
|
||||||
|
cache *common.Cache
|
||||||
|
MountPath string
|
||||||
|
logger zerolog.Logger
|
||||||
|
CheckCached bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *Torbox) GetMountPath() string {
|
||||||
|
return tb.MountPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *Torbox) GetName() string {
|
||||||
|
return tb.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *Torbox) GetLogger() zerolog.Logger {
|
||||||
|
return tb.logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *Torbox) IsAvailable(infohashes []string) map[string]bool {
|
||||||
|
// Check if the infohashes are available in the local cache
|
||||||
|
hashes, result := torrent.GetLocalCache(infohashes, tb.cache)
|
||||||
|
|
||||||
|
if len(hashes) == 0 {
|
||||||
|
// Either all the infohashes are locally cached or none are
|
||||||
|
tb.cache.AddMultiple(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Divide hashes into groups of 100
|
||||||
|
for i := 0; i < len(hashes); i += 100 {
|
||||||
|
end := i + 100
|
||||||
|
if end > len(hashes) {
|
||||||
|
end = len(hashes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out empty strings
|
||||||
|
validHashes := make([]string, 0, end-i)
|
||||||
|
for _, hash := range hashes[i:end] {
|
||||||
|
if hash != "" {
|
||||||
|
validHashes = append(validHashes, hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no valid hashes in this batch, continue to the next batch
|
||||||
|
if len(validHashes) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
hashStr := strings.Join(validHashes, ",")
|
||||||
|
url := fmt.Sprintf("%s/api/torrents/checkcached?hash=%s", tb.Host, hashStr)
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
resp, err := tb.client.MakeRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
tb.logger.Info().Msgf("Error checking availability: %v", err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
var res AvailableResponse
|
||||||
|
err = json.Unmarshal(resp, &res)
|
||||||
|
if err != nil {
|
||||||
|
tb.logger.Info().Msgf("Error marshalling availability: %v", err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if res.Data == nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
for h, cache := range *res.Data {
|
||||||
|
if cache.Size > 0 {
|
||||||
|
result[strings.ToUpper(h)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tb.cache.AddMultiple(result) // Add the results to the cache
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *Torbox) SubmitMagnet(torrent *torrent.Torrent) (*torrent.Torrent, error) {
|
||||||
|
url := fmt.Sprintf("%s/api/torrents/createtorrent", tb.Host)
|
||||||
|
payload := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(payload)
|
||||||
|
_ = writer.WriteField("magnet", torrent.Magnet.Link)
|
||||||
|
err := writer.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, url, payload)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
resp, err := tb.client.MakeRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var data AddMagnetResponse
|
||||||
|
err = json.Unmarshal(resp, &data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if data.Data == nil {
|
||||||
|
return nil, fmt.Errorf("error adding torrent")
|
||||||
|
}
|
||||||
|
dt := *data.Data
|
||||||
|
torrentId := strconv.Itoa(dt.Id)
|
||||||
|
log.Printf("Torrent: %s added with id: %s", torrent.Name, torrentId)
|
||||||
|
torrent.Id = torrentId
|
||||||
|
|
||||||
|
return torrent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTorboxStatus(status string, finished bool) string {
|
||||||
|
if finished {
|
||||||
|
return "downloaded"
|
||||||
|
}
|
||||||
|
downloading := []string{"completed", "cached", "paused", "downloading", "uploading",
|
||||||
|
"checkingResumeData", "metaDL", "pausedUP", "queuedUP", "checkingUP",
|
||||||
|
"forcedUP", "allocating", "downloading", "metaDL", "pausedDL",
|
||||||
|
"queuedDL", "checkingDL", "forcedDL", "checkingResumeData", "moving"}
|
||||||
|
switch {
|
||||||
|
case slices.Contains(downloading, status):
|
||||||
|
return "downloading"
|
||||||
|
default:
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *Torbox) GetTorrent(id string) (*torrent.Torrent, error) {
|
||||||
|
t := &torrent.Torrent{}
|
||||||
|
url := fmt.Sprintf("%s/api/torrents/mylist/?id=%s", tb.Host, id)
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
resp, err := tb.client.MakeRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
var res InfoResponse
|
||||||
|
err = json.Unmarshal(resp, &res)
|
||||||
|
if err != nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
data := res.Data
|
||||||
|
name := data.Name
|
||||||
|
t.Id = id
|
||||||
|
t.Name = name
|
||||||
|
t.Bytes = data.Size
|
||||||
|
t.Folder = name
|
||||||
|
t.Progress = data.Progress * 100
|
||||||
|
t.Status = getTorboxStatus(data.DownloadState, data.DownloadFinished)
|
||||||
|
t.Speed = data.DownloadSpeed
|
||||||
|
t.Seeders = data.Seeds
|
||||||
|
t.Filename = name
|
||||||
|
t.OriginalFilename = name
|
||||||
|
t.MountPath = tb.MountPath
|
||||||
|
t.Debrid = tb.Name
|
||||||
|
t.DownloadLinks = make(map[string]torrent.DownloadLinks)
|
||||||
|
files := make([]torrent.File, 0)
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
for _, f := range data.Files {
|
||||||
|
fileName := filepath.Base(f.Name)
|
||||||
|
if common.RegexMatch(common.SAMPLEMATCH, fileName) {
|
||||||
|
// Skip sample files
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !cfg.IsAllowedFile(fileName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg.IsSizeAllowed(f.Size) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
file := torrent.File{
|
||||||
|
Id: strconv.Itoa(f.Id),
|
||||||
|
Name: fileName,
|
||||||
|
Size: f.Size,
|
||||||
|
Path: fileName,
|
||||||
|
}
|
||||||
|
files = append(files, file)
|
||||||
|
}
|
||||||
|
var cleanPath string
|
||||||
|
if len(files) > 0 {
|
||||||
|
cleanPath = path.Clean(data.Files[0].Name)
|
||||||
|
} else {
|
||||||
|
cleanPath = path.Clean(data.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.OriginalFilename = strings.Split(cleanPath, "/")[0]
|
||||||
|
t.Files = files
|
||||||
|
//t.Debrid = tb
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *Torbox) CheckStatus(torrent *torrent.Torrent, isSymlink bool) (*torrent.Torrent, error) {
|
||||||
|
for {
|
||||||
|
t, err := tb.GetTorrent(torrent.Id)
|
||||||
|
|
||||||
|
torrent = t
|
||||||
|
|
||||||
|
if err != nil || t == nil {
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
status := torrent.Status
|
||||||
|
if status == "downloaded" {
|
||||||
|
tb.logger.Info().Msgf("Torrent: %s downloaded", torrent.Name)
|
||||||
|
if !isSymlink {
|
||||||
|
err = tb.GetDownloadLinks(torrent)
|
||||||
|
if err != nil {
|
||||||
|
return torrent, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
} else if status == "downloading" {
|
||||||
|
if !tb.DownloadUncached {
|
||||||
|
go tb.DeleteTorrent(torrent)
|
||||||
|
return torrent, fmt.Errorf("torrent: %s not cached", torrent.Name)
|
||||||
|
}
|
||||||
|
// Break out of the loop if the torrent is downloading.
|
||||||
|
// This is necessary to prevent infinite loop since we moved to sync downloading and async processing
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
return torrent, fmt.Errorf("torrent: %s has error", torrent.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
return torrent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *Torbox) DeleteTorrent(torrent *torrent.Torrent) {
|
||||||
|
url := fmt.Sprintf("%s/api/torrents/controltorrent/%s", tb.Host, torrent.Id)
|
||||||
|
payload := map[string]string{"torrent_id": torrent.Id, "action": "Delete"}
|
||||||
|
jsonPayload, _ := json.Marshal(payload)
|
||||||
|
req, _ := http.NewRequest(http.MethodDelete, url, bytes.NewBuffer(jsonPayload))
|
||||||
|
_, err := tb.client.MakeRequest(req)
|
||||||
|
if err == nil {
|
||||||
|
tb.logger.Info().Msgf("Torrent: %s deleted", torrent.Name)
|
||||||
|
} else {
|
||||||
|
tb.logger.Info().Msgf("Error deleting torrent: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *Torbox) GetDownloadLinks(t *torrent.Torrent) error {
|
||||||
|
downloadLinks := make(map[string]torrent.DownloadLinks)
|
||||||
|
for _, file := range t.Files {
|
||||||
|
url := fmt.Sprintf("%s/api/torrents/requestdl/", tb.Host)
|
||||||
|
query := gourl.Values{}
|
||||||
|
query.Add("torrent_id", t.Id)
|
||||||
|
query.Add("token", tb.APIKey)
|
||||||
|
query.Add("file_id", file.Id)
|
||||||
|
url += "?" + query.Encode()
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
resp, err := tb.client.MakeRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var data DownloadLinksResponse
|
||||||
|
if err = json.Unmarshal(resp, &data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if data.Data == nil {
|
||||||
|
return fmt.Errorf("error getting download links")
|
||||||
|
}
|
||||||
|
idx := 0
|
||||||
|
link := *data.Data
|
||||||
|
|
||||||
|
dl := torrent.DownloadLinks{
|
||||||
|
Link: link,
|
||||||
|
Filename: t.Files[idx].Name,
|
||||||
|
DownloadLink: link,
|
||||||
|
}
|
||||||
|
downloadLinks[file.Id] = dl
|
||||||
|
}
|
||||||
|
t.DownloadLinks = downloadLinks
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *Torbox) GetDownloadLink(t *torrent.Torrent, file *torrent.File) *torrent.DownloadLinks {
|
||||||
|
url := fmt.Sprintf("%s/api/torrents/requestdl/", tb.Host)
|
||||||
|
query := gourl.Values{}
|
||||||
|
query.Add("torrent_id", t.Id)
|
||||||
|
query.Add("token", tb.APIKey)
|
||||||
|
query.Add("file_id", file.Id)
|
||||||
|
url += "?" + query.Encode()
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
resp, err := tb.client.MakeRequest(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var data DownloadLinksResponse
|
||||||
|
if err = json.Unmarshal(resp, &data); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if data.Data == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
link := *data.Data
|
||||||
|
return &torrent.DownloadLinks{
|
||||||
|
Link: file.Link,
|
||||||
|
Filename: file.Name,
|
||||||
|
DownloadLink: link,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *Torbox) GetCheckCached() bool {
|
||||||
|
return tb.CheckCached
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *Torbox) GetTorrents() ([]*torrent.Torrent, error) {
|
||||||
|
return nil, fmt.Errorf("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(dc config.Debrid, cache *common.Cache) *Torbox {
|
||||||
|
rl := request.ParseRateLimit(dc.RateLimit)
|
||||||
|
headers := map[string]string{
|
||||||
|
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||||
|
}
|
||||||
|
client := request.NewRLHTTPClient(rl, headers)
|
||||||
|
return &Torbox{
|
||||||
|
Name: "torbox",
|
||||||
|
Host: dc.Host,
|
||||||
|
APIKey: dc.APIKey,
|
||||||
|
DownloadUncached: dc.DownloadUncached,
|
||||||
|
client: client,
|
||||||
|
cache: cache,
|
||||||
|
MountPath: dc.Folder,
|
||||||
|
logger: logger.NewLogger(dc.Name, config.GetConfig().LogLevel, os.Stdout),
|
||||||
|
CheckCached: dc.CheckCached,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package torbox
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type APIResponse[T any] struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error any `json:"error"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
Data *T `json:"data"` // Use pointer to allow nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type AvailableResponse APIResponse[map[string]struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
}]
|
||||||
|
|
||||||
|
type AddMagnetResponse APIResponse[struct {
|
||||||
|
Id int `json:"torrent_id"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
}]
|
||||||
|
|
||||||
|
type torboxInfo struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
AuthId string `json:"auth_id"`
|
||||||
|
Server int `json:"server"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Magnet interface{} `json:"magnet"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DownloadState string `json:"download_state"`
|
||||||
|
Seeds int `json:"seeds"`
|
||||||
|
Peers int `json:"peers"`
|
||||||
|
Ratio float64 `json:"ratio"`
|
||||||
|
Progress float64 `json:"progress"`
|
||||||
|
DownloadSpeed int64 `json:"download_speed"`
|
||||||
|
UploadSpeed int `json:"upload_speed"`
|
||||||
|
Eta int `json:"eta"`
|
||||||
|
TorrentFile bool `json:"torrent_file"`
|
||||||
|
ExpiresAt interface{} `json:"expires_at"`
|
||||||
|
DownloadPresent bool `json:"download_present"`
|
||||||
|
Files []struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
Md5 interface{} `json:"md5"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Zipped bool `json:"zipped"`
|
||||||
|
S3Path string `json:"s3_path"`
|
||||||
|
Infected bool `json:"infected"`
|
||||||
|
Mimetype string `json:"mimetype"`
|
||||||
|
ShortName string `json:"short_name"`
|
||||||
|
AbsolutePath string `json:"absolute_path"`
|
||||||
|
} `json:"files"`
|
||||||
|
DownloadPath string `json:"download_path"`
|
||||||
|
InactiveCheck int `json:"inactive_check"`
|
||||||
|
Availability int `json:"availability"`
|
||||||
|
DownloadFinished bool `json:"download_finished"`
|
||||||
|
Tracker interface{} `json:"tracker"`
|
||||||
|
TotalUploaded int `json:"total_uploaded"`
|
||||||
|
TotalDownloaded int `json:"total_downloaded"`
|
||||||
|
Cached bool `json:"cached"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
SeedTorrent bool `json:"seed_torrent"`
|
||||||
|
AllowZipped bool `json:"allow_zipped"`
|
||||||
|
LongTermSeeding bool `json:"long_term_seeding"`
|
||||||
|
TrackerMessage interface{} `json:"tracker_message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InfoResponse APIResponse[torboxInfo]
|
||||||
|
|
||||||
|
type DownloadLinksResponse APIResponse[string]
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package torrent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/common"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/utils"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Arr struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Token string `json:"-"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArrHistorySchema struct {
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"pageSize"`
|
||||||
|
SortKey string `json:"sortKey"`
|
||||||
|
SortDirection string `json:"sortDirection"`
|
||||||
|
TotalRecords int `json:"totalRecords"`
|
||||||
|
Records []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
DownloadID string `json:"downloadId"`
|
||||||
|
} `json:"records"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Torrent struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
InfoHash string `json:"info_hash"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Folder string `json:"folder"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
OriginalFilename string `json:"original_filename"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Bytes int64 `json:"bytes"` // Size of only the files that are downloaded
|
||||||
|
Magnet *utils.Magnet `json:"magnet"`
|
||||||
|
Files []File `json:"files"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Added string `json:"added"`
|
||||||
|
Progress float64 `json:"progress"`
|
||||||
|
Speed int64 `json:"speed"`
|
||||||
|
Seeders int `json:"seeders"`
|
||||||
|
Links []string `json:"links"`
|
||||||
|
DownloadLinks map[string]DownloadLinks `json:"download_links"`
|
||||||
|
MountPath string `json:"mount_path"`
|
||||||
|
|
||||||
|
Debrid string `json:"debrid"`
|
||||||
|
|
||||||
|
Arr *arr.Arr `json:"arr"`
|
||||||
|
Mu sync.Mutex `json:"-"`
|
||||||
|
SizeDownloaded int64 `json:"-"` // This is used for local download
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadLinks struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
DownloadLink string `json:"download_link"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Torrent) GetSymlinkFolder(parent string) string {
|
||||||
|
return filepath.Join(parent, t.Arr.Name, t.Folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Torrent) GetMountFolder(rClonePath string) (string, error) {
|
||||||
|
possiblePaths := []string{
|
||||||
|
t.OriginalFilename,
|
||||||
|
t.Filename,
|
||||||
|
common.RemoveExtension(t.OriginalFilename),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range possiblePaths {
|
||||||
|
_, err := os.Stat(filepath.Join(rClonePath, path))
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no path found")
|
||||||
|
}
|
||||||
|
|
||||||
|
type File struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Torrent) Cleanup(remove bool) {
|
||||||
|
if remove {
|
||||||
|
err := os.Remove(t.Filename)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Torrent) GetFile(id string) *File {
|
||||||
|
for _, f := range t.Files {
|
||||||
|
if f.Id == id {
|
||||||
|
return &f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLocalCache(infohashes []string, cache *common.Cache) ([]string, map[string]bool) {
|
||||||
|
result := make(map[string]bool)
|
||||||
|
hashes := make([]string, 0)
|
||||||
|
|
||||||
|
if len(infohashes) == 0 {
|
||||||
|
return hashes, result
|
||||||
|
}
|
||||||
|
if len(infohashes) == 1 {
|
||||||
|
if cache.Exists(infohashes[0]) {
|
||||||
|
return hashes, map[string]bool{infohashes[0]: true}
|
||||||
|
}
|
||||||
|
return infohashes, result
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedHashes := cache.GetMultiple(infohashes)
|
||||||
|
for _, h := range infohashes {
|
||||||
|
_, exists := cachedHashes[h]
|
||||||
|
if !exists {
|
||||||
|
hashes = append(hashes, h)
|
||||||
|
} else {
|
||||||
|
result[h] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return infohashes, result
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package qbit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/common"
|
||||||
|
debrid "github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/downloaders"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (q *QBit) ProcessManualFile(torrent *Torrent) (string, error) {
|
||||||
|
debridTorrent := torrent.DebridTorrent
|
||||||
|
q.logger.Info().Msgf("Downloading %d files...", len(debridTorrent.DownloadLinks))
|
||||||
|
torrentPath := common.RemoveExtension(debridTorrent.OriginalFilename)
|
||||||
|
parent := common.RemoveInvalidChars(filepath.Join(q.DownloadFolder, debridTorrent.Arr.Name, torrentPath))
|
||||||
|
err := os.MkdirAll(parent, os.ModePerm)
|
||||||
|
if err != nil {
|
||||||
|
// add previous error to the error and return
|
||||||
|
return "", fmt.Errorf("failed to create directory: %s: %v", parent, err)
|
||||||
|
}
|
||||||
|
q.downloadFiles(torrent, parent)
|
||||||
|
return torrentPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) downloadFiles(torrent *Torrent, parent string) {
|
||||||
|
debridTorrent := torrent.DebridTorrent
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
semaphore := make(chan struct{}, 5)
|
||||||
|
totalSize := int64(0)
|
||||||
|
for _, file := range debridTorrent.Files {
|
||||||
|
totalSize += file.Size
|
||||||
|
}
|
||||||
|
debridTorrent.Mu.Lock()
|
||||||
|
debridTorrent.SizeDownloaded = 0 // Reset downloaded bytes
|
||||||
|
debridTorrent.Progress = 0 // Reset progress
|
||||||
|
debridTorrent.Mu.Unlock()
|
||||||
|
client := downloaders.GetGrabClient()
|
||||||
|
progressCallback := func(downloaded int64, speed int64) {
|
||||||
|
debridTorrent.Mu.Lock()
|
||||||
|
defer debridTorrent.Mu.Unlock()
|
||||||
|
torrent.Mu.Lock()
|
||||||
|
defer torrent.Mu.Unlock()
|
||||||
|
|
||||||
|
// Update total downloaded bytes
|
||||||
|
debridTorrent.SizeDownloaded += downloaded
|
||||||
|
debridTorrent.Speed = speed
|
||||||
|
|
||||||
|
// Calculate overall progress
|
||||||
|
if totalSize > 0 {
|
||||||
|
debridTorrent.Progress = float64(debridTorrent.SizeDownloaded) / float64(totalSize) * 100
|
||||||
|
}
|
||||||
|
q.UpdateTorrentMin(torrent, debridTorrent)
|
||||||
|
}
|
||||||
|
for _, link := range debridTorrent.DownloadLinks {
|
||||||
|
if link.DownloadLink == "" {
|
||||||
|
q.logger.Info().Msgf("No download link found for %s", link.Filename)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
semaphore <- struct{}{}
|
||||||
|
go func(link debrid.DownloadLinks) {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-semaphore }()
|
||||||
|
filename := link.Filename
|
||||||
|
|
||||||
|
err := downloaders.NormalGrab(
|
||||||
|
client,
|
||||||
|
link.DownloadLink,
|
||||||
|
filepath.Join(parent, filename),
|
||||||
|
progressCallback,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
q.logger.Error().Msgf("Failed to download %s: %v", filename, err)
|
||||||
|
} else {
|
||||||
|
q.logger.Info().Msgf("Downloaded %s", filename)
|
||||||
|
}
|
||||||
|
}(link)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
q.logger.Info().Msgf("Downloaded all files for %s", debridTorrent.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) ProcessSymlink(torrent *Torrent) (string, error) {
|
||||||
|
debridTorrent := torrent.DebridTorrent
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
files := debridTorrent.Files
|
||||||
|
ready := make(chan debrid.File, len(files))
|
||||||
|
if len(files) == 0 {
|
||||||
|
return "", fmt.Errorf("no video files found")
|
||||||
|
}
|
||||||
|
q.logger.Info().Msgf("Checking %d files...", len(files))
|
||||||
|
rCloneBase := debridTorrent.MountPath
|
||||||
|
torrentPath, err := q.getTorrentPath(rCloneBase, debridTorrent) // /MyTVShow/
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get torrent path: %v", err)
|
||||||
|
}
|
||||||
|
// Fix for alldebrid
|
||||||
|
newTorrentPath := torrentPath
|
||||||
|
if newTorrentPath == "" {
|
||||||
|
// Alldebrid at times doesn't return the parent folder for single file torrents
|
||||||
|
newTorrentPath = common.RemoveExtension(debridTorrent.Name) // MyTVShow
|
||||||
|
}
|
||||||
|
torrentSymlinkPath := filepath.Join(q.DownloadFolder, debridTorrent.Arr.Name, newTorrentPath) // /mnt/symlinks/{category}/MyTVShow/
|
||||||
|
err = os.MkdirAll(torrentSymlinkPath, os.ModePerm)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create directory: %s: %v", torrentSymlinkPath, err)
|
||||||
|
}
|
||||||
|
torrentRclonePath := filepath.Join(rCloneBase, torrentPath) // leave it as is
|
||||||
|
q.logger.Debug().Msgf("Debrid torrent path: %s\nSymlink Path: %s", torrentRclonePath, torrentSymlinkPath)
|
||||||
|
for _, file := range files {
|
||||||
|
wg.Add(1)
|
||||||
|
go checkFileLoop(&wg, torrentRclonePath, file, ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(ready)
|
||||||
|
}()
|
||||||
|
|
||||||
|
for f := range ready {
|
||||||
|
q.logger.Info().Msgf("File is ready: %s", f.Path)
|
||||||
|
q.createSymLink(torrentSymlinkPath, torrentRclonePath, f)
|
||||||
|
}
|
||||||
|
return torrentPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) getTorrentPath(rclonePath string, debridTorrent *debrid.Torrent) (string, error) {
|
||||||
|
for {
|
||||||
|
q.logger.Debug().Msgf("Checking for torrent path: %s", rclonePath)
|
||||||
|
torrentPath, err := debridTorrent.GetMountFolder(rclonePath)
|
||||||
|
if err == nil {
|
||||||
|
q.logger.Debug().Msgf("Found torrent path: %s", torrentPath)
|
||||||
|
return torrentPath, err
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) createSymLink(path string, torrentMountPath string, file debrid.File) {
|
||||||
|
|
||||||
|
// Combine the directory and filename to form a full path
|
||||||
|
fullPath := filepath.Join(path, file.Name) // /mnt/symlinks/{category}/MyTVShow/MyTVShow.S01E01.720p.mkv
|
||||||
|
// Create a symbolic link if file doesn't exist
|
||||||
|
torrentFilePath := filepath.Join(torrentMountPath, file.Path) // debridFolder/MyTVShow/MyTVShow.S01E01.720p.mkv
|
||||||
|
err := os.Symlink(torrentFilePath, fullPath)
|
||||||
|
if err != nil {
|
||||||
|
q.logger.Info().Msgf("Failed to create symlink: %s: %v", fullPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
package qbit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/request"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/service"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func decodeAuthHeader(header string) (string, string, error) {
|
||||||
|
encodedTokens := strings.Split(header, " ")
|
||||||
|
if len(encodedTokens) != 2 {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
encodedToken := encodedTokens[1]
|
||||||
|
|
||||||
|
bytes, err := base64.StdEncoding.DecodeString(encodedToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
bearer := string(bytes)
|
||||||
|
|
||||||
|
colonIndex := strings.LastIndex(bearer, ":")
|
||||||
|
host := bearer[:colonIndex]
|
||||||
|
token := bearer[colonIndex+1:]
|
||||||
|
|
||||||
|
return host, token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) CategoryContext(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
category := strings.Trim(r.URL.Query().Get("category"), "")
|
||||||
|
if category == "" {
|
||||||
|
// Get from form
|
||||||
|
_ = r.ParseForm()
|
||||||
|
category = r.Form.Get("category")
|
||||||
|
if category == "" {
|
||||||
|
// Get from multipart form
|
||||||
|
_ = r.ParseMultipartForm(32 << 20)
|
||||||
|
category = r.FormValue("category")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
ctx = context.WithValue(r.Context(), "category", strings.TrimSpace(category))
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) authContext(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
host, token, err := decodeAuthHeader(r.Header.Get("Authorization"))
|
||||||
|
category := r.Context().Value("category").(string)
|
||||||
|
a := &arr.Arr{
|
||||||
|
Name: category,
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
a.Host = strings.TrimSpace(host)
|
||||||
|
a.Token = strings.TrimSpace(token)
|
||||||
|
}
|
||||||
|
svc := service.GetService()
|
||||||
|
svc.Arr.AddOrUpdate(a)
|
||||||
|
ctx := context.WithValue(r.Context(), "arr", a)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashesCtx(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_hashes := chi.URLParam(r, "hashes")
|
||||||
|
var hashes []string
|
||||||
|
if _hashes != "" {
|
||||||
|
hashes = strings.Split(_hashes, "|")
|
||||||
|
}
|
||||||
|
if hashes == nil {
|
||||||
|
// Get hashes from form
|
||||||
|
_ = r.ParseForm()
|
||||||
|
hashes = r.Form["hashes"]
|
||||||
|
}
|
||||||
|
for i, hash := range hashes {
|
||||||
|
hashes[i] = strings.TrimSpace(hash)
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), "hashes", hashes)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("Ok."))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("v4.3.2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) handleWebAPIVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte("2.7"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) handlePreferences(w http.ResponseWriter, r *http.Request) {
|
||||||
|
preferences := NewAppPreferences()
|
||||||
|
|
||||||
|
preferences.WebUiUsername = q.Username
|
||||||
|
preferences.SavePath = q.DownloadFolder
|
||||||
|
preferences.TempPath = filepath.Join(q.DownloadFolder, "temp")
|
||||||
|
|
||||||
|
request.JSONResponse(w, preferences, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) handleBuildInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
res := BuildInfo{
|
||||||
|
Bitness: 64,
|
||||||
|
Boost: "1.75.0",
|
||||||
|
Libtorrent: "1.2.11.0",
|
||||||
|
Openssl: "1.1.1i",
|
||||||
|
Qt: "5.15.2",
|
||||||
|
Zlib: "1.2.11",
|
||||||
|
}
|
||||||
|
request.JSONResponse(w, res, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) handleShutdown(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) handleTorrentsInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
//log all url params
|
||||||
|
ctx := r.Context()
|
||||||
|
category := ctx.Value("category").(string)
|
||||||
|
filter := strings.Trim(r.URL.Query().Get("filter"), "")
|
||||||
|
hashes, _ := ctx.Value("hashes").([]string)
|
||||||
|
torrents := q.Storage.GetAll(category, filter, hashes)
|
||||||
|
request.JSONResponse(w, torrents, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
// Parse form based on content type
|
||||||
|
contentType := r.Header.Get("Content-Type")
|
||||||
|
if strings.Contains(contentType, "multipart/form-data") {
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
q.logger.Info().Msgf("Error parsing multipart form: %v", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if strings.Contains(contentType, "application/x-www-form-urlencoded") {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
q.logger.Info().Msgf("Error parsing form: %v", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
http.Error(w, "Invalid content type", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isSymlink := strings.ToLower(r.FormValue("sequentialDownload")) != "true"
|
||||||
|
category := r.FormValue("category")
|
||||||
|
atleastOne := false
|
||||||
|
ctx = context.WithValue(ctx, "isSymlink", isSymlink)
|
||||||
|
|
||||||
|
// Handle magnet URLs
|
||||||
|
if urls := r.FormValue("urls"); urls != "" {
|
||||||
|
var urlList []string
|
||||||
|
for _, u := range strings.Split(urls, "\n") {
|
||||||
|
urlList = append(urlList, strings.TrimSpace(u))
|
||||||
|
}
|
||||||
|
for _, url := range urlList {
|
||||||
|
if err := q.AddMagnet(ctx, url, category); err != nil {
|
||||||
|
q.logger.Info().Msgf("Error adding magnet: %v", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
atleastOne = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle torrent files
|
||||||
|
if r.MultipartForm != nil && r.MultipartForm.File != nil {
|
||||||
|
if files := r.MultipartForm.File["torrents"]; len(files) > 0 {
|
||||||
|
for _, fileHeader := range files {
|
||||||
|
if err := q.AddTorrent(ctx, fileHeader, category); err != nil {
|
||||||
|
q.logger.Info().Msgf("Error adding torrent: %v", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
atleastOne = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !atleastOne {
|
||||||
|
http.Error(w, "No valid URLs or torrents provided", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) handleTorrentsDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
hashes, _ := ctx.Value("hashes").([]string)
|
||||||
|
if len(hashes) == 0 {
|
||||||
|
http.Error(w, "No hashes provided", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, hash := range hashes {
|
||||||
|
q.Storage.Delete(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) handleTorrentsPause(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
hashes, _ := ctx.Value("hashes").([]string)
|
||||||
|
for _, hash := range hashes {
|
||||||
|
torrent := q.Storage.Get(hash)
|
||||||
|
if torrent == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go q.PauseTorrent(torrent)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) handleTorrentsResume(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
hashes, _ := ctx.Value("hashes").([]string)
|
||||||
|
for _, hash := range hashes {
|
||||||
|
torrent := q.Storage.Get(hash)
|
||||||
|
if torrent == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go q.ResumeTorrent(torrent)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) handleTorrentRecheck(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
hashes, _ := ctx.Value("hashes").([]string)
|
||||||
|
for _, hash := range hashes {
|
||||||
|
torrent := q.Storage.Get(hash)
|
||||||
|
if torrent == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go q.RefreshTorrent(torrent)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) handleCategories(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var categories = map[string]TorrentCategory{}
|
||||||
|
for _, cat := range q.Categories {
|
||||||
|
path := filepath.Join(q.DownloadFolder, cat)
|
||||||
|
categories[cat] = TorrentCategory{
|
||||||
|
Name: cat,
|
||||||
|
SavePath: path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request.JSONResponse(w, categories, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) handleCreateCategory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := r.Form.Get("category")
|
||||||
|
if name == "" {
|
||||||
|
http.Error(w, "No name provided", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q.Categories = append(q.Categories, name)
|
||||||
|
|
||||||
|
request.JSONResponse(w, nil, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) handleTorrentProperties(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hash := r.URL.Query().Get("hash")
|
||||||
|
torrent := q.Storage.Get(hash)
|
||||||
|
properties := q.GetTorrentProperties(torrent)
|
||||||
|
request.JSONResponse(w, properties, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) handleTorrentFiles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hash := r.URL.Query().Get("hash")
|
||||||
|
torrent := q.Storage.Get(hash)
|
||||||
|
if torrent == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
files := q.GetTorrentFiles(torrent)
|
||||||
|
request.JSONResponse(w, files, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) handleSetCategory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
category := ctx.Value("category").(string)
|
||||||
|
hashes, _ := ctx.Value("hashes").([]string)
|
||||||
|
torrents := q.Storage.GetAll("", "", hashes)
|
||||||
|
for _, torrent := range torrents {
|
||||||
|
torrent.Category = category
|
||||||
|
q.Storage.AddOrUpdate(torrent)
|
||||||
|
}
|
||||||
|
request.JSONResponse(w, nil, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) handleAddTorrentTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
hashes, _ := ctx.Value("hashes").([]string)
|
||||||
|
tags := strings.Split(r.FormValue("tags"), ",")
|
||||||
|
for i, tag := range tags {
|
||||||
|
tags[i] = strings.TrimSpace(tag)
|
||||||
|
}
|
||||||
|
torrents := q.Storage.GetAll("", "", hashes)
|
||||||
|
for _, t := range torrents {
|
||||||
|
q.SetTorrentTags(t, tags)
|
||||||
|
}
|
||||||
|
request.JSONResponse(w, nil, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) handleRemoveTorrentTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
hashes, _ := ctx.Value("hashes").([]string)
|
||||||
|
tags := strings.Split(r.FormValue("tags"), ",")
|
||||||
|
for i, tag := range tags {
|
||||||
|
tags[i] = strings.TrimSpace(tag)
|
||||||
|
}
|
||||||
|
torrents := q.Storage.GetAll("", "", hashes)
|
||||||
|
for _, torrent := range torrents {
|
||||||
|
q.RemoveTorrentTags(torrent, tags)
|
||||||
|
|
||||||
|
}
|
||||||
|
request.JSONResponse(w, nil, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) handleGetTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
request.JSONResponse(w, q.Tags, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) handleCreateTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tags := strings.Split(r.FormValue("tags"), ",")
|
||||||
|
for i, tag := range tags {
|
||||||
|
tags[i] = strings.TrimSpace(tag)
|
||||||
|
}
|
||||||
|
q.AddTags(tags)
|
||||||
|
request.JSONResponse(w, nil, http.StatusOK)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package qbit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/utils"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/service"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ImportRequest struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
URI string `json:"uri"`
|
||||||
|
Arr *arr.Arr `json:"arr"`
|
||||||
|
IsSymlink bool `json:"isSymlink"`
|
||||||
|
SeriesId int `json:"series"`
|
||||||
|
Seasons []int `json:"seasons"`
|
||||||
|
Episodes []string `json:"episodes"`
|
||||||
|
|
||||||
|
Failed bool `json:"failed"`
|
||||||
|
FailedAt time.Time `json:"failedAt"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Completed bool `json:"completed"`
|
||||||
|
CompletedAt time.Time `json:"completedAt"`
|
||||||
|
Async bool `json:"async"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManualImportResponseSchema struct {
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
Queued time.Time `json:"queued"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
SendUpdatesToClient bool `json:"sendUpdatesToClient"`
|
||||||
|
UpdateScheduledTask bool `json:"updateScheduledTask"`
|
||||||
|
Id int `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewImportRequest(uri string, arr *arr.Arr, isSymlink bool) *ImportRequest {
|
||||||
|
return &ImportRequest{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
URI: uri,
|
||||||
|
Arr: arr,
|
||||||
|
Failed: false,
|
||||||
|
Completed: false,
|
||||||
|
Async: false,
|
||||||
|
IsSymlink: isSymlink,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *ImportRequest) Fail(reason string) {
|
||||||
|
i.Failed = true
|
||||||
|
i.FailedAt = time.Now()
|
||||||
|
i.Reason = reason
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *ImportRequest) Complete() {
|
||||||
|
i.Completed = true
|
||||||
|
i.CompletedAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *ImportRequest) Process(q *QBit) (err error) {
|
||||||
|
// Use this for now.
|
||||||
|
// This sends the torrent to the arr
|
||||||
|
svc := service.GetService()
|
||||||
|
magnet, err := utils.GetMagnetFromUrl(i.URI)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error parsing magnet link: %w", err)
|
||||||
|
}
|
||||||
|
torrent := CreateTorrentFromMagnet(magnet, i.Arr.Name, "manual")
|
||||||
|
debridTorrent, err := debrid.ProcessTorrent(svc.Debrid, magnet, i.Arr, i.IsSymlink)
|
||||||
|
if err != nil || debridTorrent == nil {
|
||||||
|
if debridTorrent != nil {
|
||||||
|
dbClient := service.GetDebrid().GetByName(debridTorrent.Debrid)
|
||||||
|
go dbClient.DeleteTorrent(debridTorrent)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = fmt.Errorf("failed to process torrent")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
torrent = q.UpdateTorrentMin(torrent, debridTorrent)
|
||||||
|
q.Storage.AddOrUpdate(torrent)
|
||||||
|
go q.ProcessFiles(torrent, debridTorrent, i.Arr, i.IsSymlink)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package qbit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/utils"
|
||||||
|
debrid "github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func checkFileLoop(wg *sync.WaitGroup, dir string, file debrid.File, ready chan<- debrid.File) {
|
||||||
|
defer wg.Done()
|
||||||
|
ticker := time.NewTicker(1 * time.Second) // Check every second
|
||||||
|
defer ticker.Stop()
|
||||||
|
path := filepath.Join(dir, file.Path)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
ready <- file
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateTorrentFromMagnet(magnet *utils.Magnet, category, source string) *Torrent {
|
||||||
|
torrent := &Torrent{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Hash: strings.ToLower(magnet.InfoHash),
|
||||||
|
Name: magnet.Name,
|
||||||
|
Size: magnet.Size,
|
||||||
|
Category: category,
|
||||||
|
Source: source,
|
||||||
|
State: "downloading",
|
||||||
|
MagnetUri: magnet.Link,
|
||||||
|
|
||||||
|
Tracker: "udp://tracker.opentrackr.org:1337",
|
||||||
|
UpLimit: -1,
|
||||||
|
DlLimit: -1,
|
||||||
|
AutoTmm: false,
|
||||||
|
Ratio: 1,
|
||||||
|
RatioLimit: 1,
|
||||||
|
}
|
||||||
|
return torrent
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package qbit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type QBit struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Port string `json:"port"`
|
||||||
|
DownloadFolder string `json:"download_folder"`
|
||||||
|
Categories []string `json:"categories"`
|
||||||
|
Storage *TorrentStorage
|
||||||
|
debug bool
|
||||||
|
logger zerolog.Logger
|
||||||
|
Tags []string
|
||||||
|
RefreshInterval int
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *QBit {
|
||||||
|
cfg := config.GetConfig().QBitTorrent
|
||||||
|
port := cmp.Or(cfg.Port, os.Getenv("QBIT_PORT"), "8282")
|
||||||
|
refreshInterval := cmp.Or(cfg.RefreshInterval, 10)
|
||||||
|
return &QBit{
|
||||||
|
Username: cfg.Username,
|
||||||
|
Password: cfg.Password,
|
||||||
|
Port: port,
|
||||||
|
DownloadFolder: cfg.DownloadFolder,
|
||||||
|
Categories: cfg.Categories,
|
||||||
|
Storage: NewTorrentStorage(cmp.Or(os.Getenv("TORRENT_FILE"), "/data/qbit_torrents.json")),
|
||||||
|
logger: logger.NewLogger("qbit", cfg.LogLevel, os.Stdout),
|
||||||
|
RefreshInterval: refreshInterval,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package qbit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (q *QBit) Routes() http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
if q.logger.GetLevel().String() == "debug" {
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
}
|
||||||
|
r.Use(q.CategoryContext)
|
||||||
|
r.Post("/auth/login", q.handleLogin)
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(q.authContext)
|
||||||
|
r.Route("/torrents", func(r chi.Router) {
|
||||||
|
r.Use(HashesCtx)
|
||||||
|
r.Get("/info", q.handleTorrentsInfo)
|
||||||
|
r.Post("/add", q.handleTorrentsAdd)
|
||||||
|
r.Post("/delete", q.handleTorrentsDelete)
|
||||||
|
r.Get("/categories", q.handleCategories)
|
||||||
|
r.Post("/createCategory", q.handleCreateCategory)
|
||||||
|
r.Post("/setCategory", q.handleSetCategory)
|
||||||
|
r.Post("/addTags", q.handleAddTorrentTags)
|
||||||
|
r.Post("/removeTags", q.handleRemoveTorrentTags)
|
||||||
|
r.Post("/createTags", q.handleCreateTags)
|
||||||
|
r.Get("/tags", q.handleGetTags)
|
||||||
|
r.Get("/pause", q.handleTorrentsPause)
|
||||||
|
r.Get("/resume", q.handleTorrentsResume)
|
||||||
|
r.Get("/recheck", q.handleTorrentRecheck)
|
||||||
|
r.Get("/properties", q.handleTorrentProperties)
|
||||||
|
r.Get("/files", q.handleTorrentFiles)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/app", func(r chi.Router) {
|
||||||
|
r.Get("/version", q.handleVersion)
|
||||||
|
r.Get("/webapiVersion", q.handleWebAPIVersion)
|
||||||
|
r.Get("/preferences", q.handlePreferences)
|
||||||
|
r.Get("/buildInfo", q.handleBuildInfo)
|
||||||
|
r.Get("/shutdown", q.handleShutdown)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package qbit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TorrentStorage struct {
|
||||||
|
torrents map[string]*Torrent
|
||||||
|
mu sync.RWMutex
|
||||||
|
order []string
|
||||||
|
filename string // Added to store the filename for persistence
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTorrentsFromJSON(filename string) (map[string]*Torrent, error) {
|
||||||
|
data, err := os.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
torrents := make(map[string]*Torrent)
|
||||||
|
if err := json.Unmarshal(data, &torrents); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return torrents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTorrentStorage(filename string) *TorrentStorage {
|
||||||
|
// Open the JSON file and read the data
|
||||||
|
torrents, err := loadTorrentsFromJSON(filename)
|
||||||
|
if err != nil {
|
||||||
|
torrents = make(map[string]*Torrent)
|
||||||
|
}
|
||||||
|
order := make([]string, 0, len(torrents))
|
||||||
|
for id := range torrents {
|
||||||
|
order = append(order, id)
|
||||||
|
}
|
||||||
|
// Create a new TorrentStorage
|
||||||
|
return &TorrentStorage{
|
||||||
|
torrents: torrents,
|
||||||
|
order: order,
|
||||||
|
filename: filename,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TorrentStorage) Add(torrent *Torrent) {
|
||||||
|
ts.mu.Lock()
|
||||||
|
defer ts.mu.Unlock()
|
||||||
|
ts.torrents[torrent.Hash] = torrent
|
||||||
|
ts.order = append(ts.order, torrent.Hash)
|
||||||
|
_ = ts.saveToFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TorrentStorage) AddOrUpdate(torrent *Torrent) {
|
||||||
|
ts.mu.Lock()
|
||||||
|
defer ts.mu.Unlock()
|
||||||
|
if _, exists := ts.torrents[torrent.Hash]; !exists {
|
||||||
|
ts.order = append(ts.order, torrent.Hash)
|
||||||
|
}
|
||||||
|
ts.torrents[torrent.Hash] = torrent
|
||||||
|
_ = ts.saveToFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TorrentStorage) GetByID(id string) *Torrent {
|
||||||
|
ts.mu.RLock()
|
||||||
|
defer ts.mu.RUnlock()
|
||||||
|
for _, torrent := range ts.torrents {
|
||||||
|
if torrent.ID == id {
|
||||||
|
return torrent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TorrentStorage) Get(hash string) *Torrent {
|
||||||
|
ts.mu.RLock()
|
||||||
|
defer ts.mu.RUnlock()
|
||||||
|
return ts.torrents[hash]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TorrentStorage) GetAll(category string, filter string, hashes []string) []*Torrent {
|
||||||
|
ts.mu.RLock()
|
||||||
|
defer ts.mu.RUnlock()
|
||||||
|
torrents := make([]*Torrent, 0)
|
||||||
|
for _, id := range ts.order {
|
||||||
|
torrent := ts.torrents[id]
|
||||||
|
if category != "" && torrent.Category != category {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if filter != "" && torrent.State != filter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
torrents = append(torrents, torrent)
|
||||||
|
}
|
||||||
|
if len(hashes) > 0 {
|
||||||
|
filtered := make([]*Torrent, 0, len(torrents))
|
||||||
|
for _, hash := range hashes {
|
||||||
|
if torrent := ts.torrents[hash]; torrent != nil {
|
||||||
|
filtered = append(filtered, torrent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
torrents = filtered
|
||||||
|
}
|
||||||
|
return torrents
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TorrentStorage) Update(torrent *Torrent) {
|
||||||
|
ts.mu.Lock()
|
||||||
|
defer ts.mu.Unlock()
|
||||||
|
ts.torrents[torrent.Hash] = torrent
|
||||||
|
_ = ts.saveToFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TorrentStorage) Delete(hash string) {
|
||||||
|
ts.mu.Lock()
|
||||||
|
defer ts.mu.Unlock()
|
||||||
|
torrent, exists := ts.torrents[hash]
|
||||||
|
if !exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(ts.torrents, hash)
|
||||||
|
for i, id := range ts.order {
|
||||||
|
if id == hash {
|
||||||
|
ts.order = append(ts.order[:i], ts.order[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Delete the torrent folder
|
||||||
|
if torrent.ContentPath != "" {
|
||||||
|
err := os.RemoveAll(torrent.ContentPath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = ts.saveToFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *TorrentStorage) Save() error {
|
||||||
|
ts.mu.RLock()
|
||||||
|
defer ts.mu.RUnlock()
|
||||||
|
return ts.saveToFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveToFile is a helper function to write the current state to the JSON file
|
||||||
|
func (ts *TorrentStorage) saveToFile() error {
|
||||||
|
data, err := json.MarshalIndent(ts.torrents, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(ts.filename, data, 0644)
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package qbit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/utils"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
||||||
|
db "github.com/sirrobot01/debrid-blackhole/pkg/debrid"
|
||||||
|
debrid "github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/service"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// All torrent related helpers goes here
|
||||||
|
|
||||||
|
func (q *QBit) AddMagnet(ctx context.Context, url, category string) error {
|
||||||
|
magnet, err := utils.GetMagnetFromUrl(url)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error parsing magnet link: %w", err)
|
||||||
|
}
|
||||||
|
err = q.Process(ctx, magnet, category)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to process torrent: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) AddTorrent(ctx context.Context, fileHeader *multipart.FileHeader, category string) error {
|
||||||
|
file, _ := fileHeader.Open()
|
||||||
|
defer file.Close()
|
||||||
|
var reader io.Reader = file
|
||||||
|
magnet, err := utils.GetMagnetFromFile(reader, fileHeader.Filename)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading file: %s \n %w", fileHeader.Filename, err)
|
||||||
|
}
|
||||||
|
err = q.Process(ctx, magnet, category)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to process torrent: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) Process(ctx context.Context, magnet *utils.Magnet, category string) error {
|
||||||
|
svc := service.GetService()
|
||||||
|
torrent := CreateTorrentFromMagnet(magnet, category, "auto")
|
||||||
|
a, ok := ctx.Value("arr").(*arr.Arr)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("arr not found in context")
|
||||||
|
}
|
||||||
|
isSymlink := ctx.Value("isSymlink").(bool)
|
||||||
|
debridTorrent, err := db.ProcessTorrent(svc.Debrid, magnet, a, isSymlink)
|
||||||
|
if err != nil || debridTorrent == nil {
|
||||||
|
if debridTorrent != nil {
|
||||||
|
dbClient := service.GetDebrid().GetByName(debridTorrent.Debrid)
|
||||||
|
go dbClient.DeleteTorrent(debridTorrent)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = fmt.Errorf("failed to process torrent")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
torrent = q.UpdateTorrentMin(torrent, debridTorrent)
|
||||||
|
q.Storage.AddOrUpdate(torrent)
|
||||||
|
go q.ProcessFiles(torrent, debridTorrent, a, isSymlink) // We can send async for file processing not to delay the response
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) ProcessFiles(torrent *Torrent, debridTorrent *debrid.Torrent, arr *arr.Arr, isSymlink bool) {
|
||||||
|
debridClient := service.GetDebrid().GetByName(debridTorrent.Debrid)
|
||||||
|
for debridTorrent.Status != "downloaded" {
|
||||||
|
progress := debridTorrent.Progress
|
||||||
|
q.logger.Debug().Msgf("%s -> (%s) Download Progress: %.2f%%", debridTorrent.Debrid, debridTorrent.Name, progress)
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
dbT, err := debridClient.CheckStatus(debridTorrent, isSymlink)
|
||||||
|
if err != nil {
|
||||||
|
q.logger.Error().Msgf("Error checking status: %v", err)
|
||||||
|
go debridClient.DeleteTorrent(debridTorrent)
|
||||||
|
q.MarkAsFailed(torrent)
|
||||||
|
_ = arr.Refresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
debridTorrent = dbT
|
||||||
|
torrent = q.UpdateTorrentMin(torrent, debridTorrent)
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
torrentPath string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
debridTorrent.Arr = arr
|
||||||
|
if isSymlink {
|
||||||
|
torrentPath, err = q.ProcessSymlink(torrent)
|
||||||
|
} else {
|
||||||
|
torrentPath, err = q.ProcessManualFile(torrent)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
q.MarkAsFailed(torrent)
|
||||||
|
go debridClient.DeleteTorrent(debridTorrent)
|
||||||
|
q.logger.Info().Msgf("Error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
torrent.TorrentPath = filepath.Base(torrentPath)
|
||||||
|
q.UpdateTorrent(torrent, debridTorrent)
|
||||||
|
_ = arr.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) MarkAsFailed(t *Torrent) *Torrent {
|
||||||
|
t.State = "error"
|
||||||
|
q.Storage.AddOrUpdate(t)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) UpdateTorrentMin(t *Torrent, debridTorrent *debrid.Torrent) *Torrent {
|
||||||
|
if debridTorrent == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
addedOn, err := time.Parse(time.RFC3339, debridTorrent.Added)
|
||||||
|
if err != nil {
|
||||||
|
addedOn = time.Now()
|
||||||
|
}
|
||||||
|
totalSize := debridTorrent.Bytes
|
||||||
|
progress := cmp.Or(debridTorrent.Progress, 100)
|
||||||
|
progress = progress / 100.0
|
||||||
|
sizeCompleted := int64(float64(totalSize) * progress)
|
||||||
|
|
||||||
|
var speed int64
|
||||||
|
if debridTorrent.Speed != 0 {
|
||||||
|
speed = debridTorrent.Speed
|
||||||
|
}
|
||||||
|
var eta int
|
||||||
|
if speed != 0 {
|
||||||
|
eta = int((totalSize - sizeCompleted) / speed)
|
||||||
|
}
|
||||||
|
t.ID = debridTorrent.Id
|
||||||
|
t.Name = debridTorrent.Name
|
||||||
|
t.AddedOn = addedOn.Unix()
|
||||||
|
t.DebridTorrent = debridTorrent
|
||||||
|
t.Debrid = debridTorrent.Debrid
|
||||||
|
t.Size = totalSize
|
||||||
|
t.Completed = sizeCompleted
|
||||||
|
t.Downloaded = sizeCompleted
|
||||||
|
t.DownloadedSession = sizeCompleted
|
||||||
|
t.Uploaded = sizeCompleted
|
||||||
|
t.UploadedSession = sizeCompleted
|
||||||
|
t.AmountLeft = totalSize - sizeCompleted
|
||||||
|
t.Progress = progress
|
||||||
|
t.Eta = eta
|
||||||
|
t.Dlspeed = speed
|
||||||
|
t.Upspeed = speed
|
||||||
|
t.SavePath = filepath.Join(q.DownloadFolder, t.Category) + string(os.PathSeparator)
|
||||||
|
t.ContentPath = filepath.Join(t.SavePath, t.Name) + string(os.PathSeparator)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) UpdateTorrent(t *Torrent, debridTorrent *debrid.Torrent) *Torrent {
|
||||||
|
_db := service.GetDebrid().GetByName(debridTorrent.Debrid)
|
||||||
|
rcLoneMount := _db.GetMountPath()
|
||||||
|
if debridTorrent == nil && t.ID != "" {
|
||||||
|
debridTorrent, _ = _db.GetTorrent(t.ID)
|
||||||
|
}
|
||||||
|
if debridTorrent == nil {
|
||||||
|
q.logger.Info().Msgf("Torrent with ID %s not found in %s", t.ID, _db.GetName())
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
if debridTorrent.Status != "downloaded" {
|
||||||
|
debridTorrent, _ = _db.GetTorrent(t.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.TorrentPath == "" {
|
||||||
|
tPath, _ := debridTorrent.GetMountFolder(rcLoneMount)
|
||||||
|
t.TorrentPath = filepath.Base(tPath)
|
||||||
|
}
|
||||||
|
savePath := filepath.Join(q.DownloadFolder, t.Category) + string(os.PathSeparator)
|
||||||
|
torrentPath := filepath.Join(savePath, t.TorrentPath) + string(os.PathSeparator)
|
||||||
|
t = q.UpdateTorrentMin(t, debridTorrent)
|
||||||
|
t.ContentPath = torrentPath
|
||||||
|
|
||||||
|
if t.IsReady() {
|
||||||
|
t.State = "pausedUP"
|
||||||
|
q.Storage.Update(t)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
if t.IsReady() {
|
||||||
|
t.State = "pausedUP"
|
||||||
|
q.Storage.Update(t)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
updatedT := q.UpdateTorrent(t, debridTorrent)
|
||||||
|
t = updatedT
|
||||||
|
|
||||||
|
case <-time.After(10 * time.Minute): // Add a timeout
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) ResumeTorrent(t *Torrent) bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) PauseTorrent(t *Torrent) bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) RefreshTorrent(t *Torrent) bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) GetTorrentProperties(t *Torrent) *TorrentProperties {
|
||||||
|
return &TorrentProperties{
|
||||||
|
AdditionDate: t.AddedOn,
|
||||||
|
Comment: "Debrid Blackhole <https://github.com/sirrobot01/debrid-blackhole>",
|
||||||
|
CreatedBy: "Debrid Blackhole <https://github.com/sirrobot01/debrid-blackhole>",
|
||||||
|
CreationDate: t.AddedOn,
|
||||||
|
DlLimit: -1,
|
||||||
|
UpLimit: -1,
|
||||||
|
DlSpeed: t.Dlspeed,
|
||||||
|
UpSpeed: t.Upspeed,
|
||||||
|
TotalSize: t.Size,
|
||||||
|
TotalUploaded: t.Uploaded,
|
||||||
|
TotalDownloaded: t.Downloaded,
|
||||||
|
TotalUploadedSession: t.UploadedSession,
|
||||||
|
TotalDownloadedSession: t.DownloadedSession,
|
||||||
|
LastSeen: time.Now().Unix(),
|
||||||
|
NbConnectionsLimit: 100,
|
||||||
|
Peers: 0,
|
||||||
|
PeersTotal: 2,
|
||||||
|
SeedingTime: 1,
|
||||||
|
Seeds: 100,
|
||||||
|
ShareRatio: 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) GetTorrentFiles(t *Torrent) []*TorrentFile {
|
||||||
|
files := make([]*TorrentFile, 0)
|
||||||
|
if t.DebridTorrent == nil {
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
for _, file := range t.DebridTorrent.Files {
|
||||||
|
files = append(files, &TorrentFile{
|
||||||
|
Name: file.Path,
|
||||||
|
Size: file.Size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) SetTorrentTags(t *Torrent, tags []string) bool {
|
||||||
|
torrentTags := strings.Split(t.Tags, ",")
|
||||||
|
for _, tag := range tags {
|
||||||
|
if tag == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !slices.Contains(torrentTags, tag) {
|
||||||
|
torrentTags = append(torrentTags, tag)
|
||||||
|
}
|
||||||
|
if !slices.Contains(q.Tags, tag) {
|
||||||
|
q.Tags = append(q.Tags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Tags = strings.Join(torrentTags, ",")
|
||||||
|
q.Storage.Update(t)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) RemoveTorrentTags(t *Torrent, tags []string) bool {
|
||||||
|
torrentTags := strings.Split(t.Tags, ",")
|
||||||
|
newTorrentTags := utils.RemoveItem(torrentTags, tags...)
|
||||||
|
q.Tags = utils.RemoveItem(q.Tags, tags...)
|
||||||
|
t.Tags = strings.Join(newTorrentTags, ",")
|
||||||
|
q.Storage.Update(t)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) AddTags(tags []string) bool {
|
||||||
|
for _, tag := range tags {
|
||||||
|
if tag == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !slices.Contains(q.Tags, tag) {
|
||||||
|
q.Tags = append(q.Tags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) RemoveTags(tags []string) bool {
|
||||||
|
q.Tags = utils.RemoveItem(q.Tags, tags...)
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
package qbit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BuildInfo struct {
|
||||||
|
Libtorrent string `json:"libtorrent"`
|
||||||
|
Bitness int `json:"bitness"`
|
||||||
|
Boost string `json:"boost"`
|
||||||
|
Openssl string `json:"openssl"`
|
||||||
|
Qt string `json:"qt"`
|
||||||
|
Zlib string `json:"zlib"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppPreferences struct {
|
||||||
|
AddTrackers string `json:"add_trackers"`
|
||||||
|
AddTrackersEnabled bool `json:"add_trackers_enabled"`
|
||||||
|
AltDlLimit int `json:"alt_dl_limit"`
|
||||||
|
AltUpLimit int `json:"alt_up_limit"`
|
||||||
|
AlternativeWebuiEnabled bool `json:"alternative_webui_enabled"`
|
||||||
|
AlternativeWebuiPath string `json:"alternative_webui_path"`
|
||||||
|
AnnounceIp string `json:"announce_ip"`
|
||||||
|
AnnounceToAllTiers bool `json:"announce_to_all_tiers"`
|
||||||
|
AnnounceToAllTrackers bool `json:"announce_to_all_trackers"`
|
||||||
|
AnonymousMode bool `json:"anonymous_mode"`
|
||||||
|
AsyncIoThreads int `json:"async_io_threads"`
|
||||||
|
AutoDeleteMode int `json:"auto_delete_mode"`
|
||||||
|
AutoTmmEnabled bool `json:"auto_tmm_enabled"`
|
||||||
|
AutorunEnabled bool `json:"autorun_enabled"`
|
||||||
|
AutorunProgram string `json:"autorun_program"`
|
||||||
|
BannedIPs string `json:"banned_IPs"`
|
||||||
|
BittorrentProtocol int `json:"bittorrent_protocol"`
|
||||||
|
BypassAuthSubnetWhitelist string `json:"bypass_auth_subnet_whitelist"`
|
||||||
|
BypassAuthSubnetWhitelistEnabled bool `json:"bypass_auth_subnet_whitelist_enabled"`
|
||||||
|
BypassLocalAuth bool `json:"bypass_local_auth"`
|
||||||
|
CategoryChangedTmmEnabled bool `json:"category_changed_tmm_enabled"`
|
||||||
|
CheckingMemoryUse int `json:"checking_memory_use"`
|
||||||
|
CreateSubfolderEnabled bool `json:"create_subfolder_enabled"`
|
||||||
|
CurrentInterfaceAddress string `json:"current_interface_address"`
|
||||||
|
CurrentNetworkInterface string `json:"current_network_interface"`
|
||||||
|
Dht bool `json:"dht"`
|
||||||
|
DiskCache int `json:"disk_cache"`
|
||||||
|
DiskCacheTtl int `json:"disk_cache_ttl"`
|
||||||
|
DlLimit int `json:"dl_limit"`
|
||||||
|
DontCountSlowTorrents bool `json:"dont_count_slow_torrents"`
|
||||||
|
DyndnsDomain string `json:"dyndns_domain"`
|
||||||
|
DyndnsEnabled bool `json:"dyndns_enabled"`
|
||||||
|
DyndnsPassword string `json:"dyndns_password"`
|
||||||
|
DyndnsService int `json:"dyndns_service"`
|
||||||
|
DyndnsUsername string `json:"dyndns_username"`
|
||||||
|
EmbeddedTrackerPort int `json:"embedded_tracker_port"`
|
||||||
|
EnableCoalesceReadWrite bool `json:"enable_coalesce_read_write"`
|
||||||
|
EnableEmbeddedTracker bool `json:"enable_embedded_tracker"`
|
||||||
|
EnableMultiConnectionsFromSameIp bool `json:"enable_multi_connections_from_same_ip"`
|
||||||
|
EnableOsCache bool `json:"enable_os_cache"`
|
||||||
|
EnablePieceExtentAffinity bool `json:"enable_piece_extent_affinity"`
|
||||||
|
EnableSuperSeeding bool `json:"enable_super_seeding"`
|
||||||
|
EnableUploadSuggestions bool `json:"enable_upload_suggestions"`
|
||||||
|
Encryption int `json:"encryption"`
|
||||||
|
ExportDir string `json:"export_dir"`
|
||||||
|
ExportDirFin string `json:"export_dir_fin"`
|
||||||
|
FilePoolSize int `json:"file_pool_size"`
|
||||||
|
IncompleteFilesExt bool `json:"incomplete_files_ext"`
|
||||||
|
IpFilterEnabled bool `json:"ip_filter_enabled"`
|
||||||
|
IpFilterPath string `json:"ip_filter_path"`
|
||||||
|
IpFilterTrackers bool `json:"ip_filter_trackers"`
|
||||||
|
LimitLanPeers bool `json:"limit_lan_peers"`
|
||||||
|
LimitTcpOverhead bool `json:"limit_tcp_overhead"`
|
||||||
|
LimitUtpRate bool `json:"limit_utp_rate"`
|
||||||
|
ListenPort int `json:"listen_port"`
|
||||||
|
Locale string `json:"locale"`
|
||||||
|
Lsd bool `json:"lsd"`
|
||||||
|
MailNotificationAuthEnabled bool `json:"mail_notification_auth_enabled"`
|
||||||
|
MailNotificationEmail string `json:"mail_notification_email"`
|
||||||
|
MailNotificationEnabled bool `json:"mail_notification_enabled"`
|
||||||
|
MailNotificationPassword string `json:"mail_notification_password"`
|
||||||
|
MailNotificationSender string `json:"mail_notification_sender"`
|
||||||
|
MailNotificationSmtp string `json:"mail_notification_smtp"`
|
||||||
|
MailNotificationSslEnabled bool `json:"mail_notification_ssl_enabled"`
|
||||||
|
MailNotificationUsername string `json:"mail_notification_username"`
|
||||||
|
MaxActiveDownloads int `json:"max_active_downloads"`
|
||||||
|
MaxActiveTorrents int `json:"max_active_torrents"`
|
||||||
|
MaxActiveUploads int `json:"max_active_uploads"`
|
||||||
|
MaxConnec int `json:"max_connec"`
|
||||||
|
MaxConnecPerTorrent int `json:"max_connec_per_torrent"`
|
||||||
|
MaxRatio int `json:"max_ratio"`
|
||||||
|
MaxRatioAct int `json:"max_ratio_act"`
|
||||||
|
MaxRatioEnabled bool `json:"max_ratio_enabled"`
|
||||||
|
MaxSeedingTime int `json:"max_seeding_time"`
|
||||||
|
MaxSeedingTimeEnabled bool `json:"max_seeding_time_enabled"`
|
||||||
|
MaxUploads int `json:"max_uploads"`
|
||||||
|
MaxUploadsPerTorrent int `json:"max_uploads_per_torrent"`
|
||||||
|
OutgoingPortsMax int `json:"outgoing_ports_max"`
|
||||||
|
OutgoingPortsMin int `json:"outgoing_ports_min"`
|
||||||
|
Pex bool `json:"pex"`
|
||||||
|
PreallocateAll bool `json:"preallocate_all"`
|
||||||
|
ProxyAuthEnabled bool `json:"proxy_auth_enabled"`
|
||||||
|
ProxyIp string `json:"proxy_ip"`
|
||||||
|
ProxyPassword string `json:"proxy_password"`
|
||||||
|
ProxyPeerConnections bool `json:"proxy_peer_connections"`
|
||||||
|
ProxyPort int `json:"proxy_port"`
|
||||||
|
ProxyTorrentsOnly bool `json:"proxy_torrents_only"`
|
||||||
|
ProxyType int `json:"proxy_type"`
|
||||||
|
ProxyUsername string `json:"proxy_username"`
|
||||||
|
QueueingEnabled bool `json:"queueing_enabled"`
|
||||||
|
RandomPort bool `json:"random_port"`
|
||||||
|
RecheckCompletedTorrents bool `json:"recheck_completed_torrents"`
|
||||||
|
ResolvePeerCountries bool `json:"resolve_peer_countries"`
|
||||||
|
RssAutoDownloadingEnabled bool `json:"rss_auto_downloading_enabled"`
|
||||||
|
RssMaxArticlesPerFeed int `json:"rss_max_articles_per_feed"`
|
||||||
|
RssProcessingEnabled bool `json:"rss_processing_enabled"`
|
||||||
|
RssRefreshInterval int `json:"rss_refresh_interval"`
|
||||||
|
SavePath string `json:"save_path"`
|
||||||
|
SavePathChangedTmmEnabled bool `json:"save_path_changed_tmm_enabled"`
|
||||||
|
SaveResumeDataInterval int `json:"save_resume_data_interval"`
|
||||||
|
ScanDirs ScanDirs `json:"scan_dirs"`
|
||||||
|
ScheduleFromHour int `json:"schedule_from_hour"`
|
||||||
|
ScheduleFromMin int `json:"schedule_from_min"`
|
||||||
|
ScheduleToHour int `json:"schedule_to_hour"`
|
||||||
|
ScheduleToMin int `json:"schedule_to_min"`
|
||||||
|
SchedulerDays int `json:"scheduler_days"`
|
||||||
|
SchedulerEnabled bool `json:"scheduler_enabled"`
|
||||||
|
SendBufferLowWatermark int `json:"send_buffer_low_watermark"`
|
||||||
|
SendBufferWatermark int `json:"send_buffer_watermark"`
|
||||||
|
SendBufferWatermarkFactor int `json:"send_buffer_watermark_factor"`
|
||||||
|
SlowTorrentDlRateThreshold int `json:"slow_torrent_dl_rate_threshold"`
|
||||||
|
SlowTorrentInactiveTimer int `json:"slow_torrent_inactive_timer"`
|
||||||
|
SlowTorrentUlRateThreshold int `json:"slow_torrent_ul_rate_threshold"`
|
||||||
|
SocketBacklogSize int `json:"socket_backlog_size"`
|
||||||
|
StartPausedEnabled bool `json:"start_paused_enabled"`
|
||||||
|
StopTrackerTimeout int `json:"stop_tracker_timeout"`
|
||||||
|
TempPath string `json:"temp_path"`
|
||||||
|
TempPathEnabled bool `json:"temp_path_enabled"`
|
||||||
|
TorrentChangedTmmEnabled bool `json:"torrent_changed_tmm_enabled"`
|
||||||
|
UpLimit int `json:"up_limit"`
|
||||||
|
UploadChokingAlgorithm int `json:"upload_choking_algorithm"`
|
||||||
|
UploadSlotsBehavior int `json:"upload_slots_behavior"`
|
||||||
|
Upnp bool `json:"upnp"`
|
||||||
|
UpnpLeaseDuration int `json:"upnp_lease_duration"`
|
||||||
|
UseHttps bool `json:"use_https"`
|
||||||
|
UtpTcpMixedMode int `json:"utp_tcp_mixed_mode"`
|
||||||
|
WebUiAddress string `json:"web_ui_address"`
|
||||||
|
WebUiBanDuration int `json:"web_ui_ban_duration"`
|
||||||
|
WebUiClickjackingProtectionEnabled bool `json:"web_ui_clickjacking_protection_enabled"`
|
||||||
|
WebUiCsrfProtectionEnabled bool `json:"web_ui_csrf_protection_enabled"`
|
||||||
|
WebUiDomainList string `json:"web_ui_domain_list"`
|
||||||
|
WebUiHostHeaderValidationEnabled bool `json:"web_ui_host_header_validation_enabled"`
|
||||||
|
WebUiHttpsCertPath string `json:"web_ui_https_cert_path"`
|
||||||
|
WebUiHttpsKeyPath string `json:"web_ui_https_key_path"`
|
||||||
|
WebUiMaxAuthFailCount int `json:"web_ui_max_auth_fail_count"`
|
||||||
|
WebUiPort int `json:"web_ui_port"`
|
||||||
|
WebUiSecureCookieEnabled bool `json:"web_ui_secure_cookie_enabled"`
|
||||||
|
WebUiSessionTimeout int `json:"web_ui_session_timeout"`
|
||||||
|
WebUiUpnp bool `json:"web_ui_upnp"`
|
||||||
|
WebUiUsername string `json:"web_ui_username"`
|
||||||
|
WebUiPassword string `json:"web_ui_password"`
|
||||||
|
SSLKey string `json:"ssl_key"`
|
||||||
|
SSLCert string `json:"ssl_cert"`
|
||||||
|
RSSDownloadRepack string `json:"rss_download_repack_proper_episodes"`
|
||||||
|
RSSSmartEpisodeFilters string `json:"rss_smart_episode_filters"`
|
||||||
|
WebUiUseCustomHttpHeaders bool `json:"web_ui_use_custom_http_headers"`
|
||||||
|
WebUiUseCustomHttpHeadersEnabled bool `json:"web_ui_use_custom_http_headers_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScanDirs struct{}
|
||||||
|
|
||||||
|
type TorrentCategory struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
SavePath string `json:"savePath"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Torrent struct {
|
||||||
|
ID string `json:"-"`
|
||||||
|
DebridTorrent *torrent.Torrent `json:"-"`
|
||||||
|
Debrid string `json:"debrid"`
|
||||||
|
TorrentPath string `json:"-"`
|
||||||
|
|
||||||
|
AddedOn int64 `json:"added_on,omitempty"`
|
||||||
|
AmountLeft int64 `json:"amount_left"`
|
||||||
|
AutoTmm bool `json:"auto_tmm"`
|
||||||
|
Availability float64 `json:"availability,omitempty"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
Completed int64 `json:"completed"`
|
||||||
|
CompletionOn int `json:"completion_on,omitempty"`
|
||||||
|
ContentPath string `json:"content_path"`
|
||||||
|
DlLimit int `json:"dl_limit"`
|
||||||
|
Dlspeed int64 `json:"dlspeed"`
|
||||||
|
Downloaded int64 `json:"downloaded"`
|
||||||
|
DownloadedSession int64 `json:"downloaded_session"`
|
||||||
|
Eta int `json:"eta"`
|
||||||
|
FlPiecePrio bool `json:"f_l_piece_prio,omitempty"`
|
||||||
|
ForceStart bool `json:"force_start,omitempty"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
LastActivity int64 `json:"last_activity,omitempty"`
|
||||||
|
MagnetUri string `json:"magnet_uri,omitempty"`
|
||||||
|
MaxRatio int `json:"max_ratio,omitempty"`
|
||||||
|
MaxSeedingTime int `json:"max_seeding_time,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
NumComplete int `json:"num_complete,omitempty"`
|
||||||
|
NumIncomplete int `json:"num_incomplete,omitempty"`
|
||||||
|
NumLeechs int `json:"num_leechs,omitempty"`
|
||||||
|
NumSeeds int `json:"num_seeds,omitempty"`
|
||||||
|
Priority int `json:"priority,omitempty"`
|
||||||
|
Progress float64 `json:"progress"`
|
||||||
|
Ratio int `json:"ratio,omitempty"`
|
||||||
|
RatioLimit int `json:"ratio_limit,omitempty"`
|
||||||
|
SavePath string `json:"save_path"`
|
||||||
|
SeedingTimeLimit int `json:"seeding_time_limit,omitempty"`
|
||||||
|
SeenComplete int64 `json:"seen_complete,omitempty"`
|
||||||
|
SeqDl bool `json:"seq_dl"`
|
||||||
|
Size int64 `json:"size,omitempty"`
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
SuperSeeding bool `json:"super_seeding"`
|
||||||
|
Tags string `json:"tags,omitempty"`
|
||||||
|
TimeActive int `json:"time_active,omitempty"`
|
||||||
|
TotalSize int64 `json:"total_size,omitempty"`
|
||||||
|
Tracker string `json:"tracker,omitempty"`
|
||||||
|
UpLimit int64 `json:"up_limit,omitempty"`
|
||||||
|
Uploaded int64 `json:"uploaded,omitempty"`
|
||||||
|
UploadedSession int64 `json:"uploaded_session,omitempty"`
|
||||||
|
Upspeed int64 `json:"upspeed,omitempty"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
|
||||||
|
Mu sync.Mutex `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Torrent) IsReady() bool {
|
||||||
|
return t.AmountLeft <= 0 && t.TorrentPath != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type TorrentProperties struct {
|
||||||
|
AdditionDate int64 `json:"addition_date,omitempty"`
|
||||||
|
Comment string `json:"comment,omitempty"`
|
||||||
|
CompletionDate int64 `json:"completion_date,omitempty"`
|
||||||
|
CreatedBy string `json:"created_by,omitempty"`
|
||||||
|
CreationDate int64 `json:"creation_date,omitempty"`
|
||||||
|
DlLimit int `json:"dl_limit,omitempty"`
|
||||||
|
DlSpeed int64 `json:"dl_speed,omitempty"`
|
||||||
|
DlSpeedAvg int `json:"dl_speed_avg,omitempty"`
|
||||||
|
Eta int `json:"eta,omitempty"`
|
||||||
|
LastSeen int64 `json:"last_seen,omitempty"`
|
||||||
|
NbConnections int `json:"nb_connections,omitempty"`
|
||||||
|
NbConnectionsLimit int `json:"nb_connections_limit,omitempty"`
|
||||||
|
Peers int `json:"peers,omitempty"`
|
||||||
|
PeersTotal int `json:"peers_total,omitempty"`
|
||||||
|
PieceSize int64 `json:"piece_size,omitempty"`
|
||||||
|
PiecesHave int64 `json:"pieces_have,omitempty"`
|
||||||
|
PiecesNum int64 `json:"pieces_num,omitempty"`
|
||||||
|
Reannounce int `json:"reannounce,omitempty"`
|
||||||
|
SavePath string `json:"save_path,omitempty"`
|
||||||
|
SeedingTime int `json:"seeding_time,omitempty"`
|
||||||
|
Seeds int `json:"seeds,omitempty"`
|
||||||
|
SeedsTotal int `json:"seeds_total,omitempty"`
|
||||||
|
ShareRatio int `json:"share_ratio,omitempty"`
|
||||||
|
TimeElapsed int64 `json:"time_elapsed,omitempty"`
|
||||||
|
TotalDownloaded int64 `json:"total_downloaded,omitempty"`
|
||||||
|
TotalDownloadedSession int64 `json:"total_downloaded_session,omitempty"`
|
||||||
|
TotalSize int64 `json:"total_size,omitempty"`
|
||||||
|
TotalUploaded int64 `json:"total_uploaded,omitempty"`
|
||||||
|
TotalUploadedSession int64 `json:"total_uploaded_session,omitempty"`
|
||||||
|
TotalWasted int64 `json:"total_wasted,omitempty"`
|
||||||
|
UpLimit int `json:"up_limit,omitempty"`
|
||||||
|
UpSpeed int64 `json:"up_speed,omitempty"`
|
||||||
|
UpSpeedAvg int `json:"up_speed_avg,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TorrentFile struct {
|
||||||
|
Index int `json:"index,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Size int64 `json:"size,omitempty"`
|
||||||
|
Progress int `json:"progress,omitempty"`
|
||||||
|
Priority int `json:"priority,omitempty"`
|
||||||
|
IsSeed bool `json:"is_seed,omitempty"`
|
||||||
|
PieceRange []int `json:"piece_range,omitempty"`
|
||||||
|
Availability float64 `json:"availability,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAppPreferences() *AppPreferences {
|
||||||
|
preferences := &AppPreferences{
|
||||||
|
AddTrackers: "",
|
||||||
|
AddTrackersEnabled: false,
|
||||||
|
AltDlLimit: 10240,
|
||||||
|
AltUpLimit: 10240,
|
||||||
|
AlternativeWebuiEnabled: false,
|
||||||
|
AlternativeWebuiPath: "",
|
||||||
|
AnnounceIp: "",
|
||||||
|
AnnounceToAllTiers: true,
|
||||||
|
AnnounceToAllTrackers: false,
|
||||||
|
AnonymousMode: false,
|
||||||
|
AsyncIoThreads: 4,
|
||||||
|
AutoDeleteMode: 0,
|
||||||
|
AutoTmmEnabled: false,
|
||||||
|
AutorunEnabled: false,
|
||||||
|
AutorunProgram: "",
|
||||||
|
BannedIPs: "",
|
||||||
|
BittorrentProtocol: 0,
|
||||||
|
BypassAuthSubnetWhitelist: "",
|
||||||
|
BypassAuthSubnetWhitelistEnabled: false,
|
||||||
|
BypassLocalAuth: false,
|
||||||
|
CategoryChangedTmmEnabled: false,
|
||||||
|
CheckingMemoryUse: 32,
|
||||||
|
CreateSubfolderEnabled: true,
|
||||||
|
CurrentInterfaceAddress: "",
|
||||||
|
CurrentNetworkInterface: "",
|
||||||
|
Dht: true,
|
||||||
|
DiskCache: -1,
|
||||||
|
DiskCacheTtl: 60,
|
||||||
|
DlLimit: 0,
|
||||||
|
DontCountSlowTorrents: false,
|
||||||
|
DyndnsDomain: "changeme.dyndns.org",
|
||||||
|
DyndnsEnabled: false,
|
||||||
|
DyndnsPassword: "",
|
||||||
|
DyndnsService: 0,
|
||||||
|
DyndnsUsername: "",
|
||||||
|
EmbeddedTrackerPort: 9000,
|
||||||
|
EnableCoalesceReadWrite: true,
|
||||||
|
EnableEmbeddedTracker: false,
|
||||||
|
EnableMultiConnectionsFromSameIp: false,
|
||||||
|
EnableOsCache: true,
|
||||||
|
EnablePieceExtentAffinity: false,
|
||||||
|
EnableSuperSeeding: false,
|
||||||
|
EnableUploadSuggestions: false,
|
||||||
|
Encryption: 0,
|
||||||
|
ExportDir: "",
|
||||||
|
ExportDirFin: "",
|
||||||
|
FilePoolSize: 40,
|
||||||
|
IncompleteFilesExt: false,
|
||||||
|
IpFilterEnabled: false,
|
||||||
|
IpFilterPath: "",
|
||||||
|
IpFilterTrackers: false,
|
||||||
|
LimitLanPeers: true,
|
||||||
|
LimitTcpOverhead: false,
|
||||||
|
LimitUtpRate: true,
|
||||||
|
ListenPort: 31193,
|
||||||
|
Locale: "en",
|
||||||
|
Lsd: true,
|
||||||
|
MailNotificationAuthEnabled: false,
|
||||||
|
MailNotificationEmail: "",
|
||||||
|
MailNotificationEnabled: false,
|
||||||
|
MailNotificationPassword: "",
|
||||||
|
MailNotificationSender: "qBittorrentNotification@example.com",
|
||||||
|
MailNotificationSmtp: "smtp.changeme.com",
|
||||||
|
MailNotificationSslEnabled: false,
|
||||||
|
MailNotificationUsername: "",
|
||||||
|
MaxActiveDownloads: 3,
|
||||||
|
MaxActiveTorrents: 5,
|
||||||
|
MaxActiveUploads: 3,
|
||||||
|
MaxConnec: 500,
|
||||||
|
MaxConnecPerTorrent: 100,
|
||||||
|
MaxRatio: -1,
|
||||||
|
MaxRatioAct: 0,
|
||||||
|
MaxRatioEnabled: false,
|
||||||
|
MaxSeedingTime: -1,
|
||||||
|
MaxSeedingTimeEnabled: false,
|
||||||
|
MaxUploads: -1,
|
||||||
|
MaxUploadsPerTorrent: -1,
|
||||||
|
OutgoingPortsMax: 0,
|
||||||
|
OutgoingPortsMin: 0,
|
||||||
|
Pex: true,
|
||||||
|
PreallocateAll: false,
|
||||||
|
ProxyAuthEnabled: false,
|
||||||
|
ProxyIp: "0.0.0.0",
|
||||||
|
ProxyPassword: "",
|
||||||
|
ProxyPeerConnections: false,
|
||||||
|
ProxyPort: 8080,
|
||||||
|
ProxyTorrentsOnly: false,
|
||||||
|
ProxyType: 0,
|
||||||
|
ProxyUsername: "",
|
||||||
|
QueueingEnabled: false,
|
||||||
|
RandomPort: false,
|
||||||
|
RecheckCompletedTorrents: false,
|
||||||
|
ResolvePeerCountries: true,
|
||||||
|
RssAutoDownloadingEnabled: false,
|
||||||
|
RssMaxArticlesPerFeed: 50,
|
||||||
|
RssProcessingEnabled: false,
|
||||||
|
RssRefreshInterval: 30,
|
||||||
|
SavePathChangedTmmEnabled: false,
|
||||||
|
SaveResumeDataInterval: 60,
|
||||||
|
ScanDirs: ScanDirs{},
|
||||||
|
ScheduleFromHour: 8,
|
||||||
|
ScheduleFromMin: 0,
|
||||||
|
ScheduleToHour: 20,
|
||||||
|
ScheduleToMin: 0,
|
||||||
|
SchedulerDays: 0,
|
||||||
|
SchedulerEnabled: false,
|
||||||
|
SendBufferLowWatermark: 10,
|
||||||
|
SendBufferWatermark: 500,
|
||||||
|
SendBufferWatermarkFactor: 50,
|
||||||
|
SlowTorrentDlRateThreshold: 2,
|
||||||
|
SlowTorrentInactiveTimer: 60,
|
||||||
|
SlowTorrentUlRateThreshold: 2,
|
||||||
|
SocketBacklogSize: 30,
|
||||||
|
StartPausedEnabled: false,
|
||||||
|
StopTrackerTimeout: 1,
|
||||||
|
TempPathEnabled: false,
|
||||||
|
TorrentChangedTmmEnabled: true,
|
||||||
|
UpLimit: 0,
|
||||||
|
UploadChokingAlgorithm: 1,
|
||||||
|
UploadSlotsBehavior: 0,
|
||||||
|
Upnp: true,
|
||||||
|
UpnpLeaseDuration: 0,
|
||||||
|
UseHttps: false,
|
||||||
|
UtpTcpMixedMode: 0,
|
||||||
|
WebUiAddress: "*",
|
||||||
|
WebUiBanDuration: 3600,
|
||||||
|
WebUiClickjackingProtectionEnabled: true,
|
||||||
|
WebUiCsrfProtectionEnabled: true,
|
||||||
|
WebUiDomainList: "*",
|
||||||
|
WebUiHostHeaderValidationEnabled: true,
|
||||||
|
WebUiHttpsCertPath: "",
|
||||||
|
WebUiHttpsKeyPath: "",
|
||||||
|
WebUiMaxAuthFailCount: 5,
|
||||||
|
WebUiPort: 8080,
|
||||||
|
WebUiSecureCookieEnabled: true,
|
||||||
|
WebUiSessionTimeout: 3600,
|
||||||
|
WebUiUpnp: false,
|
||||||
|
|
||||||
|
// Fields in the struct but not in the JSON (set to zero values):
|
||||||
|
WebUiPassword: "",
|
||||||
|
SSLKey: "",
|
||||||
|
SSLCert: "",
|
||||||
|
RSSDownloadRepack: "",
|
||||||
|
RSSSmartEpisodeFilters: "",
|
||||||
|
WebUiUseCustomHttpHeaders: false,
|
||||||
|
WebUiUseCustomHttpHeadersEnabled: false,
|
||||||
|
}
|
||||||
|
return preferences
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package qbit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/service"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (q *QBit) StartWorker(ctx context.Context) {
|
||||||
|
q.logger.Info().Msg("Qbit Worker started")
|
||||||
|
q.StartRefreshWorker(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) StartRefreshWorker(ctx context.Context) {
|
||||||
|
refreshCtx := context.WithValue(ctx, "worker", "refresh")
|
||||||
|
refreshTicker := time.NewTicker(time.Duration(q.RefreshInterval) * time.Second)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-refreshCtx.Done():
|
||||||
|
q.logger.Info().Msg("Qbit Refresh Worker stopped")
|
||||||
|
return
|
||||||
|
case <-refreshTicker.C:
|
||||||
|
torrents := q.Storage.GetAll("", "", nil)
|
||||||
|
if len(torrents) > 0 {
|
||||||
|
q.RefreshArrs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) RefreshArrs() {
|
||||||
|
arrs := service.GetService().Arr
|
||||||
|
for _, arr := range arrs.GetAll() {
|
||||||
|
err := arr.Refresh()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package rclone
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/webdav"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Remote struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
MountPoint string `json:"mount_point"`
|
||||||
|
Flags map[string]string `json:"flags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *Rclone) Config() string {
|
||||||
|
var content string
|
||||||
|
|
||||||
|
for _, remote := range rc.Remotes {
|
||||||
|
content += fmt.Sprintf("[%s]\n", remote.Name)
|
||||||
|
content += fmt.Sprintf("type = %s\n", remote.Type)
|
||||||
|
content += fmt.Sprintf("url = %s\n", remote.Url)
|
||||||
|
content += fmt.Sprintf("vendor = other\n")
|
||||||
|
|
||||||
|
for key, value := range remote.Flags {
|
||||||
|
content += fmt.Sprintf("%s = %s\n", key, value)
|
||||||
|
}
|
||||||
|
content += "\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rclone struct {
|
||||||
|
Remotes map[string]Remote `json:"remotes"`
|
||||||
|
logger zerolog.Logger
|
||||||
|
cmd *exec.Cmd
|
||||||
|
configPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(webdav *webdav.WebDav) (*Rclone, error) {
|
||||||
|
// Check if rclone is installed
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
configPath := fmt.Sprintf("%s/rclone.conf", cfg.Path)
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("rclone"); err != nil {
|
||||||
|
return nil, fmt.Errorf("rclone is not installed: %w", err)
|
||||||
|
}
|
||||||
|
remotes := make(map[string]Remote)
|
||||||
|
for _, handler := range webdav.Handlers {
|
||||||
|
url := fmt.Sprintf("http://localhost:%s/webdav/%s/", cfg.QBitTorrent.Port, strings.ToLower(handler.Name))
|
||||||
|
rmt := Remote{
|
||||||
|
Type: "webdav",
|
||||||
|
Name: handler.Name,
|
||||||
|
Url: url,
|
||||||
|
MountPoint: filepath.Join("/mnt/rclone/", handler.Name),
|
||||||
|
Flags: map[string]string{},
|
||||||
|
}
|
||||||
|
remotes[handler.Name] = rmt
|
||||||
|
}
|
||||||
|
|
||||||
|
rc := &Rclone{
|
||||||
|
logger: logger.NewLogger("rclone", "info", os.Stdout),
|
||||||
|
Remotes: remotes,
|
||||||
|
configPath: configPath,
|
||||||
|
}
|
||||||
|
if err := rc.WriteConfig(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *Rclone) WriteConfig() error {
|
||||||
|
|
||||||
|
// Create config directory if it doesn't exist
|
||||||
|
configDir := filepath.Dir(rc.configPath)
|
||||||
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create config directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the config file
|
||||||
|
if err := os.WriteFile(rc.configPath, []byte(rc.Config()), 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rc.logger.Info().Msgf("Wrote rclone config with %d remotes to %s", len(rc.Remotes), rc.configPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *Rclone) Start(ctx context.Context) error {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errChan := make(chan error)
|
||||||
|
for _, remote := range rc.Remotes {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(remote Remote) {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := rc.Mount(ctx, &remote); err != nil {
|
||||||
|
rc.logger.Error().Err(err).Msgf("failed to mount %s", remote.Name)
|
||||||
|
select {
|
||||||
|
case errChan <- err:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(remote)
|
||||||
|
}
|
||||||
|
return <-errChan
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *Rclone) testConnection(ctx context.Context, remote *Remote) error {
|
||||||
|
testArgs := []string{
|
||||||
|
"ls",
|
||||||
|
"--config", rc.configPath,
|
||||||
|
"--log-level", "DEBUG",
|
||||||
|
remote.Name + ":",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "rclone", testArgs...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
rc.logger.Error().Err(err).Str("output", string(output)).Msg("Connection test failed")
|
||||||
|
return fmt.Errorf("connection test failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rc.logger.Info().Msg("Connection test successful")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *Rclone) Mount(ctx context.Context, remote *Remote) error {
|
||||||
|
// Ensure the mount point directory exists
|
||||||
|
if err := os.MkdirAll(remote.MountPoint, 0755); err != nil {
|
||||||
|
rc.logger.Info().Err(err).Msgf("failed to create mount point directory: %s", remote.MountPoint)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
//if err := rc.testConnection(ctx, remote); err != nil {
|
||||||
|
// return err
|
||||||
|
//}
|
||||||
|
|
||||||
|
// Basic arguments
|
||||||
|
args := []string{
|
||||||
|
"mount",
|
||||||
|
remote.Name + ":",
|
||||||
|
remote.MountPoint,
|
||||||
|
"--config", rc.configPath,
|
||||||
|
"--vfs-cache-mode", "full",
|
||||||
|
"--log-level", "DEBUG", // Keep this, remove -vv
|
||||||
|
"--allow-other", // Keep this
|
||||||
|
"--allow-root", // Add this
|
||||||
|
"--default-permissions", // Add this
|
||||||
|
"--vfs-cache-max-age", "24h",
|
||||||
|
"--timeout", "1m",
|
||||||
|
"--transfers", "4",
|
||||||
|
"--buffer-size", "32M",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any additional flags
|
||||||
|
for key, value := range remote.Flags {
|
||||||
|
args = append(args, "--"+key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create command
|
||||||
|
rc.cmd = exec.CommandContext(ctx, "rclone", args...)
|
||||||
|
|
||||||
|
// Set up pipes for stdout and stderr
|
||||||
|
stdout, err := rc.cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr, err := rc.cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the command
|
||||||
|
if err := rc.cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel to signal mount success
|
||||||
|
mountReady := make(chan bool)
|
||||||
|
mountError := make(chan error)
|
||||||
|
|
||||||
|
// Monitor stdout
|
||||||
|
go func() {
|
||||||
|
scanner := bufio.NewScanner(stdout)
|
||||||
|
for scanner.Scan() {
|
||||||
|
text := scanner.Text()
|
||||||
|
rc.logger.Info().Msg("stdout: " + text)
|
||||||
|
if strings.Contains(text, "Mount succeeded") {
|
||||||
|
mountReady <- true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Monitor stderr
|
||||||
|
go func() {
|
||||||
|
scanner := bufio.NewScanner(stderr)
|
||||||
|
for scanner.Scan() {
|
||||||
|
text := scanner.Text()
|
||||||
|
rc.logger.Info().Msg("stderr: " + text)
|
||||||
|
if strings.Contains(text, "error") {
|
||||||
|
mountError <- fmt.Errorf("mount error: %s", text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for mount with timeout
|
||||||
|
select {
|
||||||
|
case <-mountReady:
|
||||||
|
rc.logger.Info().Msgf("Successfully mounted %s at %s", remote.Name, remote.MountPoint)
|
||||||
|
return nil
|
||||||
|
case err := <-mountError:
|
||||||
|
err = rc.cmd.Process.Kill()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
err := rc.cmd.Process.Kill()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
err := rc.cmd.Process.Kill()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("mount timeout after 30 seconds")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *Rclone) Unmount(ctx context.Context, remote *Remote) error {
|
||||||
|
if rc.cmd != nil && rc.cmd.Process != nil {
|
||||||
|
// First try graceful shutdown
|
||||||
|
if err := rc.cmd.Process.Signal(os.Interrupt); err != nil {
|
||||||
|
rc.logger.Warn().Err(err).Msg("failed to send interrupt signal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for a bit to allow graceful shutdown
|
||||||
|
done := make(chan error)
|
||||||
|
go func() {
|
||||||
|
done <- rc.cmd.Wait()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
rc.logger.Warn().Err(err).Msg("process exited with error")
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
// Force kill if it doesn't shut down gracefully
|
||||||
|
if err := rc.cmd.Process.Kill(); err != nil {
|
||||||
|
rc.logger.Error().Err(err).Msg("failed to kill process")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use fusermount to ensure the mountpoint is unmounted
|
||||||
|
cmd := exec.CommandContext(ctx, "fusermount", "-u", remote.MountPoint)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
rc.logger.Warn().Err(err).Msg("fusermount unmount failed")
|
||||||
|
// Don't return error here as the process might already be dead
|
||||||
|
}
|
||||||
|
|
||||||
|
rc.logger.Info().Msgf("Successfully unmounted %s", remote.MountPoint)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
router *chi.Mux
|
||||||
|
logger zerolog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Server {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
l := logger.NewLogger("http", cfg.QBitTorrent.LogLevel, os.Stdout)
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||||
|
|
||||||
|
return &Server{
|
||||||
|
router: r,
|
||||||
|
logger: l,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Start(ctx context.Context) error {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
// Register routes
|
||||||
|
s.router.Get("/logs", s.getLogs)
|
||||||
|
port := fmt.Sprintf(":%s", cfg.QBitTorrent.Port)
|
||||||
|
s.logger.Info().Msgf("Starting server on %s", port)
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: port,
|
||||||
|
Handler: s.router,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
s.logger.Info().Msgf("Error starting server: %v", err)
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
s.logger.Info().Msg("Shutting down gracefully...")
|
||||||
|
return srv.Shutdown(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) AddRoutes(routes func(r chi.Router) http.Handler) {
|
||||||
|
routes(s.router)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Mount(pattern string, handler http.Handler) {
|
||||||
|
s.router.Mount(pattern, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getLogs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
logFile := logger.GetLogPath()
|
||||||
|
|
||||||
|
// Open and read the file
|
||||||
|
file, err := os.Open(logFile)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Error reading log file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func(file *os.File) {
|
||||||
|
err := file.Close()
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Debug().Err(err).Msg("Error closing log file")
|
||||||
|
}
|
||||||
|
}(file)
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Disposition", "inline; filename=application.log")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
w.Header().Set("Pragma", "no-cache")
|
||||||
|
w.Header().Set("Expires", "0")
|
||||||
|
|
||||||
|
// Stream the file
|
||||||
|
_, err = io.Copy(w, file)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Debug().Err(err).Msg("Error streaming log file")
|
||||||
|
http.Error(w, "Error streaming log file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/cache"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/engine"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/repair"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
Repair *repair.Repair
|
||||||
|
Arr *arr.Storage
|
||||||
|
Debrid *engine.Engine
|
||||||
|
DebridCache *cache.Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
instance *Service
|
||||||
|
once sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
func New() *Service {
|
||||||
|
once.Do(func() {
|
||||||
|
arrs := arr.NewStorage()
|
||||||
|
deb := debrid.New()
|
||||||
|
instance = &Service{
|
||||||
|
Repair: repair.New(deb, arrs),
|
||||||
|
Arr: arrs,
|
||||||
|
Debrid: deb,
|
||||||
|
DebridCache: cache.NewManager(deb),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetService returns the singleton instance
|
||||||
|
func GetService() *Service {
|
||||||
|
if instance == nil {
|
||||||
|
instance = New()
|
||||||
|
}
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
|
func Update() *Service {
|
||||||
|
arrs := arr.NewStorage()
|
||||||
|
deb := debrid.New()
|
||||||
|
instance = &Service{
|
||||||
|
Repair: repair.New(deb, arrs),
|
||||||
|
Arr: arrs,
|
||||||
|
Debrid: deb,
|
||||||
|
}
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetDebrid() *engine.Engine {
|
||||||
|
return GetService().Debrid
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (ui *Handler) Routes() http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
if ui.logger.GetLevel().String() == "debug" {
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Get("/login", ui.LoginHandler)
|
||||||
|
r.Post("/login", ui.LoginHandler)
|
||||||
|
r.Get("/setup", ui.SetupHandler)
|
||||||
|
r.Post("/setup", ui.SetupHandler)
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(ui.authMiddleware)
|
||||||
|
r.Get("/", ui.IndexHandler)
|
||||||
|
r.Get("/download", ui.DownloadHandler)
|
||||||
|
r.Get("/repair", ui.RepairHandler)
|
||||||
|
r.Get("/config", ui.ConfigHandler)
|
||||||
|
r.Route("/internal", func(r chi.Router) {
|
||||||
|
r.Get("/arrs", ui.handleGetArrs)
|
||||||
|
r.Post("/add", ui.handleAddContent)
|
||||||
|
r.Post("/repair", ui.handleRepairMedia)
|
||||||
|
r.Get("/torrents", ui.handleGetTorrents)
|
||||||
|
r.Delete("/torrents/{hash}", ui.handleDeleteTorrent)
|
||||||
|
r.Get("/config", ui.handleGetConfig)
|
||||||
|
r.Get("/version", ui.handleGetVersion)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
+426
@@ -0,0 +1,426 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/request"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/utils"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/qbit"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/service"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/version"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AddRequest struct {
|
||||||
|
Url string `json:"url"`
|
||||||
|
Arr string `json:"arr"`
|
||||||
|
File string `json:"file"`
|
||||||
|
NotSymlink bool `json:"notSymlink"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Seasons []string `json:"seasons"`
|
||||||
|
Episodes []string `json:"episodes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArrResponse struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContentResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
ArrID string `json:"arr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RepairRequest struct {
|
||||||
|
ArrName string `json:"arr"`
|
||||||
|
MediaIds []string `json:"mediaIds"`
|
||||||
|
Async bool `json:"async"`
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed web/*
|
||||||
|
var content embed.FS
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
qbit *qbit.QBit
|
||||||
|
logger zerolog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(qbit *qbit.QBit) *Handler {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
return &Handler{
|
||||||
|
qbit: qbit,
|
||||||
|
logger: logger.NewLogger("ui", cfg.LogLevel, os.Stdout),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
store = sessions.NewCookieStore([]byte("your-secret-key")) // Change this to a secure key
|
||||||
|
templates *template.Template
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
templates = template.Must(template.ParseFS(
|
||||||
|
content,
|
||||||
|
"web/layout.html",
|
||||||
|
"web/index.html",
|
||||||
|
"web/download.html",
|
||||||
|
"web/repair.html",
|
||||||
|
"web/config.html",
|
||||||
|
"web/login.html",
|
||||||
|
"web/setup.html",
|
||||||
|
))
|
||||||
|
|
||||||
|
store.Options = &sessions.Options{
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: 86400 * 7,
|
||||||
|
HttpOnly: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) authMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Check if setup is needed
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
if cfg.NeedsSetup() && r.URL.Path != "/setup" {
|
||||||
|
http.Redirect(w, r, "/setup", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip auth check for setup page
|
||||||
|
if r.URL.Path == "/setup" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, _ := store.Get(r, "auth-session")
|
||||||
|
auth, ok := session.Values["authenticated"].(bool)
|
||||||
|
|
||||||
|
if !ok || !auth {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) verifyAuth(username, password string) bool {
|
||||||
|
// If you're storing hashed password, use bcrypt to compare
|
||||||
|
if username == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
auth := config.GetConfig().GetAuth()
|
||||||
|
if auth == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if username != auth.Username {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(auth.Password), []byte(password))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" {
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"Page": "login",
|
||||||
|
"Title": "Login",
|
||||||
|
}
|
||||||
|
if err := templates.ExecuteTemplate(w, "layout", data); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var credentials struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil {
|
||||||
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui.verifyAuth(credentials.Username, credentials.Password) {
|
||||||
|
session, _ := store.Get(r, "auth-session")
|
||||||
|
session.Values["authenticated"] = true
|
||||||
|
session.Values["username"] = credentials.Username
|
||||||
|
if err := session.Save(r, w); err != nil {
|
||||||
|
http.Error(w, "Error saving session", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
session, _ := store.Get(r, "auth-session")
|
||||||
|
session.Values["authenticated"] = false
|
||||||
|
session.Options.MaxAge = -1
|
||||||
|
err := session.Save(r, w)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) SetupHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
authCfg := cfg.GetAuth()
|
||||||
|
|
||||||
|
if !cfg.NeedsSetup() {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == "GET" {
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"Page": "setup",
|
||||||
|
"Title": "Setup",
|
||||||
|
}
|
||||||
|
if err := templates.ExecuteTemplate(w, "layout", data); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle POST (setup attempt)
|
||||||
|
username := r.FormValue("username")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
confirmPassword := r.FormValue("confirmPassword")
|
||||||
|
|
||||||
|
if password != confirmPassword {
|
||||||
|
http.Error(w, "Passwords do not match", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the password
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Error processing password", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the credentials
|
||||||
|
authCfg.Username = username
|
||||||
|
authCfg.Password = string(hashedPassword)
|
||||||
|
|
||||||
|
if err := cfg.SaveAuth(authCfg); err != nil {
|
||||||
|
http.Error(w, "Error saving credentials", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a session
|
||||||
|
session, _ := store.Get(r, "auth-session")
|
||||||
|
session.Values["authenticated"] = true
|
||||||
|
session.Values["username"] = username
|
||||||
|
if err := session.Save(r, w); err != nil {
|
||||||
|
http.Error(w, "Error saving session", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) IndexHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"Page": "index",
|
||||||
|
"Title": "Torrents",
|
||||||
|
}
|
||||||
|
if err := templates.ExecuteTemplate(w, "layout", data); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) DownloadHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"Page": "download",
|
||||||
|
"Title": "Download",
|
||||||
|
}
|
||||||
|
if err := templates.ExecuteTemplate(w, "layout", data); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) RepairHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"Page": "repair",
|
||||||
|
"Title": "Repair",
|
||||||
|
}
|
||||||
|
if err := templates.ExecuteTemplate(w, "layout", data); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) ConfigHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"Page": "config",
|
||||||
|
"Title": "Config",
|
||||||
|
}
|
||||||
|
if err := templates.ExecuteTemplate(w, "layout", data); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) handleGetArrs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
svc := service.GetService()
|
||||||
|
request.JSONResponse(w, svc.Arr.GetAll(), http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) handleAddContent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
svc := service.GetService()
|
||||||
|
|
||||||
|
results := make([]*qbit.ImportRequest, 0)
|
||||||
|
errs := make([]string, 0)
|
||||||
|
|
||||||
|
arrName := r.FormValue("arr")
|
||||||
|
notSymlink := r.FormValue("notSymlink") == "true"
|
||||||
|
|
||||||
|
_arr := svc.Arr.Get(arrName)
|
||||||
|
if _arr == nil {
|
||||||
|
_arr = arr.NewArr(arrName, "", "", arr.Sonarr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle URLs
|
||||||
|
if urls := r.FormValue("urls"); urls != "" {
|
||||||
|
var urlList []string
|
||||||
|
for _, u := range strings.Split(urls, "\n") {
|
||||||
|
if trimmed := strings.TrimSpace(u); trimmed != "" {
|
||||||
|
urlList = append(urlList, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, url := range urlList {
|
||||||
|
importReq := qbit.NewImportRequest(url, _arr, !notSymlink)
|
||||||
|
err := importReq.Process(ui.qbit)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("URL %s: %v", url, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, importReq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle torrent/magnet files
|
||||||
|
if files := r.MultipartForm.File["files"]; len(files) > 0 {
|
||||||
|
for _, fileHeader := range files {
|
||||||
|
file, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("Failed to open file %s: %v", fileHeader.Filename, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
magnet, err := utils.GetMagnetFromFile(file, fileHeader.Filename)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("Failed to parse torrent file %s: %v", fileHeader.Filename, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
importReq := qbit.NewImportRequest(magnet.Link, _arr, !notSymlink)
|
||||||
|
err = importReq.Process(ui.qbit)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("File %s: %v", fileHeader.Filename, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, importReq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.JSONResponse(w, struct {
|
||||||
|
Results []*qbit.ImportRequest `json:"results"`
|
||||||
|
Errors []string `json:"errors,omitempty"`
|
||||||
|
}{
|
||||||
|
Results: results,
|
||||||
|
Errors: errs,
|
||||||
|
}, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) handleRepairMedia(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req RepairRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := service.GetService()
|
||||||
|
|
||||||
|
_arr := svc.Arr.Get(req.ArrName)
|
||||||
|
if _arr == nil {
|
||||||
|
http.Error(w, "No Arrs found to repair", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Async {
|
||||||
|
go func() {
|
||||||
|
if err := svc.Repair.Repair([]*arr.Arr{_arr}, req.MediaIds); err != nil {
|
||||||
|
ui.logger.Error().Err(err).Msg("Failed to repair media")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
request.JSONResponse(w, "Repair process started", http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.Repair.Repair([]*arr.Arr{_arr}, req.MediaIds); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to repair: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
request.JSONResponse(w, "Repair completed", http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) handleGetVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
v := version.GetInfo()
|
||||||
|
request.JSONResponse(w, v, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) handleGetTorrents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
request.JSONResponse(w, ui.qbit.Storage.GetAll("", "", nil), http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) handleDeleteTorrent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hash := chi.URLParam(r, "hash")
|
||||||
|
if hash == "" {
|
||||||
|
http.Error(w, "No hash provided", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ui.qbit.Storage.Delete(hash)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ui *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
arrCfgs := make([]config.Arr, 0)
|
||||||
|
svc := service.GetService()
|
||||||
|
for _, a := range svc.Arr.GetAll() {
|
||||||
|
arrCfgs = append(arrCfgs, config.Arr{Host: a.Host, Name: a.Name, Token: a.Token})
|
||||||
|
}
|
||||||
|
cfg.Arrs = arrCfgs
|
||||||
|
request.JSONResponse(w, cfg, http.StatusOK)
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
{{ define "config" }}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4 class="mb-0"><i class="bi bi-gear me-2"></i>Configuration</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="configForm">
|
||||||
|
<div class="section mb-5">
|
||||||
|
<h5 class="border-bottom pb-2">General Configuration</h5>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="qbitDebug">Log Level</label>
|
||||||
|
<select class="form-select" name="qbit.log_level" id="log-level" disabled>
|
||||||
|
<option value="info">Info</option>
|
||||||
|
<option value="debug">Debug</option>
|
||||||
|
<option value="warn">Warning</option>
|
||||||
|
<option value="error">Error</option>
|
||||||
|
<option value="trace">Trace</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Register Magnet Link Button -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label>
|
||||||
|
<!-- Empty label to keep the button aligned -->
|
||||||
|
</label>
|
||||||
|
<div class="btn btn-primary w-100" onclick="registerMagnetLinkHandler()" id="registerMagnetLink">
|
||||||
|
Open Magnet Links in DecyphArr
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 mt-3">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="allowedExtensions">Allowed File Extensions</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<textarea type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="allowedExtensions"
|
||||||
|
name="allowed_file_types"
|
||||||
|
disabled
|
||||||
|
placeholder="mkv, mp4, avi, etc.">
|
||||||
|
</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mt-3">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="minFileSize">Minimum File Size</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="minFileSize"
|
||||||
|
name="min_file_size"
|
||||||
|
disabled
|
||||||
|
placeholder="e.g., 10MB, 1GB">
|
||||||
|
<small class="form-text text-muted">Minimum file size to download (0 for no limit)</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mt-3">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="maxFileSize">Maximum File Size</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="maxFileSize"
|
||||||
|
name="max_file_size"
|
||||||
|
disabled
|
||||||
|
placeholder="e.g., 50GB, 100MB">
|
||||||
|
<small class="form-text text-muted">Maximum file size to download (0 for no limit)</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Debrid Configuration -->
|
||||||
|
<div class="section mb-5">
|
||||||
|
<h5 class="border-bottom pb-2">Debrid Configuration</h5>
|
||||||
|
<div id="debridConfigs"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- QBitTorrent Configuration -->
|
||||||
|
<div class="section mb-5">
|
||||||
|
<h5 class="border-bottom pb-2">QBitTorrent Configuration</h5>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Username</label>
|
||||||
|
<input type="text" disabled class="form-control" name="qbit.username">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<input type="password" disabled class="form-control" name="qbit.password">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Port</label>
|
||||||
|
<input type="text" disabled class="form-control" name="qbit.port">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Symlink/Download Folder</label>
|
||||||
|
<input type="text" disabled class="form-control" name="qbit.download_folder">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Refresh Interval (seconds)</label>
|
||||||
|
<input type="number" class="form-control" name="qbit.refresh_interval">
|
||||||
|
</div>
|
||||||
|
<div class="col-12 mb-3">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="qbitDebug">Log Level</label>
|
||||||
|
<select class="form-select" name="qbit.log_level" id="qbitDebug" disabled>
|
||||||
|
<option value="info">Info</option>
|
||||||
|
<option value="debug">Debug</option>
|
||||||
|
<option value="warn">Warning</option>
|
||||||
|
<option value="error">Error</option>
|
||||||
|
<option value="trace">Trace</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Arr Configurations -->
|
||||||
|
<div class="section mb-5">
|
||||||
|
<h5 class="border-bottom pb-2">Arr Configurations</h5>
|
||||||
|
<div id="arrConfigs"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Repair Configuration -->
|
||||||
|
<div class="section mb-5">
|
||||||
|
<h5 class="border-bottom pb-2">Repair Configuration</h5>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Interval</label>
|
||||||
|
<input type="text" disabled class="form-control" name="repair.interval" placeholder="e.g., 24h">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input type="checkbox" disabled class="form-check-input" name="repair.enabled" id="repairEnabled">
|
||||||
|
<label class="form-check-label" for="repairEnabled">Enable Repair</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" disabled class="form-check-input" name="repair.run_on_start" id="repairOnStart">
|
||||||
|
<label class="form-check-label" for="repairOnStart">Run on Start</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
// Templates for dynamic elements
|
||||||
|
const debridTemplate = (index) => `
|
||||||
|
<div class="config-item position-relative mb-3 p-3 border rounded">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input type="text" disabled class="form-control" name="debrid[${index}].name" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Host</label>
|
||||||
|
<input type="text" disabled class="form-control" name="debrid[${index}].host" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">API Key</label>
|
||||||
|
<input type="password" disabled class="form-control" name="debrid[${index}].api_key" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Mount Folder</label>
|
||||||
|
<input type="text" disabled class="form-control" name="debrid[${index}].folder">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Rate Limit</label>
|
||||||
|
<input type="text" disabled class="form-control" name="debrid[${index}].rate_limit" placeholder="e.g., 200/minute">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check me-3 d-inline-block">
|
||||||
|
<input type="checkbox" disabled class="form-check-input" name="debrid[${index}].download_uncached">
|
||||||
|
<label class="form-check-label">Download Uncached</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check d-inline-block">
|
||||||
|
<input type="checkbox" disabled class="form-check-input" name="debrid[${index}].check_cached">
|
||||||
|
<label class="form-check-label">Check Cached</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const arrTemplate = (index) => `
|
||||||
|
<div class="config-item position-relative mb-3 p-3 border rounded">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input type="text" disabled class="form-control" name="arr[${index}].name" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label class="form-label">Host</label>
|
||||||
|
<input type="text" disabled class="form-control" name="arr[${index}].host" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label class="form-label">API Token</label>
|
||||||
|
<input type="password" disabled class="form-control" name="arr[${index}].token" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Main functionality
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
let debridCount = 0;
|
||||||
|
let arrCount = 0;
|
||||||
|
|
||||||
|
// Load existing configuration
|
||||||
|
fetch('/internal/config')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(config => {
|
||||||
|
// Load Debrid configs
|
||||||
|
config.debrids?.forEach(debrid => {
|
||||||
|
addDebridConfig(debrid);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load QBitTorrent config
|
||||||
|
if (config.qbittorrent) {
|
||||||
|
Object.entries(config.qbittorrent).forEach(([key, value]) => {
|
||||||
|
const input = document.querySelector(`[name="qbit.${key}"]`);
|
||||||
|
if (input) {
|
||||||
|
if (input.type === 'checkbox') {
|
||||||
|
input.checked = value;
|
||||||
|
} else {
|
||||||
|
input.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load Arr configs
|
||||||
|
config.arrs?.forEach(arr => {
|
||||||
|
addArrConfig(arr);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load Repair config
|
||||||
|
if (config.repair) {
|
||||||
|
Object.entries(config.repair).forEach(([key, value]) => {
|
||||||
|
const input = document.querySelector(`[name="repair.${key}"]`);
|
||||||
|
if (input) {
|
||||||
|
if (input.type === 'checkbox') {
|
||||||
|
input.checked = value;
|
||||||
|
} else {
|
||||||
|
input.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load general config
|
||||||
|
|
||||||
|
const logLevel = document.getElementById('log-level');
|
||||||
|
logLevel.value = config.log_level;
|
||||||
|
if (config.allowed_file_types && Array.isArray(config.allowed_file_types)) {
|
||||||
|
document.querySelector('[name="allowed_file_types"]').value = config.allowed_file_types.join(', ');
|
||||||
|
}
|
||||||
|
if (config.min_file_size) {
|
||||||
|
document.querySelector('[name="min_file_size"]').value = config.min_file_size;
|
||||||
|
}
|
||||||
|
if (config.max_file_size) {
|
||||||
|
document.querySelector('[name="max_file_size"]').value = config.max_file_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
document.getElementById('configForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const config = {
|
||||||
|
debrids: [],
|
||||||
|
qbittorrent: {},
|
||||||
|
arrs: [],
|
||||||
|
repair: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process form data
|
||||||
|
for (let [key, value] of formData.entries()) {
|
||||||
|
if (key.startsWith('debrid[')) {
|
||||||
|
const match = key.match(/debrid\[(\d+)\]\.(.+)/);
|
||||||
|
if (match) {
|
||||||
|
const [_, index, field] = match;
|
||||||
|
if (!config.debrids[index]) config.debrids[index] = {};
|
||||||
|
config.debrids[index][field] = value;
|
||||||
|
}
|
||||||
|
} else if (key.startsWith('qbit.')) {
|
||||||
|
config.qbittorrent[key.replace('qbit.', '')] = value;
|
||||||
|
} else if (key.startsWith('arr[')) {
|
||||||
|
const match = key.match(/arr\[(\d+)\]\.(.+)/);
|
||||||
|
if (match) {
|
||||||
|
const [_, index, field] = match;
|
||||||
|
if (!config.arrs[index]) config.arrs[index] = {};
|
||||||
|
config.arrs[index][field] = value;
|
||||||
|
}
|
||||||
|
} else if (key.startsWith('repair.')) {
|
||||||
|
config.repair[key.replace('repair.', '')] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up arrays (remove empty entries)
|
||||||
|
config.debrids = config.debrids.filter(Boolean);
|
||||||
|
config.arrs = config.arrs.filter(Boolean);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/internal/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(config)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
|
||||||
|
createToast('Configuration saved successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
createToast(`Error saving configuration: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function addDebridConfig(data = {}) {
|
||||||
|
const container = document.getElementById('debridConfigs');
|
||||||
|
container.insertAdjacentHTML('beforeend', debridTemplate(debridCount));
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
|
const input = container.querySelector(`[name="debrid[${debridCount}].${key}"]`);
|
||||||
|
if (input) {
|
||||||
|
if (input.type === 'checkbox') {
|
||||||
|
input.checked = value;
|
||||||
|
} else {
|
||||||
|
input.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
debridCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addArrConfig(data = {}) {
|
||||||
|
const container = document.getElementById('arrConfigs');
|
||||||
|
container.insertAdjacentHTML('beforeend', arrTemplate(arrCount));
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
|
const input = container.querySelector(`[name="arr[${arrCount}].${key}"]`);
|
||||||
|
if (input) {
|
||||||
|
if (input.type === 'checkbox') {
|
||||||
|
input.checked = value;
|
||||||
|
} else {
|
||||||
|
input.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
arrCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Register magnet link handler
|
||||||
|
function registerMagnetLinkHandler() {
|
||||||
|
if ('registerProtocolHandler' in navigator) {
|
||||||
|
try {
|
||||||
|
navigator.registerProtocolHandler(
|
||||||
|
'magnet',
|
||||||
|
`${window.location.origin}/download?magnet=%s`,
|
||||||
|
'DecyphArr'
|
||||||
|
);
|
||||||
|
localStorage.setItem('magnetHandler', 'true');
|
||||||
|
document.getElementById('registerMagnetLink').innerText = '✅ DecyphArr Can Open Magnet Links';
|
||||||
|
document.getElementById('registerMagnetLink').classList.add('bg-white', 'text-black');
|
||||||
|
console.log('Registered magnet link handler successfully.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to register magnet link handler:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var magnetHandler = localStorage.getItem('magnetHandler');
|
||||||
|
if (magnetHandler === 'true') {
|
||||||
|
document.getElementById('registerMagnetLink').innerText = '✅ DecyphArr Can Open Magnet Links';
|
||||||
|
document.getElementById('registerMagnetLink').classList.add('bg-white', 'text-black');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
{{ define "download" }}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4 class="mb-0"><i class="bi bi-cloud-download me-2"></i>Add New Download</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="downloadForm" enctype="multipart/form-data">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="magnetURI" class="form-label">Torrent(s)</label>
|
||||||
|
<textarea class="form-control" id="magnetURI" name="urls" rows="8" placeholder="Paste your magnet links or torrent URLs here, one per line..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="file" class="form-control" id="torrentFiles" name="torrents" multiple accept=".torrent,.magnet">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="category" class="form-label">Enter Category</label>
|
||||||
|
<input type="text" class="form-control" id="category" name="arr" placeholder="Enter Category (e.g sonarr, radarr, radarr4k)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="isSymlink" name="notSymlink">
|
||||||
|
<label class="form-check-label" for="isSymlink">
|
||||||
|
Download real files instead of symlinks
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" id="submitDownload">
|
||||||
|
<i class="bi bi-cloud-upload me-2"></i>Add to Download Queue
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const loadSavedDownloadOptions = () => {
|
||||||
|
const savedCategory = localStorage.getItem('downloadCategory');
|
||||||
|
const savedSymlink = localStorage.getItem('downloadSymlink');
|
||||||
|
document.getElementById('category').value = savedCategory || '';
|
||||||
|
document.getElementById('isSymlink').checked = savedSymlink === 'true'
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveCurrentDownloadOptions = () => {
|
||||||
|
const category = document.getElementById('category').value;
|
||||||
|
const isSymlink = document.getElementById('isSymlink').checked;
|
||||||
|
localStorage.setItem('downloadCategory', category);
|
||||||
|
localStorage.setItem('downloadSymlink', isSymlink.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load the last used download options from local storage
|
||||||
|
loadSavedDownloadOptions();
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
document.getElementById('downloadForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const submitBtn = document.getElementById('submitDownload');
|
||||||
|
const originalText = submitBtn.innerHTML;
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Adding...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
// Add URLs if present
|
||||||
|
const urls = document.getElementById('magnetURI').value
|
||||||
|
.split('\n')
|
||||||
|
.map(url => url.trim())
|
||||||
|
.filter(url => url.length > 0);
|
||||||
|
|
||||||
|
if (urls.length > 0) {
|
||||||
|
formData.append('urls', urls.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add torrent files if present
|
||||||
|
const fileInput = document.getElementById('torrentFiles');
|
||||||
|
for (let i = 0; i < fileInput.files.length; i++) {
|
||||||
|
formData.append('files', fileInput.files[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urls.length + fileInput.files.length === 0) {
|
||||||
|
createToast('Please submit at least one torrent', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urls.length + fileInput.files.length > 100) {
|
||||||
|
createToast('Please submit up to 100 torrents at a time', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
formData.append('arr', document.getElementById('category').value);
|
||||||
|
formData.append('notSymlink', document.getElementById('isSymlink').checked);
|
||||||
|
|
||||||
|
const response = await fetch('/internal/add', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) throw new Error(result.error || 'Unknown error');
|
||||||
|
if (result.errors && result.errors.length > 0) {
|
||||||
|
if (result.results.length > 0) {
|
||||||
|
createToast(`Added ${result.results.length} torrents with ${result.errors.length} errors:\n${result.errors.join('\n')}`, 'warning');
|
||||||
|
} else {
|
||||||
|
createToast(`Failed to add torrents:\n${result.errors.join('\n')}`, 'error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
createToast(`Successfully added ${result.results.length} torrents!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('magnetURI').value = '';
|
||||||
|
document.getElementById('torrentFiles').value = '';
|
||||||
|
} catch (error) {
|
||||||
|
createToast(`Error adding downloads: ${error.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save the download options to local storage when they change
|
||||||
|
document.getElementById('category').addEventListener('change', saveCurrentDownloadOptions);
|
||||||
|
document.getElementById('isSymlink').addEventListener('change', saveCurrentDownloadOptions);
|
||||||
|
|
||||||
|
// Read the URL parameters for a magnet link and add it to the download queue if found
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const magnetURI = urlParams.get('magnet');
|
||||||
|
if (magnetURI) {
|
||||||
|
document.getElementById('magnetURI').value = magnetURI;
|
||||||
|
history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
{{ define "index" }}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center gap-4">
|
||||||
|
<h4 class="mb-0 text-nowrap"><i class="bi bi-table me-2"></i>Active Torrents</h4>
|
||||||
|
<div class="d-flex align-items-center overflow-auto" style="flex-wrap: nowrap; gap: 0.5rem;">
|
||||||
|
<button class="btn btn-outline-danger btn-sm" id="batchDeleteBtn" style="display: none; flex-shrink: 0;">
|
||||||
|
<i class="bi bi-trash me-1"></i>Delete Selected
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm me-2" id="refreshBtn" style="flex-shrink: 0;">
|
||||||
|
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
|
||||||
|
</button>
|
||||||
|
<select class="form-select form-select-sm d-inline-block w-auto me-2" id="stateFilter" style="flex-shrink: 0;">
|
||||||
|
<option value="">All States</option>
|
||||||
|
<option value="downloading">Downloading</option>
|
||||||
|
<option value="pausedup">Paused</option>
|
||||||
|
<option value="error">Error</option>
|
||||||
|
</select>
|
||||||
|
<select class="form-select form-select-sm d-inline-block w-auto" id="categoryFilter">
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<input type="checkbox" class="form-check-input" id="selectAll">
|
||||||
|
</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Progress</th>
|
||||||
|
<th>Speed</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Debrid</th>
|
||||||
|
<th>State</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="torrentsList">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
let refs = {
|
||||||
|
torrentsList: document.getElementById('torrentsList'),
|
||||||
|
categoryFilter: document.getElementById('categoryFilter'),
|
||||||
|
stateFilter: document.getElementById('stateFilter'),
|
||||||
|
selectAll: document.getElementById('selectAll'),
|
||||||
|
batchDeleteBtn: document.getElementById('batchDeleteBtn'),
|
||||||
|
refreshBtn: document.getElementById('refreshBtn'),
|
||||||
|
};
|
||||||
|
let state = {
|
||||||
|
torrents: [],
|
||||||
|
selectedTorrents: new Set(),
|
||||||
|
categories: new Set(),
|
||||||
|
states: new Set('downloading', 'pausedup', 'error'),
|
||||||
|
selectedCategory: refs.categoryFilter?.value || '',
|
||||||
|
selectedState: refs.stateFilter?.value || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const torrentRowTemplate = (torrent) => `
|
||||||
|
<tr data-hash="${torrent.hash}">
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" class="form-check-input torrent-select" data-hash="${torrent.hash}" ${state.selectedTorrents.has(torrent.hash) ? 'checked' : ''}>
|
||||||
|
</td>
|
||||||
|
<td class="text-nowrap text-truncate overflow-hidden" style="max-width: 350px;" title="${torrent.name}">${torrent.name}</td>
|
||||||
|
<td class="text-nowrap">${formatBytes(torrent.size)}</td>
|
||||||
|
<td style="min-width: 150px;">
|
||||||
|
<div class="progress" style="height: 8px;">
|
||||||
|
<div class="progress-bar" role="progressbar"
|
||||||
|
style="width: ${(torrent.progress * 100).toFixed(1)}%"
|
||||||
|
aria-valuenow="${(torrent.progress * 100).toFixed(1)}"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"></div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">${(torrent.progress * 100).toFixed(1)}%</small>
|
||||||
|
</td>
|
||||||
|
<td>${formatSpeed(torrent.dlspeed)}</td>
|
||||||
|
<td><span class="badge bg-secondary">${torrent.category || 'None'}</span></td>
|
||||||
|
<td>${torrent.debrid || 'None'}</td>
|
||||||
|
<td><span class="badge ${getStateColor(torrent.state)}">${torrent.state}</span></td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteTorrent('${torrent.hash}')">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSpeed(speed) {
|
||||||
|
return `${formatBytes(speed)}/s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStateColor(state) {
|
||||||
|
const stateColors = {
|
||||||
|
'downloading': 'bg-primary',
|
||||||
|
'pausedup': 'bg-success',
|
||||||
|
'error': 'bg-danger',
|
||||||
|
};
|
||||||
|
return stateColors[state?.toLowerCase()] || 'bg-secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUI() {
|
||||||
|
// Filter torrents by selected category and state
|
||||||
|
let filteredTorrents = state.torrents;
|
||||||
|
if (state.selectedCategory) {
|
||||||
|
filteredTorrents = filteredTorrents.filter(t => t.category === state.selectedCategory);
|
||||||
|
}
|
||||||
|
if (state.selectedState) {
|
||||||
|
filteredTorrents = filteredTorrents.filter(t => t.state === state.selectedState);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the torrents list table
|
||||||
|
refs.torrentsList.innerHTML = filteredTorrents.map(torrent => torrentRowTemplate(torrent)).join('');
|
||||||
|
|
||||||
|
// Update the category filter dropdown
|
||||||
|
const currentCategories = Array.from(state.categories).sort();
|
||||||
|
const categoryOptions = ['<option value="">All Categories</option>']
|
||||||
|
.concat(currentCategories.map(cat =>
|
||||||
|
`<option value="${cat}" ${cat === state.selectedCategory ? 'selected' : ''}>${cat}</option>`
|
||||||
|
));
|
||||||
|
refs.categoryFilter.innerHTML = categoryOptions.join('');
|
||||||
|
|
||||||
|
// Clean up selected torrents that no longer exist
|
||||||
|
state.selectedTorrents = new Set(
|
||||||
|
Array.from(state.selectedTorrents)
|
||||||
|
.filter(hash => filteredTorrents.some(t => t.hash === hash))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update batch delete button visibility
|
||||||
|
refs.batchDeleteBtn.style.display = state.selectedTorrents.size > 0 ? '' : 'none';
|
||||||
|
|
||||||
|
// Update the select all checkbox state
|
||||||
|
refs.selectAll.checked = filteredTorrents.length > 0 && filteredTorrents.every(torrent => state.selectedTorrents.has(torrent.hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTorrents() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/internal/torrents');
|
||||||
|
const torrents = await response.json();
|
||||||
|
|
||||||
|
state.torrents = torrents;
|
||||||
|
state.categories = new Set(torrents.map(t => t.category).filter(Boolean));
|
||||||
|
|
||||||
|
updateUI();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading torrents:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTorrent(hash) {
|
||||||
|
if (!confirm('Are you sure you want to delete this torrent?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/internal/torrents/${hash}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
await loadTorrents();
|
||||||
|
createToast('Torrent deleted successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting torrent:', error);
|
||||||
|
createToast('Failed to delete torrent', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelectedTorrents() {
|
||||||
|
if (!confirm(`Are you sure you want to delete ${state.selectedTorrents.size} selected torrents?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deletePromises = Array.from(state.selectedTorrents).map(hash =>
|
||||||
|
fetch(`/internal/torrents/${hash}`, { method: 'DELETE' })
|
||||||
|
);
|
||||||
|
await Promise.all(deletePromises);
|
||||||
|
await loadTorrents();
|
||||||
|
createToast('Selected torrents deleted successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting torrents:', error);
|
||||||
|
createToast('Failed to delete some torrents' , 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadTorrents();
|
||||||
|
const refreshInterval = setInterval(loadTorrents, 5000);
|
||||||
|
|
||||||
|
refs.refreshBtn.addEventListener('click', loadTorrents);
|
||||||
|
refs.batchDeleteBtn.addEventListener('click', deleteSelectedTorrents);
|
||||||
|
|
||||||
|
refs.selectAll.addEventListener('change', (e) => {
|
||||||
|
const filteredTorrents = state.torrents.filter(t => {
|
||||||
|
if (state.selectedCategory && t.category !== state.selectedCategory) return false;
|
||||||
|
if (state.selectedState && t.state?.toLowerCase() !== state.selectedState.toLowerCase()) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (e.target.checked) {
|
||||||
|
filteredTorrents.forEach(torrent => state.selectedTorrents.add(torrent.hash));
|
||||||
|
} else {
|
||||||
|
filteredTorrents.forEach(torrent => state.selectedTorrents.delete(torrent.hash));
|
||||||
|
}
|
||||||
|
updateUI();
|
||||||
|
});
|
||||||
|
|
||||||
|
refs.torrentsList.addEventListener('change', (e) => {
|
||||||
|
if (e.target.classList.contains('torrent-select')) {
|
||||||
|
const hash = e.target.dataset.hash;
|
||||||
|
if (e.target.checked) {
|
||||||
|
state.selectedTorrents.add(hash);
|
||||||
|
} else {
|
||||||
|
state.selectedTorrents.delete(hash);
|
||||||
|
}
|
||||||
|
updateUI();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
refs.categoryFilter.addEventListener('change', (e) => {
|
||||||
|
state.selectedCategory = e.target.value;
|
||||||
|
updateUI();
|
||||||
|
});
|
||||||
|
|
||||||
|
refs.stateFilter.addEventListener('change', (e) => {
|
||||||
|
state.selectedState = e.target.value;
|
||||||
|
updateUI();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
clearInterval(refreshInterval);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
{{ define "layout" }}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DecyphArr - {{.Title}}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet"/>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css"/>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary-color: #2563eb;
|
||||||
|
--secondary-color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
padding: 1rem 0;
|
||||||
|
background: #fff !important;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
color: var(--primary-color) !important;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
color: var(--primary-color) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge#channel-badge {
|
||||||
|
background-color: #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge#channel-badge.beta {
|
||||||
|
background-color: #fd7e14;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||||
|
<!-- Toast messages will be created dynamically here -->
|
||||||
|
</div>
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light mb-4">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="/">
|
||||||
|
<i class="bi bi-cloud-download me-2"></i>DecyphArr
|
||||||
|
</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{if eq .Page "index"}}active{{end}}" href="/">
|
||||||
|
<i class="bi bi-table me-1"></i>Torrents
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{if eq .Page "download"}}active{{end}}" href="/download">
|
||||||
|
<i class="bi bi-cloud-download me-1"></i>Download
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{if eq .Page "repair"}}active{{end}}" href="/repair">
|
||||||
|
<i class="bi bi-tools me-1"></i>Repair
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{if eq .Page "config"}}active{{end}}" href="/config">
|
||||||
|
<i class="bi bi-gear me-1"></i>Config
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/logs" target="_blank">
|
||||||
|
<i class="bi bi-journal me-1"></i>Logs
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<span class="badge me-2" id="channel-badge">Loading...</span>
|
||||||
|
<span class="badge bg-primary" id="version-badge">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{{ if eq .Page "index" }}
|
||||||
|
{{ template "index" . }}
|
||||||
|
{{ else if eq .Page "download" }}
|
||||||
|
{{ template "download" . }}
|
||||||
|
{{ else if eq .Page "repair" }}
|
||||||
|
{{ template "repair" . }}
|
||||||
|
{{ else if eq .Page "config" }}
|
||||||
|
{{ template "config" . }}
|
||||||
|
{{ else if eq .Page "login" }}
|
||||||
|
{{ template "login" . }}
|
||||||
|
{{ else if eq .Page "setup" }}
|
||||||
|
{{ template "setup" . }}
|
||||||
|
{{ else }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* Create a toast message
|
||||||
|
* @param {string} message - The message to display
|
||||||
|
* @param {string} [type='success'] - The type of toast (success, warning, error)
|
||||||
|
*/
|
||||||
|
const createToast = (message, type = 'success') => {
|
||||||
|
type = ['success', 'warning', 'error'].includes(type) ? type : 'success';
|
||||||
|
|
||||||
|
const toastTimeouts = {
|
||||||
|
success: 5000,
|
||||||
|
warning: 10000,
|
||||||
|
error: 15000
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastContainer = document.querySelector('.toast-container');
|
||||||
|
const toastId = `toast-${Date.now()}`;
|
||||||
|
|
||||||
|
const toastHtml = `
|
||||||
|
<div id="${toastId}" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||||
|
<div class="toast-header ${type === 'error' ? 'bg-danger text-white' : type === 'warning' ? 'bg-warning text-dark' : 'bg-success text-white'}">
|
||||||
|
<strong class="me-auto">
|
||||||
|
${type === 'error' ? 'Error' : type === 'warning' ? 'Warning' : 'Success'}
|
||||||
|
</strong>
|
||||||
|
<button type="button" class="btn-close ${type === 'warning' ? '' : 'btn-close-white'}" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="toast-body">
|
||||||
|
${message.replace(/\n/g, '<br>')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
|
||||||
|
|
||||||
|
const toastElement = document.getElementById(toastId);
|
||||||
|
const toast = new bootstrap.Toast(toastElement, {
|
||||||
|
autohide: true,
|
||||||
|
delay: toastTimeouts[type]
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.show();
|
||||||
|
|
||||||
|
toastElement.addEventListener('hidden.bs.toast', () => {
|
||||||
|
toastElement.remove();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
fetch('/internal/version')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
const versionBadge = document.getElementById('version-badge');
|
||||||
|
const channelBadge = document.getElementById('channel-badge');
|
||||||
|
|
||||||
|
// Add url to version badge
|
||||||
|
versionBadge.innerHTML = `<a href="https://github.com/sirrobot01/debrid-blackhole/releases/tag/${data.version}" target="_blank" class="text-white">${data.version}</a>`;
|
||||||
|
channelBadge.textContent = data.channel.charAt(0).toUpperCase() + data.channel.slice(1);
|
||||||
|
|
||||||
|
if (data.channel === 'beta') {
|
||||||
|
channelBadge.classList.add('beta');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error fetching version:', error);
|
||||||
|
document.getElementById('version-badge').textContent = 'Unknown';
|
||||||
|
document.getElementById('channel-badge').textContent = 'Unknown';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
{{ define "login" }}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4 class="mb-0 text-center">Login</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="loginForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="username" class="form-label">Username</label>
|
||||||
|
<input type="text" class="form-control" id="username" name="username" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary">Login</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
username: document.getElementById('username').value,
|
||||||
|
password: document.getElementById('password').value
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.href = '/';
|
||||||
|
} else {
|
||||||
|
createToast('Invalid credentials', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
createToast('Login failed', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "setup" }}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4 class="mb-0 text-center">First Time Setup</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="setupForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="username" class="form-label">Choose Username</label>
|
||||||
|
<input type="text" class="form-control" id="username" name="username" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Choose Password</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="confirmPassword" class="form-label">Confirm Password</label>
|
||||||
|
<input type="password" class="form-control" id="confirmPassword" name="confirmPassword" required>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary">Set Credentials</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('setupForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
createToast('Passwords do not match', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
username: document.getElementById('username').value,
|
||||||
|
password: password,
|
||||||
|
confirmPassword: confirmPassword
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/setup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.href = '/';
|
||||||
|
} else {
|
||||||
|
const error = await response.text();
|
||||||
|
createToast(error, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Setup error:', error);
|
||||||
|
createToast('Setup failed', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
{{ define "repair" }}
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4 class="mb-0"><i class="bi bi-tools me-2"></i>Repair Media</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="repairForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="arrSelect" class="form-label">Select Arr Instance</label>
|
||||||
|
<select class="form-select" id="arrSelect" required>
|
||||||
|
<option value="">Select an Arr instance</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="mediaIds" class="form-label">Media IDs</label>
|
||||||
|
<input type="text" class="form-control" id="mediaIds"
|
||||||
|
placeholder="Enter IDs (comma-separated)">
|
||||||
|
<small class="text-muted">Enter TV DB ids for Sonarr, TM DB ids for Radarr</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="isAsync" checked>
|
||||||
|
<label class="form-check-label" for="isAsync">
|
||||||
|
Run repair in background
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" id="submitRepair">
|
||||||
|
<i class="bi bi-wrench me-2"></i>Start Repair
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Load Arr instances
|
||||||
|
fetch('/internal/arrs')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(arrs => {
|
||||||
|
const select = document.getElementById('arrSelect');
|
||||||
|
arrs.forEach(arr => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = arr.name;
|
||||||
|
option.textContent = arr.name;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
document.getElementById('repairForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const submitBtn = document.getElementById('submitRepair');
|
||||||
|
const originalText = submitBtn.innerHTML;
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Repairing...';
|
||||||
|
let mediaIds = document.getElementById('mediaIds').value.split(',').map(id => id.trim());
|
||||||
|
let arr = document.getElementById('arrSelect').value;
|
||||||
|
if (!arr) {
|
||||||
|
createToast('Please select an Arr instance', 'warning');
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = originalText;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch('/internal/repair', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
arr: document.getElementById('arrSelect').value,
|
||||||
|
mediaIds: mediaIds,
|
||||||
|
async: document.getElementById('isAsync').checked
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
createToast('Repair process initiated successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
createToast(`Error starting repair: ${error.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{{ define "setup" }}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6 col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4 class="mb-0 text-center">First Time Setup</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="setupForm" method="POST" action="/setup">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="username" class="form-label">Choose Username</label>
|
||||||
|
<input type="text" class="form-control" id="username" name="username" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Choose Password</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="confirmPassword" class="form-label">Confirm Password</label>
|
||||||
|
<input type="password" class="form-control" id="confirmPassword" name="confirmPassword" required>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary">Set Credentials</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package webdav
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/cache"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type File struct {
|
||||||
|
cache *cache.Cache
|
||||||
|
cachedTorrent *cache.CachedTorrent
|
||||||
|
file *torrent.File
|
||||||
|
offset int64
|
||||||
|
isDir bool
|
||||||
|
children []os.FileInfo
|
||||||
|
reader io.ReadCloser
|
||||||
|
}
|
||||||
|
|
||||||
|
// File interface implementations for File
|
||||||
|
|
||||||
|
func (f *File) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) GetDownloadLink() string {
|
||||||
|
file := f.file
|
||||||
|
link, err := f.cache.GetFileDownloadLink(f.cachedTorrent, file)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return link
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) Read(p []byte) (n int, err error) {
|
||||||
|
// Directories cannot be read as a byte stream.
|
||||||
|
if f.isDir {
|
||||||
|
return 0, os.ErrInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we haven't started streaming the file yet, open the HTTP connection.
|
||||||
|
if f.reader == nil {
|
||||||
|
// Create an HTTP GET request to the file's URL.
|
||||||
|
req, err := http.NewRequest("GET", f.GetDownloadLink(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to create HTTP request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we've already read some data (f.offset > 0), request only the remaining bytes.
|
||||||
|
if f.offset > 0 {
|
||||||
|
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", f.offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the HTTP request.
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("HTTP request error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept a 200 (OK) or 206 (Partial Content) status.
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
|
||||||
|
resp.Body.Close()
|
||||||
|
return 0, fmt.Errorf("unexpected HTTP status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the response body as our reader.
|
||||||
|
f.reader = resp.Body
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read data from the HTTP stream.
|
||||||
|
n, err = f.reader.Read(p)
|
||||||
|
f.offset += int64(n)
|
||||||
|
|
||||||
|
// When we reach the end of the stream, close the reader.
|
||||||
|
if err == io.EOF {
|
||||||
|
f.reader.Close()
|
||||||
|
f.reader = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) Seek(offset int64, whence int) (int64, error) {
|
||||||
|
if f.isDir {
|
||||||
|
return 0, os.ErrInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
switch whence {
|
||||||
|
case io.SeekStart:
|
||||||
|
f.offset = offset
|
||||||
|
case io.SeekCurrent:
|
||||||
|
f.offset += offset
|
||||||
|
case io.SeekEnd:
|
||||||
|
f.offset = f.file.Size - offset
|
||||||
|
default:
|
||||||
|
return 0, os.ErrInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.offset < 0 {
|
||||||
|
f.offset = 0
|
||||||
|
}
|
||||||
|
if f.offset > f.file.Size {
|
||||||
|
f.offset = f.file.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.offset, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) Readdir(count int) ([]os.FileInfo, error) {
|
||||||
|
if !f.isDir {
|
||||||
|
return nil, os.ErrInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
if count <= 0 {
|
||||||
|
return f.children, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(f.children) == 0 {
|
||||||
|
return nil, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > len(f.children) {
|
||||||
|
count = len(f.children)
|
||||||
|
}
|
||||||
|
|
||||||
|
files := f.children[:count]
|
||||||
|
f.children = f.children[count:]
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) Stat() (os.FileInfo, error) {
|
||||||
|
if f.isDir {
|
||||||
|
name := "/"
|
||||||
|
if f.cachedTorrent != nil {
|
||||||
|
name = f.cachedTorrent.Name
|
||||||
|
}
|
||||||
|
return &FileInfo{
|
||||||
|
name: name,
|
||||||
|
size: 0,
|
||||||
|
mode: 0755 | os.ModeDir,
|
||||||
|
modTime: time.Now(),
|
||||||
|
isDir: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FileInfo{
|
||||||
|
name: f.file.Name,
|
||||||
|
size: f.file.Size,
|
||||||
|
mode: 0644,
|
||||||
|
modTime: time.Now(),
|
||||||
|
isDir: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *File) Write(p []byte) (n int, err error) {
|
||||||
|
return 0, os.ErrPermission
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package webdav
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileInfo implements os.FileInfo for our WebDAV files
|
||||||
|
type FileInfo struct {
|
||||||
|
name string
|
||||||
|
size int64
|
||||||
|
mode os.FileMode
|
||||||
|
modTime time.Time
|
||||||
|
isDir bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fi *FileInfo) Name() string { return fi.name }
|
||||||
|
func (fi *FileInfo) Size() int64 { return fi.size }
|
||||||
|
func (fi *FileInfo) Mode() os.FileMode { return fi.mode }
|
||||||
|
func (fi *FileInfo) ModTime() time.Time { return fi.modTime }
|
||||||
|
func (fi *FileInfo) IsDir() bool { return fi.isDir }
|
||||||
|
func (fi *FileInfo) Sys() interface{} { return nil }
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package webdav
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/cache"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||||
|
"golang.org/x/net/webdav"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
Name string
|
||||||
|
logger zerolog.Logger
|
||||||
|
cache *cache.Cache
|
||||||
|
rootListing atomic.Value
|
||||||
|
lastRefresh time.Time
|
||||||
|
refreshMutex sync.Mutex
|
||||||
|
RootPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(name string, cache *cache.Cache, logger zerolog.Logger) *Handler {
|
||||||
|
h := &Handler{
|
||||||
|
Name: name,
|
||||||
|
cache: cache,
|
||||||
|
logger: logger,
|
||||||
|
RootPath: fmt.Sprintf("/%s", name),
|
||||||
|
}
|
||||||
|
|
||||||
|
h.refreshRootListing()
|
||||||
|
|
||||||
|
// Start background refresh
|
||||||
|
go h.backgroundRefresh()
|
||||||
|
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) backgroundRefresh() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
h.refreshRootListing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) refreshRootListing() {
|
||||||
|
h.refreshMutex.Lock()
|
||||||
|
defer h.refreshMutex.Unlock()
|
||||||
|
|
||||||
|
if time.Since(h.lastRefresh) < time.Minute {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []os.FileInfo
|
||||||
|
h.cache.GetTorrents().Range(func(key, value interface{}) bool {
|
||||||
|
cachedTorrent := value.(*cache.CachedTorrent)
|
||||||
|
if cachedTorrent != nil && cachedTorrent.Torrent != nil {
|
||||||
|
files = append(files, &FileInfo{
|
||||||
|
name: cachedTorrent.Torrent.Name,
|
||||||
|
size: 0,
|
||||||
|
mode: 0755 | os.ModeDir,
|
||||||
|
modTime: time.Now(),
|
||||||
|
isDir: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
h.rootListing.Store(files)
|
||||||
|
h.lastRefresh = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) getParentRootPath() string {
|
||||||
|
return fmt.Sprintf("/webdav/%s", h.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) getRootFileInfos() []os.FileInfo {
|
||||||
|
if listing := h.rootListing.Load(); listing != nil {
|
||||||
|
return listing.([]os.FileInfo)
|
||||||
|
}
|
||||||
|
return []os.FileInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mkdir implements webdav.FileSystem
|
||||||
|
func (h *Handler) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
|
||||||
|
return os.ErrPermission // Read-only filesystem
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveAll implements webdav.FileSystem
|
||||||
|
func (h *Handler) RemoveAll(ctx context.Context, name string) error {
|
||||||
|
return os.ErrPermission // Read-only filesystem
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename implements webdav.FileSystem
|
||||||
|
func (h *Handler) Rename(ctx context.Context, oldName, newName string) error {
|
||||||
|
return os.ErrPermission // Read-only filesystem
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
|
||||||
|
name = path.Clean("/" + name)
|
||||||
|
|
||||||
|
// Fast path for root directory
|
||||||
|
if name == h.getParentRootPath() {
|
||||||
|
return &File{
|
||||||
|
cache: h.cache,
|
||||||
|
isDir: true,
|
||||||
|
children: h.getRootFileInfos(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove root directory from path
|
||||||
|
name = strings.TrimPrefix(name, h.getParentRootPath())
|
||||||
|
name = strings.TrimPrefix(name, "/")
|
||||||
|
parts := strings.SplitN(name, "/", 2)
|
||||||
|
|
||||||
|
// Get torrent from cache using sync.Map
|
||||||
|
cachedTorrent := h.cache.GetTorrentByName(parts[0])
|
||||||
|
if cachedTorrent == nil {
|
||||||
|
h.logger.Debug().Msgf("Torrent not found: %s", parts[0])
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return &File{
|
||||||
|
cache: h.cache,
|
||||||
|
cachedTorrent: cachedTorrent,
|
||||||
|
isDir: true,
|
||||||
|
children: h.getTorrentFileInfos(cachedTorrent.Torrent),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a map for faster file lookup
|
||||||
|
fileMap := make(map[string]*torrent.File, len(cachedTorrent.Torrent.Files))
|
||||||
|
for i := range cachedTorrent.Torrent.Files {
|
||||||
|
fileMap[cachedTorrent.Torrent.Files[i].Name] = &cachedTorrent.Torrent.Files[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
if file, ok := fileMap[parts[1]]; ok {
|
||||||
|
return &File{
|
||||||
|
cache: h.cache,
|
||||||
|
cachedTorrent: cachedTorrent,
|
||||||
|
file: file,
|
||||||
|
isDir: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Debug().Msgf("File not found: %s", name)
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat implements webdav.FileSystem
|
||||||
|
func (h *Handler) Stat(ctx context.Context, name string) (os.FileInfo, error) {
|
||||||
|
f, err := h.OpenFile(ctx, name, os.O_RDONLY, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f.Stat()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) getTorrentFileInfos(torrent *torrent.Torrent) []os.FileInfo {
|
||||||
|
files := make([]os.FileInfo, 0, len(torrent.Files))
|
||||||
|
for _, file := range torrent.Files {
|
||||||
|
files = append(files, &FileInfo{
|
||||||
|
name: file.Name,
|
||||||
|
size: file.Size,
|
||||||
|
mode: 0644,
|
||||||
|
modTime: time.Now(),
|
||||||
|
isDir: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Handle OPTIONS
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create WebDAV handler
|
||||||
|
handler := &webdav.Handler{
|
||||||
|
FileSystem: h,
|
||||||
|
LockSystem: webdav.NewMemLS(),
|
||||||
|
Logger: func(r *http.Request, err error) {
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error().
|
||||||
|
Err(err).
|
||||||
|
Str("method", r.Method).
|
||||||
|
Str("path", r.URL.Path).
|
||||||
|
Msg("WebDAV error")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for GET requests on directories
|
||||||
|
if r.Method == "GET" {
|
||||||
|
if f, err := h.OpenFile(r.Context(), r.URL.Path, os.O_RDONLY, 0); err == nil {
|
||||||
|
if fi, err := f.Stat(); err == nil && fi.IsDir() {
|
||||||
|
h.serveDirectory(w, r, f)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) serveDirectory(w http.ResponseWriter, r *http.Request, file webdav.File) {
|
||||||
|
var children []os.FileInfo
|
||||||
|
if f, ok := file.(*File); ok {
|
||||||
|
children = f.children
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
children, err = file.Readdir(-1)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to list directory", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean and prepare the path
|
||||||
|
cleanPath := path.Clean(r.URL.Path)
|
||||||
|
parentPath := path.Dir(cleanPath)
|
||||||
|
showParent := cleanPath != "/" && parentPath != "." && parentPath != cleanPath
|
||||||
|
|
||||||
|
// Prepare template data
|
||||||
|
data := struct {
|
||||||
|
Path string
|
||||||
|
ParentPath string
|
||||||
|
ShowParent bool
|
||||||
|
Children []os.FileInfo
|
||||||
|
}{
|
||||||
|
Path: cleanPath,
|
||||||
|
ParentPath: parentPath,
|
||||||
|
ShowParent: showParent,
|
||||||
|
Children: children,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and execute template
|
||||||
|
tmpl, err := template.New("directory").Parse(directoryTemplate)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error().Err(err).Msg("Failed to parse directory template")
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := tmpl.Execute(w, data); err != nil {
|
||||||
|
h.logger.Error().Err(err).Msg("Failed to execute directory template")
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package webdav
|
||||||
|
|
||||||
|
const rootTemplate = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>WebDAV Shares</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #3498db;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 10px;
|
||||||
|
display: block;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
background-color: #f7f9fa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Available WebDAV Shares</h1>
|
||||||
|
<ul>
|
||||||
|
{{range .Handlers}}
|
||||||
|
<li><a href="{{$.Prefix}}{{.RootPath}}">{{$.Prefix}}{{.RootPath}}</a></li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
const directoryTemplate = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Index of {{.Path}}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #3498db;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 10px;
|
||||||
|
display: block;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
background-color: #f7f9fa;
|
||||||
|
}
|
||||||
|
.file-info {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9em;
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.parent-dir {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Index of {{.Path}}</h1>
|
||||||
|
<ul>
|
||||||
|
{{if .ShowParent}}
|
||||||
|
<li><a href="{{.ParentPath}}" class="parent-dir">Parent Directory</a></li>
|
||||||
|
{{end}}
|
||||||
|
{{range .Children}}
|
||||||
|
<li>
|
||||||
|
<a href="{{$.Path}}/{{.Name}}">
|
||||||
|
{{.Name}}{{if .IsDir}}/{{end}}
|
||||||
|
<span class="file-info">
|
||||||
|
{{if not .IsDir}}
|
||||||
|
{{.Size}} bytes
|
||||||
|
{{end}}
|
||||||
|
{{.ModTime.Format "2006-01-02 15:04:05"}}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package webdav
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||||
|
"github.com/sirrobot01/debrid-blackhole/pkg/service"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebDav struct {
|
||||||
|
Handlers []*Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *WebDav {
|
||||||
|
svc := service.GetService()
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
w := &WebDav{
|
||||||
|
Handlers: make([]*Handler, 0),
|
||||||
|
}
|
||||||
|
for name, c := range svc.DebridCache.GetCaches() {
|
||||||
|
h := NewHandler(name, c, logger.NewLogger(fmt.Sprintf("%s-webdav", name), cfg.LogLevel, os.Stdout))
|
||||||
|
w.Handlers = append(w.Handlers, h)
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wd *WebDav) Routes() http.Handler {
|
||||||
|
chi.RegisterMethod("PROPFIND")
|
||||||
|
chi.RegisterMethod("PROPPATCH")
|
||||||
|
chi.RegisterMethod("MKCOL") // Note: it was "MKOL" in your example, should be "MKCOL"
|
||||||
|
chi.RegisterMethod("COPY")
|
||||||
|
chi.RegisterMethod("MOVE")
|
||||||
|
chi.RegisterMethod("LOCK")
|
||||||
|
chi.RegisterMethod("UNLOCK")
|
||||||
|
wr := chi.NewRouter()
|
||||||
|
wr.Use(wd.commonMiddleware)
|
||||||
|
|
||||||
|
wd.setupRootHandler(wr)
|
||||||
|
wd.mountHandlers(wr)
|
||||||
|
|
||||||
|
return wr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wd *WebDav) Start(ctx context.Context) error {
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
errChan := make(chan error, len(wd.Handlers))
|
||||||
|
|
||||||
|
for _, h := range wd.Handlers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(h *Handler) {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := h.cache.Start(); err != nil {
|
||||||
|
select {
|
||||||
|
case errChan <- err:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a separate goroutine to close channel after WaitGroup
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(errChan)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Collect all errors
|
||||||
|
var errors []error
|
||||||
|
for err := range errChan {
|
||||||
|
if err != nil {
|
||||||
|
errors = append(errors, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errors) > 0 {
|
||||||
|
return fmt.Errorf("multiple handlers failed: %v", errors)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wd *WebDav) mountHandlers(r chi.Router) {
|
||||||
|
for _, h := range wd.Handlers {
|
||||||
|
r.Mount(h.RootPath, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wd *WebDav) setupRootHandler(r chi.Router) {
|
||||||
|
r.Get("/", wd.handleRoot())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wd *WebDav) commonMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("DAV", "1, 2")
|
||||||
|
w.Header().Set("Allow", "OPTIONS, PROPFIND, GET, HEAD, POST, PUT, DELETE, MKCOL, PROPPATCH, COPY, MOVE, LOCK, UNLOCK")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "OPTIONS, PROPFIND, GET, HEAD, POST, PUT, DELETE, MKCOL, PROPPATCH, COPY, MOVE, LOCK, UNLOCK")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Depth, Content-Type, Authorization")
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wd *WebDav) handleRoot() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
|
tmpl, err := template.New("root").Parse(rootTemplate)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := struct {
|
||||||
|
Handlers []*Handler
|
||||||
|
Prefix string
|
||||||
|
}{
|
||||||
|
Handlers: wd.Handlers,
|
||||||
|
Prefix: "/webdav",
|
||||||
|
}
|
||||||
|
if err := tmpl.Execute(w, data); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Install FUSE and required dependencies
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends fuse3
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create nonroot user and group
|
||||||
|
groupadd -r nonroot
|
||||||
|
useradd -r -g nonroot nonroot
|
||||||
|
|
||||||
|
# Create mount directory
|
||||||
|
mkdir -p /mnt/rclone
|
||||||
|
chown nonroot:nonroot /mnt/rclone
|
||||||
Reference in New Issue
Block a user